为什么向结构体添加一个额外的字段会大大提高其性能?
Why does adding an extra field to struct greatly improves its performance?
我注意到包装单个浮点数的结构比直接使用浮点数要慢得多,性能只有大约一半。
using System;
using System.Diagnostics;
struct Vector1 {
public float X;
public Vector1(float x) {
X = x;
}
public static Vector1 operator +(Vector1 a, Vector1 b) {
a.X = a.X + b.X;
return a;
}
}
然而,在添加额外的 'extra' 字段后,似乎发生了一些神奇的事情,性能再次变得更加合理:
struct Vector1Magic {
public float X;
private bool magic;
public Vector1Magic(float x) {
X = x;
magic = true;
}
public static Vector1Magic operator +(Vector1Magic a, Vector1Magic b) {
a.X = a.X + b.X;
return a;
}
}
我用来对这些进行基准测试的代码如下:
class Program {
static void Main(string[] args) {
int iterationCount = 1000000000;
var sw = new Stopwatch();
sw.Start();
var total = 0.0f;
for (int i = 0; i < iterationCount; i++) {
var v = (float) i;
total = total + v;
}
sw.Stop();
Console.WriteLine("Float time was {0} for {1} iterations.", sw.Elapsed, iterationCount);
Console.WriteLine("total = {0}", total);
sw.Reset();
sw.Start();
var totalV = new Vector1(0.0f);
for (int i = 0; i < iterationCount; i++) {
var v = new Vector1(i);
totalV += v;
}
sw.Stop();
Console.WriteLine("Vector1 time was {0} for {1} iterations.", sw.Elapsed, iterationCount);
Console.WriteLine("totalV = {0}", totalV);
sw.Reset();
sw.Start();
var totalVm = new Vector1Magic(0.0f);
for (int i = 0; i < iterationCount; i++) {
var vm = new Vector1Magic(i);
totalVm += vm;
}
sw.Stop();
Console.WriteLine("Vector1Magic time was {0} for {1} iterations.", sw.Elapsed, iterationCount);
Console.WriteLine("totalVm = {0}", totalVm);
Console.Read();
}
}
基准测试结果:
Float time was 00:00:02.2444910 for 1000000000 iterations.
Vector1 time was 00:00:04.4490656 for 1000000000 iterations.
Vector1Magic time was 00:00:02.2262701 for 1000000000 iterations.
Compiler/environment 设置:
OS: Windows 10 64 位
工具链:VS2017
框架:.Net 4.6.2
目标:任何 CPU 首选 32 位
如果将 64 位设置为目标,我们的结果更可预测,但比我们在 32 位目标上使用 Vector1Magic 时看到的结果要差得多:
Float time was 00:00:00.6800014 for 1000000000 iterations.
Vector1 time was 00:00:04.4572642 for 1000000000 iterations.
Vector1Magic time was 00:00:05.7806399 for 1000000000 iterations.
对于真正的向导,我在这里包含了 IL 的转储:https://pastebin.com/sz2QLGEx
进一步调查表明这似乎特定于 windows 运行时,因为单声道编译器生成相同的 IL。
在单声道运行时,与原始浮点数相比,两种结构变体的性能大约慢 2 倍。这与我们在 .Net 上看到的性能有很大不同。
这是怎么回事?
*注意这个问题最初包含一个有缺陷的基准过程(感谢 Max Payne 指出这一点),并且已经更新以更准确地反映时间。
这不应该发生。这显然是某种错位,迫使 JIT 无法正常工作。
struct Vector1 //Works fast in 32 Bit
{
public double X;
}
struct Vector1 //Works fast in 64 Bit and 32 Bit
{
public double X;
public double X2;
}
您还必须致电:
Console.WriteLine(total); 将时间精确地增加到 Vector1Magic 时间,这是有道理的。问题仍然存在,为什么 Vector1 这么慢。
可能在 64 位模式下结构未针对 sizeof(foo) < 64 位进行优化。
好像是7年前回答的:
Why is 16 byte the recommended size for struct in C#?
CIL 代码是相同的(实际上)。但 x86 汇编代码不是。
我认为,这是 JIT 编译器优化的一些特性。
编译器为 Vector1
.
生成以下汇编代码
C#(注释中部分汇编 x86):
var totalV = new Vector1(0.0f);
/*
01300576 fldz
01300578 fstp dword ptr [ebp-14h]
*/
for (int i = 0; i < iterationCount; i++)
{
var v = new Vector1(i);
/*
0130057D mov dword ptr [ebp-4Ch],ecx ; ecx - is index "i"
01300580 fild dword ptr [ebp-4Ch]
01300583 fstp dword ptr [ebp-4Ch]
01300586 fld dword ptr [ebp-4Ch]
*/
totalV += v;
/*
01300589 lea eax,[ebp-14h]
0130058C mov eax,dword ptr [eax]
0130058E lea edx,[ebp-18h]
01300591 mov dword ptr [edx],eax
01300593 fadd dword ptr [ebp-18h]
01300596 fstp dword ptr [ebp-18h]
01300599 mov eax,dword ptr [ebp-18h]
0130059C mov dword ptr [ebp-14h],eax
*/
}
编译器为 Vector1Magic
.
生成以下汇编代码
C#(注释中部分汇编 x86):
var totalVm = new Vector1Magic(0.0f);
/*
01300657 mov byte ptr [ebp-20h],1 ; here's assignment "magic=true"
0130065B fldz
0130065D fstp dword ptr [ebp-1Ch]
*/
for (int i = 0; i < iterationCount; i++)
{
var vm = new Vector1Magic(i);
/*
01300662 mov dword ptr [ebp-4Ch],edx ; edx - is index "i"
01300665 fild dword ptr [ebp-4Ch]
01300668 fstp dword ptr [ebp-4Ch]
0130066B fld dword ptr [ebp-4Ch]
*/
totalVm += vm;
/*
0130066E movzx ecx,byte ptr [ebp-20h] ; here's some work with "unused" magic field
01300672 fld dword ptr [ebp-1Ch]
01300675 faddp st(1),st
01300677 fstp dword ptr [ebp-1Ch]
0130067A mov byte ptr [ebp-20h],cl ; here's some work with "unused" magic field
*/
}
显然这个 asm 块会影响性能:
;Vector1
01300589 lea eax,[ebp-14h]
0130058C mov eax,dword ptr [eax]
0130058E lea edx,[ebp-18h]
01300591 mov dword ptr [edx],eax
01300593 fadd dword ptr [ebp-18h]
01300596 fstp dword ptr [ebp-18h]
01300599 mov eax,dword ptr [ebp-18h]
0130059C mov dword ptr [ebp-14h],eax
;Vector1Magic
0130066E movzx ecx,byte ptr [ebp-20h] ; here's some work with "unused" magic field
01300672 fld dword ptr [ebp-1Ch]
01300675 faddp st(1),st
01300677 fstp dword ptr [ebp-1Ch]
0130067A mov byte ptr [ebp-20h],cl ; here's some work with "unused" magic field
JIT 编译器以不同方式处理具有一个字段和多个字段的结构的操作。可能它期望对所有字段进行 Vector1Magic
操作(并且 "unused" 也是)。
jit 有一个称为 "struct promotion" 的优化,它可以有效地用多个局部变量替换结构局部变量或参数,一个用于结构的每个字段。
然而,单个结构包装浮点数的结构提升被禁用。原因有点晦涩,但大致是:
- 简单包装原始类型的结构在传递给调用或从调用返回时被视为结构大小的整数值
- 在提升分析期间,jit 无法判断该结构是否曾传递给调用或从调用返回。
- 将 int 重新分类为 float(反之亦然)的调用所需的代码序列被认为在运行时开销很大。
- 因此没有提升结构,因此对 float 字段的访问和操作有点慢。
所以粗略地说,jit 优先考虑降低调用站点的成本,而不是提高使用字段的地方的成本。有时(如您上面的情况,运营成本占主导地位)这不是正确的选择。
如您所见,如果使结构变大,则传递和返回结构的规则会发生变化(现在通过引用返回传递),这会取消阻止升级。
在 CoreCLR sources 中,您可以在 Compiler::lvaShouldPromoteStructVar
中看到这种逻辑。
我注意到包装单个浮点数的结构比直接使用浮点数要慢得多,性能只有大约一半。
using System;
using System.Diagnostics;
struct Vector1 {
public float X;
public Vector1(float x) {
X = x;
}
public static Vector1 operator +(Vector1 a, Vector1 b) {
a.X = a.X + b.X;
return a;
}
}
然而,在添加额外的 'extra' 字段后,似乎发生了一些神奇的事情,性能再次变得更加合理:
struct Vector1Magic {
public float X;
private bool magic;
public Vector1Magic(float x) {
X = x;
magic = true;
}
public static Vector1Magic operator +(Vector1Magic a, Vector1Magic b) {
a.X = a.X + b.X;
return a;
}
}
我用来对这些进行基准测试的代码如下:
class Program {
static void Main(string[] args) {
int iterationCount = 1000000000;
var sw = new Stopwatch();
sw.Start();
var total = 0.0f;
for (int i = 0; i < iterationCount; i++) {
var v = (float) i;
total = total + v;
}
sw.Stop();
Console.WriteLine("Float time was {0} for {1} iterations.", sw.Elapsed, iterationCount);
Console.WriteLine("total = {0}", total);
sw.Reset();
sw.Start();
var totalV = new Vector1(0.0f);
for (int i = 0; i < iterationCount; i++) {
var v = new Vector1(i);
totalV += v;
}
sw.Stop();
Console.WriteLine("Vector1 time was {0} for {1} iterations.", sw.Elapsed, iterationCount);
Console.WriteLine("totalV = {0}", totalV);
sw.Reset();
sw.Start();
var totalVm = new Vector1Magic(0.0f);
for (int i = 0; i < iterationCount; i++) {
var vm = new Vector1Magic(i);
totalVm += vm;
}
sw.Stop();
Console.WriteLine("Vector1Magic time was {0} for {1} iterations.", sw.Elapsed, iterationCount);
Console.WriteLine("totalVm = {0}", totalVm);
Console.Read();
}
}
基准测试结果:
Float time was 00:00:02.2444910 for 1000000000 iterations.
Vector1 time was 00:00:04.4490656 for 1000000000 iterations.
Vector1Magic time was 00:00:02.2262701 for 1000000000 iterations.
Compiler/environment 设置: OS: Windows 10 64 位 工具链:VS2017 框架:.Net 4.6.2 目标:任何 CPU 首选 32 位
如果将 64 位设置为目标,我们的结果更可预测,但比我们在 32 位目标上使用 Vector1Magic 时看到的结果要差得多:
Float time was 00:00:00.6800014 for 1000000000 iterations.
Vector1 time was 00:00:04.4572642 for 1000000000 iterations.
Vector1Magic time was 00:00:05.7806399 for 1000000000 iterations.
对于真正的向导,我在这里包含了 IL 的转储:https://pastebin.com/sz2QLGEx
进一步调查表明这似乎特定于 windows 运行时,因为单声道编译器生成相同的 IL。
在单声道运行时,与原始浮点数相比,两种结构变体的性能大约慢 2 倍。这与我们在 .Net 上看到的性能有很大不同。
这是怎么回事?
*注意这个问题最初包含一个有缺陷的基准过程(感谢 Max Payne 指出这一点),并且已经更新以更准确地反映时间。
这不应该发生。这显然是某种错位,迫使 JIT 无法正常工作。
struct Vector1 //Works fast in 32 Bit
{
public double X;
}
struct Vector1 //Works fast in 64 Bit and 32 Bit
{
public double X;
public double X2;
}
您还必须致电: Console.WriteLine(total); 将时间精确地增加到 Vector1Magic 时间,这是有道理的。问题仍然存在,为什么 Vector1 这么慢。
可能在 64 位模式下结构未针对 sizeof(foo) < 64 位进行优化。
好像是7年前回答的: Why is 16 byte the recommended size for struct in C#?
CIL 代码是相同的(实际上)。但 x86 汇编代码不是。
我认为,这是 JIT 编译器优化的一些特性。
编译器为 Vector1
.
C#(注释中部分汇编 x86):
var totalV = new Vector1(0.0f);
/*
01300576 fldz
01300578 fstp dword ptr [ebp-14h]
*/
for (int i = 0; i < iterationCount; i++)
{
var v = new Vector1(i);
/*
0130057D mov dword ptr [ebp-4Ch],ecx ; ecx - is index "i"
01300580 fild dword ptr [ebp-4Ch]
01300583 fstp dword ptr [ebp-4Ch]
01300586 fld dword ptr [ebp-4Ch]
*/
totalV += v;
/*
01300589 lea eax,[ebp-14h]
0130058C mov eax,dword ptr [eax]
0130058E lea edx,[ebp-18h]
01300591 mov dword ptr [edx],eax
01300593 fadd dword ptr [ebp-18h]
01300596 fstp dword ptr [ebp-18h]
01300599 mov eax,dword ptr [ebp-18h]
0130059C mov dword ptr [ebp-14h],eax
*/
}
编译器为 Vector1Magic
.
C#(注释中部分汇编 x86):
var totalVm = new Vector1Magic(0.0f);
/*
01300657 mov byte ptr [ebp-20h],1 ; here's assignment "magic=true"
0130065B fldz
0130065D fstp dword ptr [ebp-1Ch]
*/
for (int i = 0; i < iterationCount; i++)
{
var vm = new Vector1Magic(i);
/*
01300662 mov dword ptr [ebp-4Ch],edx ; edx - is index "i"
01300665 fild dword ptr [ebp-4Ch]
01300668 fstp dword ptr [ebp-4Ch]
0130066B fld dword ptr [ebp-4Ch]
*/
totalVm += vm;
/*
0130066E movzx ecx,byte ptr [ebp-20h] ; here's some work with "unused" magic field
01300672 fld dword ptr [ebp-1Ch]
01300675 faddp st(1),st
01300677 fstp dword ptr [ebp-1Ch]
0130067A mov byte ptr [ebp-20h],cl ; here's some work with "unused" magic field
*/
}
显然这个 asm 块会影响性能:
;Vector1
01300589 lea eax,[ebp-14h]
0130058C mov eax,dword ptr [eax]
0130058E lea edx,[ebp-18h]
01300591 mov dword ptr [edx],eax
01300593 fadd dword ptr [ebp-18h]
01300596 fstp dword ptr [ebp-18h]
01300599 mov eax,dword ptr [ebp-18h]
0130059C mov dword ptr [ebp-14h],eax
;Vector1Magic
0130066E movzx ecx,byte ptr [ebp-20h] ; here's some work with "unused" magic field
01300672 fld dword ptr [ebp-1Ch]
01300675 faddp st(1),st
01300677 fstp dword ptr [ebp-1Ch]
0130067A mov byte ptr [ebp-20h],cl ; here's some work with "unused" magic field
JIT 编译器以不同方式处理具有一个字段和多个字段的结构的操作。可能它期望对所有字段进行 Vector1Magic
操作(并且 "unused" 也是)。
jit 有一个称为 "struct promotion" 的优化,它可以有效地用多个局部变量替换结构局部变量或参数,一个用于结构的每个字段。
然而,单个结构包装浮点数的结构提升被禁用。原因有点晦涩,但大致是:
- 简单包装原始类型的结构在传递给调用或从调用返回时被视为结构大小的整数值
- 在提升分析期间,jit 无法判断该结构是否曾传递给调用或从调用返回。
- 将 int 重新分类为 float(反之亦然)的调用所需的代码序列被认为在运行时开销很大。
- 因此没有提升结构,因此对 float 字段的访问和操作有点慢。
所以粗略地说,jit 优先考虑降低调用站点的成本,而不是提高使用字段的地方的成本。有时(如您上面的情况,运营成本占主导地位)这不是正确的选择。
如您所见,如果使结构变大,则传递和返回结构的规则会发生变化(现在通过引用返回传递),这会取消阻止升级。
在 CoreCLR sources 中,您可以在 Compiler::lvaShouldPromoteStructVar
中看到这种逻辑。