Inline arrays (C# 12)

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:

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é avec new.
  • Affectation dans un objet de type Span<Point> instancié avec stackalloc.
  • 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’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 objet TElement en appelant la fonction Unsafe.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” avec MemoryMarshal.CreateSpan(). Bien-qu’un objet Span soit créé quand on appelle MemoryMarshal.CreateSpan(), il n’y a pas de déplacements de données en mémoire. Les objets TElement 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 objet Span mais l’objet fourni dans le constructeur de Span 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’objet Span), il donne une possitilité d’accéder rapidement aux objets TElement en mémoire.

      Pour résumer, la ligne: MemoryMarshal.CreateSpan(ref Unsafe.As<TBuffer, TElement>(ref buffer), length) permet d’obtenir un objet Span qui facilitera les manipulations du contenu du tableau “inline” de façon optimisée.

  • 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 objet TElement en appelant la fonction Unsafe.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 objet TElement se trouvant à un index donné dans la tableau “inline”

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 une ref struct qui ne peut être une donnée membre que d’une autre ref struct. Un objet Span 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.

Leave a Reply