web-dev-qa-db-fra.com

Pourquoi la somme d'un tableau de types de valeurs est-elle plus lente que celle d'un tableau de types de référence?

J'essaie de mieux comprendre le fonctionnement de la mémoire dans .NET, donc je joue avec BenchmarkDotNet et diagnostozers . J'ai créé un benchmark comparant les performances de class et struct en additionnant les éléments du tableau. Je m'attendais à ce que la somme des types de valeurs soit toujours plus rapide. Mais pour les tableaux courts, ce n'est pas le cas. Quelqu'un peut-il expliquer cela?

Le code:

internal class ReferenceType
{
    public int Value;
}

internal struct ValueType
{
    public int Value;
}

internal struct ExtendedValueType
{
    public int Value;
    private double _otherData; // this field is here just to make the object bigger
}

J'ai trois tableaux:

    private ReferenceType[] _referenceTypeData;
    private ValueType[] _valueTypeData;
    private ExtendedValueType[] _extendedValueTypeData;

Que j'initialise avec le même ensemble de valeurs aléatoires.

Ensuite, une méthode de référence:

    [Benchmark]
    public int ReferenceTypeSum()
    {
        var sum = 0;

        for (var i = 0; i < Size; i++)
        {
            sum += _referenceTypeData[i].Value;
        }

        return sum;
    }

Size est un paramètre de référence. Deux autres méthodes de référence (ValueTypeSum et ExtendedValueTypeSum) sont identiques, sauf que je fais la somme sur _valueTypeData ou _extendedValueTypeData. Code complet pour le benchmark .

Résultats de référence:

DefaultJob: .NET Framework 4.7.2 (CLR 4.0.30319.42000), RyuJIT-v4.7.3190.0 64 bits

               Method | Size |      Mean |     Error |    StdDev | Ratio | RatioSD |
--------------------- |----- |----------:|----------:|----------:|------:|--------:|
     ReferenceTypeSum |  100 |  75.76 ns | 1.2682 ns | 1.1863 ns |  1.00 |    0.00 |
         ValueTypeSum |  100 |  79.83 ns | 0.3866 ns | 0.3616 ns |  1.05 |    0.02 |
 ExtendedValueTypeSum |  100 |  78.70 ns | 0.8791 ns | 0.8223 ns |  1.04 |    0.01 |
                      |      |           |           |           |       |         |
     ReferenceTypeSum |  500 | 354.78 ns | 3.9368 ns | 3.6825 ns |  1.00 |    0.00 |
         ValueTypeSum |  500 | 367.08 ns | 5.2446 ns | 4.9058 ns |  1.03 |    0.01 |
 ExtendedValueTypeSum |  500 | 346.18 ns | 2.1114 ns | 1.9750 ns |  0.98 |    0.01 |
                      |      |           |           |           |       |         |
     ReferenceTypeSum | 1000 | 697.81 ns | 6.8859 ns | 6.1042 ns |  1.00 |    0.00 |
         ValueTypeSum | 1000 | 720.64 ns | 5.5592 ns | 5.2001 ns |  1.03 |    0.01 |
 ExtendedValueTypeSum | 1000 | 699.12 ns | 9.6796 ns | 9.0543 ns |  1.00 |    0.02 |

Noyau: .NET Core 2.1.4 (CoreCLR 4.6.26814.03, CoreFX 4.6.26814.02), RyuJIT 64 bits

               Method | Size |      Mean |     Error |    StdDev | Ratio | RatioSD |
--------------------- |----- |----------:|----------:|----------:|------:|--------:|
     ReferenceTypeSum |  100 |  76.22 ns | 0.5232 ns | 0.4894 ns |  1.00 |    0.00 |
         ValueTypeSum |  100 |  80.69 ns | 0.9277 ns | 0.8678 ns |  1.06 |    0.01 |
 ExtendedValueTypeSum |  100 |  78.88 ns | 1.5693 ns | 1.4679 ns |  1.03 |    0.02 |
                      |      |           |           |           |       |         |
     ReferenceTypeSum |  500 | 354.30 ns | 2.8682 ns | 2.5426 ns |  1.00 |    0.00 |
         ValueTypeSum |  500 | 372.72 ns | 4.2829 ns | 4.0063 ns |  1.05 |    0.01 |
 ExtendedValueTypeSum |  500 | 357.50 ns | 7.0070 ns | 6.5543 ns |  1.01 |    0.02 |
                      |      |           |           |           |       |         |
     ReferenceTypeSum | 1000 | 696.75 ns | 4.7454 ns | 4.4388 ns |  1.00 |    0.00 |
         ValueTypeSum | 1000 | 697.95 ns | 2.2462 ns | 2.1011 ns |  1.00 |    0.01 |
 ExtendedValueTypeSum | 1000 | 687.75 ns | 2.3861 ns | 1.9925 ns |  0.99 |    0.01 |

J'ai exécuté le test de performance avec les compteurs matériels BranchMispredictions et CacheMisses, mais il n'y a pas de raté de cache ni de mauvaise prédiction de branche. J'ai également vérifié le code IL de publication et les méthodes de référence ne diffèrent que par des instructions qui chargent des variables de type référence ou valeur.

Pour des tailles de tableau plus grandes, la somme des types de valeurs est toujours plus rapide (par exemple parce que les types de valeurs occupent moins de mémoire), mais je ne comprends pas pourquoi elle est plus lente pour les tableaux plus courts. Qu'est-ce que je manque ici? Et pourquoi agrandir le struct (voir ExtendedValueType) rend la sommation un peu plus rapide?

---- MISE À JOUR ----

Inspiré par un commentaire de @usr, j'ai réexécuté la référence avec LegacyJit. J'ai également ajouté un diagnostiqueur de mémoire inspiré de @Silver Shroud (oui, il n'y a pas d'allocations de tas).

Job = LegacyJitX64 Jit = LegacyJit Platform = X64 Runtime = Clr

               Method | Size |       Mean |      Error |     StdDev | Ratio | RatioSD | Gen 0/1k Op | Gen 1/1k Op | Gen 2/1k Op | Allocated Memory/Op |
--------------------- |----- |-----------:|-----------:|-----------:|------:|--------:|------------:|------------:|------------:|--------------------:|
     ReferenceTypeSum |  100 |   110.1 ns |  0.6836 ns |  0.6060 ns |  1.00 |    0.00 |           - |           - |           - |                   - |
         ValueTypeSum |  100 |   109.5 ns |  0.4320 ns |  0.4041 ns |  0.99 |    0.00 |           - |           - |           - |                   - |
 ExtendedValueTypeSum |  100 |   109.5 ns |  0.5438 ns |  0.4820 ns |  0.99 |    0.00 |           - |           - |           - |                   - |
                      |      |            |            |            |       |         |             |             |             |                     |
     ReferenceTypeSum |  500 |   517.8 ns | 10.1271 ns | 10.8359 ns |  1.00 |    0.00 |           - |           - |           - |                   - |
         ValueTypeSum |  500 |   511.9 ns |  7.8204 ns |  7.3152 ns |  0.99 |    0.03 |           - |           - |           - |                   - |
 ExtendedValueTypeSum |  500 |   534.7 ns |  3.0168 ns |  2.8219 ns |  1.03 |    0.02 |           - |           - |           - |                   - |
                      |      |            |            |            |       |         |             |             |             |                     |
     ReferenceTypeSum | 1000 | 1,058.3 ns |  8.8829 ns |  8.3091 ns |  1.00 |    0.00 |           - |           - |           - |                   - |
         ValueTypeSum | 1000 | 1,048.4 ns |  8.6803 ns |  8.1196 ns |  0.99 |    0.01 |           - |           - |           - |                   - |
 ExtendedValueTypeSum | 1000 | 1,057.5 ns |  5.9456 ns |  5.5615 ns |  1.00 |    0.01 |           - |           - |           - |                   - |

Avec l'héritage JIT, les résultats sont comme prévu - mais plus lents que les résultats précédents!. Ce qui suggère que RyuJit fait des améliorations de performances magiques, qui font mieux sur les types de référence.

---- MISE À JOUR 2 ----

Merci pour les bonnes réponses! J'ai beaucoup appris!

Ci-dessous les résultats d'un autre benchmark. Je compare les méthodes à l'origine évaluées, les méthodes optimisées, comme suggéré par @usr et @xoofx:

[Benchmark]
public int ReferenceTypeOptimizedSum()
{
    var sum = 0;
    var array = _referenceTypeData;

    for (var i = 0; i < array.Length; i++)
    {
        sum += array[i].Value;
    }

    return sum;
} 

et versions déroulées, comme suggéré par @AndreyAkinshin, avec les optimisations ci-dessus ajoutées:

[Benchmark]
public int ReferenceTypeUnrolledSum()
{
    var sum = 0;
    var array = _referenceTypeData;

    for (var i = 0; i < array.Length; i += 16)
    {
        sum += array[i].Value;
        sum += array[i + 1].Value;
        sum += array[i + 2].Value;
        sum += array[i + 3].Value;
        sum += array[i + 4].Value;
        sum += array[i + 5].Value;
        sum += array[i + 6].Value;
        sum += array[i + 7].Value;
        sum += array[i + 8].Value;
        sum += array[i + 9].Value;
        sum += array[i + 10].Value;
        sum += array[i + 11].Value;
        sum += array[i + 12].Value;
        sum += array[i + 13].Value;
        sum += array[i + 14].Value;
        sum += array[i + 15].Value;
    }

    return sum;
}

Code complet ici.

Résultats de référence:

BenchmarkDotNet = v0.11.3, OS = Windows 10.0.17134.345 (1803/April2018Update/Redstone4) Intel Core i5-6400 CPU 2,70GHz (Skylake), 1 CPU, 4 logique et 4 cores physiques Fréquence = 2648439 Hz, résolution = 377,5809 ns, minuterie = TSC

DefaultJob: .NET Framework 4.7.2 (CLR 4.0.30319.42000), RyuJIT-v4.7.3190.0 64 bits

                        Method | Size |     Mean |     Error |    StdDev | Ratio | RatioSD |
------------------------------ |----- |---------:|----------:|----------:|------:|--------:|
              ReferenceTypeSum |  512 | 344.8 ns | 3.6473 ns | 3.4117 ns |  1.00 |    0.00 |
                  ValueTypeSum |  512 | 361.2 ns | 3.8004 ns | 3.3690 ns |  1.05 |    0.02 |
          ExtendedValueTypeSum |  512 | 347.2 ns | 5.9686 ns | 5.5831 ns |  1.01 |    0.02 |

     ReferenceTypeOptimizedSum |  512 | 254.5 ns | 2.4427 ns | 2.2849 ns |  0.74 |    0.01 |
         ValueTypeOptimizedSum |  512 | 353.0 ns | 1.9201 ns | 1.7960 ns |  1.02 |    0.01 |
 ExtendedValueTypeOptimizedSum |  512 | 280.3 ns | 1.2423 ns | 1.0374 ns |  0.81 |    0.01 |

      ReferenceTypeUnrolledSum |  512 | 213.2 ns | 1.2483 ns | 1.1676 ns |  0.62 |    0.01 |
          ValueTypeUnrolledSum |  512 | 201.3 ns | 0.6720 ns | 0.6286 ns |  0.58 |    0.01 |
  ExtendedValueTypeUnrolledSum |  512 | 223.6 ns | 1.0210 ns | 0.9550 ns |  0.65 |    0.01 |
23

Dans Haswell, Intel a introduit des stratégies supplémentaires pour la prédiction de branche pour les petites boucles (c'est pourquoi nous ne pouvons pas observer cette situation sur IvyBridge). Il semble qu'une stratégie de branche particulière dépend de nombreux facteurs, dont l'alignement du code natif. La différence entre LegacyJIT et RyuJIT peut s'expliquer par différentes stratégies d'alignement des méthodes. Malheureusement, je ne peux pas fournir tous les détails pertinents de ce phénomène de performances (Intel garde les détails de mise en œuvre secrets; mes conclusions sont basées uniquement sur mes propres expériences d'ingénierie inverse du processeur), mais je peux vous dire comment améliorer ce benchmark.

La principale astuce qui améliore vos résultats est le déroulement manuel de la boucle, ce qui est essentiel pour les nanobenchmarks sur Haswell + avec RyuJIT. Les phénomènes ci-dessus n'affectent que les petites boucles, nous pouvons donc résoudre le problème avec un énorme corps de boucle. En fait, quand vous avez une référence comme

[Benchmark]
public void MyBenchmark()
{
    Foo();
}

BenchmarkDotNet génère la boucle suivante:

for (int i = 0; i < N; i++)
{
    Foo(); Foo(); Foo(); Foo();
    Foo(); Foo(); Foo(); Foo();
    Foo(); Foo(); Foo(); Foo();
    Foo(); Foo(); Foo(); Foo();
}

Vous pouvez contrôler le nombre d'appels internes dans cette boucle via UnrollFactor. Si vous avez votre propre petite boucle à l'intérieur d'un benchmark, vous devez la dérouler de la même manière:

[Benchmark(Baseline = true)]
public int ReferenceTypeSum()
{
    var sum = 0;

    for (var i = 0; i < Size; i += 16)
    {
        sum += _referenceTypeData[i].Value;
        sum += _referenceTypeData[i + 1].Value;
        sum += _referenceTypeData[i + 2].Value;
        sum += _referenceTypeData[i + 3].Value;
        sum += _referenceTypeData[i + 4].Value;
        sum += _referenceTypeData[i + 5].Value;
        sum += _referenceTypeData[i + 6].Value;
        sum += _referenceTypeData[i + 7].Value;
        sum += _referenceTypeData[i + 8].Value;
        sum += _referenceTypeData[i + 9].Value;
        sum += _referenceTypeData[i + 10].Value;
        sum += _referenceTypeData[i + 11].Value;
        sum += _referenceTypeData[i + 12].Value;
        sum += _referenceTypeData[i + 13].Value;
        sum += _referenceTypeData[i + 14].Value;
        sum += _referenceTypeData[i + 15].Value;
    }

    return sum;
}

Une autre astuce est l'échauffement agressif (par exemple, 30 itérations). Voilà à quoi ressemble l'étape de préchauffage sur ma machine:

WorkloadWarmup   1: 4194304 op, 865744000.00 ns, 206.4095 ns/op
WorkloadWarmup   2: 4194304 op, 892164000.00 ns, 212.7085 ns/op
WorkloadWarmup   3: 4194304 op, 861913000.00 ns, 205.4961 ns/op
WorkloadWarmup   4: 4194304 op, 868044000.00 ns, 206.9578 ns/op
WorkloadWarmup   5: 4194304 op, 933894000.00 ns, 222.6577 ns/op
WorkloadWarmup   6: 4194304 op, 890567000.00 ns, 212.3277 ns/op
WorkloadWarmup   7: 4194304 op, 923509000.00 ns, 220.1817 ns/op
WorkloadWarmup   8: 4194304 op, 861953000.00 ns, 205.5056 ns/op
WorkloadWarmup   9: 4194304 op, 862454000.00 ns, 205.6251 ns/op
WorkloadWarmup  10: 4194304 op, 862565000.00 ns, 205.6515 ns/op
WorkloadWarmup  11: 4194304 op, 867301000.00 ns, 206.7807 ns/op
WorkloadWarmup  12: 4194304 op, 841892000.00 ns, 200.7227 ns/op
WorkloadWarmup  13: 4194304 op, 827717000.00 ns, 197.3431 ns/op
WorkloadWarmup  14: 4194304 op, 828257000.00 ns, 197.4719 ns/op
WorkloadWarmup  15: 4194304 op, 812275000.00 ns, 193.6615 ns/op
WorkloadWarmup  16: 4194304 op, 792011000.00 ns, 188.8301 ns/op
WorkloadWarmup  17: 4194304 op, 792607000.00 ns, 188.9722 ns/op
WorkloadWarmup  18: 4194304 op, 794428000.00 ns, 189.4064 ns/op
WorkloadWarmup  19: 4194304 op, 794879000.00 ns, 189.5139 ns/op
WorkloadWarmup  20: 4194304 op, 794914000.00 ns, 189.5223 ns/op
WorkloadWarmup  21: 4194304 op, 794061000.00 ns, 189.3189 ns/op
WorkloadWarmup  22: 4194304 op, 793385000.00 ns, 189.1577 ns/op
WorkloadWarmup  23: 4194304 op, 793851000.00 ns, 189.2688 ns/op
WorkloadWarmup  24: 4194304 op, 793456000.00 ns, 189.1747 ns/op
WorkloadWarmup  25: 4194304 op, 794194000.00 ns, 189.3506 ns/op
WorkloadWarmup  26: 4194304 op, 793980000.00 ns, 189.2996 ns/op
WorkloadWarmup  27: 4194304 op, 804402000.00 ns, 191.7844 ns/op
WorkloadWarmup  28: 4194304 op, 801002000.00 ns, 190.9738 ns/op
WorkloadWarmup  29: 4194304 op, 797860000.00 ns, 190.2246 ns/op
WorkloadWarmup  30: 4194304 op, 802668000.00 ns, 191.3710 ns/op

Par défaut, BenchmarkDotNet essaie de détecter de telles situations et d'augmenter le nombre d'itérations de préchauffage. Malheureusement, ce n'est pas toujours possible (en supposant que nous voulons avoir une étape de réchauffement "rapide" dans les cas "simples").

Et voici mes résultats (vous pouvez trouver la liste complète du benchmark mis à jour ici: https://Gist.github.com/AndreyAkinshin/4c9e0193912c99c0b314359d5c5d0a4e ):

BenchmarkDotNet=v0.11.3, OS=macOS Mojave 10.14.1 (18B75) [Darwin 18.2.0]
Intel Core i7-4870HQ CPU 2.50GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=3.0.100-preview-009812
  [Host]     : .NET Core 2.0.5 (CoreCLR 4.6.0.0, CoreFX 4.6.26018.01), 64bit RyuJIT
  Job-IHBGGW : .NET Core 2.0.5 (CoreCLR 4.6.0.0, CoreFX 4.6.26018.01), 64bit RyuJIT

IterationCount=30  WarmupCount=30  

               Method | Size |     Mean |     Error |    StdDev |   Median | Ratio | RatioSD |
--------------------- |----- |---------:|----------:|----------:|---------:|------:|--------:|
     ReferenceTypeSum |  256 | 180.7 ns | 0.4514 ns | 0.6474 ns | 180.8 ns |  1.00 |    0.00 |
         ValueTypeSum |  256 | 154.4 ns | 1.8844 ns | 2.8205 ns | 153.3 ns |  0.86 |    0.02 |
 ExtendedValueTypeSum |  256 | 183.1 ns | 2.2283 ns | 3.3352 ns | 181.1 ns |  1.01 |    0.02 |
9
AndreyAkinshin

Il s'agit en effet d'un comportement très étrange.

Le code généré pour la boucle principale pour le type de référence est le suivant:

M00_L00:
mov     r9,rcx
cmp     edx,[r9+8]
jae     ArrayOutOfBound
movsxd  r10,edx
mov     r9,[r9+r10*8+10h]
add     eax,[r9+8]
inc     edx
cmp     edx,r8d
jl      M00_L00

tandis que pour la boucle de type valeur:

M00_L00:
mov     r9,rcx
cmp     edx,[r9+8]
jae     ArrayOutOfBound
movsxd  r10,edx
add     eax,[r9+r10*4+10h]
inc     edx
cmp     edx,r8d
jl      M00_L00

La différence se résume donc à:

Pour type de référence :

mov     r9,[r9+r10*8+10h]
add     eax,[r9+8]

Pour type de valeur :

add     eax,[r9+r10*4+10h]

Avec une instruction et aucun accès indirect à la mémoire, le type de valeur devrait être plus rapide ...

J'ai essayé de le faire à travers Intel Architecture Code Analyzer et la sortie IACA pour le type de référence est:

Throughput Analysis Report
--------------------------
Block Throughput: 1.72 Cycles       Throughput Bottleneck: Dependency chains
Loop Count:  35
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
|  Port  |   0   -  DV   |   1   |   2   -  D    |   3   -  D    |   4   |   5   |   6   |   7   |
--------------------------------------------------------------------------------------------------
| Cycles |  1.0     0.0  |  1.0  |  1.5     1.5  |  1.5     1.5  |  0.0  |  1.0  |  1.0  |  0.0  |
--------------------------------------------------------------------------------------------------

DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3)
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion occurred
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256/AVX512 instruction, dozens of cycles penalty is expected
X - instruction not supported, was not accounted in Analysis

| Num Of   |                    Ports pressure in cycles                         |      |
|  Uops    |  0  - DV    |  1   |  2  -  D    |  3  -  D    |  4   |  5   |  6   |  7   |
-----------------------------------------------------------------------------------------
|   1*     |             |      |             |             |      |      |      |      | mov r9, rcx
|   2^     |             |      | 0.5     0.5 | 0.5     0.5 |      | 1.0  |      |      | cmp edx, dword ptr [r9+0x8]
|   0*F    |             |      |             |             |      |      |      |      | jnb 0x22
|   1      |             |      |             |             |      |      | 1.0  |      | movsxd r10, edx
|   1      |             |      | 0.5     0.5 | 0.5     0.5 |      |      |      |      | mov r9, qword ptr [r9+r10*8+0x10]
|   2^     | 1.0         |      | 0.5     0.5 | 0.5     0.5 |      |      |      |      | add eax, dword ptr [r9+0x8]
|   1      |             | 1.0  |             |             |      |      |      |      | inc edx
|   1*     |             |      |             |             |      |      |      |      | cmp edx, r8d
|   0*F    |             |      |             |             |      |      |      |      | jl 0xffffffffffffffe6
Total Num Of Uops: 9

Pour type de valeur :

Throughput Analysis Report
--------------------------
Block Throughput: 1.74 Cycles       Throughput Bottleneck: Dependency chains
Loop Count:  26
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
|  Port  |   0   -  DV   |   1   |   2   -  D    |   3   -  D    |   4   |   5   |   6   |   7   |
--------------------------------------------------------------------------------------------------
| Cycles |  1.0     0.0  |  1.0  |  1.0     1.0  |  1.0     1.0  |  0.0  |  1.0  |  1.0  |  0.0  |
--------------------------------------------------------------------------------------------------

DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3)
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion occurred
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256/AVX512 instruction, dozens of cycles penalty is expected
X - instruction not supported, was not accounted in Analysis

| Num Of   |                    Ports pressure in cycles                         |      |
|  Uops    |  0  - DV    |  1   |  2  -  D    |  3  -  D    |  4   |  5   |  6   |  7   |
-----------------------------------------------------------------------------------------
|   1*     |             |      |             |             |      |      |      |      | mov r9, rcx
|   2^     |             |      | 1.0     1.0 |             |      | 1.0  |      |      | cmp edx, dword ptr [r9+0x8]
|   0*F    |             |      |             |             |      |      |      |      | jnb 0x1e
|   1      |             |      |             |             |      |      | 1.0  |      | movsxd r10, edx
|   2      | 1.0         |      |             | 1.0     1.0 |      |      |      |      | add eax, dword ptr [r9+r10*4+0x10]
|   1      |             | 1.0  |             |             |      |      |      |      | inc edx
|   1*     |             |      |             |             |      |      |      |      | cmp edx, r8d
|   0*F    |             |      |             |             |      |      |      |      | jl 0xffffffffffffffea
Total Num Of Uops: 8

Il y a donc un léger avantage pour le type de référence (1,72 cycle par boucle vs 1,74 cycle)

Je ne suis pas un expert dans le déchiffrement de la sortie IACA, mais je suppose que cela est lié à l'utilisation du port (mieux distribué pour le type de référence entre 2-3)

Les "ports" sont des micro-unités d'exécution dans le CPU. Pour Skylake par exemple, ils sont divisés comme ceci (from Les tableaux d'instructions d'Agner optimisent les ressources )

Port 0: Integer, f.p. and vector ALU, mul, div, branch
Port 1: Integer, f.p. and vector ALU
Port 2: Load
Port 3: Load
Port 4: Store
Port 5: Integer and vector ALU
Port 6: Integer ALU, branch
Port 7: Store address

Cela ressemble à une optimisation de micro-instruction (uop) très subtile, mais ne peut pas expliquer pourquoi.

Notez que vous pouvez améliorer le codegen pour la boucle comme ceci:

[Benchmark]
public int ValueTypeSum()
{
    var sum = 0;

    // NOTE: Caching the array to a local variable (that will avoid the reload of the Length inside the loop)
    var arr = _valueTypeData;
    // NOTE: checking against `array.Length` instead of `Size`, to completely remove the ArrayOutOfBound checks
    for (var i = 0; i < arr.Length; i++)
    {
        sum += arr[i].Value;
    }

    return sum;
}

La boucle sera légèrement mieux optimisée et vous devriez également obtenir des résultats plus cohérents.

7
xoofx

Je pense que la raison pour laquelle le résultat est si proche est d'utiliser une taille si petite et de ne rien allouer dans le tas (à l'intérieur de votre boucle d'initialisation de tableau) pour fragmenter les éléments du tableau d'objets.

Dans votre code de référence, seuls les éléments du tableau d'objets sont alloués à partir du tas (*), de cette façon, MemoryAllocator peut allouer chaque élément séquentiellement (**) dans le tas. Lorsque le code de référence commence à s'exécuter, les données seront lues du ram vers les caches de processeur et puisque vos éléments de tableau d'objets écrits dans le ram dans un ordre séquentiel (dans un bloc contigu), ils seront mis en cache et c'est pourquoi vous n'obtenez aucun échec de cache.

Pour mieux voir cela, vous pouvez avoir un autre tableau d'objets (de préférence avec des objets plus gros) qui sera alloué sur le tas pour fragmenter vos éléments de tableau d'objets référencés. Cela peut entraîner des échecs de cache se produire plus tôt que votre configuration actuelle. Dans un scénario réel, il y aura d'autres threads qui alloueront sur le même tas et fragmenteront davantage les objets réels du tableau. L'accès à ram prend également beaucoup plus de temps que l'accès au cache cpu (ou à un cycle cpu). (Cochez ceci post concernant ce sujet).

(*) Le tableau ValueType alloue tout l'espace requis pour les éléments du tableau lorsque vous l'initialisez avec new ValueType[Size]; Les éléments du tableau ValueType seront contigus dans le ram.

(**) L'élément object objectArr [i] et objectArr [i + 1] (et ainsi de suite) seront côte à côte dans le tas, lorsque le bloc ram est mis en cache, probablement tous les éléments du tableau d'objets seront lus dans le cache cpu, donc aucun accès RAM ne sera requis lorsque vous parcourez le tableau.

5
miskender

J'ai regardé le démontage sur .NET Core 2.1 x64.

Le code de type ref me semble optimal. Le code machine charge chaque référence d'objet, puis charge le champ de chaque instance.

Les variantes de type de valeur ont une vérification de plage de tableau. Le clonage en boucle n'a pas réussi. Cette vérification de plage vient du fait que la limite supérieure de la boucle est Size. Ça devrait être array.Length pour que le JIT puisse reconnaître ce modèle et ne pas générer de vérification de plage.


Ceci est la version ref. J'ai marqué la boucle principale. L'astuce pour trouver la boucle principale est de trouver d'abord le saut arrière vers le haut de la boucle.

enter image description here

Voici la variante de valeur:

enter image description here

jae est la vérification de plage.


Il s'agit donc d'une limitation JIT. Si cela vous intéresse, ouvrez un problème GitHub sur le référentiel coreclr et dites-leur que le clonage de boucle a échoué ici.

Le JIT non hérité sur 4.7.2 a le même comportement de vérification de plage. Le code généré est identique pour la version ref:

enter image description here

Je n'ai pas regardé le code JIT hérité mais je suppose qu'il ne parvient pas à éliminer les vérifications de plage. Je crois qu'il ne prend pas en charge le clonage en boucle.

4
usr