我为Project Euler Q14编写了这两个解决方案,用汇编和c++。他们采用了相同的蛮力方法来测试Collatz猜想。装配方案由:
nasm -felf64 p14.asm && gcc p14.o -o p14
c++是用以下工具编译的:
g++ p14.cpp -o p14
组装、p14.asm:
section .data
fmt db "%d", 10, 0
global main
extern printf
section .text
main:
mov rcx, 1000000
xor rdi, rdi ; max i
xor rsi, rsi ; i
l1:
dec rcx
xor r10, r10 ; count
mov rax, rcx
l2:
test rax, 1
jpe even
mov rbx, 3
mul rbx
inc rax
jmp c1
even:
mov rbx, 2
xor rdx, rdx
div rbx
c1:
inc r10
cmp rax, 1
jne l2
cmp rdi, r10
cmovl rdi, r10
cmovl rsi, rcx
cmp rcx, 2
jne l1
mov rdi, fmt
xor rax, rax
call printf
ret
c++, p14.cpp:
#include <iostream>
int sequence(long n) {
int count = 1;
while (n != 1) {
if (n % 2 == 0)
n /= 2;
else
n = 3*n + 1;
++count;
}
return count;
}
int main() {
int max = 0, maxi;
for (int i = 999999; i > 0; --i) {
int s = sequence(i);
if (s > max) {
max = s;
maxi = i;
}
}
std::cout << maxi << std::endl;
}
我知道编译器优化可以提高速度,但是我没有看到很多方法来进一步优化我的汇编解决方案(从编程的角度说,而不是从数学角度说)。
c++代码每一项使用模数,每一项使用除法,而汇编代码每一项只使用一个除法。
但是这个程序集比c++解决方案平均要长1秒。为什么会这样?我问这个问题主要是出于好奇。
执行时间
我的系统:1.4 GHz Intel Celeron 2955U上的64位Linux (Haswell微架构)。
g++(未优化):平均1272毫秒。
g++ -O3:平均578毫秒。
Asm (div)(原始):平均2650毫秒。
Asm (shr):平均679 ms。
@johnfound asm(与NASM组装):平均501毫秒。
@hidefromkgb asm:平均200毫秒。
@hidefromkgb asm,由@Peter Cordes优化:平均145毫秒。
@Veedrac c++: -O3平均81毫秒,-O0平均305毫秒。
为了获得更好的性能:一个简单的改变是观察到n = 3n+1后,n将是偶数,因此您可以立即除以2。n不等于1,所以不需要检验。所以你可以保存一些if语句,然后写:
while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
n = (3*n + 1) / 2;
if (n % 2 == 0) {
do n /= 2; while (n % 2 == 0);
if (n == 1) break;
}
}
这是一个重大的胜利:如果你看n的最低8位,直到你除以2 8次的所有步骤都完全由这8位决定。例如,如果最后8位是0x01,即二进制,则您的数字是????0000 0001那么接下来的步骤是:
3n+1 -> ???? 0000 0100
/ 2 -> ???? ?000 0010
/ 2 -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2 -> ???? ???0 0010
/ 2 -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2 -> ???? ???? ?010
/ 2 -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2 -> ???? ???? ???0
/ 2 -> ???? ???? ????
所有这些步骤都可以预测,256k + 1被81k + 1取代。所有组合都会发生类似的情况。所以你可以用一个大的switch语句来循环:
k = n / 256;
m = n % 256;
switch (m) {
case 0: n = 1 * k + 0; break;
case 1: n = 81 * k + 1; break;
case 2: n = 81 * k + 1; break;
...
case 155: n = 729 * k + 425; break;
...
}
运行循环直到n≤128,因为在这一点上,n可以变成1,并且小于8个除法除以2,并且一次执行8个或更多的步骤将使您错过第一次达到1的点。然后继续“正常”循环——或者准备一个表格,告诉你还需要多少步才能达到1。
PS:我强烈怀疑Peter Cordes的建议会让它更快。除了一个分支之外,根本没有条件分支,并且除了在循环实际结束时,该分支将被正确预测。代码是这样的
static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }
while (n > 128) {
size_t lastBits = n % 256;
n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}
在实践中,您将测量一次处理n的最后9,10,11,12位是否会更快。对于每一位,表中的条目数将翻倍,当表不再适合L1缓存时,我预计会放缓。
pp。如果你需要运算的次数:在每次迭代中,我们做了8次除以2,以及一个可变的(3n + 1)次运算,所以计算运算次数的一个明显的方法是另一个数组。但是我们实际上可以计算出步数(基于循环的迭代次数)。
我们可以稍微重新定义这个问题:如果是奇数,将n替换为(3n + 1) / 2;如果是偶数,将n替换为n / 2。那么每次迭代都将执行8步,但你可以认为这是作弊:-)所以假设有r个操作n <- 3n+1和s个操作n <- n/2。结果是n' = n * 3^r / 2^s,因为n <- 3n+1意味着n <- 3n * (1 +1 /3n)。取对数,得到r = (s + log2 (n' / n)) / log2(3)。
如果我们循环到n≤1,000,000,并且有一个预先计算好的表,从n≤1,000,000的任何起点需要多少次迭代,那么按照上面的方法计算r,四舍五入到最接近的整数,将会给出正确的结果,除非s真的很大。
声称c++编译器可以产生比合格的汇编语言程序员更优的代码是一个非常严重的错误。尤其是在这种情况下。人类总是能比编译器编写出更好的代码,这种特殊情况很好地说明了这一点。
您看到的时间差异是因为问题中的汇编代码在内部循环中远远不是最优的。
(下面的代码是32位的,但可以很容易地转换为64位)
例如,序列函数可以优化到只有5条指令:
.seq:
inc esi ; counter
lea edx, [3*eax+1] ; edx = 3*n+1
shr eax, 1 ; eax = n/2
cmovc eax, edx ; if CF eax = edx
jnz .seq ; jmp if n<>1
整个代码看起来像这样:
include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"
start:
InitializeAll
mov ecx, 999999
xor edi, edi ; max
xor ebx, ebx ; max i
.main_loop:
xor esi, esi
mov eax, ecx
.seq:
inc esi ; counter
lea edx, [3*eax+1] ; edx = 3*n+1
shr eax, 1 ; eax = n/2
cmovc eax, edx ; if CF eax = edx
jnz .seq ; jmp if n<>1
cmp edi, esi
cmovb edi, esi
cmovb ebx, ecx
dec ecx
jnz .main_loop
OutputValue "Max sequence: ", edi, 10, -1
OutputValue "Max index: ", ebx, 10, -1
FinalizeAll
stdcall TerminateAll, 0
为了编译这段代码,需要使用FreshLib。
在我的测试中,(1 GHz AMD A4-1200处理器),上面的代码大约比问题中的c++代码快四倍(当用-O0编译时:430毫秒vs. 1900毫秒),当用-O3编译时,快两倍多(430毫秒vs. 830毫秒)。
两个程序的输出是相同的:在i = 837799上,max sequence = 525。