【问题标题】:Why is summing an array of value types slower then summing an array of reference types?为什么对值类型数组求和比对引用类型数组求和要慢?
【发布时间】:2019-05-10 18:43:29
【问题描述】:

我正在尝试更好地了解内存在 .NET 中的工作原理,因此我正在使用 BenchmarkDotNet and diagnozers。我创建了一个基准,通过对数组项求和来比较 classstruct 的性能。我希望对值类型求和总是会更快。但对于短数组,它不是。谁能解释一下?

代码:

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
}

我有三个数组:

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

我使用相同的随机值集进行初始化。

然后是基准方法:

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

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

        return sum;
    }

Size 是一个基准参数。 另外两个基准测试方法(ValueTypeSumExtendedValueTypeSum)是相同的,除了我在 _valueTypeData_extendedValueTypeData 上求和。 Full code for the benchmark.

基准测试结果:

默认作业:.NET Framework 4.7.2 (CLR 4.0.30319.42000),64 位 RyuJIT-v4.7.3190.0

               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 |

核心:.NET Core 2.1.4(CoreCLR 4.6.26814.03,CoreFX 4.6.26814.02),64 位 RyuJIT

               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 |

我已经使用BranchMispredictionsCacheMisses 硬件计数器运行基准测试,但没有缓存未命中或分支错误预测。我还检查了发布 IL 代码,基准测试方法的不同之处仅在于加载引用或值类型变量的指令。

对于更大的数组大小,求和值类型数组总是更快(例如,因为值类型占用的内存更少),但我不明白为什么较短的数组会更慢。我在这里想念什么?为什么让struct 更大(参见ExtendedValueType)会使求和更快?

---- 更新----

受@usr 评论的启发,我使用 LegacyJit 重新运行了基准测试。受@Silver Shroud 启发,我还添加了内存诊断器(是的,没有堆分配)。

Job=LegacyJitX64 Jit=LegacyJit Platform=X64 运行时=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 |           - |           - |           - |                   - |

使用旧版 JIT,结果符合预期 - 但比早期结果慢!。这表明 RyuJit 做了一些神奇的性能改进,在引用类型上做得更好。

---- 更新 2 ----

感谢您的精彩回答!我学到了很多东西!

下面是另一个基准测试的结果。我正在比较 @usr 和 @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;
} 

和@AndreyAkinshin 建议的展开版本,添加了上述优化:

[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;
}

Full code here.

基准测试结果:

BenchmarkDotNet=v0.11.3,操作系统=Windows 10.0.17134.345 (1803/April2018Update/Redstone4) Intel Core i5-6400 CPU 2.70GHz (Skylake),1 个 CPU,4 个逻辑核心和 4 个物理核心 频率=2648439 Hz,分辨率=377.5809 ns,定时器=TSC

默认作业:.NET Framework 4.7.2 (CLR 4.0.30319.42000),64 位 RyuJIT-v4.7.3190.0

                        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 |

【问题讨论】:

  • 您的基准测试似乎有效。这是一个奇怪的结果。尽管所有这些数据都在 CPU 缓存中,但 ref 版本应该仍然较慢。这可能是 JIT 生成的代码效率稍低的情况。
  • 是的@usr,你是对的——看我的编辑。
  • 我在不同的架构上做了一些测试:代码在 IvyBridge 上的行为符合预期(ValueType 稍快),然后 Haswell 出现了奇怪的行为,SkyLake 仍然存在(尽管只有 3 -4% 的差异)。所以答案可能与 Haswell 引入的优化有关

标签: c# performance memory-management microbenchmark


【解决方案1】:

在 Haswell 中,英特尔为小循环引入了额外的分支预测策略(这就是我们无法在 IvyBridge 上观察到这种情况的原因)。 似乎特定的分支策略取决于许多因素,包括本机代码对齐。 LegacyJIT 和 RyuJIT 之间的差异可以通过方法的不同对齐策略来解释。 不幸的是,我无法提供这种性能现象的所有相关细节 (Intel对实现细节保密;我的结论仅基于我自己的CPU逆向工程实验), 但我可以告诉你如何让这个基准变得更好。

提高结果的主要技巧是手动循环展开,这对于使用 RyuJIT 在 Haswell+ 上进行纳米基准测试至关重要。 上述现象只影响小循环,所以我们可以用一个巨大的循环体来解决这个问题。 事实上,当你有一个像

这样的基准时
[Benchmark]
public void MyBenchmark()
{
    Foo();
}

BenchmarkDotNet 生成以下循环:

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

您可以通过UnrollFactor 控制此循环中的内部调用次数。 如果您在基准测试中有自己的小循环,您应该以同样的方式展开它:

[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;
}

另一个技巧是积极的热身(例如,30 次迭代)。 这就是我机器上预热阶段的样子:

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

默认情况下,BenchmarkDotNet 会尝试检测此类情况并增加预热迭代的次数。 不幸的是,这并不总是可能的(假设我们希望在“简单”情况下有“快速”预热阶段)。

这是我的结果(您可以在此处找到更新基准的完整列表: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 |

【讨论】:

    【解决方案2】:

    这确实是一种很奇怪的行为。

    引用类型的核心循环生成代码如下:

    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
    

    while 用于值类型循环:

    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
    

    所以区别归结为:

    对于参考类型

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

    对于值类型

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

    只有一条指令,没有间接内存访问,值类型应该更快...

    我尝试通过Intel Architecture Code Analyzer 运行它,reference type 的 IACA 输出是:

    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
    

    对于值类型

    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
    

    所以引用类型有一点优势(每个循环 1.72 个周期 vs 1.74 个周期)

    我不是破译 IACA 输出的专家,但我的猜测是它与端口使用有关(更好地分布在 2-3 之间的引用类型)

    “端口”是 CPU 中的微执行单元。以 Skylake 为例,它们是这样划分的(来自Instruction tables from Agner optimize resources

    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
    

    它看起来像是一个非常微妙的微指令(uop)优化,但无法解释原因。

    请注意,您可以像这样改进循环的代码生成:

    [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;
    }
    

    循环会得到更好的优化,你也应该得到更一致的结果。

    【讨论】:

    • 您的分析确实表明了略微放缓的原因。实际的区别是add eax,[r9+8] 能够重用分配给cmp edx,[r9+8] 的[r9+8] 的物理寄存器,当所有物理寄存器都用尽时,这平均会导致执行管道中的延迟更小,从而使结果无限偏向参考版本,它被基准的性质放大。
    • 代理实验比证明或反驳假设是在禁用超线程的情况下运行,标准偏差应该更低,因为您可以使用所有物理寄存器并减少消耗。
    • 奇怪的是,您在两个循环中都看到了范围检查。你是在什么运行时运行的?
    【解决方案3】:

    我认为结果如此接近的原因是使用了非常小的大小,并且没有在堆中(在数组初始化循环内)分配任何东西来分割对象数组元素。

    在您的基准代码中,只有对象数组元素从堆(*)中分配,这样 MemoryAllocator 可以在堆中顺序分配每个元素(**)。当基准代码开始执行时,数据将从 ram 读取到 cpu 缓存,并且由于您的对象数组元素按顺序(以连续块)顺序写入 ram,它们将被缓存,这就是为什么您不会得到任何缓存未命中的原因。

    为了更好地了解这一点,您可以拥有另一个对象数组(最好是更大的对象),它将在堆上分配以分割您的基准对象数组元素。这可能会导致缓存未命中比您当前的设置更早发生。在现实生活场景中,会有其他线程在同一个堆上分配,并进一步分割数组的实际对象。访问 ram 也比访问 cpu 缓存(或 cpu 周期)花费更多的时间。 (检查此post 关于此主题)。

    (*) ValueType 数组在使用new ValueType[Size] 初始化时为数组元素分配所需的所有空间; ValueType 数组元素在内存中是连续的。

    (**) objectArr[i]对象元素和objectArr[i+1](等等)会在堆中并排,当ram block缓存时,可能所有对象数组元素都会被读取到cpu缓存,因此当您遍历数组时不需要 ram 访问。

    【讨论】:

    • 我同意连续分配可以解释为什么 struct 不比数组大小小的 ref 更好。但这并没有解释为什么 ref 在这些数组大小上总是更好,以及为什么拥有更大的结构实际上更快
    • @KevinGosse 我认为结果在误差范围内,执行 100 次测试会产生不同的结果。因为我们谈论的是低于 20ns 的差异。
    • 我在我的计算机上始终如一地重现这些结果,数组大小为 100/500/1000。 stddev 相当低,我认为它是可靠的
    • @KevinGosse 这真的很有趣,您的基准测试套件是否有多个运行周期选项(例如执行 100 次和 10 次预热)。我无法理解为什么更大的值类型数组应该表现更好的原因。也可以尝试改变测试的顺序。
    • BenchmarkDotNet 非常擅长处理所有的预热/迭代次数。我也试过改变测试的顺序,但结果还是一致的
    【解决方案4】:

    我查看了 .NET Core 2.1 x64 上的反汇编。

    ref 类型代码对我来说是最佳选择。机器代码正在加载每个对象引用,然后从每个实例中加载字段。

    值类型变体具有数组范围检查。循环克隆没有成功。这个范围检查是因为循环上限是Size。它应该是array.Length,以便 JIT 可以识别此模式而不生成范围检查。


    这是参考版本。我已经标记了核心循环。找到核心循环的诀窍是先找到返回到循环顶部的跳转。

    这是值变体:

    jae 是范围检查。


    所以这是 JIT 限制。如果您对此感兴趣,请在 coreclr 存储库上打开一个 GitHub 问题并告诉他们循环克隆在此处失败。

    4.7.2 上的非旧版 JIT 具有相同的范围检查行为。生成的代码看起来与 ref 版本相同:

    我没有查看过旧的 JIT 代码,但我认为它无法消除任何范围检查。我相信它不支持循环克隆。

    【讨论】:

      猜你喜欢
      • 2017-01-28
      • 2012-09-05
      • 2021-08-12
      • 1970-01-01
      • 2022-09-28
      • 1970-01-01
      • 2014-07-28
      相关资源
      最近更新 更多