假设
对于任何简单操作,包含单个基元的readonly struct
应该或多或少地与基元本身一样快。
测试
下面的所有测试都在Windows 7 x64上运行.NET Core 2.2,代码经过优化。在 .NET 4.7.2 上进行测试时,我也得到了类似的结果。
测试:多头
用long
类型测试这个前提,似乎成立:
// =============== SETUP ===================
public readonly struct LongStruct
{
public readonly long Primitive;
public LongStruct(long value) => Primitive = value;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static LongStruct Add(in LongStruct lhs, in LongStruct rhs)
=> new LongStruct(lhs.Primitive + rhs.Primitive);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long LongAdd(long lhs, long rhs) => lhs + rhs;
// =============== TESTS ===================
public static void TestLong(long a, long b, out long result)
{
var sw = Stopwatch.StartNew();
for (var i = 1000000000; i > 0; --i)
{
a = LongAdd(a, b);
}
sw.Stop();
result = a;
return sw.ElapsedMilliseconds;
}
public static void TestLongStruct(LongStruct a, LongStruct b, out LongStruct result)
{
var sw = Stopwatch.StartNew();
for (var i = 1000000000; i > 0; --i)
{
a = LongStruct.Add(a, b);
}
sw.Stop();
result = a;
return sw.ElapsedMilliseconds;
}
// ============= TEST LOOP =================
public static void RunTests()
{
var longStruct = new LongStruct(1);
var count = 0;
var longTime = 0L;
var longStructTime = 0L;
while (true)
{
count++;
Console.WriteLine("Test #" + count);
longTime += TestLong(1, 1, out var longResult);
var longMean = longTime / count;
Console.WriteLine($"Long: value={longResult}, Mean Time elapsed: {longMean} ms");
longStructTime += TestLongStruct(longStruct, longStruct, out var longStructResult);
var longStructMean = longStructTime / count;
Console.WriteLine($"LongStruct: value={longStructResult.Primitive}, Mean Time elapsed: {longStructMean} ms");
Console.WriteLine();
}
}
使用LongAdd
,以便测试循环匹配 - 每个循环都调用一个执行一些添加的方法,而不是为基元情况内联
在我的机器上,这两个时间已经稳定在彼此的2%以内,足够接近,我相信它们已经优化为几乎相同的代码。
IL 的差异相当小:
- 测试循环代码是相同的,只是调用了哪个方法(
LongAdd
vsLongStruct.Add
)。 LongStruct.Add
有一些额外的说明:- 一对
ldfld
指令,用于从结构加载Primitive
- 将新
long
装回LongStruct
的newobj
说明
- 一对
因此,要么抖动优化了这些指令,要么它们基本上是免费的。
测试:双打
如果我采用上面的代码并将每个long
替换为double
,我期望相同的结果(绝对值较慢,因为添加指令会稍慢,但两者的幅度相同)。
我实际看到的是DoubleStruct
版本比double
版本慢约4.8倍(即480%)。
IL 与long
情况相同(除了将int64
和LongStruct
交换为float64
和DoubleStruct
),但不知何故,运行时正在为DoubleStruct
情况执行大量额外的工作,而这些工作在LongStruct
情况或double
情况下不存在。
测试:其他类型
测试其他一些原始类型,我发现float
(465%)的行为方式与double
相同,而short
和int
的行为方式与long
相同,因此似乎与浮点有关,导致无法进行某些优化。
问题
为什么DoubleStruct
和FloatStruct
比double
和float
慢得多,long
、int
和short
等价物没有这种减速?
这本身并不是一个答案,但它在 x86 和 x64 上都是一个更严格的基准测试,所以希望它能为其他人提供更多的信息来解释这一点。
我试图用BenchmarkDotNet复制这一点。我还想看看删除in
会有什么不同。我分别将其作为 x86 和 x64 运行。
x86 (旧版)
| Method | Mean | Error | StdDev |
|----------------------- |---------:|---------:|---------:|
| TestLong | 257.9 ms | 2.099 ms | 1.964 ms |
| TestLongStruct | 529.3 ms | 4.977 ms | 4.412 ms |
| TestLongStructWithIn | 526.2 ms | 6.722 ms | 6.288 ms |
| TestDouble | 256.7 ms | 1.466 ms | 1.300 ms |
| TestDoubleStruct | 342.5 ms | 5.189 ms | 4.600 ms |
| TestDoubleStructWithIn | 338.7 ms | 3.808 ms | 3.376 ms |
x64 (龙吉特)
| Method | Mean | Error | StdDev |
|----------------------- |-----------:|----------:|----------:|
| TestLong | 269.8 ms | 5.359 ms | 9.099 ms |
| TestLongStruct | 266.2 ms | 6.706 ms | 8.236 ms |
| TestLongStructWithIn | 270.4 ms | 4.150 ms | 3.465 ms |
| TestDouble | 270.4 ms | 5.336 ms | 6.748 ms |
| TestDoubleStruct | 1,250.9 ms | 24.702 ms | 25.367 ms |
| TestDoubleStructWithIn | 577.1 ms | 12.159 ms | 16.644 ms |
我可以使用 RyuJIT 在 x64 上复制它,但不能在 x86 上使用 LegacyJIT 复制它。这似乎是 RyuJIT 管理优化long
情况的工件,但不是double
情况 - LegacyJIT 也没有管理优化。
我不知道为什么TestDoubleStruct在RyuJIT上是一个异常值。
法典:
public readonly struct LongStruct
{
public readonly long Primitive;
public LongStruct(long value) => Primitive = value;
public static LongStruct Add(LongStruct lhs, LongStruct rhs)
=> new LongStruct(lhs.Primitive + rhs.Primitive);
public static LongStruct AddWithIn(in LongStruct lhs, in LongStruct rhs)
=> new LongStruct(lhs.Primitive + rhs.Primitive);
}
public readonly struct DoubleStruct
{
public readonly double Primitive;
public DoubleStruct(double value) => Primitive = value;
public static DoubleStruct Add(DoubleStruct lhs, DoubleStruct rhs)
=> new DoubleStruct(lhs.Primitive + rhs.Primitive);
public static DoubleStruct AddWithIn(in DoubleStruct lhs, in DoubleStruct rhs)
=> new DoubleStruct(lhs.Primitive + rhs.Primitive);
}
public class Benchmark
{
[Benchmark]
public void TestLong()
{
for (var i = 1000000000; i > 0; --i)
{
LongAdd(1, 2);
}
}
[Benchmark]
public void TestLongStruct()
{
var a = new LongStruct(1);
var b = new LongStruct(2);
for (var i = 1000000000; i > 0; --i)
{
LongStruct.Add(a, b);
}
}
[Benchmark]
public void TestLongStructWithIn()
{
var a = new LongStruct(1);
var b = new LongStruct(2);
for (var i = 1000000000; i > 0; --i)
{
LongStruct.AddWithIn(a, b);
}
}
[Benchmark]
public void TestDouble()
{
for (var i = 1000000000; i > 0; --i)
{
DoubleAdd(1, 2);
}
}
[Benchmark]
public void TestDoubleStruct()
{
var a = new DoubleStruct(1);
var b = new DoubleStruct(2);
for (var i = 1000000000; i > 0; --i)
{
DoubleStruct.Add(a, b);
}
}
[Benchmark]
public void TestDoubleStructWithIn()
{
var a = new DoubleStruct(1);
var b = new DoubleStruct(2);
for (var i = 1000000000; i > 0; --i)
{
DoubleStruct.AddWithIn(a, b);
}
}
public static long LongAdd(long lhs, long rhs) => lhs + rhs;
public static double DoubleAdd(double lhs, double rhs) => lhs + rhs;
}
class Program
{
static void Main(string[] args)
{
var summary = BenchmarkRunner.Run<Benchmark>();
Console.ReadLine();
}
}
为了好玩,下面是两种情况的 x64 程序集:
法典
using System;
public class C {
public long AddLongs(long a, long b) {
return a + b;
}
public LongStruct AddLongStructs(LongStruct a, LongStruct b) {
return LongStruct.Add(a, b);
}
public LongStruct AddLongStructsWithIn(LongStruct a, LongStruct b) {
return LongStruct.AddWithIn(a, b);
}
public double AddDoubles(double a, double b) {
return a + b;
}
public DoubleStruct AddDoubleStructs(DoubleStruct a, DoubleStruct b) {
return DoubleStruct.Add(a, b);
}
public DoubleStruct AddDoubleStructsWithIn(DoubleStruct a, DoubleStruct b) {
return DoubleStruct.AddWithIn(a, b);
}
}
public readonly struct LongStruct
{
public readonly long Primitive;
public LongStruct(long value) => Primitive = value;
public static LongStruct Add(LongStruct lhs, LongStruct rhs)
=> new LongStruct(lhs.Primitive + rhs.Primitive);
public static LongStruct AddWithIn(in LongStruct lhs, in LongStruct rhs)
=> new LongStruct(lhs.Primitive + rhs.Primitive);
}
public readonly struct DoubleStruct
{
public readonly double Primitive;
public DoubleStruct(double value) => Primitive = value;
public static DoubleStruct Add(DoubleStruct lhs, DoubleStruct rhs)
=> new DoubleStruct(lhs.Primitive + rhs.Primitive);
public static DoubleStruct AddWithIn(in DoubleStruct lhs, in DoubleStruct rhs)
=> new DoubleStruct(lhs.Primitive + rhs.Primitive);
}
x86 程序集
C.AddLongs(Int64, Int64)
L0000: mov eax, [esp+0xc]
L0004: mov edx, [esp+0x10]
L0008: add eax, [esp+0x4]
L000c: adc edx, [esp+0x8]
L0010: ret 0x10
C.AddLongStructs(LongStruct, LongStruct)
L0000: push esi
L0001: mov eax, [esp+0x10]
L0005: mov esi, [esp+0x14]
L0009: add eax, [esp+0x8]
L000d: adc esi, [esp+0xc]
L0011: mov [edx], eax
L0013: mov [edx+0x4], esi
L0016: pop esi
L0017: ret 0x10
C.AddLongStructsWithIn(LongStruct, LongStruct)
L0000: push esi
L0001: mov eax, [esp+0x10]
L0005: mov esi, [esp+0x14]
L0009: add eax, [esp+0x8]
L000d: adc esi, [esp+0xc]
L0011: mov [edx], eax
L0013: mov [edx+0x4], esi
L0016: pop esi
L0017: ret 0x10
C.AddDoubles(Double, Double)
L0000: fld qword [esp+0xc]
L0004: fadd qword [esp+0x4]
L0008: ret 0x10
C.AddDoubleStructs(DoubleStruct, DoubleStruct)
L0000: fld qword [esp+0xc]
L0004: fld qword [esp+0x4]
L0008: faddp st1, st0
L000a: fstp qword [edx]
L000c: ret 0x10
C.AddDoubleStructsWithIn(DoubleStruct, DoubleStruct)
L0000: fld qword [esp+0xc]
L0004: fadd qword [esp+0x4]
L0008: fstp qword [edx]
L000a: ret 0x10
x64 程序集
C..ctor()
L0000: ret
C.AddLongs(Int64, Int64)
L0000: lea rax, [rdx+r8]
L0004: ret
C.AddLongStructs(LongStruct, LongStruct)
L0000: lea rax, [rdx+r8]
L0004: ret
C.AddLongStructsWithIn(LongStruct, LongStruct)
L0000: lea rax, [rdx+r8]
L0004: ret
C.AddDoubles(Double, Double)
L0000: vzeroupper
L0003: vmovaps xmm0, xmm1
L0008: vaddsd xmm0, xmm0, xmm2
L000d: ret
C.AddDoubleStructs(DoubleStruct, DoubleStruct)
L0000: sub rsp, 0x18
L0004: vzeroupper
L0007: mov [rsp+0x28], rdx
L000c: mov [rsp+0x30], r8
L0011: mov rax, [rsp+0x28]
L0016: mov [rsp+0x10], rax
L001b: mov rax, [rsp+0x30]
L0020: mov [rsp+0x8], rax
L0025: vmovsd xmm0, qword [rsp+0x10]
L002c: vaddsd xmm0, xmm0, [rsp+0x8]
L0033: vmovsd [rsp], xmm0
L0039: mov rax, [rsp]
L003d: add rsp, 0x18
L0041: ret
C.AddDoubleStructsWithIn(DoubleStruct, DoubleStruct)
L0000: push rax
L0001: vzeroupper
L0004: mov [rsp+0x18], rdx
L0009: mov [rsp+0x20], r8
L000e: vmovsd xmm0, qword [rsp+0x18]
L0015: vaddsd xmm0, xmm0, [rsp+0x20]
L001c: vmovsd [rsp], xmm0
L0022: mov rax, [rsp]
L0026: add rsp, 0x8
L002a: ret
夏普实验室
如果在循环中添加:
法典
public class C {
public void AddLongs(long a, long b) {
for (var i = 1000000000; i > 0; --i) {
long c = a + b;
}
}
public void AddLongStructs(LongStruct a, LongStruct b) {
for (var i = 1000000000; i > 0; --i) {
a = LongStruct.Add(a, b);
}
}
public void AddLongStructsWithIn(LongStruct a, LongStruct b) {
for (var i = 1000000000; i > 0; --i) {
a = LongStruct.AddWithIn(a, b);
}
}
public void AddDoubles(double a, double b) {
for (var i = 1000000000; i > 0; --i) {
a = a + b;
}
}
public void AddDoubleStructs(DoubleStruct a, DoubleStruct b) {
for (var i = 1000000000; i > 0; --i) {
a = DoubleStruct.Add(a, b);
}
}
public void AddDoubleStructsWithIn(DoubleStruct a, DoubleStruct b) {
for (var i = 1000000000; i > 0; --i) {
a = DoubleStruct.AddWithIn(a, b);
}
}
}
public readonly struct LongStruct
{
public readonly long Primitive;
public LongStruct(long value) => Primitive = value;
public static LongStruct Add(LongStruct lhs, LongStruct rhs)
=> new LongStruct(lhs.Primitive + rhs.Primitive);
public static LongStruct AddWithIn(in LongStruct lhs, in LongStruct rhs)
=> new LongStruct(lhs.Primitive + rhs.Primitive);
}
public readonly struct DoubleStruct
{
public readonly double Primitive;
public DoubleStruct(double value) => Primitive = value;
public static DoubleStruct Add(DoubleStruct lhs, DoubleStruct rhs)
=> new DoubleStruct(lhs.Primitive + rhs.Primitive);
public static DoubleStruct AddWithIn(in DoubleStruct lhs, in DoubleStruct rhs)
=> new DoubleStruct(lhs.Primitive + rhs.Primitive);
}
x86
C.AddLongs(Int64, Int64)
L0000: push ebp
L0001: mov ebp, esp
L0003: mov eax, 0x3b9aca00
L0008: dec eax
L0009: test eax, eax
L000b: jg L0008
L000d: pop ebp
L000e: ret 0x10
C.AddLongStructs(LongStruct, LongStruct)
L0000: push ebp
L0001: mov ebp, esp
L0003: push esi
L0004: mov esi, 0x3b9aca00
L0009: mov eax, [ebp+0x10]
L000c: mov edx, [ebp+0x14]
L000f: add eax, [ebp+0x8]
L0012: adc edx, [ebp+0xc]
L0015: mov [ebp+0x10], eax
L0018: mov [ebp+0x14], edx
L001b: dec esi
L001c: test esi, esi
L001e: jg L0009
L0020: pop esi
L0021: pop ebp
L0022: ret 0x10
C.AddLongStructsWithIn(LongStruct, LongStruct)
L0000: push ebp
L0001: mov ebp, esp
L0003: push esi
L0004: mov esi, 0x3b9aca00
L0009: mov eax, [ebp+0x10]
L000c: mov edx, [ebp+0x14]
L000f: add eax, [ebp+0x8]
L0012: adc edx, [ebp+0xc]
L0015: mov [ebp+0x10], eax
L0018: mov [ebp+0x14], edx
L001b: dec esi
L001c: test esi, esi
L001e: jg L0009
L0020: pop esi
L0021: pop ebp
L0022: ret 0x10
C.AddDoubles(Double, Double)
L0000: push ebp
L0001: mov ebp, esp
L0003: mov eax, 0x3b9aca00
L0008: dec eax
L0009: test eax, eax
L000b: jg L0008
L000d: pop ebp
L000e: ret 0x10
C.AddDoubleStructs(DoubleStruct, DoubleStruct)
L0000: push ebp
L0001: mov ebp, esp
L0003: mov eax, 0x3b9aca00
L0008: fld qword [ebp+0x10]
L000b: fld qword [ebp+0x8]
L000e: faddp st1, st0
L0010: fstp qword [ebp+0x10]
L0013: dec eax
L0014: test eax, eax
L0016: jg L0008
L0018: pop ebp
L0019: ret 0x10
C.AddDoubleStructsWithIn(DoubleStruct, DoubleStruct)
L0000: push ebp
L0001: mov ebp, esp
L0003: mov eax, 0x3b9aca00
L0008: fld qword [ebp+0x10]
L000b: fadd qword [ebp+0x8]
L000e: fstp qword [ebp+0x10]
L0011: dec eax
L0012: test eax, eax
L0014: jg L0008
L0016: pop ebp
L0017: ret 0x10
x64
C.AddLongs(Int64, Int64)
L0000: mov eax, 0x3b9aca00
L0005: dec eax
L0007: test eax, eax
L0009: jg L0005
L000b: ret
C.AddLongStructs(LongStruct, LongStruct)
L0000: mov eax, 0x3b9aca00
L0005: add rdx, r8
L0008: dec eax
L000a: test eax, eax
L000c: jg L0005
L000e: ret
C.AddLongStructsWithIn(LongStruct, LongStruct)
L0000: mov eax, 0x3b9aca00
L0005: add rdx, r8
L0008: dec eax
L000a: test eax, eax
L000c: jg L0005
L000e: ret
C.AddDoubles(Double, Double)
L0000: vzeroupper
L0003: mov eax, 0x3b9aca00
L0008: vaddsd xmm1, xmm1, xmm2
L000d: dec eax
L000f: test eax, eax
L0011: jg L0008
L0013: ret
C.AddDoubleStructs(DoubleStruct, DoubleStruct)
L0000: sub rsp, 0x18
L0004: vzeroupper
L0007: mov [rsp+0x28], rdx
L000c: mov [rsp+0x30], r8
L0011: mov eax, 0x3b9aca00
L0016: mov rdx, [rsp+0x28]
L001b: mov [rsp+0x10], rdx
L0020: mov rdx, [rsp+0x30]
L0025: mov [rsp+0x8], rdx
L002a: vmovsd xmm0, qword [rsp+0x10]
L0031: vaddsd xmm0, xmm0, [rsp+0x8]
L0038: vmovsd [rsp], xmm0
L003e: mov rdx, [rsp]
L0042: mov [rsp+0x28], rdx
L0047: dec eax
L0049: test eax, eax
L004b: jg L0016
L004d: add rsp, 0x18
L0051: ret
C.AddDoubleStructsWithIn(DoubleStruct, DoubleStruct)
L0000: push rax
L0001: vzeroupper
L0004: mov [rsp+0x18], rdx
L0009: mov [rsp+0x20], r8
L000e: mov eax, 0x3b9aca00
L0013: vmovsd xmm0, qword [rsp+0x20]
L001a: vmovaps xmm1, xmm0
L001f: vaddsd xmm1, xmm1, [rsp+0x18]
L0026: vmovsd [rsp], xmm1
L002c: mov rdx, [rsp]
L0030: mov [rsp+0x18], rdx
L0035: dec eax
L0037: test eax, eax
L0039: jg L001a
L003b: add rsp, 0x8
L003f: ret
夏普实验室
我对组装不够熟悉,无法解释它到底在做什么,但很明显,AddDoubleStructs
年进行的工作比AddLongStructs
多。
请参阅@canton7的答案,了解我的结论所依据的一些计时结果和 x86 asm 输出。 (我没有Windows或C#编译器)。
异常:SharpLab上循环的"发布"asm与@canton7的BenchmarkDotNet性能数据不匹配。 asm 显示TestDouble
确实在循环内执行a+=b
,但时序显示它的运行速度与 1/时钟整数循环一样快。(FP 添加延迟在所有 AMD K8/K10/Bulldozer-family/Ryzen 和 Intel P6 上为 3 到 5 个周期,通过 Skylake。
也许这只是第一次优化,运行更长时间后,JIT 将完全优化 FP 添加(因为未返回该值)。所以我认为不幸的是,我们仍然没有真正运行 asm,但我们可以看到 JIT 优化器造成的混乱。
我不明白TestDoubleStructWithIn
怎么会比整数循环慢,但只有两倍慢(不是 3 倍),除非long
循环不是以每时钟 1 次迭代的速度运行。 有了如此高的计数,启动开销应该可以忽略不计。 保存在内存中的循环计数器可以解释它(对所有内容施加每次迭代~6个周期的瓶颈,隐藏除非常慢的FP版本之外的任何延迟。 但@canton7表示,他们使用发布版本进行了测试。 但是由于功率/热量限制,他们的i7-8650U可能无法在所有环路中保持最大涡轮增压= 4.20 GHz。 (全核最小持续频率 = 1.90 GHz),所以以秒为单位查看时间而不是周期可能会让我们在没有瓶颈的情况下进行循环? 这仍然不能解释原始双精度与长相同的速度;这些一定是被优化掉了。
期望这个类以你使用它的方式内联和优化是合理的。 一个好的编译器会做到这一点。 但是 JIT 必须快速编译,所以它并不总是好的,显然在这种情况下不适合double
.
对于整数循环,x86-64 上的 64 位整数加法具有 1 个周期延迟,而现代超标量 CPU 具有足够的吞吐量来运行包含加法的循环,其速度与仅倒计时计数器的空循环相同。 因此,我们无法从时间上判断编译器是否在循环之外a + b * 1000000000
(但仍运行空循环),或者什么。
@canton7使用SharpLab查看JIT x86-64 asm的独立版本AddDoubleStructs
,以及调用它的循环。 独立和循环,x86-64,发布模式。
我们可以看到,对于原始long c = a + b
它完全优化了添加(但保留了一个空的倒计时循环)! 如果我们使用a = a+b;
我们会得到一个实际的add
指令,即使a
不是从函数返回的。
loops.AddLongs(Int64, Int64)
L0000: mov eax, 0x3b9aca00 # i = init
# do {
# long c = a+b optimized out
L0005: dec eax # --i;
L0007: test eax, eax
L0009: jg L0005 # }while(i>0);
L000b: ret
但是结构版本有一个实际的add
指令,来自a = LongStruct.Add(a, b);
. (我们确实得到了同样a = a+b;
原始long
。
loops.AddLongStructs(LongStruct a, LongStruct b)
L0000: mov eax, 0x3b9aca00
L0005: add rdx, r8 # a += b; other insns are identical
L0008: dec eax
L000a: test eax, eax
L000c: jg L0005
L000e: ret
但是,如果我们将其更改为LongStruct.Add(a, b);
(不在任何地方分配结果),我们将在循环外获得L0006: add rdx, r8
(吊起 a+b),然后在循环内L0009: mov rcx, rdx
/L000c: mov [rsp], rcx
。 (注册副本,然后存储到死的刮擦空间,完全疯了。 在 C# 中(与 C/C++ 不同),将a+b;
本身编写为语句是一个错误,因此我们无法看到原始等价物是否仍然会导致愚蠢的浪费指令。Only assignment, call, increment, decrement, await, and new object expressions can be used as a statement
.
我认为我们不能将这些错过的优化归咎于结构本身。 但是,即使您在循环中/没有add
的情况下对此进行基准测试,也不会在现代 x86 上导致此循环的实际减速。 空循环达到 1/时钟循环吞吐量瓶颈,环路上只有 2 个 uops(dec
和宏融合test/jg
),只要它们不引入任何比 1/时钟更糟糕的瓶颈,就为另外 2 个 uop 留出空间而不会减慢速度。 (https://agner.org/optimize/) 例如 具有 3 个周期延迟的imul edx, r8d
会将环路减慢 3 倍。 "4 uops"前端吞吐量假设是最新的英特尔。 推土机系列较窄,锐龙为5宽。
这些是类的非静态成员函数(没有原因,但我没有立即注意到,所以现在不更改它)。 在 asm 调用约定中,第一个参数 (RCX) 是this
指针,参数 2 和 3 是成员函数(RDX 和 R8)的显式参数。
JIT 代码生成在dec eax
之后放置了一个额外的test eax,eax
,该已经根据i - 1
设置了 FLAGS(除了我们不测试的 CF)。 起点是正编译时常量;任何 C 编译器都会将其优化为dec eax
/jnz
. 我认为dec eax
/jg
也可以工作,当dec
产生零时会失败,因为1 > 1
是错误的。
DoubleStruct vs. 调用约定
C# 在 x86-64 上使用的调用约定在整数寄存器中传递 8 字节结构,这对于包含double
的结构很糟糕(因为它必须反弹到 XMM 寄存器才能执行vaddsd
或其他 FP 操作)。 因此,对于非内联函数调用的结构来说,存在不可避免的缺点。
### stand-alone versions of functions: not inlined into a loop
# with primitive double, args are passed in XMM regs
standalone.AddDoubles(Double, Double)
L0000: vzeroupper
L0003: vmovaps xmm0, xmm1 # stupid missed optimization defeating the purpose of AVX 3-operand instructions
L0008: vaddsd xmm0, xmm0, xmm2 # vaddsd xmm0, xmm1, xmm2 would do retval = a + b
L000d: ret
# without `in`. Significantly less bad with `in`, see the link.
standalone.AddDoubleStructs(DoubleStruct a, DoubleStruct b)
L0000: sub rsp, 0x18 # reserve 24 bytes of stack space
L0004: vzeroupper # Weird to use this in a function that doesn't have any YMM vectors...
L0007: mov [rsp+0x28], rdx # spill args 2 (rdx=double a) and 3 (r8=double b) to the stack.
L000c: mov [rsp+0x30], r8 # (first arg = rcx = unused this pointer)
L0011: mov rax, [rsp+0x28]
L0016: mov [rsp+0x10], rax # copy a to another place on the stack!
L001b: mov rax, [rsp+0x30]
L0020: mov [rsp+0x8], rax # copy b to another place on the stack!
L0025: vmovsd xmm0, qword [rsp+0x10]
L002c: vaddsd xmm0, xmm0, [rsp+0x8] # add a and b in the SSE/AVX FPU
L0033: vmovsd [rsp], xmm0 # store the result to yet another stack location
L0039: mov rax, [rsp] # reload it into RAX, the return value
L003d: add rsp, 0x18
L0041: ret
这完全是疯了。这是发布模式代码生成,但编译器将结构存储到内存中,然后在实际将它们加载到 FPU 之前再次重新加载 + 存储它们。 (我猜int->int副本可能是一个构造函数,但我不知道。 我通常会查看 C/C++ 编译器输出,这在优化构建中通常不会这么愚蠢)。
在函数 arg 上使用in
可以避免将每个输入的额外副本复制到第二个堆栈位置,但它仍然会通过存储/重新加载将它们从整数传输到 XMM。
这就是 gcc 对 int->xmm 所做的默认调整,但这是一个错过的优化。 Agner Fog(在他的微拱指南中)说,AMD的优化手册建议在调整Bulldozer时存储/重新加载,但他发现即使在AMD上也不会更快。 (其中 ALU int->xmm 具有 ~10 个周期延迟,而英特尔或锐龙为 2 到 3 个周期,每个时钟的吞吐量与存储相同。
这个函数的一个很好的实现(如果我们坚持调用约定)将是vmovq xmm0, rdx
/vmovq xmm1, r8
,然后是 vaddsd 然后是vmovq rax, xmm0
/ret
。
内联到循环后
基元double
优化类似于long
:
- 原语:
double c = a + b;
完全优化 a = a + b
(就像@canton7使用的那样)仍然没有,即使结果仍未使用。 这将成为vaddsd
延迟的瓶颈(3 到 5 个周期,具体取决于推土机、锐龙、英特尔、天湖前和天湖。 但它确实留在登记册中。
loops.AddDoubles(Double, Double)
L0000: vzeroupper
L0003: mov eax, 0x3b9aca00
# do {
L0008: vaddsd xmm1, xmm1, xmm2 # a += b
L000d: dec eax # --i
L000f: test eax, eax
L0011: jg L0008 # }while(i>0);
L0013: ret
内联结构版本
在将函数内联到循环中后,所有存储/重新加载开销都应该消失;这是内联要点的很大一部分。令人惊讶的是,它不会优化。 2x 存储/重新加载位于循环携带的数据依赖链(FP 添加)的关键路径上!! 这是一个巨大的优化缺失。
在现代英特尔上,存储/重新加载延迟约为 5 或 6 个周期,比 FP 添加慢。a
在进入 XMM0 的途中被加载/存储,然后在返回的途中再次加载/存储。
loops.AddDoubleStructs(DoubleStruct, DoubleStruct)
L0000: sub rsp, 0x18
L0004: vzeroupper
L0007: mov [rsp+0x28], rdx # spill function args: a
L000c: mov [rsp+0x30], r8 # and b
L0011: mov eax, 0x3b9aca00 # i= init
# do {
L0016: mov rdx, [rsp+0x28]
L001b: mov [rsp+0x10], rdx # tmp_a = copy a to another local
L0020: mov rdx, [rsp+0x30]
L0025: mov [rsp+0x8], rdx # tmp_b = copy b
L002a: vmovsd xmm0, qword [rsp+0x10] # tmp_a
L0031: vaddsd xmm0, xmm0, [rsp+0x8] # + tmp_b
L0038: vmovsd [rsp], xmm0 # tmp_a = sum
L003e: mov rdx, [rsp]
L0042: mov [rsp+0x28], rdx # a = copy tmp_a
L0047: dec eax # --i;
L0049: test eax, eax
L004b: jg L0016 # }while(i>0)
L004d: add rsp, 0x18
L0051: ret
原始double
循环优化为一个简单的循环,将所有内容保存在寄存器中,没有违反严格FP的聪明优化。 即不将其转换为乘法,或使用多个累加器来隐藏 FP 增加延迟。 (但我们从long
版本中知道,无论如何,编译器都不会做得更好。 它将所有添加作为一个长依赖链进行,因此每 3 个周期(Broadwell 或更早版本,Ryzen)或 4 个周期(Skylake)addsd
一个。