为什么对值类型数组求和比引用类型数组慢?

25
我试图更好地理解.NET中的内存工作原理,因此我正在使用BenchmarkDotNet和诊断工具进行测试。我创建了一个基准测试,通过对数组项求和来比较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上进行求和。完整的基准代码

基准结果:

DefaultJob : .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启发(是的,没有堆分配)。

工作=LegacyJitX64 Jit=LegacyJit 平台=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;
}

完整代码请参见此处。

基准测试结果:

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

DefaultJob : .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 |

1
你的基准测试似乎是有效的。这是一个奇怪的结果。虽然所有这些数据都在CPU缓存中,但参考版本仍应该更慢。这可能是JIT生成了稍微低效的代码的情况。 - usr
是的@usr,你说得对 - 请看我的编辑。 - Maciek Świszczowski
我在不同的架构上进行了几次测试:代码在IvyBridge上表现如预期(ValueType稍微快一些),但是在Haswell上出现了奇怪的行为,并且在SkyLake上仍然存在(尽管只有3-4%的差异)。因此,答案可能与Haswell引入的优化相关。 - Kevin Gosse
4个回答

10
在Haswell架构中,英特尔推出了针对小循环的额外分支预测策略(这就是为什么我们在Ivy Bridge上无法观察到这种情况的原因)。 似乎特定的分支策略取决于许多因素,包括本机代码的对齐方式。 LegacyJIT和RyuJIT之间的差异可以通过不同的方法对齐策略来解释。 不幸的是,我无法提供此性能现象的所有相关详细信息(因为英特尔将实现细节保密,我的结论仅基于我自己进行CPU反向工程实验的经验), 但我可以告诉你如何使此基准测试更好。
改善结果的主要技巧是手动循环展开,这对于在Haswell+与RyuJIT上进行微型基准测试至关重要。 上述现象仅影响小循环,因此我们可以通过使用大型循环体来解决该问题。 事实上,当您有像下面这样的基准测试时:
[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控制此循环中的内部调用次数。如果您在基准测试中拥有自己的小循环,应以相同的方式展开它:

您可以通过使用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 |

7

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

参考类型的核心循环生成的代码如下:

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

当值类型循环时:

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]

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

我试图通过英特尔架构代码分析器运行此代码,对于引用类型的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个周期对比1.74个周期)。

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

"端口"是CPU中的微执行单元。例如,对于Skylake,它们被划分如下(来自Agner优化资源的指令表)。

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] 能够重复使用为 [r9+8] 分配的物理寄存器,与 cmp edx,[r9+8] 的区别在于此。当所有物理寄存器都用尽时,这会导致执行流水线中平均延迟更小,从而在微不足道地偏向参考版本,这种差异在基准测试的性质下被放大。 - Red Knight
1
一个验证假设的代理实验是禁用超线程运行,标准差应该更低,因为你可以使用所有物理寄存器并避免较少的资源耗尽。 - Red Knight
很奇怪你在两个循环中都看到了范围检查。你在哪个运行时上运行的? - usr

5
我认为造成结果如此接近的原因是使用了非常小的大小,并且在数组初始化循环中没有分配堆中的任何内容来分割对象数组元素。
在您的基准代码中,只有对象数组元素从堆中分配(*),这样MemoryAllocator就可以按顺序(**)在堆中分配每个元素。当基准代码开始执行时,数据将从RAM读取到CPU缓存中,由于对象数组元素按顺序(在连续块中)写入RAM,它们将被缓存,这就是为什么您不会遇到任何缓存未命中的原因。
为了更好地理解这一点,您可以拥有另一个对象数组(最好具有更大的对象),它将在堆上分配以分割您基准测试的对象数组元素。这可能会导致缓存未命中比您当前的设置更早发生。在实际情况下,将有其他线程在同一堆上分配并进一步分割数组的实际对象。访问RAM所需的时间比访问CPU缓存(或CPU周期)要长得多。(有关此主题,请查看此帖子)。
(*)ValueType数组在使用new ValueType [Size]进行初始化时会分配所有所需的数组元素的空间; ValueType数组元素将在RAM中连续。
(**)objectArr [i]对象元素和objectArr [i + 1](以及其他元素)将在堆中并排放置,当RAM块被缓存时,可能会将所有对象数组元素读取到CPU缓存中,因此在迭代数组时不需要访问RAM。

我同意连续分配可以解释为什么在低数组大小时struct并不比ref更好。但它并不能解释为什么在这些数组大小上ref始终更好,以及为什么拥有一个更大的struct实际上更快。 - Kevin Gosse
@KevinGosse 我认为结果在误差范围内,执行几百次测试会产生不同的结果。因为我们谈论的是小于20纳秒的差异。 - miskender
我在我的电脑上反复测试了数组大小为100/500/1000的情况,结果一直如此。标准差非常低,我认为这是可靠的。 - Kevin Gosse
@KevinGosse 这真的很有趣,你的基准测试套件是否具有多个运行周期选项(例如执行100次,热身10次)。我无法理解为什么更大的值类型数组应该表现更好。还可以尝试更改测试顺序。 - miskender
1
BenchmarkDotNet 在处理所有预热/迭代次数方面非常出色。我还尝试更改测试的顺序,但结果仍然一致。 - Kevin Gosse
我已经在.NET Core下更新了我的问题,并附上了结果。类似的模式。 - Maciek Świszczowski

4
我查看了.NET Core 2.1 x64的反汇编代码。
对我来说,引用类型代码看起来是最优的。机器代码正在加载每个对象引用,然后从每个实例加载字段。
值类型变量具有数组范围检查。循环克隆未成功。这个范围检查是因为循环上限是Size。它应该是array.Length,这样JIT可以识别这个模式并且不会生成一个范围检查。
这是引用版本。我标记了核心循环。找到核心循环的技巧是首先找到回跳到循环顶部的位置。

enter image description here

这是值变体:

enter image description here

jae是范围检查。
所以这是JIT的限制。如果您关心这个问题,请在coreclr存储库上开启GitHub问题,并告诉他们循环克隆在这里失败了。
4.7.2上的非遗留JIT具有相同的范围检查行为。引用版本的生成代码看起来相同:

enter image description here

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接