有时可以使用关联性来消除数据依赖性,我很好奇它可以提供多少帮助。我很惊讶地发现,通过手动展开一个简单的循环,我在Java(build 1.7.0_51-b13)和C(gcc 4.4)中几乎可以获得加速因子4 0.3)。
所以我要么做一些非常愚蠢的事情,要么编译器忽略了一个强大的工具。我从
开始int a = 0;
for (int i=0; i<N; ++i) a = M1 * a + t[i];
计算接近String.hashCode()
的内容(设置M1=31
并使用char[]
)。计算非常简单,t.length=1000
在我的i5-2400 @ 3.10GHz(Java和C)上大约需要1.2微秒。
观察每两步a
乘以M2 = M1*M1
并添加一些内容。这导致了这段代码
int a = 0;
for (int i=0; i<N; i+=2) {
a = M2 * a + (M1 * t[i] + t[i+1]); // <-- note the parentheses!
}
if (i < len) a = M1 * a + t[i]; // Handle odd length.
这恰好是第一个代码段的两倍。奇怪的是,省略括号可以节省20%的加速时间。有趣的是,这可以重复,可以达到3.8倍。
与java不同,gcc -O3
选择不展开循环。这是明智的选择,因为它无论如何都无济于事(如-funroll-all-loops
所示)。
所以我的问题 1 是:什么阻止了这样的优化?
谷歌搜索不起作用,我只获得了“关联数组”和“关联运算符”。
我稍稍打磨了我的benchmark,现在可以提供一些results。除了展开4次之外没有加速,可能是因为乘法和加法一起需要4个周期。
由于Java已经展开循环,所有的努力工作都已完成。我们得到的是像
...pre-loop
for (int i=0; i<N; i+=2) {
a2 = M1 * a + t[i];
a = M1 * a2 + t[i+1];
}
...post-loop
有趣的部分可以重写,如
a = M1 * ((M1 * a) + t[i]) + t[i+1]; // latency 2mul + 2add
这表明有2次乘法和2次加法,所有这些都是按顺序执行的,因此在现代x86 CPU上需要8个周期。我们现在需要的只是一些小学数学(即使遇到溢出或其他问题,也可以为int
工作,但不适用于浮点数。)
a = ((M1 * (M1 * a)) + (M1 * t[i])) + t[i+1]; // latency 2mul + 2add
到目前为止,我们一无所获,但它允许我们折叠常数
a = ((M2 * a) + (M1 * t[i])) + t[i+1]; // latency 1mul + 2add
通过重新组合总和获得更多收益
a = (M2 * a) + ((M1 * t[i]) + t[i+1]); // latency 1mul + 1add
答案 0 :(得分:4)
以下是我理解你的两种情况的方法:在第一种情况下,你有一个循环,需要N步;在第二种情况下,您手动将第一个案例的两个连续迭代合并为一个,因此在第二种情况下您只需要执行N / 2步骤。你的第二种情况运行得更快,你想知道为什么哑编译器不能自动执行它。
没有任何东西可以阻止编译器进行这样的优化。但请注意,重新编写原始循环会导致更大的可执行文件大小:你在for
循环内有更多指令,在循环后有更多if
如果N = 1或N = 3,原始循环可能会更快(分支越少,缓存/预取/分支预测越好)。它使你的情况更快,但在其他情况下可能会让事情变得更慢。它是否值得进行这种优化并不是一个明确的因素,在编译器中实现这样的优化可能非常重要。
顺便说一句,你所做的与循环矢量化非常相似,但在你的情况下,你手动完成并行步骤并插入结果。 Eric Brumer的Compiler Confidential演讲将让你深入了解为什么重写循环通常很棘手,有什么缺点/缺点(更大的可执行文件大小,在某些情况下可能更慢)。所以编译器编写者非常清楚这种优化的可能性,并且正在积极地研究它,但它通常非常重要,也可能使事情变得更慢。
请为我尝试一下:
int a = 0;
for (int i=0; i<N; ++i)
a = ((a<<5) - a) + t[i];
假设M1=31
。原则上,编译器应该足够聪明,可以将31*a
重写为(a<<5)-a
,但我很好奇它是否确实如此。