为什么对值类型数组求和比对引用类型数组求和慢?
Why is summing an array of value types slower then summing an array of reference types?
我试图更好地理解内存在 .NET 中的工作原理,所以我正在玩 BenchmarkDotNet and diagnozers。我创建了一个基准,通过对数组项求和来比较 class
和 struct
的性能。我希望求和值类型总是会更快。但对于短数组来说不是。谁能解释一下?
代码:
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
是基准参数。
其他两个基准方法(ValueTypeSum
和 ExtendedValueTypeSum
)是相同的,除了我在 _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 |
我已经 运行 使用 BranchMispredictions
和 CacheMisses
硬件计数器进行基准测试,但没有缓存未命中也没有分支预测错误。我还检查了发布 IL 代码,基准测试方法仅在加载引用或值类型变量的指令上有所不同。
对于更大的数组,求和值类型数组总是更快(例如,因为值类型占用更少的内存),但我不明白为什么对于更短的数组求和更慢。我在这里想念什么?为什么将 struct
变大(参见 ExtendedValueType
)会使求和速度更快一些?
----更新----
受到@usr 的评论的启发,我重新运行 了 LegacyJit 的基准测试。我还添加了受@Silver Shroud 启发的内存诊断器(是的,没有堆分配)。
Job=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, OS=Windows 10.0.17134.345 (1803/April2018Update/Redstone4)
英特尔酷睿 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 |
我认为结果如此接近的原因是使用了一个非常小的大小并且没有在堆中(在你的数组初始化循环内)分配任何东西来分割对象数组元素。
在您的基准测试代码中,只有对象数组元素从堆中分配 (*),这样 MemoryAllocator 可以按顺序 (**) 在堆中分配每个元素。当基准代码开始执行时,数据将从 ram 读取到 cpu 缓存,并且由于您的对象数组元素按顺序(在连续块中)写入 ram,因此它们将被缓存,这就是为什么您没有得到任何缓存未命中。
为了更好地了解这一点,您可以有另一个对象数组(最好有更大的对象),它将在堆上分配以分段您的基准对象数组元素。这可能会导致缓存未命中早于您当前的设置发生。在现实生活中,会有其他线程在同一个堆上进行分配,并进一步分割数组的实际对象。此外,访问 ram 比访问 cpu 缓存(或 cpu 周期)花费更多的时间。 (检查此 post 关于此主题)。
(*) ValueType 数组在您使用 new ValueType[Size]
初始化数组元素时分配数组元素所需的所有 space; ValueType 数组元素在 ram 中将是连续的。
(**) objectArr[i]对象元素和objectArr[i+1](以此类推)会并排在堆中,当ram block缓存时,可能会读取所有对象数组元素cpu 缓存,因此在遍历数组时不需要 ram 访问。
这确实是一个很奇怪的行为。
引用类型的核心循环生成代码如下:
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 之间的引用类型)
"port"是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;
}
循环会稍微优化一些,你应该也会得到更一致的结果。
我查看了 .NET Core 2.1 x64 上的反汇编。
ref 类型代码对我来说看起来是最佳的。机器代码正在加载每个对象引用,然后从每个实例加载字段。
值类型变体具有数组范围检查。循环克隆没有成功。这个范围检查来是因为循环上限是Size
。它应该是 array.Length
以便 JIT 可以识别此模式而不生成范围检查。
这是参考版。我已经标记了核心循环。找到核心循环的技巧是先找到回跳到循环的顶部。
这是值变体:
jae
是范围检查。
所以这是一个 JIT 限制。如果您关心这个问题,请在 coreclr 存储库上打开一个 GitHub 问题,并告诉他们这里循环克隆失败。
4.7.2 上的非遗留 JIT 具有相同的范围检查行为。生成的代码对于 ref 版本看起来是一样的:
我没有查看遗留的 JIT 代码,但我认为它无法消除任何范围检查。我相信它不支持循环克隆。
在 Haswell 中,Intel 引入了额外的小循环分支预测策略(这就是为什么我们无法在 IvyBridge 上观察到这种情况)。
似乎特定的分支策略取决于许多因素,包括本机代码对齐。
LegacyJIT 和 RyuJIT 之间的差异可以通过不同的方法对齐策略来解释。
不幸的是,我无法提供此性能现象的所有相关详细信息
(英特尔对实施细节保密;我的结论仅基于我自己的 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 会尝试检测此类情况并增加预热迭代次数。
不幸的是,这并不总是可能的(假设我们希望在 "simple" 情况下有 "fast" 预热阶段)。
这是我的结果(您可以在此处找到更新基准的完整列表: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 |
我试图更好地理解内存在 .NET 中的工作原理,所以我正在玩 BenchmarkDotNet and diagnozers。我创建了一个基准,通过对数组项求和来比较 class
和 struct
的性能。我希望求和值类型总是会更快。但对于短数组来说不是。谁能解释一下?
代码:
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
是基准参数。
其他两个基准方法(ValueTypeSum
和 ExtendedValueTypeSum
)是相同的,除了我在 _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 |
我已经 运行 使用 BranchMispredictions
和 CacheMisses
硬件计数器进行基准测试,但没有缓存未命中也没有分支预测错误。我还检查了发布 IL 代码,基准测试方法仅在加载引用或值类型变量的指令上有所不同。
对于更大的数组,求和值类型数组总是更快(例如,因为值类型占用更少的内存),但我不明白为什么对于更短的数组求和更慢。我在这里想念什么?为什么将 struct
变大(参见 ExtendedValueType
)会使求和速度更快一些?
----更新----
受到@usr 的评论的启发,我重新运行 了 LegacyJit 的基准测试。我还添加了受@Silver Shroud 启发的内存诊断器(是的,没有堆分配)。
Job=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, OS=Windows 10.0.17134.345 (1803/April2018Update/Redstone4) 英特尔酷睿 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 |
我认为结果如此接近的原因是使用了一个非常小的大小并且没有在堆中(在你的数组初始化循环内)分配任何东西来分割对象数组元素。
在您的基准测试代码中,只有对象数组元素从堆中分配 (*),这样 MemoryAllocator 可以按顺序 (**) 在堆中分配每个元素。当基准代码开始执行时,数据将从 ram 读取到 cpu 缓存,并且由于您的对象数组元素按顺序(在连续块中)写入 ram,因此它们将被缓存,这就是为什么您没有得到任何缓存未命中。
为了更好地了解这一点,您可以有另一个对象数组(最好有更大的对象),它将在堆上分配以分段您的基准对象数组元素。这可能会导致缓存未命中早于您当前的设置发生。在现实生活中,会有其他线程在同一个堆上进行分配,并进一步分割数组的实际对象。此外,访问 ram 比访问 cpu 缓存(或 cpu 周期)花费更多的时间。 (检查此 post 关于此主题)。
(*) ValueType 数组在您使用 new ValueType[Size]
初始化数组元素时分配数组元素所需的所有 space; ValueType 数组元素在 ram 中将是连续的。
(**) objectArr[i]对象元素和objectArr[i+1](以此类推)会并排在堆中,当ram block缓存时,可能会读取所有对象数组元素cpu 缓存,因此在遍历数组时不需要 ram 访问。
这确实是一个很奇怪的行为。
引用类型的核心循环生成代码如下:
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 之间的引用类型)
"port"是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;
}
循环会稍微优化一些,你应该也会得到更一致的结果。
我查看了 .NET Core 2.1 x64 上的反汇编。
ref 类型代码对我来说看起来是最佳的。机器代码正在加载每个对象引用,然后从每个实例加载字段。
值类型变体具有数组范围检查。循环克隆没有成功。这个范围检查来是因为循环上限是Size
。它应该是 array.Length
以便 JIT 可以识别此模式而不生成范围检查。
这是参考版。我已经标记了核心循环。找到核心循环的技巧是先找到回跳到循环的顶部。
这是值变体:
jae
是范围检查。
所以这是一个 JIT 限制。如果您关心这个问题,请在 coreclr 存储库上打开一个 GitHub 问题,并告诉他们这里循环克隆失败。
4.7.2 上的非遗留 JIT 具有相同的范围检查行为。生成的代码对于 ref 版本看起来是一样的:
我没有查看遗留的 JIT 代码,但我认为它无法消除任何范围检查。我相信它不支持循环克隆。
在 Haswell 中,Intel 引入了额外的小循环分支预测策略(这就是为什么我们无法在 IvyBridge 上观察到这种情况)。 似乎特定的分支策略取决于许多因素,包括本机代码对齐。 LegacyJIT 和 RyuJIT 之间的差异可以通过不同的方法对齐策略来解释。 不幸的是,我无法提供此性能现象的所有相关详细信息 (英特尔对实施细节保密;我的结论仅基于我自己的 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 会尝试检测此类情况并增加预热迭代次数。 不幸的是,这并不总是可能的(假设我们希望在 "simple" 情况下有 "fast" 预热阶段)。
这是我的结果(您可以在此处找到更新基准的完整列表: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 |