Attribut SkipLocalsInit (C# 9.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.

Cette fonctionnalité est une optimisation dont le but est d’éviter au compilateur d’émettre une instruction MSIL pour initialiser des variables locales.

Par défaut, une instruction MSIL permet d’initialiser à zéro les variables locales et les données allouées avec stackalloc lors de leur déclaration. Pour certains algorithmes et dans le but d’optimiser l’exécution du code, il est désormais possible de supprimer l’instruction permettant cette initialisation à zéro.

.local init

Quand des variables locales sont déclarées dans une fonction, les instructions MSIL .locals init sont émises:

  • .locals: permet de déclarer une variable locale accessible avec un nom symbolique.
  • init permet d’initialiser systématiquement ces variables à zéro.

Ces instructions sont suivis d’un tableau déclarant ces variables avec leur type et un leur nom symbolique:

.locals init (<type var 0> V_0, <type var 1> V_1, ..., <type var N> V_N) 

Lorsque init n’est pas émise:

.locals (<type var 0> V_0, <type var 1> V_1, ..., <type var N> V_N)

Par exemple si considère le code suivant:

public void Example()
{
  int a = 0;
  int b = 0;
  int c = 0;
  Console.WriteLine(a+b+c);
}

Le code MSIL correspondant est (en mode release):

.method public hidebysig instance void  Example() cil managed
{
  // Code size       15 (0xf)
  .maxstack  2
  // Instruction permettant l'initialisation 
  //  à zéro des variables locales
  .locals init (int32 V_0, int32 V_1)
  IL_0000:  ldc.i4.0
  IL_0001:  ldc.i4.0
  IL_0002:  stloc.0
  IL_0003:  ldc.i4.0
  IL_0004:  stloc.1
  IL_0005:  ldloc.0
  IL_0006:  add
  IL_0007:  ldloc.1
  IL_0008:  add
  IL_0009:  call       void [System.Console]System.Console::WriteLine(int32)
  IL_000e:  ret
} // end of method LocalInit::Example

Dans ce code, on peut voir 2 variables V_0 et V_1 alors que 3 variables a, b et c sont déclarées dans le code C#. Il s’agit d’une optimisation du compilateur dans le cadre du mode release.
Le nom des variables est 0 et 1, les instructions y font référence par la suite comme par exemple:

  • stloc.0 (pour STore in LOCal 0): pour affecter le 1er niveau de la pile à la variable 0.
  • stloc.1 (pour STore in LOCal 1): pour affecter le 1er niveau de la pile à la variable 1.
  • ldloc.0 (pour LoaD LOCal 0): pour ajouter dans le pile la valeur de la variable 0.
  • ldloc.1 (pour LoaD LOCal 1): pour ajouter dans le pile la valeur de la variable 1.

ldc.i4.0 (pour LoaD Constant 0 in 4-byte Integer) ne fait pas référence à la variable 0, cette instruction ajoute dans la pile la constante 0 sous forme d’un entier 32 bits (sur 4 octets).

Conséquences de l’utilisation de SkipLocalsInitAttribute

Code MSIL

A partir de C# 9.0, on peut utliser l’attribut SkipLocalsInitAttribute au dessus d’une méthode, d’une classe, d’une structure, d’une interface, d’un constructeur ou d’une propriété pour indiquer que les variables locales se trouvant dans ces objets ne seront pas initialisées à zéro. Ainsi si on place l’attribut:

  • Sur une méthode, toutes les variables locales de la méthode ne seront pas initialisées à zéro.
  • Sur une classe, toutes les variables locales se trouvant dans les méthodes de la classe ne seront pas initialisées à zéro.
  • Sur une propriété, toutes les variables locales se trouvant dans l’implémentation du get ou set de la propriété ne seront pas initialisées à zéro. On peut s’en rendre compte si on implémente la propriété en implémentant le get et set, par exemple:
    public class Example
    {
      public int PropExample
      {
        get
        {
         // Implémentation getter
        }
        set
        {
          // Implémentation setter
        }
      }
    }
    
  • etc…

Par exemple, si on utilise l’attribut SkipLocalsInitAttribute sur la méthode de l’exemple plus haut:

[SkipLocalsInit]
public void Example()
{
  int a = 0;
  int b = 0;
  int c = 0;
  Console.WriteLine(a+b+c);
}

On obtient le code MSIL:

.method public hidebysig instance void  Example() cil managed
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.SkipLocalsInitAttribute::.ctor() = ( 01 00 00 00 ) 
  // Code size       15 (0xf)
  .maxstack  2
  // L'instruction init est absente
  .locals (int32 V_0, int32 V_1)
  IL_0000:  ldc.i4.0
  IL_0001:  ldc.i4.0
  IL_0002:  stloc.0
  IL_0003:  ldc.i4.0
  IL_0004:  stloc.1
  IL_0005:  ldloc.0
  IL_0006:  add
  IL_0007:  ldloc.1
  IL_0008:  add
  IL_0009:  call       void [System.Console]System.Console::WriteLine(int32)
  IL_000e:  ret
} // end of method LocalInit::Example

On peut voir que init a été supprimé et que les variables locales sont déclarées directement avec:

.locals (int32 V_0, int32 V_1)

Conséquence dans l’exécution

L’utilisation de l’attribut SkipLocalsInitAttribute ne doit se faire que dans des conditions particulières où le gain en performance est significatif. La conséquence la plus importante d’utiliser cet attribut est que l’initialisation à zéro n’est plus vérifiée ce qui peut entraîner des comportements inattendus si on ne prend pas soin de n’utiliser que des variables initialisées.

La documentation indique que le gain en performance est particulièrement significatif avec stackalloc. Pour rappel stackalloc permet d’allouer un tableau sur la pile et de retourner un pointeur vers ce tableau. A partir de C# 7.2, stackalloc permet de renvoyer un objet de type Span<T> ou ReadOnlySpan<T> qui sera un point d’accès performant vers le tableau sans effectuer d’allocations et sans utiliser de pointeur. L’absence de pointeur permet de se passer d’exécuter le code dans un contexte unsafe.
Pour davantage de détails sur stackalloc, voir stackalloc en C# 7.2.

Si on considère les implémentations suivantes:

public void Example
{
  Span<int> s = stackalloc int[50];
  foreach (int item in s)
    Console.WriteLine(item);
}

A l’exécution, pas de surprise, on obtient une suite de 0:

0
0
0
0
0
...

Si on place [SkipLocalsInit] au dessus de la méthode, l’exécution devient:

217
-1986532568
217
0
0
...

Les éléments du tableau n’étant plus initialisés à zéro, il peut contenir d’autres valeurs.

Initialiser des variables locales permet de garantir que l’exécution du code est vérifiable et qu’elle ne va pas effectuer des opérations dangereuses. A l’opposé des opérations de manipulation de pointeurs conduit à produire du code non vérifiable puisque le compilateur ne peut pas garantir que le code généré ne va pas effectuer d’opérations non autorisées pouvant, par exemple, corrompre la mémoire. Lorsqu’une variable n’est pas initialisée, le compilateur génère une erreur pour forcer son initialisation. Le fait d’utiliser [SkipLocalsInit] peut produit du code dont les variables peuvent contenir des données arbitraires en particulier pour des variables allouées sur la pile.

Comparaison des performances

Comme on l’a déjà indiqué, l’utilisation de l’attribut [SkipLocalsInit] est réservée aux cas où il y a un gain en performance. Ainsi l’absence d’initialisation peut présenter un intérêt si l’algorithme effectue de nombreuses déclarations de variables locales et si ces déclarations sont significatives par rapport aux restes des instructions.

Par exemple, on va considérer 2 algorithmes:

  • Le 1er algorithme effectue d’abord l’allocation d’un bloc mémoire sur la pile en utilisant stackalloc. Ensuite un traitement est effectué sur des éléments du bloc mémoire en utilisant une boucle for. L’intérêt de cet algorithme est que l’allocation n’est pas significative par rapport à la boucle.
    Le code de cet algorithme est:

    public static int Use_StackAlloc_Outside_For_Loop()
    {
      Span<int> s = stackalloc int[2048];
      int result = 0;
      for (int i = 0; i < s.Length; i++)
      {
        result += s[i];
      }
    
      return result; 
    }
    
  • Le 2e algorithme effectue les allocations de blocs mémoire à l’intérieur d’une boucle for. Le but de cet algorithme est de trouver un exemple pour lequel toutes les allocations représentent un coût en performance plus important.
    Le code est:

    public static int Use_StackAlloc_In_For_Loop()
    {
      int result = 0;
      for (int i = 0; i < s.Length; i++)
      {
        Span<int> s = stackalloc int[2048];
        result += s[0];
      }
    
      return result; 
    }
    

On exécute ces 2 algorithmes avec et sans l’attribut [SkipLocalsInit]:

[Benchmark]
public int Use_StackAlloc_Outside_For_Loop_Without_SkipLocalsInit()
{
  return Use_StackAlloc_Outside_For_Loop();
}

[Benchmark]
[SkipLocalsInit]
public int Use_StackAlloc_Outside_For_Loop_With_SkipLocalsInit()
{
  return Use_StackAlloc_Outside_For_Loop();
}

[Benchmark]
public int Use_StackAlloc_In_For_Loop_Without_SkipLocalsInit()
{
  return Use_StackAlloc_In_For_Loop();
}

[Benchmark]
[SkipLocalsInit]
public int Use_StackAlloc_In_For_Loop_With_SkipLocalsInit()
{
  return Use_StackAlloc_In_For_Loop();
}

Les résultats sont:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.18363.1916 (1909/November2019Update/19H2)
Intel Xeon CPU E5-2690 v3 2.6GHz, 2 CPU, 4 Logical and 4 physical cores
.NET SDK=5.0.302
  [Host]     : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT
  DefaultJob : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT

|                                                 Method |     Mean |     Error |    StdDev |
|--------------------------------------------------------|---------:|----------:|----------:|
| Use_StackAlloc_Outside_For_Loop_Without_SkipLocalsInit | 1.919 us | 0.0347 us | 0.0325 us |
|    Use_StackAlloc_Outside_For_Loop_With_SkipLocalsInit | 1.926 us | 0.0279 us | 0.0261 us |
|      Use_StackAlloc_In_For_Loop_Without_SkipLocalsInit | 3.994 us | 0.0770 us | 0.0683 us |
|         Use_StackAlloc_In_For_Loop_With_SkipLocalsInit | 3.963 us | 0.0768 us | 0.0754 us |

On peut remarquer que les 2 exemples avec l’allocation à l’extérieur de la boucle for (Use_StackAlloc_Outside_For_Loop_Without_SkipLocalsInit() et Use_StackAlloc_Outside_For_Loop_With_SkipLocalsInit()) ont un temps d’exécution très similaire. L’utilisation de [SkipLocalsInit] n’apporte rien en temps d’exécution, les résultats montrent même que le temps est plus long avec l’attribut. Ces résultats peuvent s’expliquer de la façon suivante:

  • Etant donné que l’allocation ne se fait qu’une seule fois, elle est peu significative par rapport à l’exécution de la boucle for. L’absence d’initialisation à zéro est une opération si peu couteuse par rapport au reste de l’algorithme qu’on n’en voit pas les conséquences sur le temps de traitement.
  • Le temps de traitement avec l’attribut est plus long. Ceci peut s’expliquer par le fait qu’avec l’attribut, l’absence d’initialisation à zéro implique que la structure contient des valeurs non nulles. La somme de ces valeurs est plus couteuses que la somme de valeur nulle dans le cas de l’absence de l’attribut d’où le temps d’exécution plus long avec l’attribut.

Dans le cas où les allocations se font dans la boucle for (Use_StackAlloc_In_For_Loop_Without_SkipLocalsInit() et Use_StackAlloc_In_For_Loop_With_SkipLocalsInit()), elles sont beaucoup plus nombreuses et donc plus significatives par rapport au reste des instructions. On peut ainsi voir le gain de temps de calcul, l’utilisation de l’attribut permet réduire le temps de traitement par rapport à son absence. En revanche on peut remarque que le gain est très faible (<1%).

Pour aller plus loin…

Pour éviter les erreurs d’implémentation et les comportement inattendus, le compilateur indique lorsqu’une variable n’est pas initialisée, par exemple:

int a;
Console.WriteLine(a);  // ⚠ ERREUR ⚠ Use of unassigned local variable 'a'

Dans le cas d’une liste, quelque soit l’utilisation de [SkipLocalsInit], il n’y a pas de conséquences car à l’instanciation d’un objet System.Collections.List<T> il n’y a aucun objet dans la liste. Quand on ajoute un élément, la longueur de la liste est portée à 1 toutefois 4 emplacements sont créés et la capacité est 4. A l’ajout du 5e élément, la taille réelle de la liste est doublée et portée à 8 toutefois la longueur accessible est 5. Ainsi étant donné qu’il est nécessaire d’ajouter des éléments, les emplacements accessibles de la liste sont de fait, initialisées.

Dans le cas d’un tableau, l’utilisation de [SkipLocalsInit] n’a pas de conséquences: tous les emplacements du tableau sont initialisés à zéro. Si on exécute le code suivant:

[SkipLocalsInit]
public void Example()
{
  int[] array = new int[5];
  for (int i = 0; i < array.Length; i++)
    Console.WriteLine(array[i]);
}

Le résultat est:

0
0
0
0
0

Pour d’autres types d’objet, il peut y avoir un impact si on utilise [SkipLocalsInit] comme on a pu le voir précédemment avec stackalloc. Les objets Span<T> ou ReadOnlySpan<T> obtenus peuvent contenir des valeurs inattendues.

D’autres cas de figure peuvent mener les objets à contenir des valeurs inattendues avec [SkipLocalsInit] comme la manipulation de pointeur, des appels Platform/Invoke ou

Manipulation de pointeur

Si on manipule des pointeurs dans un contexte unsafe, le compilateur n’indique pas si une variable n’est pas initialisée. Par exemple si on écrit:

[SkipLocalsInit]
public unsafe void UsingPointer()
{
  int i;  // Pas d’initialisation
  int* ptr = &i; 
  Console.WriteLine(*ptr);
}

Ce code ne provoque pas d’erreur à la compilation. La valeur affichée est différente à chaque exécution. Si on supprime l’attribut [SkipLocalsInit], le résultat est toujours 0 malgré l’absence d’initialisation explicite.

Appels Platform/Invoke

Les appels Platform/Invoke permettent des appels à du code natif en passant en argument des objets ou des pointeurs. La manipulation de ces objets par le code natif échappe à la vérification du compilateur ce qui peut mener à l’utilisation d’objets dont la valeur peut être inattendue.

Par exemple si on considère le code natif suivant exposé de façon à permettre un appel Platform/Invoke (pour plus de détails sur ce type d’appel, voir Platform invoke en 5 min):

  • .cpp:
    void SetValueFromNativeCode(int* valueToSet)
    {
      *valueToSet = 5;
    }
    
  • .h:
    extern "C" __declspec(dllexport) void SetValueFromNativeCode(int* valueToSet);
    
  • Code C#:
    [SkipLocalsInit]
    public void CallNativeCode()
    {
      int a;
      SetValueFromNativeCode(out a);  // La valeur est affectée dans le code natif
      Console.WriteLine(a); // Le résultat est 5
    }
    
    [DllImport("CalledNativeDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
    public extern static void SetValueFromNativeCode(out int valueToSet);
    

Si on modifie la fonction SetValueFromNativeCode() pour ne pas affecter de valeur:

void SetValueFromNativeCode(int* valueToSet)
{
  //*valueToSet = 5;
}

Sachant que a n’est pas initialisé dans le code C# aussi bien explicitement qu’implicitement à cause de l’attribut [SkipLocalsInit], sa valeur est non prévisible.

Utilisation de structure

Si on considère la structure suivante:

public struct CustomStruct
{
	public int x;
	public int y;
}

Si on effectue des allocations sur la pile de cette structure en utilisant stackalloc, les propriétés de la structure sont à zéro même sans initialisation explicite:

public void UseCustomStruct()
{	
	Span<CustomStruct> customStructs = stackalloc CustomStruct[5]; 
	for (int i = 0; i < customStructs.Length; i++)
	{
		customStructs[i].x = 5;
		Console.WriteLine($"({customStructs[i].x};{customStructs[i].y})");
	}
} 

Le résultat est:

(5;0)
(5;0)
(5;0)
(5;0)
(5;0)

Si on rajoute [SkipLocalsInit] sur la méthode, on obtient:

(5;0)
(5;49803632)
(5;2045470872)
(5;49803844)
(5;49803824)

La propriété y n’étant pas initialisée explicitement, sa valeur est non prévisible.

Leave a Reply