可预测地分析单个函数

Tod*_*odd 5 c optimization gcc profiling

我需要一种更好的方法来分析数字代码。假设我在 64 位 x86 上的 Cygwin 中使用 GCC,并且我不打算购买商业工具。

情况是这样的。我有一个函数在一个线程中运行。除了内存访问之外,没有任何代码依赖性或 I/O,可能有一些数学库的链接是例外。但在大多数情况下,所有这些都是表查找、索引计算和数值处理。我已经缓存对齐堆和堆栈上的所有数组。由于算法的复杂性、循环展开和长宏,汇编列表可能会变得很长——数千条指令。

我一直在求助于使用 Matlab 中的 tic/toc 计时器、bash shell 中的时间实用程序,或者直接在函数周围使用时间戳计数器 (rdtsc)。问题是这样的:时间的方差(可能高达运行时间的 20%)大于我正在做的改进的大小,所以我无法知道代码是更好还是更糟改变后。你可能会认为是时候放弃了。但我不同意。如果您坚持不懈,许多渐进式改进可以带来两到三倍的性能提升。

我多次遇到的一个特别令人抓狂的问题是我进行了更改,并且性能似乎持续提高了 20%。第二天,收益就消失了。现在有可能我对代码进行了我认为无害的更改,然后完全忘记了它。但我想知道是否有可能发生其他事情。就像 GCC 可能不会像我相信的那样产生 100% 的确定性输出。或者可能是更简单的事情,比如操作系统将我的进程移到了一个更繁忙的核心。

我已经考虑了以下内容,但我不知道这些想法中的任何一个是否可行或有意义。如果是,我需要有关如何实施解决方案的明确说明。 目标是最小化运行时的差异,以便我可以有意义地比较优化代码的不同版本。

  • 将我的处理器的一个核心专用于运行我的例程。
  • 直接控制缓存(加载或清除)。
  • 确保我的 dll 或可执行文件始终加载到内存中的同一位置。我的想法是,缓存的集合关联性可能与 RAM 中的代码/数据位置交互,以改变每次运行的性能。
  • 某种周期精确仿真器工具(非商业)。
  • 是否可以对上下文切换进行一定程度的控制?或者它甚至重要吗?我的想法是上下文切换的时间会导致可变性,可能是导致管道在不合时宜的时间被刷新。

过去,我通过计算汇编列表中的指令在 RISC 架构上取得了成功。当然,这只适用于指令数量很少的情况。某些编译器(如 TI 的 C67x 代码编辑器)将为您详细分析它如何使 ALU 保持忙碌。

我还没有发现 GCC/GAS 生成的程序集列表特别有用。随着全面优化,代码被移动到所有地方。对于分散在汇编列表中的单个代码块,可以有多个位置指令。此外,即使我能理解程序集如何映射回我的原始代码,我也不确定现代 x86 机器上的指令数和性能之间是否有很大的相关性。

我尝试使用 gcov 进行逐行分析,但由于我构建的 GCC 版本与 MinGW 编译器不兼容,因此无法正常工作。

您可以做的最后一件事是在多次试运行中取平均值,但这需要很长时间。


编辑(RE:调用堆栈采样)

我的第一个问题是,实际上,我该怎么做?在你的一张幻灯片中,你展示了使用 Visual Studio 暂停程序。我拥有的是由 GCC 编译的 DLL,并在 Cygwin 中进行了全面优化。然后由 Matlab 使用 VS2013 编译器编译的 mex DLL 调用它。

我使用 Matlab 的原因是因为我可以轻松地试验不同的参数并将结果可视化,而无需编写或编译任何低级代码。此外,我可以将优化的 DLL 与高级 Matlab 代码进行比较,以确保我的优化没有破坏任何东西。

我使用 GCC 的原因是我使用它的经验比使用 Microsoft 的编译器要多得多。我熟悉许多标志和扩展。此外,至少在过去,Microsoft 一直不愿意维护和更新本机 C 编译器 (C99)。最后,我看到 GCC 使商业编译器大吃一惊,我还查看了汇编列表以了解它实际上是如何完成的。所以我对编译器的实际想法有一些直觉。

现在,关于猜测要解决的问题。这不是真正的问题;这更像是猜测如何要解决这个问题。在这个例子中,正如在数值算法中经常发生的那样,实际上没有 I/O(不包括内存)。没有函数调用。几乎没有任何抽象。就像我坐在一块纱布上。我可以看到下面的计算机架构,中间真的没有什么。如果我重新卷起所有的循环,我可能可以将代码放在大约一页左右,而且我几乎可以计算得到的汇编指令。然后,我可以粗略比较单个内核能够执行的理论操作次数,以了解我离最佳状态有多近。问题是我失去了从展开中获得的自动向量化和指令级并行化。展开,汇编列表太长,无法以这种方式分析。

关键是这段代码真的没有太多内容。然而,由于编译器和现代计算机体系结构的难以置信的复杂性,即使在这个级别上也有相当多的优化。但我不知道微小的变化会影响编译代码的输出。让我举几个例子。

第一个有点模糊,但我相信我已经看到它发生了几次。你做一个小小的改变,就能得到 10% 的改进。你再做一个小的改变,又得到了 10% 的改进。您撤消第一个更改并获得另外 10% 的改进。嗯?编译器优化既不是线性的,也不是单调的。有可能,第二个更改需要一个额外的寄存器,这通过强制编译器更改其寄存器分配算法来破坏第一个更改。也许,第二次优化以某种方式阻碍了编译器进行优化的能力,而这通过撤消第一次优化来修复。谁知道。除非编译器内省到足以在每个抽象级别转储其完整分析,否则您将永远不会真正知道最终的程序集是如何结束的。

这是最近发生在我身上的一个更具体的例子。我正在手动编码 AVX 内在函数以加速过滤器操作。我想我可以展开外循环来增加指令级并行性。所以我做了,结果是代码慢了两倍。发生的事情是没有足够的 256 位寄存器可供使用。所以编译器暂时将结果保存在堆栈上,这会降低性能。

正如我在您评论的这篇文章中提到的,最好告诉编译器您想要什么,但不幸的是,您通常别无选择,被迫手动调整优化,通常是通过猜测和检查。

所以我想我的问题是,在这些场景中(代码在展开之前实际上很小,每次增量性能变化都很小,并且您在非常低的抽象级别上工作),是否会更好地具有“精度”计时”,还是调用堆栈采样更能告诉我哪个代码更胜一筹?

Mik*_*vey 1

我是否正确,您正在做的是对要修复的内容进行有根据的猜测,修复它,然后尝试衡量它是否有任何区别?

我采用了不同的方法,当代码变大时,这种方法尤其有效。我不是猜测(我当然可以),而是通过使用此方法让程序告诉我时间是如何度过的。如果该方法告诉我大约30% 花在做某事上,我就可以集中精力寻找更好的方法来做到这一点。然后我就可以运行它并计时。我不需要太多的精确度。如果更好的话,那就太好了。如果情况更糟,我可以撤消更改。如果大致相同,我可以说“哦,好吧,也许它没有节省太多,但让我们再做一遍以找到另一个问题,”

我不用担心。如果有一种方法可以加速程序,这将精确地指出它。通常,问题不仅仅是一个简单的陈述,例如“线路或例程 X 花费了 Y% 的时间”,而是“在某些情况下这样做的原因是 Z”,而实际的修复可能在其他地方。修复后,可以再次完成该过程,因为以前很小的另一个问题现在更大(以百分比表示,因为通过修复第一个问题,总数已减少)。重复是关键,因为每个加速因子都会乘以之前的所有加速因子,就像复利一样。

当程序不再指出我可以修复的问题时,我可以确定它几乎是最佳的,或者至少没有其他人可能击败它。

在这个过程中,我从来不需要非常精确地测量时间。之后,如果我想在幻灯片中吹嘘它,也许我会做多次计时以获得更小的标准误差,但即便如此,人们真正关心的是整体加速因素,而不是精度。