【问题标题】:Cost of inlining methods in C#C# 中内联方法的成本
【发布时间】:2012-03-22 08:14:48
【问题描述】:

我最近在 C# 中实现了一个快速排序算法。对包含数百万个项目的整数数组进行排序,代码的性能大约比 .NET 的实现低 10%。

private static void QS(int[] arr, int left, int right)
{
    if (left >= right) return;

    var pIndex = Partition(arr, left, right);
    QS( arr, left, pIndex);
    QS( arr, pIndex + 1, right);
}

在包含 500 万个项目的数组上,此代码比 .NET 慢约 60 毫秒。

随后,我创建了另一个方法,该方法将Partition() 方法内联到QS() 中(消除了方法调用和return 语句)。然而,这导致性能下降到比 .NET 的排序方法慢约 250 毫秒。

为什么会这样?

编辑: 这是Partition() 方法的代码。在QS() 的内联版本中,除了return 语句之外,该方法的全部内容替换了var pIndex = Partition(arr, left, right); 行。

private static int Partition(int[] arr, int left, int right)
{
    int pivot = arr[left];
    int leftPoint = left - 1;
    int pIndex = right + 1;
    int temp = 0;

    while (true)
    {
        do { pIndex--; } while (arr[pIndex] > pivot);
        do { leftPoint++; } while (arr[leftPoint] < pivot);

        if (leftPoint < pIndex)
        {
            temp = arr[leftPoint];
            arr[leftPoint] = arr[pIndex];
            arr[pIndex] = temp;
        }
        else { break; }
    }
    return pIndex;
}

编辑#2: 如果有人对编译感兴趣,这里是调用算法的代码:

编辑#3: Haymo 建议的新测试代码。

private static void Main(string[] args)
{
    const int globalRuns = 10;
    const int localRuns = 1000;

    var source = Enumerable.Range(1, 200000).OrderBy(n => Guid.NewGuid()).ToArray();
    var a = new int[source.Length];

    int start, end, total;

    for (int z = 0; z < globalRuns; z++)
    {
        Console.WriteLine("Run #{0}", z+1);

        total = 0;
        for (int i = 0; i < localRuns; i++)
        {
            Array.Copy(source, a, source.Length);
            start = Environment.TickCount;
            Array.Sort(a);
            end = Environment.TickCount;
            total += end - start;
        }
        Console.WriteLine("{0}\t\tTtl: {1}ms\tAvg: {2}ms", ".NET", total, total / localRuns);

        total = 0;
        for (int i = 0; i < localRuns; i++)
        {
            Array.Copy(source, a, source.Length);
            start = Environment.TickCount;
            Quicksort.SortInline(a);
            end = Environment.TickCount;
            total += end - start;
        }
        Console.WriteLine("{0}\t\tTtl: {1}ms\tAvg: {2}ms", "Inlined", total, total / localRuns);

        total = 0;
        for (int i = 0; i < localRuns; i++)
        {
            Array.Copy(source, a, source.Length);
            start = Environment.TickCount;
            Quicksort.SortNonInline(a);
            end = Environment.TickCount;
            total += end - start;
        }
        Console.WriteLine("{0}\tTtl: {1}ms\tAvg: {2}ms\n", "Not inlined", total, total / localRuns);
    }
}

【问题讨论】:

  • 你能提供一个可编译的例子吗?
  • 能否发布您使用的完整代码,包括您用来测量时间的代码?
  • 即时优化器已经内联方法。它可以做出比您更好的决策,它实际上知道机器代码是什么样的,并且可以判断内联是真正的优化还是只会导致代码膨胀。真正的优化是让代码更智能。优化 QS 有很好的记录,初学者只需查看 Wikipedia 文章。
  • 测量是使用Stopwatch 实例进行的。 Stopwatch 实例在调用QS() 之前立即启动,并在QS() 调用之后的行上停止。我将其更改为使用Environment.TickCount,但无论如何他们给出了相同的结果。
  • .NET 快速排序有一个内联(并且相当智能)的分区方法。它还使用 IComparable 方法进行所有比较,这可能会增加开销,但这可能会通过分区步骤中使用的算法得到缓解。

标签: c# performance inline quicksort


【解决方案1】:

根据问题中给出的信息,只能猜测和说出一些想法。

您的测量是否正确?请记住,为了获得可靠的性能结果,应该(至少)

  • 在没有附加调试器的情况下运行发布版本
  • 足够频繁地重复测试并平均结果
  • 使测试公平,即。为所有测试对象提供相同的“资源配置”

为了确保(假设的)性能下降的根源确实与函数内联有关,我们可以调查生成的 IL 代码。甚至更好:由 JIT 编译器生成的机器指令。

对于ILNumerics,我们实现了自定义快速排序并进行了很多性能测量。最终算法比 CLR 版本快几倍。手动内联只是一项改进,这是提高性能所必需的。其他是:

  • 不使用递归
  • 使用不安全的比较/交换
  • 对较小的分区使用插入排序
  • 针对有限大小的临时(堆栈替换)数组进行优化

很多时候,奇怪的性能结果的根源在于算法(错误)使用内存的方式。另一个可能在于不同的指令流,最终或多或少地成功地被任何涉及的编译器/处理器优化。整体执行性能是一个高度复杂的野兽,很难确定性地猜测,因此分析器是你最好的朋友!

@Edit:通过查看您的主测试例程,您似乎主要是在测量处理器/主内存的内存带宽。长度为 5*10e6 的 int[] 数组大小约为 19 MB。这很可能超出了您的缓存范围。因此,由于强制缓存未命中,处理器大部分时间都会等待内存。这使得很难猜测任何代码重新制定的影响。我建议尝试以这种方式测量:(伪代码)

  • 生成测试数据
  • 为副本分配数组
  • 迭代全局重复次数(比如 10 次)

    • Array.Sort 的内部重复(例如 1000)
      • 复制(未排序的)测试数据
      • 按Array.Sort对副本进行排序
      • 添加时间
    • Array.Sort 的平均时间

    • Quicksort.Sort 的内部重复(例如 1000)

      • 复制(未排序的)测试数据
      • 按 Quicksort.Sort 对副本进行排序
      • 添加时间
    • Quicksort.Sort 的平均时间

    • Quicksort.Sort2 的内部重复(例如 1000)

      • 复制(未排序的)测试数据
      • 按 Quicksort.Sort2 对副本进行排序
      • 添加时间
    • Quicksort.Sort2 的平均时间

目标是使快速排序仅使用缓存中的数据。因此,请确保不要从新内存重新创建副本,而是只有两个全局实例:原始实例和要排序的副本。两者必须同时适合您的(最后一级)缓存!有了一些余量(对于系统上的其他进程),一个好的猜测是只使用两个阵列的可用最后一级缓存大小的一半。根据您的真实缓存大小,250k 的测试长度似乎更合理。

@Edit2:我运行了您的代码,得到了相同的结果,并在 VS 调试器中观察了(优化的)机器指令。以下是两个版本的相关部分:

Not inlined: 
    69:                 do { pIndex--; } while (arr[pIndex] > pivot);
00000017  dec         ebx 
00000018  cmp         ebx,esi 
0000001a  jae         00000053 
0000001c  cmp         dword ptr [ecx+ebx*4+8],edi 
00000020  jg          00000017 
    70:                 do { leftPoint++; } while (arr[leftPoint] < pivot);
00000022  inc         edx 
00000023  cmp         edx,esi 
00000025  jae         00000053 
00000027  cmp         dword ptr [ecx+edx*4+8],edi 
0000002b  jl          00000022 


Inlined: 
    97:                 do { pIndex--; } while (arr[pIndex] > pivot);
00000038  dec         dword ptr [ebp-14h] 
0000003b  mov         eax,dword ptr [ebp-14h] 
0000003e  cmp         eax,edi 
00000040  jae         00000097 
00000042  cmp         dword ptr [esi+eax*4+8],ebx 
00000046  jg          00000038 
    98:                 do { leftPoint++; } while (arr[leftPoint] < pivot);
00000048  inc         ecx 
00000049  cmp         ecx,edi 
0000004b  jae         00000097 
0000004d  cmp         dword ptr [esi+ecx*4+8],ebx 
00000051  jl          00000048 

可以看出,“未内联”版本更好地利用寄存器来减少运行索引(第 69 / 97 行)。显然,JIT 决定不将相应的寄存器压入和弹出堆栈,因为同一函数中的其他代码正在使用同一寄存器。由于这是一个热循环(CLR 无法识别这一点),因此整体执行速度会受到影响。因此,在这种特定情况下,手动内联 Partition 函数是无利可图的。

但是,如您所知,不能保证其他版本的 CLR 也能做到这一点。 64 位甚至可能会出现差异。

【讨论】:

  • 谢谢,我会继续玩这个算法。尽管通过执行您的所有三个建议,我确实试图获得可靠的结果。我附上了主程序代码,如果你有兴趣编译它。
  • 我已经实现了你的伪代码并运行了 200k 个项目,结果相同。 :)
  • @joshhah 我想运行这个测试。你能发布你的代码吗?
  • 好吧,这只是一个猜测;-) 如果您发布您的(完整)代码,我会查看 IL 级别。
  • @AnastasiaRushda 你好,我已经把测试代码放在PasteBin
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-06-28
  • 1970-01-01
  • 2021-01-24
  • 1970-01-01
  • 2013-04-26
  • 1970-01-01
相关资源
最近更新 更多