Co może wyjaśnić narzut związany z używaniem const w tym przypadku?

9

Uderzam tu głową o ścianę, więc mam nadzieję, że niektórzy z was mogą mnie wykształcić. Robiłem testy wydajności przy użyciu BenchmarkDotNet i wpadłem na ten dziwny przypadek, w którym wydaje się, że zadeklarowanie członka constznacznie obniża wydajność.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;

namespace PerfTest
{
    [DisassemblyDiagnoser(printAsm: true, printSource: true)]
    public class Test
    {
        private int[] data;
        private int Threshold = 90;
        private const int ConstThreshold = 90;

        [GlobalSetup]
        public void GlobalSetup()
        {
            data = new int[1000];
            var random = new Random(42);
            for (var i = 0; i < data.Length; i++)
            {
                data[i] = random.Next(100);
            }
        }

        static void Main(string[] args)
        {
            var summary = BenchmarkRunner.Run<Test>();
        }

        [Benchmark(Baseline = true)]
        public void ClampToMemberValue()
        {
            for (var i = 0; i < data.Length; i++)
            {
                if (data[i] > Threshold) data[i] = Threshold;
            }
        }

        [Benchmark]
        public void ClampToConstValue()
        {
            for (var i = 0; i < data.Length; i++)
            {
                if (data[i] > ConstThreshold) data[i] = ConstThreshold;
            }
        }
    }
}

Zauważ, że jedyną różnicą między dwiema metodami testowymi jest to, czy porównują się one ze zwykłą zmienną składową lub stałym składnikiem.

Według BenchmarkDotNet użycie stałej jest znacznie wolniejsze i nie rozumiem dlaczego.

BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362
Intel Core i7-5820K CPU 3.30GHz (Broadwell), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.0.100
  [Host]     : .NET Core 3.0.0 (CoreCLR 4.700.19.46205, CoreFX 4.700.19.46214), 64bit RyuJIT
  DefaultJob : .NET Core 3.0.0 (CoreCLR 4.700.19.46205, CoreFX 4.700.19.46214), 64bit RyuJIT


|             Method |     Mean |    Error |   StdDev | Ratio |
|------------------- |---------:|---------:|---------:|------:|
| ClampToMemberValue | 590.4 ns | 1.980 ns | 1.852 ns |  1.00 |
|  ClampToConstValue | 724.6 ns | 4.184 ns | 3.709 ns |  1.23 |

Patrzenie na skompilowany kod JIT nie wyjaśnia tego, o ile wiem. Oto kod dwóch metod. Jedyną różnicą jest to, czy porównanie jest dokonywane względem rejestru czy literału.

00007ff9`7f1b8500 PerfTest.Test.ClampToMemberValue()
            for (var i = 0; i < data.Length; i++)
                 ^^^^^^^^^
00007ff9`7f1b8504 33c0            xor     eax,eax
            for (var i = 0; i < data.Length; i++)
                            ^^^^^^^^^^^^^^^
00007ff9`7f1b8506 488b5108        mov     rdx,qword ptr [rcx+8]
00007ff9`7f1b850a 837a0800        cmp     dword ptr [rdx+8],0
00007ff9`7f1b850e 7e2e            jle     00007ff9`7f1b853e
00007ff9`7f1b8510 8b4910          mov     ecx,dword ptr [rcx+10h]
                if (data[i] > Threshold) data[i] = Threshold;
                ^^^^^^^^^^^^^^^^^^^^^^^^
00007ff9`7f1b8513 4c8bc2          mov     r8,rdx
00007ff9`7f1b8516 458b4808        mov     r9d,dword ptr [r8+8]
00007ff9`7f1b851a 413bc1          cmp     eax,r9d
00007ff9`7f1b851d 7324            jae     00007ff9`7f1b8543
00007ff9`7f1b851f 4c63c8          movsxd  r9,eax
00007ff9`7f1b8522 43394c8810      cmp     dword ptr [r8+r9*4+10h],ecx
00007ff9`7f1b8527 7e0e            jle     00007ff9`7f1b8537
                if (data[i] > Threshold) data[i] = Threshold;
                                         ^^^^^^^^^^^^^^^^^^^^
00007ff9`7f1b8529 4c8bc2          mov     r8,rdx
00007ff9`7f1b852c 448bc9          mov     r9d,ecx
00007ff9`7f1b852f 4c63d0          movsxd  r10,eax
00007ff9`7f1b8532 47894c9010      mov     dword ptr [r8+r10*4+10h],r9d
            for (var i = 0; i < data.Length; i++)
                                             ^^^
00007ff9`7f1b8537 ffc0            inc     eax
00007ff9`7f1b8539 394208          cmp     dword ptr [rdx+8],eax
00007ff9`7f1b853c 7fd5            jg      00007ff9`7f1b8513
        }
        ^
00007ff9`7f1b853e 4883c428        add     rsp,28h

i

00007ff9`7f1a8500 PerfTest.Test.ClampToConstValue()
            for (var i = 0; i < data.Length; i++)
                 ^^^^^^^^^
00007ff9`7f1a8504 33c0            xor     eax,eax
            for (var i = 0; i < data.Length; i++)
                            ^^^^^^^^^^^^^^^
00007ff9`7f1a8506 488b5108        mov     rdx,qword ptr [rcx+8]
00007ff9`7f1a850a 837a0800        cmp     dword ptr [rdx+8],0
00007ff9`7f1a850e 7e2d            jle     00007ff9`7f1a853d
                if (data[i] > ConstThreshold) data[i] = ConstThreshold;
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ff9`7f1a8510 488bca          mov     rcx,rdx
00007ff9`7f1a8513 448b4108        mov     r8d,dword ptr [rcx+8]
00007ff9`7f1a8517 413bc0          cmp     eax,r8d
00007ff9`7f1a851a 7326            jae     00007ff9`7f1a8542
00007ff9`7f1a851c 4c63c0          movsxd  r8,eax
00007ff9`7f1a851f 42837c81105a    cmp     dword ptr [rcx+r8*4+10h],5Ah
00007ff9`7f1a8525 7e0f            jle     00007ff9`7f1a8536
                if (data[i] > ConstThreshold) data[i] = ConstThreshold;
                                              ^^^^^^^^^^^^^^^^^^^^^^^^^
00007ff9`7f1a8527 488bca          mov     rcx,rdx
00007ff9`7f1a852a 4c63c0          movsxd  r8,eax
00007ff9`7f1a852d 42c74481105a000000 mov   dword ptr [rcx+r8*4+10h],5Ah
            for (var i = 0; i < data.Length; i++)
                                             ^^^
00007ff9`7f1a8536 ffc0            inc     eax
00007ff9`7f1a8538 394208          cmp     dword ptr [rdx+8],eax
00007ff9`7f1a853b 7fd3            jg      00007ff9`7f1a8510
        }
        ^
00007ff9`7f1a853d 4883c428        add     rsp,28h

Jestem pewien, że coś przeoczyłem, ale w tej chwili nie mogę tego zrozumieć, więc szukam informacji na temat tego, co może to wyjaśnić.

Brian Rasmussen
źródło
@OlivierRogier Pamiętam, że BenchmarkDotNet zawodzi podczas uruchamiania w Debugowaniu.
Euforyczny
Rzeczywiście, użycie stopera dowodzi, że użycie const int jest trochę wolniejsze niż pole na prostym a * a ... nawet jeśli kod IL używa więcej operandów.
Olivier Rogier
1
Korzystając z BenchmarkDotNet 12.0 i .Net Framework 4,8, wykonuję dokładny kod z pytania i nie widzę żadnej znaczącej różnicy w wynikach między tymi dwiema metodami, gdy działam w x86. Widzę zaobserwowaną różnicę po przejściu na x64.
NineBerry,
Te cmpi movinstrukcje, które są stosowane na ścieżce const zajmują więcej pamięci niż instrukcji w rejestr ponieważ kodujący liczbę wymaga dodatkowych bajtów i całkowitego ujęcia więcej cykli CPU do wykonywania (9 bajtów vs 5 bajtów movi 6 bajtów w porównaniu do 5 bajtów CMP) . I chociaż istnieje dodatkowa mov ecx,dword ptr [rcx+10h]instrukcja dla wersji nie-stałej, najprawdopodobniej jest zoptymalizowana przez kompilator JIT, aby znajdował się poza pętlą w wersji.
Dmytro Mukalov,
@DmytroMukalov Ale czy optymalizacja dla wersji innej niż const nie spowodowałaby, że zachowywałaby się inaczej w równoległym wykonywaniu? W jaki sposób kompilator może go zoptymalizować, gdy zmienną można zmienić w innym wątku.
Euforyczny

Odpowiedzi:

4

Patrząc na https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html

Uważam, że powinieneś używać [IterationSetup]zamiast [GlobalSetup]. W konfiguracji globalnej datazmiana jest zmieniana raz, a następnie zmiana datajest ponownie wykorzystywana w testach porównawczych.

Zmieniłem więc kod, aby użyć właściwej inicjalizacji. Zmieniono zmienne, aby częstsze sprawdzanie. I dodał kilka innych odmian.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;

namespace PerfTest
{
    [DisassemblyDiagnoser(printAsm: true, printSource: true)]
    public class Test
    {
        private int[] data;
        private int[] data_iteration;

        private int Threshold = 50;
        private const int ConstThreshold = 50;

        [GlobalSetup]
        public void GlobalSetup()
        {
            data = new int[100000];
            var random = new Random(42);
            for (var i = 0; i < data.Length; i++)
            {
                data[i] = random.Next(100);
            }
        }

        [IterationSetup]
        public void IterationSetup()
        {
            data_iteration = new int[data.Length];
            Array.Copy(data, data_iteration, data.Length);
        }

        static void Main(string[] args)
        {
            var summary = BenchmarkRunner.Run<Test>();
        }

        [Benchmark]
        public void ClampToClassConstValue()
        {
            for (var i = 0; i < data_iteration.Length; i++)
            {
                if (data_iteration[i] > ConstThreshold) data_iteration[i] = ConstThreshold;
            }
        }

        [Benchmark]
        public void ClampToLocalConstValue()
        {
            const int ConstThresholdLocal = 50;
            for (var i = 0; i < data_iteration.Length; i++)
            {
                if (data_iteration[i] > ConstThresholdLocal) data_iteration[i] = ConstThresholdLocal;
            }
        }

        [Benchmark]
        public void ClampToInlineValue()
        {
            for (var i = 0; i < data_iteration.Length; i++)
            {
                if (data_iteration[i] > 50) data_iteration[i] = 50;
            }
        }

        [Benchmark]
        public void ClampToLocalVariable()
        {
            var ThresholdLocal = 50;
            for (var i = 0; i < data_iteration.Length; i++)
            {
                if (data_iteration[i] > ThresholdLocal) data_iteration[i] = ThresholdLocal;
            }
        }

        [Benchmark(Baseline = true)]
        public void ClampToMemberValue()
        {
            for (var i = 0; i < data_iteration.Length; i++)
            {
                if (data_iteration[i] > Threshold) data_iteration[i] = Threshold;
            }
        }
    }
}

Wyniki wyglądają bardziej normalnie:

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.17134.1069 (1803/April2018Update/Redstone4)
Intel Core i7-8850H CPU 2.60GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
Frequency=2531250 Hz, Resolution=395.0617 ns, Timer=TSC
.NET Core SDK=3.0.100
  [Host]     : .NET Core 3.0.0 (CoreCLR 4.700.19.46205, CoreFX 4.700.19.46214), X64 RyuJIT
  Job-INSHHX : .NET Core 3.0.0 (CoreCLR 4.700.19.46205, CoreFX 4.700.19.46214), X64 RyuJIT

InvocationCount=1  UnrollFactor=1

|                 Method |     Mean |    Error |   StdDev |   Median | Ratio | RatioSD |
|----------------------- |---------:|---------:|---------:|---------:|------:|--------:|
| ClampToClassConstValue | 391.5 us | 17.86 us | 17.54 us | 384.2 us |  1.02 |    0.05 |
| ClampToLocalConstValue | 399.6 us |  9.49 us | 11.66 us | 399.0 us |  1.05 |    0.07 |
|     ClampToInlineValue | 384.1 us |  5.99 us |  5.00 us | 383.0 us |  1.00 |    0.06 |
|   ClampToLocalVariable | 382.7 us |  3.60 us |  3.00 us | 382.0 us |  1.00 |    0.05 |
|     ClampToMemberValue | 379.6 us |  8.48 us | 16.73 us | 371.8 us |  1.00 |    0.00 |

Wydaje się, że nie ma różnicy między różnymi wariantami. W tym scenariuszu wszystko jest zoptymalizowane lub stała nie jest w żaden sposób zoptymalizowana.

Euforyk
źródło
Ja też się tym bawiłem i myślę, że masz coś do roboty, więc dziękuję za wkład. Jeśli tablica przetrwa między testami porównawczymi, przewidywanie gałęzi będzie różne w obu przypadkach. Pogrzebię jeszcze trochę.
Brian Rasmussen
@BrianRasmussen Myślę, że jedną główną różnicą jest to, że kiedy tablica przetrwa ze swoimi wartościami, tylko pierwszy test porównawczy, który zostanie uruchomiony, musi wykonać pracę polegającą na zmianie tablicy. Dla wszystkich dalszych testów porównawczych w tej samej tablicy, if nigdy nie będzie prawdziwe.
NineBerry,
@NineBerry dobry punkt. Jeśli większość testów przebiega ze zmienionymi wartościami, nadal nie potrafię wyjaśnić różnicy, ale konfiguracja iteracji wydaje się mieć znaczenie, więc jest tu coś do zagłębienia. Dzięki Wam obojgu!
Brian Rasmussen
W rzeczywistości mój punkt widzenia nie był tak dobry. Biorąc pod uwagę oryginalny kod w pytaniu, GlobalSetupjest wykonywany dwukrotnie, jeden raz przed każdym testem porównawczym, więc obie metody zaczynają od tego samego warunku wstępnego.
NineBerry
@NineBerry Tak. Ale każda metoda jest wykonywana wiele razy jako sposób na wygładzenie ekstremów. Tak więc dla każdej metody jest jedna iteracja, która jest OK, a następnie wszystkie inne iteracje, które zachowują się inaczej.
Euforyczny