【发布时间】:2010-09-16 11:34:41
【问题描述】:
Loop unwinding 是帮助编译器优化性能的常用方法。我想知道性能增益是否以及在多大程度上受到循环主体的影响:
- 语句数
- 函数调用次数
- 使用复杂的数据类型、虚拟方法等
- 动态(解除)内存分配
您使用什么规则(经验法则?)来决定是否展开性能关键循环?在这些情况下,您还考虑了哪些其他优化?
【问题讨论】:
标签: c++ performance algorithm optimization
Loop unwinding 是帮助编译器优化性能的常用方法。我想知道性能增益是否以及在多大程度上受到循环主体的影响:
您使用什么规则(经验法则?)来决定是否展开性能关键循环?在这些情况下,您还考虑了哪些其他优化?
【问题讨论】:
标签: c++ performance algorithm optimization
一般来说,手动展开循环是不值得的。编译器更了解目标架构的工作原理,如果有好处,就会展开循环。
有些代码路径在展开时对 Pentium-M 类型 CPU 有利,但对 Core2 没有好处。如果我手动展开,编译器将无法再做出决定,我最终可能会得到不是最优的代码。例如。与我试图实现的完全相反。
在某些情况下,我会手动展开性能关键循环,但只有在我知道编译器(在手动展开后)能够使用架构特定功能(例如 SSE 或 MMX 指令)时,我才会这样做。然后,只有这样我才会这样做。
顺便说一句 - 现代 CPU 在执行可预测的分支方面非常有效。这正是循环。如今,循环开销是如此之小,以至于几乎没有什么不同。然而,由于代码大小的增加可能会产生内存延迟效应。
【讨论】:
这是一个优化问题,因此只有一个经验法则:测试性能,并仅在您的测试表明您需要时尝试循环展开优化。首先考虑破坏性较小的优化。
【讨论】:
根据我的经验,循环展开以及它所花费的工作在以下情况下是有效的:
部分展开通常会减少 80% 的收益。因此,不是循环遍历 N x M 图像的所有像素(NM 次迭代),其中 N 始终可以被 8 整除,而是在每个包含 8 个像素的块上循环 (NM/8) 次。如果您正在执行一些使用某些相邻像素的操作,这尤其有效。
我在手动优化 MMX 或 SSE 指令(一次 8 或 16 个像素)中的逐像素操作方面取得了非常好的结果,但我也花了几天时间手动优化某些东西,结果发现优化的版本是编译器的运行速度快了十倍。
顺便说一句,对于循环展开的最(美丽|显着)示例,请查看Duffs device
【讨论】:
需要考虑的重要一点:在工作场所的生产代码中,代码的未来可读性远远超过循环展开的好处。硬件便宜,程序员的时间不便宜。如果这是解决已证明的性能问题的唯一方法(例如在低功率设备中),我只会担心循环展开。
其他想法:编译器的特性差异很大,并且在某些情况下,如 Java,由 HotspotJVM 即时确定,因此我反对在任何情况下展开循环。
【讨论】:
这些优化高度依赖于执行代码的 cpu,应该由编译器完成,但如果您正在编写这样的编译器,您可能需要查看英特尔文档 Intel(R) 64 and IA-32 Architectures Optimization Reference Manual 第 3.4 节。 1.7:
展开小循环,直到分支和归纳变量的开销(通常)占循环执行时间的 10% 以下。
避免过度展开循环;这可能会破坏跟踪缓存或指令缓存。
展开经常执行且具有可预测迭代次数的循环,以将迭代次数减少到 16 次或更少。除非它增加代码大小以致工作集不再适合跟踪或指令缓存,否则请执行此操作。如果循环体包含多个条件分支,则展开,使迭代次数为 16/(# 个条件分支)。
您也可以免费订购纸质版here。
【讨论】:
手动展开循环在较新的处理器上可能效率不高,但它们在 GPU 和轻型架构(如 ARM)上仍然有用,因为它们在预测方面不如当前一代 CPU 处理器,而且因为测试和跳转实际上会浪费这些处理器上的周期.
也就是说,它应该只在非常紧凑的循环和块中完成,因为展开后代码会显着膨胀,这会破坏小型设备上的缓存,最终会遇到一个非常糟糕的问题.
但请注意,展开循环应该是优化时的最后手段。它会在一定程度上扭曲您的代码,使其无法维护,并且阅读它的人可能会在以后抢购并威胁您和您的家人。知道这一点,让它值得:)
使用宏可以极大地帮助使代码更具可读性,并且可以使展开故意。
例子:
for(int i=0; i<256; i++)
{
a+=(ptr + i) << 8;
a-=(ptr + i - k) << 8;
// And possibly some more
}
可以展开到:
#define UNROLL (i) \
a+=(ptr[i]) << 8; \
a-=(ptr[i-k]) << 8;
for(int i=0; i<32; i++)
{
UNROLL(i);
UNROLL(i+1);
UNROLL(i+2);
UNROLL(i+3);
UNROLL(i+4);
UNROLL(i+5);
UNROLL(i+6);
UNROLL(i+7);
}
在一个不相关但仍然有些相关的注释中,如果您真的想在指令计数方面获胜,请确保所有常量在您的代码中统一为尽可能少的立即数,这样您就不会得到以下结果组装:
// Bad
MOV r1, 4
// ...
ADD r2, r2, 1
// ...
ADD r2, r2, 4
代替:
// Better
ADD r2, r2, 8
通常,严肃的编译器会保护您免受此类事情的影响,但并非所有人都会这样做。保留那些 '#define'、'enum' 和 'static const' 方便,并非所有编译器都会优化本地 'const' 变量。
【讨论】:
基本上,展开是有用的循环结构的成本是循环体的重要部分。大多数循环(以及几乎所有可以展开的循环)的结构包括(a)递增一个整数,(b)将其与另一个整数进行比较,以及(c)跳跃——其中两个是最快的CPU 指令。因此,几乎在任何循环中,身体都会超过结构的重量,从而产生微不足道的增益。如果你的身体中甚至有一个函数调用,身体将比结构慢一个数量级——你永远不会注意到这一点。
几乎唯一可以真正从展开中受益的东西是 memcpy(),其中循环体只是将一个字节从一个位置移动到另一个位置 --- 这就是为什么许多 C 和 C++ 编译器自动内联和展开过去十年的 memcpy。
【讨论】:
手动循环展开通常只对最琐碎的循环有用。
作为参考,g++ 中的 C++ 标准库在整个源代码中展开了两个循环,它们实现了带有和不带有谓词的“查找”函数,如下所示:
while(first != last && !(*first == val))
++first;
我查看了这些和其他循环,并决定只为这种微不足道的循环是值得做的。
当然,最好的答案是只展开那些您的分析器显示这样做有用的循环!
【讨论】:
如果您已尽其所能,并且这是您剩余的热点,并且循环内几乎没有任何内容,那么展开是有意义的。这些是很多“如果”。为了验证这是否是您的最后选择,try this
【讨论】:
根据我的经验,循环展开可以在我的 intel i7 cpu 上不使用 SEE 的情况下带来 20% 到 50% 的性能。
对于单一次操作的简单循环,循环中有一次条件跳转和一次增量的开销。每一个跳转和增量执行多个操作可能是有效的。有效循环展开的示例如下代码:
在下面没有展开的代码中,有一个比较 + 一个 jumb + 每个 sum 操作一个增量的开销。此外,所有操作都必须等待先前操作的结果。
template<class TData,class TSum>
inline TSum SumV(const TData* pVec, int nCount)
{
const TData* pEndOfVec = pVec + nCount;
TSum nAccum = 0;
while(pVec < pEndOfVec)
{
nAccum += (TSum)(*pVec++);
}
return nAccum;
}
在展开的代码中,每四次求和运算有一个比较 + 一个 jumb + 一个增量的开销。而且还有很多操作不需要等待前一次操作的结果,可以更好的被编译器优化。
template<class TData,class TSum>
inline TSum SumV(const TData* pVec, int nCount)
{
const TData* pEndOfVec = pVec + nCount;
TSum nAccum = 0;
int nCount4 = nCount - nCount % 4;
const TData* pEndOfVec4 = pVec + nCount4;
while (pVec < pEndOfVec4)
{
TSum val1 = (TSum)(pVec[0]);
TSum val2 = (TSum)(pVec[1]);
TSum val3 = (TSum)(pVec[2]);
TSum val4 = (TSum)(pVec[3]);
nAccum += val1 + val2 + val3 + val4;
pVec += 4;
}
while(pVec < pEndOfVec)
{
nAccum += (TSum)(*pVec++);
}
return nAccum;
}
【讨论】: