Les pointeurs de fonction (C# 9.0)

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

Le but de la fonctionnalité des pointeurs de fonction en C# est de proposer une syntaxe pour facilement manipuler ce type de pointeurs. La manipulation de pointeurs est possible en C# toutefois avant C# 9, manipuler des pointeurs de fonction n’était pas direct, cela nécessitait de passer par l’émission directe d’instructions en MSIL ce qui complique l’écriture de code et éventuellement le débugage.

Plus techniquement, l’intérêt principal de cette manipulation de pointeurs de fonction directement à travers le code est de permettre d’accéder aux instructions IL (i.e. Intermediate Language) ldftn et calli. Ces instructions servent respectivement, à pousser dans la pile un pointeur de fonction non managé et à appeler cette méthode.

Le but de cet article est de rentrer dans les détails de cette nouvelle fonctionnalité pour en comprendre le fonctionnement et l’intérêt. Dans un 1er temps, quelques explications seront apportées sur des sujets autour de la fonctionnalité comme:

  • Les instructions CIL/MSIL,
  • La manipulation de pointeurs en C# et en C++,
  • Les fonctions intrinsèques du compilateur.

Dans un 2e temps, on apportera plus de précisions sur cette nouvelle fonctionnalité.

Quelques explications en préambule

Code MSIL

Compilation

En .NET, le code n’est pas directement compilé en code machine comme cela est le cas pour du code C++ natif. Le code .NET est compilé dans des assemblies contenant des instructions MSIL (pour MicroSoft Intermediate Language). Ces instructions sont exécutables par le CLR (i.e. Common Language Runtime).

Compilation avec Roslyn vs Compilation avec le JIT

A l’exécution et suivant les besoins du CLR, les instructions MSIL sont de nouveau compilées en code machine par le compilateur JIT (i.e. Just In Time). Le code machine généré est ensuite exécuté par la machine. Les instructions MSIL sont compilées à la demande, en fonction des appels qui sont effectués. Si des instructions correspondant à une fonction ne sont pas appelées alors ces instructions ne seront pas compilées par le compilateur JIT. D’autre part, le compilateur JIT effectue des optimisations dans le code généré suivant la façon dont les fonctions sont appelées. Ainsi les performances d’exécution d’un programmation peuvent s’améliorer au fur et à mesure de son exécution.

MSIL vs CIL

Le code MSIL (pour MicroSoft Intermediate Language) correspond à un ensemble d’instructions exécutables par le CLR .NET. Le code CIL (pour Common Intermediate Language) correspond aux mêmes jeux d’instructions toutefois ce terme est utilisé dans le cadre du standard CLI (i.e. Common Language Infrastructure).

Fonctionnement générale du code IL

Le code IL généré après la compilation est un code lisible. Ce code se trouvant dans les assemblies peut facilement être décompilé avec ILDasm (i.e. Intermediate Language Dissambler) ou par DotPeek.
ILDasm est fourni avec le SDK du framework .NET accessible, par exemple, avec des chemins du type: C:\Program Files (x86)\Microsoft SDKs\Windows\<version>\bin\NETFX 4.8 Tools\ildasm.exe.
Avec .NET Core, il est possible de l’utiliser avec le package NuGet Microsoft.NETCore.ILDasm.

L’exécution d’instructions MSIL consiste d’une façon générale à effectuer 3 types d’opérations:

  1. Pousser les opérandes des commandes ou les paramètres de fonction dans la pile
  2. Exécuter la commande ou la fonction MSIL. Cette exécution récupère les opérandes et les paramètres dans la pile pour effectuer son traitement puis éventuellement pousse le ou les résultats dans la pile.
  3. Lire et récupérer le résultat dans la pile.

D’une façon générale, on distingue 2 catégories d’objets en .NET: les objets de type valeur et les objets de type référence:

  • Les objets de type valeur sont manipulés par valeur et sont généralement stockés dans la pile. Dans certains cas, ces objets peuvent être stockés dans le tas (par exemple dans le cas du boxing, d’objets statiques etc…)
  • Les objets de type référence sont manipulés par référence et sont stockés dans le tas managé. Les références des objets de type référence sont des objets de type valeur qui sont stockés dans la pile.

Les manipulations de ces objets correspondent à les stocker dans une variable ou à les passer en argument de fonction.
Pour davantage de détails, voir Type valeur vs type référence.

Ainsi dans la pile, on peut retrouver:

  • Les variables locales d’une fonction
  • Les arguments d’une fonction

Une pile fonctionne en mode LIFO (i.e. Last In First Out). Les opérations effectuées sur la pile sont:

  • Pousser une objet sur la pile c’est-à-dire ajouter une valeur. L’objet est rajouté au sommet de la pile. Cette opération est effectuée par des commandes MSIL avec le préfixe ld... pour load.
  • Enlever un objet de la pile. L’objet enlevé est celui se trouvant au sommet de la pile. Cette opération est effectuée par des commandes MSIL avec le préfixe st... pour store. Généralement l’objet est enlevé de la pile pour être stocké dans une variable.

Pour comprendre comment fonctionne le code MSIL, on propose quelques exemples:

Exemple simple d’une fonction

Code C# Code MSIL
namespace Cs9
{
  public class SimpleFunctionTests
  {
    public int AddNumbers(int startNumber)
    {
      int result = startNumber;

      {
        Console.WriteLine("Enter number: ");
        string numberAsString = 
          Console.ReadLine();
        if (int.TryParse(numberAsString, 
          out int number))
        {
          result += number;
        }

        Console.WriteLine
          ($"Result is: {result}");
      }

      return result;
    }
  }
}
.class public auto ansi beforefieldinit 
  Cs9.SimpleFunctionTests extends [System.Runtime]System.Object
{
  .method public hidebysig instance default 
  int32 AddNumbers(int32 startNumber) cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x2194
  // Code size 70 (0x46)
  .maxstack 2
  .locals init(int32 V_0, string V_1, int32 V_2, bool V_3, int32 V_4)
  IL_0000: nop
  IL_0001: ldarg.1
  IL_0002: stloc.0
  IL_0003: nop
  IL_0004: ldstr "Enter number: "
  IL_0009: call void class 
    [System.Console]System.Console::WriteLine(string)
  IL_000e: nop
  IL_000f: call string class 
    [System.Console]System.Console::ReadLine()
  IL_0014: stloc.1
  IL_0015: ldloc.1
  IL_0016: ldloca.s class V_2
  IL_0018: call bool class 
    int32::TryParse(string, byreference)
  IL_001d: stloc.3
  IL_001e: ldloc.3
  IL_001f: brfalse.s   IL_0027
  IL_0021: nop
  IL_0022: ldloc.0
  IL_0023: ldloc.2
  IL_0024: add
  IL_0025: stloc.0
  IL_0026: nop
  IL_0027: ldstr "Result is: {0}"
  IL_002c: ldloc.0
  IL_002d: box class System.Int32
  IL_0032: call string class 
    string::Format(string, [System.Runtime]System.Object)
  IL_0037: call void class 
    [System.Console]System.Console::WriteLine(string)
  IL_003c: nop
  IL_003d: nop
  IL_003e: ldloc.0
  IL_003f: stloc.s class V_4
  IL_0041: br.s   IL_0043
  IL_0043: ldloc.s class V_4
  IL_0045: ret
  } 
  
  .method public hidebysig specialname rtspecialname instance default 
  void .ctor() cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x21E6
  // Code size 8 (0x8)
  .maxstack 8
  IL_0000: ldarg.0
  IL_0001: call instance void class 
    [System.Runtime]System.Object::.ctor()
  IL_0006: nop
  IL_0007: ret
  } 
} 

Dans le code MSIL, d’une façon générale les méthodes et fonctions appelées récupèrent la valeur de leur argument dans la pile. Lorsqu’une valeur est récupérée, elle est supprimée de la pile. Le résultat d’une fonction est ajoutée dans la pile.

Explication du code MSIL:

// Un objet de type référence dérive toujours de System.Object
.class public auto ansi beforefieldinit Cs9.SimpleFunctionTests 
  extends [System.Runtime]System.Object
{
  // Signature de la méthode AddNumbers() avec son argument
  // hidebysig signifie "hide by name-and-signature" pour 
  // indiquer que les fonctions doivent être identifiées en 
  // utilisant le nom et la signature (et non seulement le nom). 
  .method public hidebysig instance default int32 AddNumbers(int32 startNumber) cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x2194
  // Code size 70 (0x46)
  // Indique la profondeur maximale de la pile nécessaire à 
  // l’exécution de la fonction. 
  .maxstack 2
  // Indique les variables locales
  .locals init(int32 V_0, string V_1, int32 V_2, bool V_3, int32 V_4)
  // Signifie "No OPeration". Cette instruction indique au compilateur JIT 
  // les emplacements où le code machine peut être associé à une instruction MSIL. 
  IL_0000: nop
  // Ajoute la valeur de l’argument 1 c’est-à-dire startNumber à la pile (load argument 1) 
  IL_0001: ldarg.1
  // Récupère la 1ère valeur de la pile pour la stocker dans la variable locale 
  // loc.0 (store local 0).
  IL_0002: stloc.0
  IL_0003: nop
  // Ajoute la chaine de caractère "Enter number: " dans la pile
  IL_0004: ldstr "Enter number: "
  // Appelle la méthode statique Console.WriteLine. 
  // Cette méthode va récupérer la 1ère valeur de la pile
  IL_0009: call void class [System.Console]System.Console::WriteLine(string)
  IL_000e: nop
  // Appelle la méthode Console.ReadLine. 
  // Cette méthode va placer son résultat dans la pile
  IL_000f: call string class [System.Console]System.Console::ReadLine()
  // Récupère la 1ère valeur de la pile pour la stocker dans la variable locale loc.1
  IL_0014: stloc.1
  // Ajoute la valeur de la variable locale loc.1 dans la pile
  IL_0015: ldloc.1
  // Ajoute l’adresse de la variable V_2 dans la pile (load local short form)
  IL_0016: ldloca.s class V_2
  // Appelle de la fonction int32.TryParse(). Cette fonction va récupérer 
  // la valeur de ses arguments dans la pile. Elle ajoute son résultat dans la pile. 
  IL_0018: call bool class int32::TryParse(string, byreference)
  IL_001d: stloc.3
  IL_001e: ldloc.3
  // Va à l’instruction IL_0027 si la 1ère valeur dans la pile est false (branch false short).
  IL_001f: brfalse.s   IL_0027
  IL_0021: nop
  IL_0022: ldloc.0
  IL_0023: ldloc.2
  // Ajoute les 2 premières valeurs de la pile (ces valeurs sont supprimées de la pile). 
  // La fonction ajoute le résultat de l’addition dans la pile. 
  IL_0024: add
  IL_0025: stloc.0
  IL_0026: nop
  IL_0027: ldstr "Result is: {0}"
  IL_002c: ldloc.0
  // Effectue une opération de boxing (conversion d’un objet de type valeur en un objet 
  // de type référence dérivant de System.Object). Cette opération est nécessaire pour 
  // exécuter ToString() sur un objet dérivant de System.Object. Le résultat de ToString() 
  // est utilisé pour "Result is: {0}".
  IL_002d: box class System.Int32
  IL_0032: call string class string::Format(string, [System.Runtime]System.Object)
  IL_0037: call void class [System.Console]System.Console::WriteLine(string)
  IL_003c: nop
  IL_003d: nop
  IL_003e: ldloc.0
  IL_003f: stloc.s class V_4
  // Va à l’instruction IL_0043 (branch short)
  IL_0041: br.s   IL_0043
  IL_0043: ldloc.s class V_4
  // Retour de la méthode ou de la fonction. Dans le cas d’une fonction, 
  // le résultat se trouve dans la pile.
  IL_0045: ret
  } 

  // Un constructeur par défaut est rajouté par le compilateur
  .method public hidebysig specialname rtspecialname instance default void .ctor() cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x21E6
  // Code size 8 (0x8)
  .maxstack 8
  IL_0000: ldarg.0
  IL_0001: call instance void class [System.Runtime]System.Object::.ctor()
  IL_0006: nop
  IL_0007: ret
  } 
}

Le code indiqué précédemment est un code généré en mode debug, on peut voir que de nombreuses instructions peuvent sembler inutile comme par exemple:

  • Les instructions nop
    • IL_0000: nop
    • IL_0003: nop
    • etc…
  • Des instructions où on stocke la 1ère valeur de la pile dans une variable alors que l’instruction suivante repousse la valeur de la variable dans la pile:
    • IL_0014: stloc.1
    • IL_0015: ldloc.1
  • Des instructions inutiles indiquant de passer à la ligne suivante:
    • IL_0041: br.s IL_0043
    • IL_0043: ldloc.s class V_4

La raison est que le compilateur effectue peu d’optimisation, les instructions du code C# sont directement traduites en instructions MSIL. Si on compile le même code en mode release, on peut voir que les instructions inutiles ne sont plus présentes, par exemple pour la fonction AddNumbers():

.method public hidebysig instance default int32 AddNumbers(int32 startNumber) cil managed
{
  // Method begins at Relative Virtual Address (RVA) 0x216C
  // Code size 53 (0x35)
  .maxstack 2
  .locals init(int32 V_0, int32 V_1)
  IL_0000: ldarg.1
  IL_0001: stloc.0
  IL_0002: ldstr "Enter number: "
  IL_0007: call void class [System.Console]System
    .Console::WriteLine(string)
  IL_000c: call string class [System.Console]System
    .Console::ReadLine()
  IL_0011: ldloca.s class V_1
  IL_0013: call bool class int32::TryParse(string, byreference)
  IL_0018: brfalse.s   IL_001e
  IL_001a: ldloc.0
  IL_001b: ldloc.1
  IL_001c: add
  IL_001d: stloc.0
  IL_001e: ldstr "Result is: {0}"
  IL_0023: ldloc.0
  IL_0024: box class System.Int32
  IL_0029: call string class string::Format(string, 
    [System.Runtime]System.Object)
  IL_002e: call void class [System.Console]System
    .Console::WriteLine(string)
  IL_0033: ldloc.0
  IL_0034: ret
}

Dans la suite, on présentera le code MSIL en mode release.

Exemple d’un appel de fonction

Si on considère le code suivant:

Code C#
public class SimpleClass
{
  public void ExecuteMe()
  {
    Console.WriteLine("OK");
  }
}

class Program
{
  static void Main(string[] args)
  {
    var simpleClass = new SimpleClass();
    simpleClass.ExecuteMe();
  }
}
    
Code MSIL du
Main()
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       11 (0xb)
  .maxstack  8
  IL_0000:  newobj     instance void FunctionPointerTests
    .SimpleClass::.ctor()
  IL_0005:  callvirt   instance void FunctionPointerTests
    .SimpleClass::ExecuteMe()
  IL_000a:  ret
} // end of method Program::Main
        

Dans ce code, 2 instructions sont importantes:

  • newobj permettant d’instancier un objet de type référence et d’ajouter la référence à la pile.
  • callvirt permettant d’appeler dans un objet une méthode correspondant à une signature particulière en utilisant la référence de cet objet dans la pile. D’autres explications sont apportées sur la fonction callvirt par la suite.

Exemple d’un appel Platform/Invoke

Si on considère le code suivant permettant d’appeler la fonction native Multiply() dans la DLL appelée NativeDll.dll:

Code C#
class Program
{
    static void Main(string[] args)
    {
        Multiply(2, 4);
    }

    [DllImport("NativeDll.dll", 
      CallingConvention = CallingConvention.StdCall, 
      CharSet = CharSet.Unicode)]
    public extern static int Multiply(int arg1, int arg2);
}
Code MSIL du
Main()
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       9 (0x9)
  .maxstack  8
  IL_0000:  ldc.i4.2
  IL_0001:  ldc.i4.4
  IL_0002:  call       int32 FunctionPointerTests.Program
    ::Multiply(int32,int32)
  IL_0007:  pop
  IL_0008:  ret
} // end of method Program::Main

.method public hidebysig static 
  pinvokeimpl("NativeDll.dll" unicode stdcall) 
  int32  Multiply(int32 arg1,int32 arg2) cil managed preservesig
{
}

L’instruction importante est call qui permet d’appeler une méthode particulière suivant sa signature. Dans le cas de l’appel Platform/Invoke, la méthode Multiply() est statique.

Delegates

Les delegates en C# sont des références vers une méthode comportant une signature particulière. Un delegate définit le type de la référence et non la référence elle-même. Par exemple, un delegate peut se définir de cette façon:

public delegate int ArithmeticOperation(int a, int b);

La fonction suivante possède une signature compatible avec le delegate:

public static int MultiplyIntegers(int a, int b)
{
  return a * b;
}

On peut instancier le delegate et l’exécuter de cette façon:

ArithmeticOperation operation = MultiplyIntegers;
int result = operation(2, 6);

Dans cet exemple, le delegate operation contient une référence vers la méthode statique MultiplyIntegers().

On peut aussi utiliser des méthodes d’instance plutôt que des méthodes statiques, par exemple si on considère la classe:

public class DelegateExample
{
  public int ExecuteOperation(int arg1, int arg2, ArithmeticOperation operation)
  {
    return operation(arg1, arg2);
  }

  public int AddIntegers(int arg1, int arg2)
  {
    return arg1 + arg2;
  }

  public int MultiplyIntegers(int arg1, int arg2)
  {
    return arg1 * arg2;
  }
}

On peut instancier un delegate avec une méthode d’instance:

var delegateExampleInstance = new DelegateExample();
ArithmeticOperation operation = new ArithmeticOperation(delegateExampleInstance.MultiplyIntegers);
int result = delegateExampleInstance.ExecuteOperation(3, 4, operation);

Ou plus directement:

int result = delegateExampleInstance.ExecuteOperation(3, 4, delegateExampleInstance.AddIntegers);

Par rapport à des pointeurs de fonction classiques, l’intérêt des delegates est qu’ils sont sûrs, vérifiables par le compilateur et le type est déterminé à la compilation. Etant donné qu’il s’agit de références managées, ils sont compatibles avec les traitements du Garbage Collector. Enfin lors d’appels Platform/Invoke, les delegates peuvent être convertis en pointeurs de fonctions et vice-versa, des pointeurs peuvent être convertis en delegates.

Du point de vue du code MSIL, les delegates sont compilés en classe dans laquelle se trouve les membres suivants:

  • Un constructeur avec pour arguments l’instance de la classe de la méthode déléguée et un entier contenant un pointeur vers la méthode déléguée.
  • Une méthode Invoke() utilisée pour exécuter la méthode déléguée de façon synchrone. La signature de Invoke() est la même que celle de la méthode déléguée.
  • Des méthodes BeginInvoke() et EndInvoke() utilisées pour exécuter la méthode déléguée de façon asynchrone.

Pointeur de fonction C++

Les pointeurs de fonction sont représentés en C++ par une déclaration du type <type de retour> (*<nom du pointeur>)(<type arguments en entrée>). Par exemple, pour déclarer un pointeur de fonction nommé fcnPtr permettant de pointer vers une fonction dont la signature est int(double, bool):

int (*fcnPtr)(double, bool);

La déclaration de la fonction peut être du type:

int PointedFunction(double arg1, bool arg2) 
{ 
  // ...
}

Si la signature de la fonction cible est int* (double, bool) c’est-à-dire que l’argument de retour est un pointeur d’un entier, par exemple:

int* PointedFunction(double arg1, bool arg2) 
{ 
  // ... 
}

Le pointeur de fonction doit être déclaré de cette façon:

int* (*fcnPtr)(double, bool);

Initialisation et affectation

A ce stade le pointeur de fonction plus haut, est juste déclaré et non initialisé. Pour l’initialiser, on peut écrire:

int (*fcnPtr)(double, bool) { &PointedFunction };

Dans cet exemple, la fonction PointedFunction peut être une fonction d’instance ou une fonction statique.
Ce pointeur est instancié sur la pile, il est donc perdu à la sortie de la méthode dans laquelle il a été instancié.

Pour affecter une méthode à un pointeur de fonction:

int (*fcnPtr)(double, bool); // Déclaration
fcnPtr = &PointedFunction; // Affectation

Appel en utilisant un pointeur de fonction

On peut appeler une méthode en utilisant un pointeur de fonction avec 2 syntaxes:

  • Par référencement explicite en utilisant la forme (*<nom du pointeur>), par exemple:
    int (*fcnPtr)(double, bool) { &PointedFunction };
    int result = (*fcnPtr)(5, false);
    
  • Par déférencement implicite en utilisant directement la forme <nom du pointeur>, par exemple:
    int (*fcnPtr)(double, bool) { &PointedFunction };
    int result = fcnPtr(5, false);

Passage de pointeur de fonction en argument

Un pointeur de fonction en tant qu’argument doit être indiqué de la même façon que les autres arguments, en utilisant sa déclaration. Par exemple:

int ExecuteOperation(int arg1, int arg2, int (*operationToExecute)(int, int))
{
  return operationToExecute(arg1, arg2);
}

On peut appeler cette méthode en indiquant directement les fonctions, par exemple si on déclare la fonction:

int MultiplyIntegers(int arg1, int arg2)
{
  return arg1 * arg2;
}

// ...
int result = ExecuteOperation(2, 3, MultiplyIntegers);

Cast void*

Comme pour tous les pointeurs, un pointeur de fonction peut être casté en pointeur void*

  • Conversion implicite en void*, par exemple:
    int (*fcnPtr)(double, bool){ &PointedFunction };
    void* voidFcnPtr = fcnPtr; // Conversion implicite
    
  • Conversion explicite de void* vers un pointeur de fonction avec reinterpret_cast, par exemple:
    void* voidFcnPtr = ... 
    int (*otherFcnPtr)(double, bool) = reinterpret_cast<int(*)(double, bool)>(voidFcnPtr);

Type alias

La déclaration d’un pointeur de fonction peut être simplifiée en utilisant un type alias, par exemple:

using AliasName = int(*)(double, bool);

Cet alias peut être utilisé directement pour remplacer la déclaration du pointeur:

AliasName fcnPtr; // déclaration du pointeur
// ...
AliasName fcnPtr { &PointedFunction }; // déclaration + affectation
fcnPtr = &PointedFunction; // Affectation

L’alias peut être utilisé aussi pour les arguments:

using OperationAlias = int(*)(int, int);

// ...
int ExecuteOperation(int arg1, int arg2, OperationAlias operationToExecute)
{
  return operationToExexute(arg1, arg2);
}

Utiliser des pointeurs de fonction avant C# 9

Avant C# 9, dans certaines conditions, il était possible de manipuler des pointeurs de fonctions toutefois ces différentes approches ne permettant pas d’utiliser l’instruction MSIL calli. D’autre part, ces approches ne sont possibles qu’entre des appels entre du code managé et du code natif. Par exemple, on peut utiliser:

Manipuler des pointeurs de fonction

Dans cet exemple, l’appel à la méthode peut se faire en utilisant le delegate. Si on considère une méthode externe fournissant un pointeur de fonction sous la forme void* dont la signature est int(int, int):

using unsafe class CallFunctionPointer
{
  public delegate int MultiplyDelegate(int arg1, int arg2);

  [DllImport(...)]
  public extern static void* GetFunctionPointer();

  public int Multiply(int a, int b)
  {
    void* nativePtr = GetFunctionPointer();
    IntPtr ptr = new IntPtr(nativePtr);
    MultiplyDelegate multiplyDelegate = Marshal.GetDelegateForFunctionPointer<MultiplyDelegate>(ptr);
    return multiplyDelegate(a, b);
  }
}

Pour que ces méthodes soient exécutables, il faut que le code unsafe soit autorisé.

Comment compiler du code unsafe ?

Pour compiler du code unsafe et autoriser le compilateur à utliser le mot-clé unsafe, il faut l’autoriser dans les propriétés du projet:

  • Dans les propriétés du projet dans Visual Studio, il faut cocher la propriété “Allow unsafe code” dans l’onglet Build.
  • En éditant directement le fichier .csproj, il faut rajouter le nœud AllowUnsafeBlocks dans PropertyGroup:
    <Project Sdk="Microsoft.NET.Sdk"> 
        <PropertyGroup> 
          <!—- ... -—> 
          <AllowUnsafeBlocks>true</AllowUnsafeBlocks> 
        </PropertyGroup> 
      </Project> 
      

Une autre syntaxe plus directe permet d’éviter d’utiliser du code unsafe:

Code C#
public class CallFunctionPointer
{
  public delegate int MultiplyDelegate(int arg1, 
    int arg2);

  [DllImport(...)]
  public extern static void* GetFunctionPointer();  
  
  public int Multiply(int a, int b)
  {
    IntPtr ptr = GetFunctionPointer();
    MultiplyDelegate multiplyDelegate = Marshal
      .GetDelegateForFunctionPointer<MultiplyDelegate>(ptr);
    return multiplyDelegate(a, b);
  }
}
Code MSIL de
Multiply()
.method public hidebysig instance 
  int32  Multiply(int32 a, int32 b) cil managed
{
  // Code size     18 (0x12)
  .maxstack  8
  // Appel Platform/Invoke pour récupérer 
  // un pointeur de fonction
  IL_0000: call   native int FunctionPointerTests
    .CallFunctionPointer::GetFunctionPointer()
  // "Conversion" en délégué managé
  IL_0005: call   !!0 [System.Runtime.InteropServices]
    System.Runtime.InteropServices.Marshal
    ::GetDelegateForFunctionPointer
      <class FunctionPointerTests
      .CallFunctionPointer/MultiplyDelegate>(native int)
  IL_000a: ldarg.1
  IL_000b: ldarg.2
  // Appel du delegate
  IL_000c: callvirt  instance int32 FunctionPointerTests
    .CallFunctionPointer/MultiplyDelegate
    ::Invoke(int32,int32)
  IL_0011:  ret
} // end of method CallFunctionPointer::Multiply

Cette méthode génère un appel à callvirt car l’appel se fait une utilisant un délégué managé.

Fournir un pointeur de fonction

La conversion d’un delegate en pointeur de fonction est aussi possible en utilisant les capacités de marshalling de Platform/Invoke:

Code C#
public unsafe class FunctionPointerProvider
{
  [UnmanagedFunctionPointer(CallingConvention.StdCall)]
  public delegate int MultiplyDelegate(int arg1, 
    int arg2);

  [DllImport(...)]
  public extern static int MultiplyWithFunctionPointer(
    int arg1, 
    int arg2, 
    [MarshalAs(UnmanagedType.FunctionPtr)]MultiplyDelegate 
      functionDelegate);

  private int Multiply(int arg1, int arg2)
  {
    return arg1 * arg2;
  }

  public int MultiplyIntegers(int a, int b)
  {
    MultiplyDelegate functionDelegate = Multiply;
    int result = MultiplyWithFunctionPointer(a, b, 
      functionDelegate);
  }
}
Code MSIL de
MultiplyIntegers()
.method public hidebysig instance 
  int32  MultiplyIntegers(int32 a, int32 b) cil managed
{
  // Code size     22 (0x16)
  .maxstack  3
  .locals init (class FunctionPointerTests
  .FunctionPointerProvider/MultiplyDelegate V_0)
  IL_0000:  ldarg.0
  // Ajout dans la pile du pointeur de fonction
  // natif vers la function Multiply()
  IL_0001:  ldftn  instance int32 FunctionPointerTests
    .FunctionPointerProvider::Multiply(int32,int32)
  // Instanciation d'un délégué managé 
  // avec le pointeur natif
  IL_0007:  newobj instance void FunctionPointerTests
    .FunctionPointerProvider/MultiplyDelegate::
    .ctor(object,native int)
  IL_000c:  stloc.0
  IL_000d:  ldarg.1
  IL_000e:  ldarg.2
  IL_000f:  ldloc.0
  // Appel Platform/Invoke 
  IL_0010:  call   int32 FunctionPointerTests
    .FunctionPointerProvider
    ::MultiplyWithFunctionPointer(int32,int32,
      class FunctionPointerTests
      .FunctionPointerProvider/MultiplyDelegate)
  IL_0015:  ret
} 

Dans cet exemple, durant le marshalling, le delegate est directement converti en pointeur de fonction. L’attribut UnmanagedFunctionPointerAttribute permet d’indiquer que le delegate peut être utilisé par du code natif.

Utiliser les pointeurs de fonction delegate* à partir de C# 9

Le but de cette partie est d’expliquer la fonctionnalité des pointeurs de fonction en C# 9 en justifiant son intérêt par rapport aux autres solutions existantes. On explicitera quelques cas d’utilisation de cette fonctionnalité.

call vs callvirt vs calli

Comme on a pu le voir précédemment, call et callvirt sont des instructions MSIL pour appeler des méthodes:

  • call permet d’appeler des méthodes non virtuelles, statiques ou des surcharges d’une méthode se trouvant dans une classe mère.
  • callvirt permet d’appeler une méthode virtuelle dans le cas où la méthode à exécuter se trouve dans une classe fille.

Dans la pratique le compilateur C# utilise quasi toujours callvirt pour effectuer des appels de méthode lorsqu’il s’agit d’autres méthodes managées. call sera utilisé lorsqu’il n’y a pas de doutes sur l’emplacement de la méthode à appeler (comme dans le cas de méthodes statiques puisqu’une classe statique ne peut pas hériter d’une autre classe et une méthode statique ne peut pas être overrider). Les appels Platform/Invoke avec DllImport rentre aussi dans le cadre des utilisations de call.

Ainsi:

  • call effectue une recherche dans la table de méthodes de la classe. Le résultat de cette recherche fournit un pointeur correspondant à un décalage par rapport à l’adresse de la classe.
  • callvirt effectue une recherche dans la table de méthodes virtuelles de l’instance de la classe. Le résultat fournit un pointeur correspondant à un décalage par rapport à l’adresse de l’instance de la classe.

D’un point de vue de la syntaxe MSIL, call et callvirt utilisent les objets se trouvant dans la pile en tant qu’argument de la fonction à appeler. Dans le code MSIL, les instructions call ou callvirt sont suivies d’indications sur la méthode à appeler:

  • instance pour indiquer s’il s’agit d’une méthode faisant partie d’un objet instancié:
    call  instance  void Cs9.Example::MethodName()
  • [<Assembly où se trouve la méthode à appeler>] éventuellement une indication sur l’assembly dans laquelle se trouve la méthode statique à appeler, par exemple:
    call  void [System.Console]System.Console::WriteLine(int32)

L’instruction calli est différente de call et callvirt puisqu’elle utilise un pointeur de fonction dans la pile pour effectuer l’appel. calli pour call indirect permet d’effectuer un appel indirect en utilisant un pointeur se trouvant au sommet de la pile. Le pointeur doit être poussé au préalable en utilisant les instructions ldftn ou ldvirtftn:

  • ldftn: charge le pointeur de la fonction à appeler en utilisant la table de méthodes de la classe. La fonction est reconnue à partir de sa signature. Le pointeur de fonction est poussé dans la pile.
  • ldvirtftn: cette instruction a la même fonction que ldftn La différence est que ldvirtftn effectue la recherche dans la table des fonctions virtuelles de l’instance de la classe.

ldftn et ldvirtftn permettent de pousser un pointeur dans la pile, ce pointeur peut ensuite être utilisé par calli pour appeler une méthode:

  • L’utilisation de ldftn et calli est un équivalent de call.
  • L’utlisation de ldvirtftn et calli est un équivalent de callvirt.

Il n’y a pas forcément de différences significatives de performance entre les utilisations de ldftn/ldvirtftn + calli et call/callvirt, la différence est que ldftn/ldvirtftn et calli étant des instructions séparées, elles peuvent faire l’objet d’optimisation par le compilateur au moment où elles sont appelées.

Pourquoi manipuler des pointeurs de fonction en C# ?

Une fonction comporte des arguments, cette fonction effectue un traitement et éventuellement renvoie un résultat. Les arguments sont généralement des variables contenant des valeurs utilisées lors du traitement. Ce paradigme de programmation est de type impératif: une fonction sert à appliquer un traitement comme s’il s’agissait d’une fonction mathématique.
Un autre paradigme comme la programmation fonctionnelle nécessite de pouvoir passer en paramètre d’autres fonctions (cf. “higher-order function) et de renvoyer une fonction en résultat.
Sans aller jusqu’à l’application stricte des principes de la programmation fonctionnelle, on peut avoir le besoin de passer en paramètre de fonction un comportement. Les pointeurs de fonction ou les delegates en C# permettent d’effectuer ce type de manipulation en autorisant le passage de fonction en argument d’une autre fonction. On peut, ainsi, passer en argument un comportement plutôt que simplement des valeurs. Le gain est, par exemple, de composer une suite de traitements sans avoir à réellement exécuter ce traitement.

Les delegates en C# permettent de passer en argument de fonction d’autres fonctions. Techniquement, si des appels s’effectuent seulement de code managé vers du code managé, il n’y a pas de nécessité d’utiliser autre chose que les delegates pour plusieurs raisons:

  • Ils sont supportés par le Garbage Collector
  • Ils permettent des appels rapides
  • Ils peuvent être appelés de façon asynchrone

Dans le cadre d’appels entre du code managé et du code natif, on peut aussi utiliser les delegates car ils peuvent être marshalé et transformé en pointeurs de fonction lors d’appels Platform/Invoke. Cette solution utilise les instructions call dans le code MSIL car le delegate est implémenté sous la forme d’un wrapper de méthode (voir plus haut).
A la différence, les pointeurs de fonction en C# apportent la même solution technique lors d’appels entre du code managé et du code natif toutefois ils permettent de tirer partie de l’instruction MSIL calli. Cette instruction va directement utilisée un pointeur de fonction pour appeler le code de la méthode à exécuter.

Limitations de C# concernant les pointeurs de fonctions avant C# 9

Avant C# 9, les utilisations des pointeurs de fonction sont possibles toutefois ils utilisent call lors des appels (comme on a pu le voir plus haut). L’instruction MSIL calli n’est pas utilisée alors que cette instruction est celle qui est le plus adaptée pour appeler des méthodes en utilisant un pointeur. Le choix d’utiliser call peut s’expliquer par le fait de privilégier un procédé plus sûr pour appeler la méthode via un pointeur.

Ainsi malgré l’existence de l’instruction MSIL calli, il n’existe pas de possibilité de l’utiliser en utilisant du code C# usuel. Pour des besoins d’optimisation (cf. Inline IL ASM), certains développeurs ont forcé l’utilisation de calli en passant par du code C# émettant directement l’instruction avec OpCodes.Calli et DynamicMethod.GetILGenerator().

Pour palier à cette difficulté d’utiliser calli, une nouvelle syntaxe a été introduite en C# 9 permettant réellement de générer cette instruction.

Manipuler des pointeurs de fonction en C# 9

A partir de C# 9, il est possible d’utiliser une syntaxe permettant de manipuler les pointeurs de fonction et d’autoriser des appels sans passer par du code Platform/Invoke. Les appels peuvent être fait entre du code managé ⇔ managé et du code managé ⇔ natif. L’inconvénient est que ces manipulations nécessitent toujours un contexte unsafe.

Ces pointeurs sont représentés par la syntaxe:

  • delegate* managed<int, float, long> cette syntaxe correspond à un pointeur de fonction dont la signature est long (int, float) c’est-à-dire:
    • Le type de retour est long
    • Les arguments sont de type int et float dans cet ordre.
    • Ce pointeur de fonction ne peut être utilisé que dans le code managé (à cause de la convention d’appel).
  • delegate* unmanaged<int, float, long> cette syntaxe correspond à un pointeur de fonction à utiliser dans le cadre d’appels à du code natif. Sans précision, le CLR détermine la convention d’appel suivant le contexte.
  • delegate* unmanaged[StdCall]<int, float, long> cette syntaxe permet de préciser des éléments comme la convention d’appels:
    • StdCall pour désigner la convention par défaut de l’API Win32
    • Cdecl pour la convention d’appels des programmes C et C++.
    • Fastcall pour des appels optimisés en C++.
    • Thiscall qui fournit un pointeur this à la méthode lors de l’appel.

L’intérêt le plus direct des delegate* est de pouvoir remplacer l’utilisation des delegates managés et de permettre les conversions de pointeurs de fonction en void*.
Par exemple si on reprend l’exemple précédent qui permettait de fournir et d’utiliser un pointeur de fonction en utilisant un délégué managé, l’implémentation est directe en utilisant delegate*:

  • Pour utiliser un pointeur de fonction
    Code C#
    public unsafe class CallFunctionPointer
    {
      [DllImport(...)]
      public extern static delegate* unmanaged<int, int, int> 
        GetFunctionPointer();  
      
      public int Multiply(int a, int b)
      {
        delegate* unmanaged<int, int, int> fcnPtr = 
          GetFunctionPointer();
        return fcnPtr(a, b);
      }
    }
    
    Code MSIL de
    Multiply()
    .method public hidebysig instance 
        int32  Multiply(int32 a,int32 b) cil managed
    {
      // Code size     15 (0xf)
      .maxstack  3
      .locals init (method unmanaged cdecl int32 *(int32,
        int32) V_0)
      IL_0000:  call     method unmanaged cdecl 
        int32 *(int32,int32) FunctionPointerTests
        .CallFunctionPointer::GetFunctionPointer()
      IL_0005:  stloc.0
      IL_0006:  ldarg.1
      IL_0007:  ldarg.2
      IL_0008:  ldloc.0
      // Appel de fonction en utilisant le pointeur avec 
      // calli
      IL_0009:  calli    unmanaged cdecl int32(int32,int32)
      IL_000e:  ret
    } // end of method CallFunctionPointer::Multiply
    
  • Pour fournir un pointeur de fonction:
    Code C#
    public unsafe class FunctionPointerProvider
    {
      [UnmanagedFunctionPointer(CallingConvention.StdCall)]
      public delegate int MultiplyDelegate(int arg1, int arg2);
    
      public static MultiplyDelegate MultiplyAction = Multiply;
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      public int MultiplyIntegers(int a, int b)
      {
        delegate* unmanaged[Stdcall]<int, int, int> fcnPtr = 
          (delegate* unmanaged[Stdcall]<int, int, int>)
          Marshal.GetFunctionPointerForDelegate(MultiplyAction);
        return MultiplyWithFunctionPointer(a, b, fcnPtr);
      }
    
      [DllImport(...)]
      public extern static int MultiplyWithFunctionPointer(
        int arg1, 
        int arg2,
        delegate* unmanaged[Stdcall]<int, int, int> fcnPtr);
    }
    Code MSIL de
    MultiplyIntegers()
    .method public hidebysig instance 
        int32  MultiplyIntegers(int32 a,int32 b) cil managed
    {
      // Code size     22 (0x16)
      .maxstack  3
      .locals init (class FunctionPointerTests
        .FunctionPointerProvider/MultiplyDelegate V_0)
      IL_0000:  ldarg.0
      // Ajout dans la pile du pointeur natif
      // vers Multiply() avec ldftn
      IL_0001:  ldftn  instance int32 FunctionPointerTests
        .FunctionPointerProvider::Multiply(int32,int32)
      IL_0007:  newobj instance void FunctionPointerTests
        .FunctionPointerProvider/MultiplyDelegate::
        .ctor(object,native int)
      IL_000c:  stloc.0
      IL_000d:  ldarg.1
      IL_000e:  ldarg.2
      IL_000f:  ldloc.0
      // Appel Platform/Invoke
      IL_0010:  call   int32 FunctionPointerTests
        .FunctionPointerProvider
        ::MultiplyWithFunctionPointer(int32,int32,
          class FunctionPointerTests
          .FunctionPointerProvider/MultiplyDelegate)
      IL_0015:  ret
    } 
    

    Dans cet exemple, il n’y a pas d’utilisation de calli puisqu’on ne fait que fournir le pointeur de fonction, il n’y a pas d’appels de fonction en utilisant un pointeur.

Un delegate* ne peut pas être initialisé en C# qu’avec une fonction statique

Contrairement aux delegates managés, il n’est possible d’instancier un delegate* qu’avec une fonction statique en C#.
On peut écrire:

public unsafe class FunctionPointerProvider
{
  private static int Multiply(int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <int, int, int> fcnPtr = &Multiply; // OK
    // ...
  }
}

Mais ce code provoque une erreur à la compilation:

public unsafe class FunctionPointerProvider
{
  private int Multiply(int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <int, int, int> fcnPtr = &Multiply; // ⚠ ERREUR ⚠
    // ...
  }
}

Une solution est d’utiliser une fonction statique et de fournir une instance de la classe, par exemple:

public unsafe class FunctionPointerProvider
{
  private static int Multiply(FunctionPointerProvider instance, int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <FunctionPointerProvider, int, int, int> fcnPtr = &Multiply; // OK
    // ...
  }
}

Conversions de delegate*

Comme on peut le voir dans les exemples précédents, il est possible d’effectuer quelques manipulations sur les pointeurs de fonction comme:

  • Effectuer des conversions de delegate* vers void* et inversement:
    • La conversion est implicite dans le sens delegate*void*:
      delegate* managed<int, int, int> functionPointer = ...
      void* voidPointer = functionPointer; // Conversion implicite
      
    • La conversion doit être explicite dans le sens void*delegate*:
      void* voidPointer = ...;
      // Conversion explicite
      delegate* managed<int, int, int> functionPointer = (delegate* managed<int, int, int>)voidPointer; 
      
  • Dans le même sens, on peut convertir les delegate* en IntPtr:
    delegate* managed<int, int, int> functionPointer = ...
    IntPtr pointer = new IntPtr(functionPointer);
    

    Pour afficher l’adresse du pointeur:

    Console.WriteLine(pointer.ToString("X"));

Benchmark

De façon à comparer les performances des appels en utilisant des pointeurs de fonctions, on se propose plusieurs cas de figure d’exécution d’un algorithme. Cet algorithme effectue un traitement qui n’a pas de sens mathématique et dont la complexité est Ο(loopCount * 20)

  • loopCount est un nombre de boucles qu’on choisit suffisamment grand pour que l’exécution de l’algorithme soit significatif.
  • 20 car dans l’algorithme, un tableau de 20 entiers est parcouru. Ce nombre d’entiers est choisi arbitrairement.

Durant ce traitement une multiplication entre 2 entiers est effectuée et répétée loopCount * 20 fois. On effectue volontairement cette multiplication dans une fonction séparée de façon à modifier les appels suivant les différents cas de figure:

  • Un appel normal à une fonction managée: cet appel sert de référence.
  • Un appel en utilisant un délégué managé: cet appel s’effectue seulement dans le code managé. Techniquement cet appel est très semblable à un appel normal puisque le delegate est une fonction managée.
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code managé: ce scénario ne s’effectue que dans du code managé. Il permet d’instancier un pointeur d’une fonction managée. Les appels sont ensuite effectués en utilisant ce pointeur de fonction managé.
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code natif: ce scénario permet d’effectuer plusieurs appels à une fonction se trouvant dans du code natif en utilisant un pointeur de fonction. Le pointeur de fonction se trouvant dans le code natif est récupéré avec un appel Platform/Invoke.
  • Un appel en fournissant à une fonction native un pointeur d’une fonction managée: ce scénario permet d’utiliser un pointeur vers une fonction managée à partir de code natif.

L’implémentation de ce benchmark est la suivante:

public void RunBenchmark()
{
  var firstArray = new int[] { 23, 87,  51, 98, 29, 75, 93, 48, 24, 83, 47, 38, 62, 22, 97, 15, 52, 41, 74, 13 };
  var secondArray = firstArray.Reverse().ToArray();

  int arrayLength = firstArray.Length;
  int value = 0;
  int offset = 0;
  bool add = true;
  for (int i = 0; i < loopCount; i++)
  {
     for (int j = 0; j < arrayLength; j++)
     {
        int index = (offset + j) % arrayLength;
        int multiplicationResult = Multiply(firstArray[index], secondArray[index]);
        if (add)
          value += multiplicationResult;
        else
          value -= multiplicationResult;

        add = !add;
     }

     offset++;
  }
}

avec

private int Multiply(int arg1, int arg2)
{
  return arg1 * arg2;
}
Code sur GitHub

Le code de cet exemple se trouve dans le repository GitHub: github.com/msoft/Cs9_FunctionPointer

On décline ensuite cette implémentation suivant les différents types d’appels à effectuer en ne modifiant que l’appel à la fonction effectuant la multiplication.

  • Un appel normal à une fonction managée:
    On crée la classe suivante:

    public class MultiplyClass
    {
      public int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    }
    

    On instancie cette classe pour l’utiliser dans la fonction exécutant le benchmark:

    public class Benchmark
    {
      private readonly MultiplyClass multiplyClass;
    
      public Benchmark()
      {
        this.multiplyClass = new MultiplyClass();
      }
    
      [Benchmark]
      public void InstanceFunctionCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyClass.Multiply(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un délégué managé
    On crée un delegate managé pour wrapper l’appel à la fonction MultiplyClass.Multiply()

    public class Benchmark
    {
      private readonly MultiplyClass multiplyClass;
      private delegate int multiplyDelegate(int arg1, int arg2); // Définition du delegate
      private readonly multiplyDelegate multiplyManagedDelegate;
    
      public Benchmark()
      {
        this.multiplyClass = new MultiplyClass();
        this.multiplyManagedDelegate = this.multiplyClass.Multiply;
      }
    
      [Benchmark]
      public void ManagedDelegateCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyManagedDelegate(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code managé:
    On ajoute une fonction statique permettant d’effectuer la multiplication et on crée un pointeur de fonction vers cette fonction statique. On appelle ensuite le pointeur dans la méthode du benchmark:

    public unsafe class Benchmark
    {
      private readonly delegate* <int, int, int> multiplyManagedPointer;
    
      public Benchmark()
      {
        this.multiplyManagedPointer = &Multiply;
      }
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      [Benchmark]
      public void ManagedFunctionPointerCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyManagedPointer(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code natif
    On crée une fonction native permettant de renvoyer un pointeur vers une fonction dans le code natif. Ce code se trouve dans un projet permettant de générer une DLL C++:

    • Dans le fichier .cpp:
      int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
      
      void* GetMultiplyFunctionPointer()
      {
      	int (*)(int, int) fcnPtr = &Multiply;
      	return reinterpret_cast<void*>(fcnPtr);
      }
      
    • Dans le fichier .h:
      extern "C" __delspec(dllexport) void* GetMultiplyFunctionPointer();
      static int Multiply(int arg1, int arg2);
      

    Dans le code C#, on crée une indication pour effectuer un appel Platform/Invoke avec DllImport:

    public unsafe class Benchmark
    {
      private readonly delegate* unmanaged<int, int, int> multiplyUnmanagedPointer;
    
      [DllImport("NativeCallee.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
      public extern static delegate* unmanaged<int, int, int> GetMultiplyFunctionPointer();
    
      public Benchmark()
      {
        this.multiplyUnmanagedPointer = GetMultiplyFunctionPointer();
      }
    
      [Benchmark]
      public void UnmanagedFunctionPointerCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
              // ...      
            int multiplicationResult = multiplyUnmanagedPointer(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en fournissant à une fonction native un pointeur d’une fonction managée:
    La méthode d’exécution du benchmark est codée coté code natif. Un paramètre de cette méthode permet d’indiquer un pointeur de fonction qui va effectuer la multiplication. Dans le cadre de ce test, on fournit le pointeur d’une fonction managée.

    Coté code natif, l’implémentation est:

    • Dans le fichier .cpp:
      void PerformBenchmarkWithFunctionPointer(int loopCount, int(*multiplyFcn)(int, int))
      {
        const int arrayLength = 20;
      
        int firstArray[arrayLength] = { 23, 87, 51, 98, 29, 75, 93, 48, 24, 83, 47,
          38, 62, 22, 97, 15, 52, 41, 74, 13 };
        int secondArray[arrayLength];
      
        for (int i = 0; i < arrayLength; i++)
        {
          secondArray[i] = firstArray[arrayLength - i];
        }
      
        int value = 0;
        int offset = 0;
        bool add = true;
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            int index = (offset + j) % arrayLength;
            int multiplicationResult = multiplyFcn(firstArray[index], secondArray[index]);
            if (add)
              value += multiplicationResult;
            else
              value -= multiplicationResult;
      
            add = !add;
          }
      
          offset++;
        }
      }
      
    • Dans le fichier .h:
      extern "C" __declspec(dllexport) void PerformBenchmarkWithFunctionPointer(int loopCount, 
          int(*multiplyFcn)(int, int));
        

    Le code C# permettant d’appeler le code natif est:

    public unsafe class Benchmark
    {
      private readonly delegate* <int, int, int> multiplyManagedPointer;
    
      [DllImport("NativeCallee.dll", 
        CallingConvention = CallingConvention.StdCall, 
        CharSet = CharSet.Unicode)]
      public extern static int PerformBenchmarkWithFunctionPointer(int loopCount, 
        delegate* <int, int, int> multiplFcn);
    
      public Benchmark()
      {
        this.multiplyManagedPointer = &Multiply;
      }
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      [Benchmark]
      public void UnmanagedFunctionPointerCall()
      {
        PerformBenchmarkWithFunctionPointer(loopCount, this.multiplyManagedPointer);
      }
    }
    

Les résultats de l’exécution sont:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.18363.1679 (1909/November2019Update/19H2)
Intel Xeon CPU ES-2697 v3 2,6Ghz, 2 CPU, 4 logical and 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	|
|------------------------------------------	|--------	|--------	|--------	|
|                     InstanceFunctionCall	| 49.51ms	| 1.054ms	| 3.075ms	|
|                      ManagedDelegateCall	| 65.37ms	| 1.306ms	| 3.130ms	|
|                ManagedFuntionPointerCall	| 77.87ms	| 1.549ms	| 4.266ms	|
|             UnmanagedFunctionPointerCall	| 75.76ms	| 1.494ms	| 2.281ms	|
|   ProvideFunctionPointerToNativeFunction      | 49.04ms       | 0.979ms	| 1.089ms	|

Si on exécute plusieurs fois ces tests, les résultats peuvent être sensiblement différents toutefois les différences de performances entre les différents cas de figure sont les mêmes:

  • InstanceFunctionCall() l’appel normal à une fonction managée est la référence. Le temps d’exécution est le plus court.
  • ManagedDelegateCall() l’utilisation d’un delegate managé introduit un temps de traitement plus long dans ce test bien que dans la pratique l’utilisation d’un delegate managé n’entraîne pas des performances moins bonnes.
  • ManagedFuntionPointerCall() et UnmanagedFunctionPointerCall() les appels utilisant un pointeur de fonction delegate* provoquent tous les 2 un temps de traitement plus long. Le choix des delegate* n’est pas anodin et doit se faire s’il apporte un gain par rapport à des appels à du code natif sans passer par des pointeurs de fonction.
  • ProvideFunctionPointerToNativeFunction() ce cas de figure n’est pas vraiment pertinent par rapport aux tests précédents puisque la majorité du code est exécutée par le runtime C++. Les performances semblent égalées celles d’un appel normal malgré l’utilisation d’un pointeur de fonction.

On peut juste retenir que l’utilisation de pointeurs de fonction dégrade les performances par rapport à un appel normal. L’utilisation de ces pointeurs doit se faire si le gain est avéré et permet d’éviter, par exemple, d’effectuer une succession d’appels de type Platform/Invoke.

Références

Compiler intrinsics

MSIL/CIL:

Calli

Delegates

GC Premptive vs Cooperative

Leave a Reply