Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 12.
Cette fonctionnalité est très avancée. Elle convient à un besoin très précis d’optimisation et très peu nombreux seront les développeurs qui auront le réel besoin de s’en servir. Il est probable que Microsoft a eu un besoin d’optimisation dans le cadre du framework et a été amené à effectuer cette optimisation.
Inline arrays (littéralement “tableaux en ligne”) qu’on appellera par la suite “tableau inline”, permet de créer des tableaux à taille fixe d’un type struct
. Ces tableaux permettent, par exemple, de créer un buffer en ligne aux caractéristiques similaires à un buffer non managé à taille fixe. L’intérêt de ce type de buffer est d’améliorer le temps d’accès aux éléments dans le buffer.
Par exemple, si une liste classique List<int>
est un objet de type référence stocké dans le tas managé, accessible par l’intermédiaire d’une référence. Les objets stockés sont des entiers Int32
qui sont des objets de type valeur toutefois comme la liste est un objet de type référence, les entiers seront stockés aussi dans le tas managé comme la liste. L’accès aux éléments de la liste est complexe puisqu’il se fait par référence et dans le tas managé ce qui rend cette accès plus lent. D’autre part, le stockage dans le tas managé par l’intermédiaire de références ne permet pas d’avoir des zones continues en mémoire ce qui rend plus complexe de partager ces données avec du code natif.
L’objet Span
introduit en C# 7.2 permet de corriger certains de ces problèmes (pour davantage de détails concernant Span
: cdiese.fr/csharp7-ref-struct/#cs7-ref_readonly_struct-span). Span
est un objet ref struct
qui est donc exclusivement stocké sur la pile (voir Structure exclusivement stockée dans la pile: “ref struct” (C# 7, C# 8.0)). De la même façon que les tableaux “inline”, il donne la possibilité d’allouer des espaces de taille fixe et continus en mémoire de façon à améliorer l’accès aux données. Ce type d’objet facilite le partage de données avec du code natif puisque leur représentation en mémoire est simple. Il suffit d’indiquer au code natif l’emplacement du premier objet. Etant donné que l’espace alloué est continu, que les éléments stockés ont une taille fixe, l’accès à ces éléments est facile et prévisible.
Syntaxe
Du point de vue de la syntaxe, un tableau “inline” doit être déclaré de cette façon:
- Il doit s’agir d’une structure
- Il doit comporter un membre, peu importe le nom de cette donnée membre.
- On doit utiliser l’attribut System.Runtime.CompilerServices.InlineArray
Par exemple pour déclarer un tableau “inline” d’entiers:
[InlineArray(10)]
public struct IntegerArray
{
private int element;
}
L’attribut [InlineArray(10)]
permet d’indiquer que le tableau contient 10 éléments.
Le parcours se fait en itérant sur le tableau directement:
IntegerArray integers = new IntegerArray();
for (int i = 0; i < 10; i++)
{
integers[i] = i;
}
Pour le contenu, on peut utiliser des structures plus complexes, par exemple:
public struct Point
{
public int X;
public int Y;
}
[InlineArray(10)]
public struct PointInlineArray
{
private Point point;
}
Par exemple pour y accéder:
PointInlineArray points = new PointInlineArray();
for (int i = 0; i < 10; i++)
{
Point newPoint = new Point();
newPoint.X = 4;
newPoint.Y = 6;
points[i] = newPoint;
}
Pour rendre le tableau “inline” plus générique, on peut aussi utiliser un generic:
[InlineArray(10)]
public struct PointInlineArray<T>
{
private T element;
}
Allocation sur la pile
Comme indiqué précédemment, un des grands intérêts des tableaux “inline” est d’effectuer des allocations sur la pile ce qui permet d’augmenter les performances concernant les manipulations des éléments de cet objet.
Par exemple, si on effectue un benchmark sur le code suivant:
[MemoryDiagnoser(false)]
public class InlineArrays
{
[Benchmark]
public void UseInlineArray()
{
PointInlineArray points = new PointInlineArray();
for (int i = 0; i < 10; i++)
{
Point newPoint = new Point();
newPoint.X = 4;
newPoint.Y = 6;
points[i] = newPoint;
}
}
}
En lançant l’exécution avec:
BenchmarkRunner.Run<InlineArrays>();
On obtient:
| Method | Mean | Error | StdDev | Allocated |
|--------------- |---------:|----------:|----------:|----------:|
| UseInlineArray | 4.362 ns | 0.1174 ns | 0.1040 ns | - |
On peut voir qu’il n’y a pas eu d’allocations dans le tas managé (car la colonne “Allocated” est vide).
Comparaison des performances
On va juste essayer dans cette partie, de comparer les performances quant à l’accès des éléments d’un tableau “inline”. Pour cet exemple, on va se contenter d’effectuer des écritures dans le tableau à partir de code managé.
On considère 4 méthodes qui effectuent des affectations dans un tableau:
- Affectation dans un tableau simple:
Point[]
. - Affectation dans un objet de type
Span<Point>
instancié avecnew
. - Affectation dans un objet de type
Span<Point>
instancié avecstackalloc
. - Affectation dans un tableau “inline”:
PointInlineArray
.
Le code avec un tableau simple Point[]
est:
[Benchmark]
public void UseSimpleArray()
{
var points = new Point[10];
for (int i = 0; i < 10; i++)
{
Point newPoint = new Point();
newPoint.X = i + 4;
newPoint.Y = i + 6;
points[i] = newPoint;
}
}
Le code avec un objet de type Span<Point>
instancié avec new
est:
[Benchmark]
public void UseSpanWithNew()
{
Span<Point> points = new Point[10];
for (int i = 0; i < 10; i++)
{
Point newPoint = new Point();
newPoint.X = i + 4;
newPoint.Y = i + 6;
points[i] = newPoint;
}
}
Comme on l’a évoqué précédemment, l’objet Span<>
propose des fonctionnalités semblables aux tableaux “inline”. L’instanciation avec new
effectue un allocation dans le tas managé avant de rendre l’objet accessible dans la pile.
Le code avec un objet de type Span<Point>
instancié avec stackalloc
est:
[Benchmark]
public void UseSpanWithStackalloc()
{
Span<Point> points = stackalloc Point[10];
for (int i = 0; i < 10; i++)
{
Point newPoint = new Point();
newPoint.X = i + 4;
newPoint.Y = i + 6;
points[i] = newPoint;
}
}
La différence avec le code précédent est d’utiliser stackalloc
qui permet d’effectuer l’allocation directement sur la pile ce qui rend le code plus rapide à l’exécution qu’en utilisant le tas managé.
Le code avec un tableau “inline” PointInlineArray
est:
[InlineArray(10)]
public struct PointInlineArray
{
private Point Point;
}
[Benchmark]
public void UseInlineArray()
{
var points = new PointInlineArray();
for (int i = 0; i < 10; i++)
{
Point newPoint = new Point();
newPoint.X = i + 4;
newPoint.Y = i + 6;
points[i] = newPoint;
}
}
En exécutant le code pour effectuer des comparaisons, on obtient:
| Method | Mean | Error | StdDev | Allocated |
|---------------------- |---------:|----------:|----------:|----------:|
| UseSimpleArray | 8.807 ns | 0.2067 ns | 0.4268 ns | 104 B |
| UseSpanWithNew | 8.633 ns | 0.1757 ns | 0.2887 ns | 104 B |
| UseSpanWithStackalloc | 4.030 ns | 0.1090 ns | 0.1564 ns | - |
| UseInlineArray | 4.400 ns | 0.1010 ns | 0.0896 ns | - |
On peut constater que l’utilisation d’un tableau “inline” est plus performant que Span
avec new
mais moins performant que Span
avec stackalloc
. Les tableaux “inline” n’utilisent pas le tas managé comme Span
avec stackalloc
ce qui explique ce gain en performance. Le tableau simple utilise le tas d’où l’accès aux éléments moins bon.
L’inconvénient d’utiliser Span
+ stackalloc
est que l’objet créé doit être utilisé exclusivement dans le corps de la méthode, il ne peut pas être retourné en résultat de fonction ou en donnée membre d’un objet. Ce n’est pas le cas pour un tableau “inline” qui est considéré dans le code comme une structure: on peut donc l’utiliser comme donnée membre ou en retour d’une fonction.
Code MSIL
Si on regarde le code MSIL pour voir les objets qui sont générés à la suite de l’utilisation d’un tableau “inline”, on peut voir ce sont des objets Span
qui sont utilisés.
Par exemple pour le code correspondant aux objets précédents, on peut voir que le compilateur rajoute une classe <PrivateImplementationDetails>
contenant les fonctions InlineArrayAsSpan()
et InlineArrayElementRef()
:
L’implémentation de ces fonctions est:
- Pour
InlineArrayAsSpan()
:- Code MSIL:
.method assembly hidebysig static valuetype [System.Runtime]System.Span`1<!!TElement> InlineArrayAsSpan<TBuffer,TElement>(!!TBuffer& buffer, int32 length) cil managed { // Code size 13 (0xd) .maxstack 8 IL_0000: ldarg.0 IL_0001: call !!1& [System.Runtime]System.Runtime.CompilerServices.Unsafe::As<!!0,!!1>(!!0&) IL_0006: ldarg.1 IL_0007: call valuetype [System.Runtime]System.Span`1<!!0> [System.Memory]System.Runtime.InteropServices.MemoryMarshal::CreateSpan<!!1>(!!0&, int32) IL_000c: ret }
- L’équivalent en C# est:
public Span<TElement> InlineArrayAsSpan<TBuffer, TElement>(TBuffer buffer, int length) { return MemoryMarshal.CreateSpan(ref Unsafe.As<TBuffer, TElement>(ref buffer), length); }
Ce code est appelé pour obtenir un objet
Span
qui permettra d’accéder aux objets du tableau “inline”. Dans un premier temps, la référence du tableau “inline” est fournie par l’intermédiaire de l’argumentbuffer
. Cette référence est utilisée pour effectuer un cast sous forme de réinterprétation du type du tableau “inline” dans un objetTElement
en appelant la fonctionUnsafe.As()
. Cette étape permet d’obtenir un pointeur vers le premier élément du tableau “inline”.Ensuite, un objet
Span
est créé à partir du cast du tableau “inline” avecMemoryMarshal.CreateSpan()
. Bien-qu’un objetSpan
soit créé quand on appelleMemoryMarshal.CreateSpan()
, il n’y a pas de déplacements de données en mémoire. Les objetsTElement
ou le tableau “inline” sont manipulés par référence. Ces objets étant de type valeur, se trouvent dans la pile. Les casts effectuent une réinterprétation des objets en mémoire dans des types différents sans changer l’emplacement de ces objets. Enfin,MemoryMarshal.CreateSpan()
crée un objetSpan
mais l’objet fourni dans le constructeur deSpan
est une référence. Ce constructeur n’effectue donc pas de déplacement ou de copie des données se trouvant dans le tableau “inline” (cf. code source de l’objetSpan
), il donne une possitilité d’accéder rapidement aux objetsTElement
en mémoire.Pour résumer, la ligne:
MemoryMarshal.CreateSpan(ref Unsafe.As<TBuffer, TElement>(ref buffer), length)
permet d’obtenir un objetSpan
qui facilitera les manipulations du contenu du tableau “inline” de façon optimisée.
- Code MSIL:
- Pour
InlineArrayElementRef()
:- Code MSIL:
.method assembly hidebysig static !!TElement& InlineArrayElementRef<TBuffer,TElement>(!!TBuffer& buffer, int32 index) cil managed { // Code size 13 (0xd) .maxstack 8 IL_0000: ldarg.0 IL_0001: call !!1& [System.Runtime]System.Runtime.CompilerServices.Unsafe::As<!!0,!!1>(!!0&) IL_0006: ldarg.1 IL_0007: call !!0& [System.Runtime]System.Runtime.CompilerServices.Unsafe::Add<!!1>(!!0&, int32) IL_000c: ret }
- L’équivalent en C# est:
public ref TElement InlineArrayElementRef<TBuffer, TElement>(ref TBuffer buffer, int index) { return ref Unsafe.Add(ref Unsafe.As<TBuffer, TElement>(ref buffer), index); }
Cette méthode permet d’obtenir la référence d’un élément à un index donné du tableau “inline”. La référence du tableau “inline” est fournie par l’intermédiaire de l’argument
buffer
. Cette référence est utilisée pour effectuer un cast sous forme de réinterprétation du type du tableau “inline” dans un objetTElement
en appelant la fonctionUnsafe.As()
. Cette étape permet d’obtenir un pointeur vers le premier élément du tableau “inline”.Ensuite, la fonction
Unsafe.Add()
trouve la référence d’un objetTElement
se trouvant à un index donné dans la tableau “inline”
- Code MSIL:
L’implémentation de la méthode UseInlineArray()
est:
public void UseInlineArray()
{
var points = new PointInlineArray();
for (int i = 0; i < 10; i++)
{
Point newPoint = new Point();
newPoint.X = i + 4;
newPoint.Y = i + 6;
points[i] = newPoint;
}
}
Le code MSIL généré est:
.maxstack 3
.locals init (valuetype CS12.PointInlineArray V_0,
int32 V_1,
valuetype CS12.Point V_2,
valuetype [System.Runtime]System.Span`1<valuetype CS12.Point> V_3)
IL_0000: ldloca.s V_0
IL_0002: initobj CS12.PointInlineArray
IL_0008: ldc.i4.0
IL_0009: stloc.1
IL_000a: br.s IL_0044
IL_000c: ldloca.s V_2
IL_000e: initobj CS12.Point
IL_0014: ldloca.s V_2
IL_0016: ldloc.1
IL_0017: ldc.i4.4
IL_0018: add
IL_0019: stfld int32 CS12.Point::X
IL_001e: ldloca.s V_2
IL_0020: ldloc.1
IL_0021: ldc.i4.6
IL_0022: add
IL_0023: stfld int32 CS12.Point::Y
IL_0028: ldloca.s V_0
IL_002a: ldc.i4.s 10
IL_002c: call valuetype [System.Runtime]System.Span`1<!!1> '<PrivateImplementationDetails>'::InlineArrayAsSpan<valuetype CS12.PointInlineArray,valuetype CS12.Point>(!!0&, int32)
IL_0031: stloc.31
IL_0032: ldloca.s V_3
IL_0034: ldloc.1
IL_0035: call instance !0& valuetype [System.Runtime]System.Span`1<valuetype CS12.Point>::get_Item(int32)
IL_003a: ldloc.2
IL_003b: stobj CS12.Point
IL_0040: ldloc.1
IL_0041: ldc.i4.1
IL_0042: add
IL_0043: stloc.1
IL_0044: ldloc.1
IL_0045: ldc.i4.s 10
IL_0047: blt.s IL_000c
IL_0049: ret
On peut voir que le compilateur utilise InlineArrayAsSpan()
pour obtenir un objet Span
qui permettra d’accéder aux éléments du tableau “inline”. On pourrait se demander pourquoi la fonction InlineArrayAsSpan()
est exécutée à chaque exécution de la boucle (ligne IL_002c
), il suffirait de créer l’objet Span
à l’extérieur de la boucle et de l’utiliser par la suite (la boucle est de la ligne IL_000c
à IL_0047
). Comme on a pu le voir plus haut, les appels à InlineArrayAsSpan()
ne sont pas très couteux car cette fonction effectue principalement de la réinterprétation de type en mémoire sans copier ou déplacer des objets.
Cette exécution récurrente de la fonction InlineArrayAsSpan()
pourrait expliquer la différence de performance entre les exemples UseInlineArray()
et UseSpanWithStackalloc()
que l’on a observé plus haut. Le code MSIL de la méthode UseSpanWithStackalloc()
semble plus optimisé:
.maxstack 3
.locals init (valuetype [System.Runtime]System.Span`1<valuetype CS12.Point> V_0,
int32 V_1,
valuetype CS12.Point V_2)
IL_0000: ldc.i4.s 10
IL_0002: conv.u
IL_0003: sizeof CS12.Point
IL_0009: mul.ovf.un
IL_000a: localloc
IL_000c: ldc.i4.s 10
IL_000e: newobj instance void valuetype [System.Runtime]System.Span`1<valuetype CS12.Point>::.ctor(void*,
int32)
IL_0013: stloc.0
IL_0014: ldc.i4.0
IL_0015: stloc.1
IL_0016: br.s IL_0046
IL_0018: ldloca.s V_2
IL_001a: initobj CS12.Point
IL_0020: ldloca.s V_2
IL_0022: ldloc.1
IL_0023: ldc.i4.4
IL_0024: add
IL_0025: stfld int32 CS12.Point::X
IL_002a: ldloca.s V_2
IL_002c: ldloc.1
IL_002d: ldc.i4.6
IL_002e: add
IL_002f: stfld int32 CS12.Point::Y
IL_0034: ldloca.s V_0
IL_0036: ldloc.1
IL_0037: call instance !0& valuetype [System.Runtime]System.Span`1<valuetype CS12.Point>::get_Item(int32)
IL_003c: ldloc.2
IL_003d: stobj CS12.Point
IL_0042: ldloc.1
IL_0043: ldc.i4.1
IL_0044: add
IL_0045: stloc.1
IL_0046: ldloc.1
IL_0047: ldc.i4.s 10
IL_0049: blt.s IL_0018
IL_004b: ret
Dans cet extrait, l’instanciation de l’objet Span
se trouve à la ligne IL_000e
et la boucle va de la ligne IL_0018
à la ligne IL_0049
.
Même si dans l’exemple présenté, on a pu observer des performances un peu moins bonnes entre les tableaux “inline” et l’utilisation d’un Span
avec stackalloc
, il faut garder en tête que cette différence est très liée à l’exemple et à la boucle qu’on exécute. L’utilisation de Span
+ stackalloc
ne convient pas à tous les cas d’utilisation puisque:
- Un objet
Span
est uneref struct
qui ne peut être une donnée membre que d’une autreref struct
. Un objetSpan
ne peut pas être une donnée membre d’une classe. - L’instanciation avec
stackalloc
contraint à effectuer l’instanciation dans le corps d’une méthode, elle ne peut pas être faite dans une constructeur ou auto-implémentée.
A la différence, il est possible de stocker un tableau “inline” en tant que donnée membre d’un objet de type référence.
- InlineArrayAttribute Class: https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.inlinearrayattribute?view=net-9.0
- C# 12: Inline arrays: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12#inline-arrays
- All About Span: Exploring a New .NET Mainstay: https://learn.microsoft.com/en-us/archive/msdn-magazine/2018/january/csharp-all-about-span-exploring-a-new-net-mainstay
- Exploring the generated code: T[], Span<T>, and Immutable collections: https://andrewlock.net/behind-the-scenes-of-collection-expressions-part-3-arrays-span-of-t-and-immutable-collections/
- Difference between in and ref readonly parameters: https://stackoverflow.com/questions/77508998/difference-between-in-and-ref-readonly-parameters#:~:text=The%20actual%20difference%20between%20in,%2C%20just%20compile%2Dtime%20warnings.
- ref readonly parameters: https://github.com/dotnet/csharplang/blob/main/proposals/csharp-12.0/ref-readonly-parameters.md