52

我对.net 中低级算法的效率感兴趣。我希望让我们能够选择在未来使用 C# 而不是 C++ 编写更多代码,但一个绊脚石是在循环和随机访问数组时发生的 .net 中的边界检查。

一个有启发性的例子是一个函数,它计算两个数组中对应元素的乘积之和(这是两个向量的点积)。

static void SumProduct(double[] X, double[] Y)
{
    double sum = 0;
    int length = X.Length;
    if (length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    for (int i = 0; i < length; i++) // Check X.Length instead? See below
        sum += X[i] * Y[i];
}

据我所知,并且不知道足够的 IL 或 x86 来检查,编译器不会优化X and Y的边界检查。我错了和/或有没有办法编写我的代码以允许编译器帮助我?

更多详细信息

有许多支持和反对使用特定语言的效率论据,尤其是最好专注于“大 O”算法成本而不是比例常数,更高级别的语言可以帮助您做到这一点。关于 .net 中的边界检查,我发现的最好的文章是MSDN上 CLR 中的 Array Bounds Check Elimination(也在关于启用优化重要性的堆栈溢出答案中引用)。

这可以追溯到 2009 年,所以我想知道从那时起情况是否发生了显着变化。此外,这篇文章揭示了一些真正的微妙之处,这些细节会引起我的注意,因此仅出于这个原因,我就欢迎一些专家的建议。

例如,在我上面的代码中,我最好写i< X.Length而不是i < length. 此外,我还天真地假设对于具有单个数组的算法,编写一个foreach循环会更好地向编译器声明您的意图,并给它优化边界检查的最佳机会。

根据SumForBAD下面的 MSDN 文章,我认为肯定会优化,但不会。而SumFor将被直接优化,并且SumForEach也会被优化,但不是微不足道的(如果数组被传递给函数 as ,可能根本不会被优化IEnumerable<int>)?

static double SumForBAD(double[] X)
{
    double sum = 0;
    int length = X.Length; // better to use i < X.length in loop
    for (int i = 0; i < length; i++)
        sum += X[i];
    return sum;
}

static double SumFor(double[] X)
{
    double sum = 0;
    for (int i = 0; i < X.Length; i++)
        sum += X[i];
    return sum;
}

static double SumForEach(double[] X)
{
    double sum = 0;
    foreach (int element in X)
        sum += element;
    return sum;
}

我根据 doug65536 的回答做了一些调查。在 C++ 中,我比较了进行边界检查的 SumProduct 的时间

for(int i=0; i<n; ++i) sum += v1[i]*v2[i];

针对执行两次边界检查的另一个版本

for(int i=0; i<n1 && i <n2; ++i) sum += v1[i]*v2[i];

我发现第二个版本速度较慢,但​​只有 3.5% 左右(Visual Studio 2010,优化构建,默认选项)。但是我突然想到,在 C# 中,可能有三个边界检查。一个显式(在此问题开头i < length的函数中)和两个隐式(和)。所以我测试了第三个 C++ 函数,带有三个边界检查static void SumProduct(double[] X, double[] Y)X[i]Y[i]

for(int i=0; i<n1 && i <n2 && i <n3; ++i) sum += v1[i]*v2[i];

这比第一个慢了 35%,值得关注。我在这个问题上做了更多调查,为什么在某些机器上添加额外的检查循环会产生很大的不同,而在其他机器上会产生很小的差异?. 有趣的是,边界检查的成本似乎在不同的机器上差异很大。

4

4 回答 4

39

边界检查无关紧要,因为:

  • 边界检查由cmp/jae指令对组成,它融合到现代 CPU 架构上的单个微操作中(术语是“宏操作融合”)。比较和分支非常优化。

  • 边界检查是一个前向分支,将静态预测为不采取,也降低了成本。分支永远不会被占用。(如果它被采用,无论如何都会抛出异常,因此错误预测成本变得完全无关紧要)

  • 只要有任何内存延迟,推测执行就会将循环的许多迭代排队,因此解码额外指令对的成本几乎消失了。

内存访问可能会成为您的瓶颈,因此诸如删除边界检查之类的微优化效果将消失。

于 2013-05-23T16:43:54.887 回答
30

64 位

64 位抖动在消除边界检查方面做得很好(至少在简单的场景中)。我return sum;在您的方法末尾添加,然后在发布模式下使用 Visual Studio 2010 编译程序。在下面的反汇编中(我用 C# 翻译注释),请注意:

  • 没有边界检查X,即使您的代码i与而length不是X.Length. 这是对文章中描述的行为的改进。
  • 在主循环之前,有一个检查来确保Y.Length >= X.Length.
  • 主循环(偏移量 00000032 到 00000052)不包含任何边界检查。

拆卸

; Register assignments:
;    rcx  := i
;    rdx  := X
;    r8   := Y
;    r9   := X.Length ("length" in your code, "XLength" below)
;    r10  := Y.Length ("YLength" below)
;    r11  := X.Length - 1 ("XLengthMinus1" below)
;    xmm1 := sum

; (Prologue)
00000000  push        rbx
00000001  push        rdi
00000002  sub         rsp,28h

; (Store arguments X and Y in rdx and r8)
00000006  mov         r8,rdx   ; Y
00000009  mov         rdx,rcx  ; X

; int XLength = X.Length;
0000000c  mov         r9,qword ptr [rdx+8]

; int XLengthMinus1 = XLength - 1;
00000010  movsxd      rax,r9d
00000013  lea         r11,[rax-1]

; int YLength = Y.Length;
00000017  mov         r10,qword ptr [r8+8]

; if (XLength != YLength)
;     throw new ArgumentException("X and Y must be same size");
0000001b  cmp         r9d,r10d
0000001e  jne         0000000000000060

; double sum = 0;
00000020  xorpd       xmm1,xmm1

; if (XLength > 0)
; {
00000024  test        r9d,r9d
00000027  jle         0000000000000054

;     int i = 0;
00000029  xor         ecx,ecx
0000002b  xor         eax,eax

;     if (XLengthMinus1 >= YLength)
;         throw new IndexOutOfRangeException();
0000002d  cmp         r11,r10
00000030  jae         0000000000000096

;     do
;     {
;         sum += X[i] * Y[i];
00000032  movsd       xmm0,mmword ptr [rdx+rax+10h]
00000038  mulsd       xmm0,mmword ptr [r8+rax+10h]
0000003f  addsd       xmm0,xmm1
00000043  movapd      xmm1,xmm0

;         i++;
00000047  inc         ecx
00000049  add         rax,8

;     }
;     while (i < XLength);
0000004f  cmp         ecx,r9d
00000052  jl          0000000000000032
; }

; return sum;
00000054  movapd      xmm0,xmm1

; (Epilogue)
00000058  add         rsp,28h
0000005c  pop         rdi
0000005d  pop         rbx
0000005e  ret

00000060  ...

00000096  ...

32 位

不幸的是,32 位抖动并不那么聪明。在下面的反汇编中,请注意:

  • 没有边界检查X,即使您的代码i与而length不是X.Length. 同样,这是对文章中描述的行为的改进。
  • 主循环(偏移量 00000018 到 0000002a)包含对Y.

拆卸

; Register assignments:
;    eax  := i
;    ecx  := X
;    edx  := Y
;    esi  := X.Length ("length" in your code, "XLength" below)

; (Prologue)
00000000  push        ebp
00000001  mov         ebp,esp
00000003  push        esi

; double sum = 0;
00000004  fldz

; int XLength = X.Length;
00000006  mov         esi,dword ptr [ecx+4]

; if (XLength != Y.Length)
;     throw new ArgumentException("X and Y must be same size");
00000009  cmp         dword ptr [edx+4],esi
0000000c  je          00000012
0000000e  fstp        st(0)
00000010  jmp         0000002F

; int i = 0;
00000012  xor         eax,eax

; if (XLength > 0)
; {
00000014  test        esi,esi
00000016  jle         0000002C

;     do
;     {
;         double temp = X[i];
00000018  fld         qword ptr [ecx+eax*8+8]

;         if (i >= Y.Length)
;             throw new IndexOutOfRangeException();
0000001c  cmp         eax,dword ptr [edx+4]
0000001f  jae         0000005A

;         sum += temp * Y[i];
00000021  fmul        qword ptr [edx+eax*8+8]
00000025  faddp       st(1),st

;         i++;
00000027  inc         eax

;     while (i < XLength);
00000028  cmp         eax,esi
0000002a  jl          00000018
; }

; return sum;
0000002c  pop         esi
0000002d  pop         ebp
0000002e  ret

0000002f  ...

0000005a  ...

加起来

抖动自 2009 年以来有所改善,64 位抖动可以生成比 32 位抖动更高效的代码。

但是,如果有必要,您始终可以通过使用不安全的代码和指针来完全绕过数组边界检查(正如 svick 指出的那样)。基类库中的一些性能关键代码使用此技术。

于 2013-06-16T22:53:31.263 回答
12

确保不执行边界检查的一种方法是使用指针,您可以在 C# 中以不安全模式执行此操作(这需要您在项目属性中设置一个标志):

private static unsafe double SumProductPointer(double[] X, double[] Y)
{
    double sum = 0;
    int length = X.Length;
    if (length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    fixed (double* xp = X, yp = Y)
    {
        for (int i = 0; i < length; i++)
            sum += xp[i] * yp[i];
    }
    return sum;
}

我尝试测量您的原始方法、X.Length更改的方法以及使用指针的代码,在 .Net 4.5 下编译为 x86 和 x64。具体来说,我尝试计算长度为 10 000 的向量的方法并运行该方法 10 000 次。

结果与 Michael Liu 的回答非常一致:这三种方法之间没有可测量的差异,这意味着边界检查要么没有完成,要么它对性能的影响微不足道。虽然 x86 和 x64 之间存在可测量的差异:x64 慢了大约 34 %。

我使用的完整代码:

static void Main()
{
    var random = new Random(42);
    double[] x = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();
    double[] y = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();

    // make sure JIT doesn't affect the results
    SumProduct(x, y);
    SumProductLength(x, y);
    SumProductPointer(x, y);

    var stopwatch = new Stopwatch();
    stopwatch.Start();
    for (int i = 0; i < 10000; i++)
    {
        SumProduct(x, y);
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
    stopwatch.Restart();
    for (int i = 0; i < 10000; i++)
    {
        SumProductLength(x, y);
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
    stopwatch.Restart();
    for (int i = 0; i < 10000; i++)
    {
        SumProductPointer(x, y);
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
}

private static double SumProduct(double[] X, double[] Y)
{
    double sum = 0;
    int length = X.Length;
    if (length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    for (int i = 0; i < length; i++)
        sum += X[i] * Y[i];
    return sum;
}

private static double SumProductLength(double[] X, double[] Y)
{
    double sum = 0;
    if (X.Length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    for (int i = 0; i < X.Length; i++)
        sum += X[i] * Y[i];
    return sum;
}

private static unsafe double SumProductPointer(double[] X, double[] Y)
{
    double sum = 0;
    int length = X.Length;
    if (length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    fixed (double* xp = X, yp = Y)
    {
        for (int i = 0; i < length; i++)
            sum += xp[i] * yp[i];
    }
    return sum;
}
于 2013-06-16T23:56:34.190 回答
0

首先,我要感谢所有在这篇文章中发言的人,从原始 OP 到提供非常详细和有见地的解释的人。我真的非常喜欢阅读现有的答案。由于已经有大量关于循环如何以及为什么以它们的方式工作的理论,我想提供一些经验(通过某种定义权威)测量:

结论:

  • Foreach 循环比 For 循环快。
  • 局部变量比数组.Length属性快。
  • GC-pinning 使用unsafe fixed并不比普通的 For 循环快。

基准代码:

using System;
using System.Diagnostics;
using System.Runtime;

namespace demo
{
    class MainClass
    {
        static bool ByForArrayLength (byte[] data)
        {
            for (int i = 0; i < data.Length; i++)
                if (data [i] != 0)
                    return false;
            return true;
        }

        static bool ByForLocalLength (byte[] data)
        {
            int len = data.Length;
            for (int i = 0; i < len; i++)
                if (data [i] != 0)
                    return false;
            return true;
        }

        static unsafe bool ByForUnsafe (byte[] data)
        {
            fixed (byte* datap = data)
            {
                int len = data.Length;
                for (int i = 0; i < len; i++)
                    if (datap [i] != 0)
                        return false;
                return true;
            }
        }

        static bool ByForeach (byte[] data)
        {
            foreach (byte b in data)
                if (b != 0)
                    return false;
            return true;
        }

        static void Measure (Action work, string description)
        {
            GCSettings.LatencyMode = GCLatencyMode.LowLatency;
            var watch = Stopwatch.StartNew ();
            work.Invoke ();
            Console.WriteLine ("{0,-40}: {1} ms", description, watch.Elapsed.TotalMilliseconds);
        }

        public static void Main (string[] args)
        {
            byte[] data = new byte[256 * 1024 * 1024];
            Measure (() => ByForArrayLength (data), "For with .Length property");
            Measure (() => ByForLocalLength (data), "For with local variable");
            Measure (() => ByForUnsafe (data), "For with local variable and GC-pinning");
            Measure (() => ByForeach (data), "Foreach loop");
        }
    }
}

结果:(使用 Mono 运行时)

$ mcs Program.cs -optimize -unsafe
For with .Length property               : 440,9208 ms
For with local variable                 : 333,2252 ms
For with local variable and GC-pinning  : 330,2205 ms
Foreach loop                            : 280,5205 ms
于 2018-04-07T15:49:02.693 回答