C#优化器性能差?

13

我刚刚写了一个小例子来检查C#编译器的优化器在索引器的情况下的行为。这个例子很简单——我只是把一个数组封装在一个类里,然后尝试填充它的值:一次是直接填充,一次是通过索引器填充(内部访问数据的方式与直接解决方案完全相同)。

    public class ArrayWrapper
    {
        public ArrayWrapper(int newWidth, int newHeight)
        {
            width = newWidth;
            height = newHeight;

            data = new int[width * height];
        }

        public int this[int x, int y]
        {
            get
            {
                return data[y * width + x];
            }
            set
            {
                data[y * width + x] = value;
            }
        }

        public readonly int width, height;
        public readonly int[] data;
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            ArrayWrapper bigArray = new ArrayWrapper(15000, 15000);

            Stopwatch stopwatch = new Stopwatch();
            stopwatch.Start();
            for (int y = 0; y < bigArray.height; y++)
                for (int x = 0; x < bigArray.width; x++)
                    bigArray.data[y * bigArray.width + x] = 12;
            stopwatch.Stop();

            Console.WriteLine(String.Format("Directly: {0} ms", stopwatch.ElapsedMilliseconds));

            stopwatch.Restart();
            for (int y = 0; y < bigArray.height; y++)
                for (int x = 0; x < bigArray.width; x++)
                    bigArray[x, y] = 12;
            stopwatch.Stop();

            Console.WriteLine(String.Format("Via indexer: {0} ms", stopwatch.ElapsedMilliseconds));

            Console.ReadKey();
        }
    }

很多stackoverflow的文章告诉我,程序员应该高度信任优化器来完成它的工作。但是在这种情况下,结果相当令人惊讶:

Directly: 1282 ms
Via indexer: 2134 ms

(在启用优化的 Release 配置下编译,我进行了双重检查。)

这是一个巨大的差异,无论如何都不可能是统计误差(而且它既可扩展又可重复)。

这是一个非常不愉快的惊喜:在这种情况下,我期望编译器会内联索引器(它甚至没有包含任何范围检查),但它没有这样做。以下是反汇编代码(请注意,我的注释只是对正在发生的事情的猜测):

直接查询

                    bigArray.data[y * bigArray.width + x] = 12;
000000a2  mov         eax,dword ptr [ebp-3Ch]  // Evaluate index of array
000000a5  mov         eax,dword ptr [eax+4] 
000000a8  mov         edx,dword ptr [ebp-3Ch] 
000000ab  mov         edx,dword ptr [edx+8] 
000000ae  imul        edx,dword ptr [ebp-10h]  
000000b2  add         edx,dword ptr [ebp-14h]  // ...until here
000000b5  cmp         edx,dword ptr [eax+4]    // Range checking
000000b8  jb          000000BF 
000000ba  call        6ED23CF5                 // Throw IndexOutOfRange
000000bf  mov         dword ptr [eax+edx*4+8],0Ch // Assign value to array

通过索引器

                    bigArray[x, y] = 12;
0000015e  push        dword ptr [ebp-18h]       // Push x and y
00000161  push        0Ch                       // (prepare parameters)
00000163  mov         ecx,dword ptr [ebp-3Ch] 
00000166  mov         edx,dword ptr [ebp-1Ch] 
00000169  cmp         dword ptr [ecx],ecx 
0000016b  call        dword ptr ds:[004B27DCh]  // Call the indexer

(...)

                data[y * width + x] = value;
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  sub         esp,8 
00000006  mov         dword ptr [ebp-8],ecx 
00000009  mov         dword ptr [ebp-4],edx 
0000000c  cmp         dword ptr ds:[004B171Ch],0 // Some additional checking, I guess?
00000013  je          0000001A 
00000015  call        6ED24648                   
0000001a  mov         eax,dword ptr [ebp-8]        // Evaluating index
0000001d  mov         eax,dword ptr [eax+4] 
00000020  mov         edx,dword ptr [ebp-8] 
00000023  mov         edx,dword ptr [edx+8] 
00000026  imul        edx,dword ptr [ebp+0Ch] 
0000002a  add         edx,dword ptr [ebp-4]        // ...until here
0000002d  cmp         edx,dword ptr [eax+4]        // Range checking
00000030  jb          00000037 
00000032  call        6ED23A5D                     // Throw IndexOutOfRange exception
00000037  mov         ecx,dword ptr [ebp+8] 
0000003a  mov         dword ptr [eax+edx*4+8],ecx  // Actual assignment
                }
0000003e  nop 
0000003f  mov         esp,ebp 
00000041  pop         ebp 
00000042  ret         8                            // Returning

这是一个完全的灾难(从代码优化的角度来看)。所以我的问题是:

  • 为什么这段代码(实际上相当简单)没有得到正确的优化?
  • 我如何修改这段代码,使其像我想要的那样进行优化(如果可能的话)?
  • 程序员能否像依赖于C++的优化器一样依赖于C#的优化器?

好的,我知道,最后一个问题很难回答。但是最近我看到了很多关于C++性能的问题,惊讶于优化器可以做到多么多(例如,std::tie的总内联,两个std::tuple构造函数和重载的operator <等)。


编辑:(响应评论)

实际上这仍然是我的错,因为我在运行IDE时检查了性能。现在我从IDE中运行了相同的程序,并在运行时动态调试它。现在我得到:

                    bigArray.data[y * bigArray.width + x] = 12;
000000ae  mov         eax,dword ptr [ebp-10h] 
000000b1  imul        eax,edx 
000000b4  add         eax,ebx 
000000b6  cmp         eax,edi 
000000b8  jae         000001FA 
000000be  mov         dword ptr [ecx+eax*4+8],0Ch 

索引器

                    bigArray[x, y] = 12;
0000016b  mov         eax,dword ptr [ebp-14h] 
0000016e  imul        eax,edx 
00000171  add         eax,ebx 
00000173  cmp         eax,edi 
00000175  jae         000001FA 
0000017b  mov         dword ptr [ecx+eax*4+8],0Ch 

这些代码完全相同(在CPU指令方面)。 运行后,索引版本的结果甚至比直接版本更好,但只有因为缓存机制。 将测试放入循环中后,一切都恢复正常:

Directly: 573 ms
Via indexer: 353 ms
Directly: 356 ms
Via indexer: 362 ms
Directly: 351 ms
Via indexer: 370 ms
Directly: 351 ms
Via indexer: 354 ms
Directly: 359 ms
Via indexer: 356 ms

教训已经吸取。即使在发布模式下编译,无论是在IDE中还是独立运行,都存在巨大的差异。感谢@harold提出的想法。


2
你确定在启动后是以发布模式并带有调试器附加的方式运行吗?看起来有点像调试模式。 - harold
你说得对,没错!我在 非 IDE 环境下运行了相同的代码,实际上索引器比直接赋值更快!我会尝试检索反汇编代码。如果你愿意,你可以回答这个问题,这样我就可以接受它 :) - Spook
我本来想发布一个带有“真正”的汇编代码的答案,但是我刚刚设置好这台电脑,它还不想合作。 - harold
关于这个踩票,您想发表评论吗? - Spook
1个回答

7
在调试器立即附加的情况下运行代码会生成缓慢的代码(除非您启用“抑制模块加载时的JIT优化”,但这会使调试有点困难)。我用来查看优化的汇编过程是抛出一个异常(有条件地,比如静态变量为0时,这样优化器不会过于着急),并在崩溃时附加调试器。您可能需要通过“手动选择调试器”路线进行操作。此外,请确保您启用了“显示外部代码”(从调用堆栈上下文菜单中)。
我获得的直接访问代码是:
innerloop:
  mov  eax,dword ptr [esi+8]   ; bigArray.width
  imul eax,ebx                 ; * y
  add  eax,edi                 ; + x
  mov  edx,dword ptr [ebp-14h] ; pointer to bigArray.data
  mov  ecx,dword ptr [ebp-10h] ; \
  cmp  eax,ecx                 ; |  bounds check
  jae  00000087                ; /
  mov  dword ptr [edx+eax*4+8],0Ch ; data[index] = 12
  inc  edi                     ; x++
  cmp  edi,dword ptr [esi+8]   ; \
  jl   innerloop               ; / if (x < bigArray.width) goto innerloop

对于索引器:

innerloop:
  mov  eax,dword ptr [esi+8]   ; bigArray.width
  imul eax,ebx                 ; * y
  add  eax,edi                 ; + x
  mov  edx,dword ptr [ebp-14h] ; pointer to bigArray.data
  mov  ecx,dword ptr [ebp-10h] ; \
  cmp  eax,ecx                 ; |  bounds check
  jae  00000087                ; /
  mov  dword ptr [edx+eax*4+8],0Ch ; data[index] = 12
  inc  edi                     ; x++
  cmp  edi,dword ptr [esi+8]   ; \
  jl   innerloop               ; / if (x < bigArray.width) goto innerloop

这并不是粘贴错误,内部循环的代码确实完全相同


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接