gpt4 book ai didi

c# - 简单基准测试中奇怪的性能提升

转载 作者:IT王子 更新时间:2023-10-29 03:36:22 36 4
gpt4 key购买 nike

昨天我发现了一个 article by Christoph Nahr titled ".NET Struct Performance"它针对添加两个点结构(double 元组)的方法对多种语言(C++、C#、Java、JavaScript)进行了基准测试。

事实证明,C++ 版本执行大约需要 1000 毫秒(1e9 次迭代),而 C# 在同一台机器上不能低于 ~3000 毫秒(并且在 x64 中执行更差)。

为了自己测试,我采用了 C# 代码(并稍微简化为仅调用按值传递参数的方法),并在 i7-3610QM 机器(单核 3.1Ghz 加速)、8GB RAM 上运行它,Win8.1,使用 .NET 4.5.2,RELEASE 构建 32 位(x86 WoW64,因为我的操作系统是 64 位)。这是简化版:

public static class CSharpTest
{
private const int ITERATIONS = 1000000000;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}

public static void Main()
{
Point a = new Point(1, 1), b = new Point(1, 1);

Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();

Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
}

Point 定义为:

public struct Point 
{
private readonly double _x, _y;

public Point(double x, double y) { _x = x; _y = y; }

public double X { get { return _x; } }

public double Y { get { return _y; } }
}

运行它会产生与文章中类似的结果:

Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms

第一次奇怪的观察

由于应该内联该方法,我想知道如果我完全删除结构并将整个结构简单地内联在一起,代码将如何执行:

public static class CSharpTest
{
private const int ITERATIONS = 1000000000;

public static void Main()
{
// not using structs at all here
double ax = 1, ay = 1, bx = 1, by = 1;

Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
{
ax = ax + by;
ay = ay + bx;
}
sw.Stop();

Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
ax, ay, sw.ElapsedMilliseconds);
}
}

并且得到了几乎相同的结果(在几次重试后实际上慢了 1%),这意味着 JIT-ter 似乎在优化所有函数调用方面做得很好:

Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms

这也意味着基准测试似乎没有衡量任何 struct 性能,实际上似乎只衡量基本的 double 算法(在其他所有内容都被优化之后)。

奇怪的东西

奇怪的部分来了。如果我只是在循环外添加另一个秒表(是的,我在多次重试后将其缩小到这个疯狂的步骤),代码运行快三倍:

public static void Main()
{
var outerSw = Stopwatch.StartNew(); // <-- added

{
Point a = new Point(1, 1), b = new Point(1, 1);

var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();

Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}

outerSw.Stop(); // <-- added
}

Result: x=1000000001 y=1000000001, Time elapsed: 961 ms

太可笑了!而且 Stopwatch 不会给我错误的结果,因为我可以清楚地看到它在一秒钟后结束。

谁能告诉我这里可能发生了什么?

(更新)

下面是同一个程序中的两个方法,说明不是JITting的原因:

public static class CSharpTest
{
private const int ITERATIONS = 1000000000;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}

public static void Main()
{
Test1();
Test2();

Console.WriteLine();

Test1();
Test2();
}

private static void Test1()
{
Point a = new Point(1, 1), b = new Point(1, 1);

var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();

Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}

private static void Test2()
{
var swOuter = Stopwatch.StartNew();

Point a = new Point(1, 1), b = new Point(1, 1);

var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();

Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);

swOuter.Stop();
}
}

输出:

Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms

Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms

Here is a pastebin. 您需要在 .NET 4.x 上将其作为 32 位版本运行(在代码中进行了几次检查以确保这一点)。

(更新 4)

根据@usr 对@Hans 回答的评论,我检查了两种方法的优化反汇编,它们有很大不同:

Test1 on the left, Test2 on the right

这似乎表明差异可能是由于编译器在第一种情况下表现得很滑稽,而不是双字段对齐?

此外,如果我添加两个变量(总偏移量为 8 个字节),我仍然会获得相同的速度提升 - 而且它似乎不再与 Hans Passant 提到的字段对齐有关:

// this is still fast?
private static void Test3()
{
var magical_speed_booster_1 = "whatever";
var magical_speed_booster_2 = "whatever";

{
Point a = new Point(1, 1), b = new Point(1, 1);

var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();

Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}

GC.KeepAlive(magical_speed_booster_1);
GC.KeepAlive(magical_speed_booster_2);
}

最佳答案

有一种非常简单的方法可以始终获得程序的“快速”版本。 Project > Properties > Build 选项卡,取消选中“Prefer 32-bit”选项,确保 Platform target 选择是 AnyCPU。

您真的不喜欢 32 位,不幸的是,对于 C# 项目,它始终默认打开。从历史上看,Visual Studio 工具集在 32 位进程上工作得更好,这是微软一直在努力解决的一个老问题。是时候删除该选项了,VS2015 特别解决了 64 位代码的最后几个真正障碍,具有全新的 x64 抖动和对 Edit+Continue 的普遍支持。

废话少说,您发现了变量对齐的重要性。处理器非常关心它。如果一个变量在内存中没有对齐,那么处理器必须做额外的工作来打乱字节以使它们以正确的顺序排列。有两个明显的未对齐问题,一个是字节仍然在单个 L1 缓存行内,这需要额外的周期才能将它们移动到正确的位置。还有一个特别糟糕的,你发现的那个,其中一部分字节在一个缓存行中,一部分在另一个缓存行中。这需要两个单独的内存访问并将它们粘合在一起。慢三倍。

doublelong 类型是 32 位进程中的麻烦制造者。它们的大小为 64 位。并且可以因此得到 4 位的错位,CLR 只能保证 32 位对齐。在 64 位进程中不是问题,所有变量都保证对齐到 8。这也是 C# 语言不能保证它们是原子的根本原因。以及为什么当 double 组的元素超过 1000 个时,它们会分配到大对象堆中。 LOH 提供了 8 的对齐保证。并解释了为什么添加局部变量可以解决问题,对象引用是 4 个字节,因此它将 double 变量移动 4,现在使其对齐。偶然。

32 位 C 或 C++ 编译器会做额外的工作以确保 double 不会错位。这不是一个简单的问题来解决,当一个函数被输入时,堆栈可能会错位,因为唯一的保证是它与 4 对齐。这样一个函数的序言需要做额外的工作才能使其与 8 对齐。同样的技巧在托管程序中不起作用,垃圾收集器非常关心局部变量在内存中的确切位置。必要的,以便它可以发现 GC 堆中的对象仍然被引用。它无法正确处理这样一个移动了 4 的变量,因为在进入该方法时堆栈未对齐。

这也是 .NET 抖动不容易支持 SIMD 指令的潜在问题。它们有更强的对齐要求,处理器也无法自行解决。 SSE2 需要 16 位对齐,AVX 需要 32 位对齐。无法在托管代码中获取。

最后但同样重要的是,还要注意,这使得在 32 位模式下运行的 C# 程序的性能非常不可预测。当您访问作为字段存储在对象中的 doublelong 时,当垃圾收集器压缩堆时,perf 可能会发生巨大变化。这会移动内存中的对象,这样的字段现在可能会突然错位/对齐。当然非常随机,可能会让人头疼:)

好吧,没有简单的修复,只有一个,64 位代码是 future 。只要 Microsoft 不更改项目模板,就删除抖动强制。也许下一个版本,当他们对 Ryujit 更有信心时。

关于c# - 简单基准测试中奇怪的性能提升,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/32114308/

36 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com