F#生成相同汇编的代码性能差距较大,是什么原因?
问题背景
我正尝试理解为何两段生成完全相同汇编的F#代码会出现如此大的性能差异。我定义了一个结构体用于包装数组,以强制使用度量单位(Units of Measure)作为索引,度量单位会在编译期被擦除,理论上不会带来性能开销,结构体代码如下:
[<Struct>] type StructWrapper<[<Measure>] 'Measure, 'Value> = val Values : array<'Value> new (values: array<'Value>) = { Values = values } member this.Item with get (idx: int<'Measure>) = this.Values.[int idx] member this.Length = LanguagePrimitives.Int32WithMeasure<'Measure> this.Values.Length
随后我编写了两个测试函数,使用Fasmi工具查看生成的汇编代码:
[<Measure>] type ItemIdx let test1 (x: array<float>) = let mutable acc = 0.0 let mutable idx = 0 let len = x.Length while idx < len do acc <- acc + x[idx] idx <- idx + 1 acc let test2 (x: StructWrapper<ItemIdx, float>) = let mutable acc = 0.0 let mutable idx = 0<ItemIdx> let len = x.Length while idx < len do acc <- acc + x[idx] idx <- idx + 1<ItemIdx> acc
查看结果显示test1和test2生成的汇编完全一致,内容如下:
;Arr.test1(Double[]) L0000: vzeroupper L0003: vxorps xmm0, xmm0, xmm0 L0007: xor eax, eax L0009: mov edx, [rcx+8] L000c: cmp eax, edx L000e: jge short L001e L0010: movsxd r8, eax L0013: vaddsd xmm0, xmm0, [rcx+r8*8+0x10] L001a: inc eax L001c: jmp short L000c L001e: ret ;Arr.test2(StructWrapper`1<Double>) L0000: vzeroupper L0003: vxorps xmm0, xmm0, xmm0 L0007: xor eax, eax L0009: mov edx, [rcx+8] L000c: cmp eax, edx L000e: jge short L001e L0010: movsxd r8, eax L0013: vaddsd xmm0, xmm0, [rcx+r8*8+0x10] L001a: inc eax L001c: jmp short L000c L001e: ret
但我使用BenchmarkDotNet做基准测试时却得到了差异极大的结果,我原本预期二者耗时接近,测试代码如下:
[<Measure>] type ItemIdx let iterationCount = 1_000 let numberCount = 1_000_000 let rawArray = [|1.0 .. (float numberCount)|] let structApproach = [|1.0 .. (float numberCount)|] |> StructWrapper<ItemIdx, _> type Benchmark () = [<Benchmark>] member _.RawArray () = let mutable result = 0.0 let mutable idx = 0 let len = rawArray.Length while idx < len do result <- result + rawArray.[idx] idx <- idx + 1 result [<Benchmark>] member _.StructWrapper () = let mutable result = 0.0 let mutable idx = 0<ItemIdx> let len = structApproach.Length while idx < len do result <- result + structApproach.[idx] idx <- idx + 1<ItemIdx> result
基准测试的环境与结果如下:
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
AMD Ryzen 9 3900X, 1 CPU, 24 logical and 12 physical cores
.NET SDK=6.0.100
[Host] : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT DEBUG
DefaultJob : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
| Method | Mean | Error | StdDev |
|---|---|---|---|
| RawArray | 688.8 us | 1.77 us | 1.48 us |
| StructWrapper | 2,280.1 us | 24.34 us | 20.32 us |
原因解答
核心差异在于你对比汇编的测试函数和BenchmarkDotNet测试方法的访问上下文不一致:
- 你对比汇编的
test1和test2都是把操作对象作为参数传入的,这种场景下JIT可以直接把存在寄存器(x64下为rcx)的参数值的内部字段直接用于循环,不需要额外寻址,所以汇编完全一致。 - 但Benchmark测试中的
structApproach是全局静态值类型变量,RawArray测试访问的rawArray是全局静态引用类型变量:- 静态引用类型的数组地址会被JIT在循环前加载到寄存器,循环全程不需要重新访问静态存储区
- 静态值类型的
StructWrapper访问其成员时,JIT不会把它内部的Values数组字段提升到循环外,每次索引访问都需要先从静态存储区读取结构体的Values字段,再做数组寻址,额外增加了大量内存访问开销。
你可以通过修改StructWrapper测试方法验证这个结论:在方法开头先把全局的structApproach赋值给本地变量,再基于本地变量做循环,二者的性能就会基本一致。
内容的提问来源于stack exchange,提问作者Matthew Crews
相关产品推荐
相关产品推荐

