Wskaźniki do funkcji w C#

5 Lutego 2020

Od początku semestru robiłem małe kroczki w stronę mojej pracy magisterskiej, ale dopiero po zakończeniu semestru ostro wziąłem się do pracy. Próbuję skompilować Haskell na .NET, tak żeby działał efektywnie. Oznacza to wyciskanie tyle ile się da z dostępnego środowiska.

W tym poście opowiem nieco o kompilowaniu aplikacji dotnetowych do kodu maszynowego, jak tworzenie delegatu spowalnia program i o modyfikowaniu kompilatora C#.

Kod maszynowy

Jednym z powodów, dla którego używanie platformy .NET jest fajne, jest przenośność skompilowanego kodu. Mogę pisać program w Visual Studio na Windowsie i potem wysłać go koledze, który pracuje na Linuxie. W tym wypadku to ja jestem tym kolegą, bo prawie wszystkie moje czynności programistyczne opierają się na technologiach open source.

Platforma .NET korzysta z kompilacji Just-In-Time emitując kod maszynowy w trakcie działania programu. W większości przypadków ten kod jest wystarczająco szybki. Ale ja walczę o prędkość bliską kompilatorowi Haskella GHC, który produkuje kod maszynowy.

Ponadto, żeby stwierdzić czy JITowany kod jest wystarczająco dobry, musiałbym skompilować samemu runtime, żeby móc użyć odpowiednich flag, które spowodują jego wyplucie. Kiedyś to zrobiłem i napisałem post „Co produkuje .NET JIT?”, ale teraz już nie chciałem się w to bawić (i chyba instrukcje się przedawniły).

Na szczęście z pomocą przyszło CoreRT, które jest cały czas w fazie alfa. Specjalny kompilator używa silnika RyuJIT i produkuje pojedynczy plik binarny, w którym jest nie tylko twój kod, ale również skompilowane wszystkie zależności. Dlatego „Hello World” waży ponad 1MB, ale pracują nad tym.

Żeby użyć kompilatora CoreRT należy poczynić następujące kroki:

  1. Utworzyć plik nuget.config w folderze projektu poleceniem dotnet new nuget.
  2. Dodać do niego linijkę

     <add key="dotnet-core" value="https://dotnetfeed.blob.core.windows.net/dotnet-core/index.json" />
    
  3. W projekcie dołączyć referencję do paczki NuGet (miałem problemy z najnowszą więc używam ciut starszej)

     <ItemGroup>
         <PackageReference Include="Microsoft.DotNet.ILCompiler" Version="1.0.0-alpha-27*" />
     </ItemGroup>
    
  4. Opublikować projekt (flaga win-x64 dla Windowsa)

     dotnet publish -c Release -r linux-x64
    
  5. Wynik znajduje się w bin/Release/netcoreapp3.1/linux-x64/native

Co nie działa w tak skompilowanym programie? Nie można używać niektórych elementów refleksji, m.in. emitować dynamicznie kod.

Można dodatkowo spróbować zmniejszyć program dodając flagi

<PropertyGroup>
    <!-- Optimize Speed/Size -->
    <IlcOptimizationPreference>Speed</IlcOptimizationPreference>
    <!-- Remove language specific code -->
    <IlcInvariantGlobalization>true</IlcInvariantGlobalization>
    <!-- Remove any Reflection code -->
    <IlcDisableReflection>true</IlcDisableReflection>
</PropertyGroup>

Wolne delegaty

W moim projekcie w początkowej wersji tworzyłem mnóstwo klas, które były do siebie bardzo podobne. Przechowywały kilka argumentów, a potem były wołane z wynikiem obliczenia i robiły kolejny kawałek algorytmu.

Doszedłem do wniosku, że mogę utworzyć pewne uniwersalne, generyczne klasy, które będą dostawać argumenty do przechowania i statyczną funkcję, którą mają wywołać po otrzymaniu wyniku obliczeń. Taki kod powinien być szybki.

Ale ze względu na specyfikę tego jak delegaty są kompilowane w CoreRT, mój kod zrobił się 4-5 krotnie wolniejszy. I problemem nie jest wywołanie delegatu - to jest bardzo szybkie. Problemem jest jego dynamiczne tworzenie.

Mógłbym próbować temu zaradzić przez tworzenie statycznych delegatów, po jednym na każdą funkcję, którą chcę przekazać w kontynuacji. Ale pomyślałem, że może da się jeszcze szybciej.

I tak właśnie odkryłem, że CLR potrafi pracować na wskaźnikach. Ale kompilator C# na to nie pozwala. Chodzi o to, żeby móc emitować dwa opcode’y:

  • ldftn - do ładowania wskaźnika do funkcji na stos
  • calli - do wołania funkcji na podstawie wskaźnika

Modyfikacja kompilatora Roslyn

Odkąd powstał Roslyn i jego źródła zostały otwarte, ludzie prosili o nowe feature’y. W którejś z kolejnych wersji kompilatora możliwe, że pojawią się wbudowane, oficjalne sposoby do pracy ze wskaźnikami na funkcje. O tym jest to issue. Znacznie bliżej jest zbliżony feature o nazwie static delegates, który ma pojawić się w C# 9.0.

Jednak to, że kiedyś wydadzą lepszy kompilator nie rozwiązuje mojego problemu teraz. Na szczęście ludzie podejmowali próby samodzielnego wprowadzenia tych funkcjonalności, ale w ogólniejszej formie bez zmiany języka. Tutaj jest ciekawa dyskusja, której dwóch uczestników stworzyło swoje prototypy mające na celu umożliwienie dostępu do tych funkcji ILa, które kompilator C# nie emituje.

Jeden prototyp jest zbyt stary, żeby dało się go obecnie uruchomić. Natomiast drugi udało mi się przenieść na najnowszą wersję kompilatora Roslyn i udostępnić na moim GitHubie: manio143/roslyn.

Dzięki temu byłem w stanie napisać taki oto test:

using System;
namespace System.Runtime.CompilerServices {
    [AttributeUsage(AttributeTargets.Method)]
    internal class CompilerIntrinsicAttribute : Attribute { }
}
namespace roslyn_test
{
    class Program {
        [System.Runtime.CompilerServices.CompilerIntrinsic]
        public static unsafe extern void* LoadFunctionPointer<T, U>(Func<T,U> fun);

        [System.Runtime.CompilerServices.CompilerIntrinsic]
        public static unsafe extern TOut TailCallIndirectGeneric<T, TOut>(T i, void* funPtr);

        static void Main(string[] args) {
            Test(() => TestRunner.CreateDelegate());
            Test(() => TestRunner.CreatePointer());
        }
        public static void Test(Func<TestBase> f) {
            double sum = 0, min = 100000, max = 0;
            for (int i = 0; i < 50; i++) {
                var t = System.Diagnostics.Stopwatch.StartNew();

                for (int j = 0; j < 100; j++)
                    f().Eval(j);
                
                t.Stop();
                
                var x = t.Elapsed.TotalMilliseconds;
                sum += x;
                min = x < min ? x : min;
                max = x > max ? x : max;
            }
            var avg = Math.Round(sum / 50, 5);
            Console.WriteLine("{0}  \tAVG: {1}, MIN: {2}, MAX: {3}", f(), avg, min, max);
        }
    }

    public abstract class TestBase {
        public abstract int Eval(int x);
    }
    public sealed class TestDelegate : TestBase {
        Func<int, int> f;
        public TestDelegate(Func<int, int> f) {
            this.f = f;
        }
        public override int Eval(int x) {
            return this.f(x);
        }
    }
    public unsafe sealed class TestPointer : TestBase {
        void* f;
        public TestPointer(void* f) {
            this.f = f;
        }
        public override int Eval(int x) {
            unsafe {
                return Program.TailCallIndirectGeneric<int, int>(x, f);
            }
        }
    }

    public static class TestRunner {
        public static int F(int x) {
            return 1;
        }
        public static TestBase CreateDelegate() {
            return new TestDelegate(new Func<int, int>(F));
        }
        public static TestBase CreatePointer() {
            unsafe {
                return new TestPointer(Program.LoadFunctionPointer<int,int>(F));
            }
        }
    }
}

Uruchomiłem ten program normalnie przez dotnet run w konfiguracji Release, przez publikację z CoreRT, a także sprawdziłem różnicę przy włączonej i wyłączonej TieredCompilation (w .NET Core 3.1 domyślnie włączona).

Wyniki (czas w milisekundach):

* JIT TieredCompilation Off:
    roslyn_test.TestDelegate    AVG: 0.00871, MIN: 0.0062, MAX: 0.0812
    roslyn_test.TestPointer     AVG: 0.00433, MIN: 0.0021, MAX: 0.0485
* JIT TieredCompilation On:
    roslyn_test.TestDelegate    AVG: 0.01200, MIN: 0.0069, MAX: 0.1405
    roslyn_test.TestPointer     AVG: 0.00731, MIN: 0.0013, MAX: 0.1918
* CoreRT:
    roslyn_test.TestDelegate    AVG: 0.01055, MIN: 0.0038, MAX: 0.0203
    roslyn_test.TestPointer     AVG: 0.00306, MIN: 0.0009, MAX: 0.0288

Więc widać, że korzystanie ze wskaźników jest szybsze od delegatów. Największy koszt jest ponoszony podczas tworzenia delegatu. Widać też, że TieredCompilation może być wolniejsze. No i delegaty z JIT są średnio szybsze niż z CoreRT.

Co z bezpieczeństwem?

Wskaźniki do funkcji mają ten problem, że potencjalnie mogą wskazywać w dowolne miejsce w pamięci. Dlatego trzeba się z nimi obchodzić ostrożnie. Kod który używa wskaźników musi być oznaczony unsafe i trzeba również powiedzieć kompilatorowi, żeby dopuścił niebezpieczny kod, dodając do pliku projektu

<PropertyGroup>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

Jednym z powodów, dla którego w C# jeszcze tej funkcjonalności nie ma, jest wsparcie AppDomain i ładowanie i odładowanie dynamicznie modułów z kodem. Wskaźniki nie są w żaden sposób śledzone i może się okazać, że twój poprawny wskaźnik wskazuje na funkcję, która została usunięta z pamięci.

Co dalej?

Skoro jestem już w stanie używać wskaźników do funkcji i sprawdziłem, że są szybsze od delegatów to czas użyć ich w moim runtimie i przetestować jak działają.

Jeśli chcesz śledzić moje postępy do tutaj jest repo mojej pracy magisterskiej (z dobrze udokumentowanym kodem): manio143/Lazer. Po zrobieniu szybkiego runtime’u następnym krokiem będzie napisać kompilator Haskella do C#. W tym celu używam STG – pośredniej formy kompilator GHC.