BITWISE AND操作如何在C程序中占用比ARITHMETIC ADDITION操作更多的CPU时钟?

我想测试按位运算是否真的比算术运算更快。 我以为他们是。

我写了一个小的C程序来测试这个假设,令我惊讶的是,加法平均比按位AND运算少。 这对我来说是令人惊讶的,我无法理解为什么会这样。

根据我所知的附加,来自较低有效位的进位应该被携带到下一位,因为结果也取决于进位。 对我来说,逻辑运算符比加法更慢是没有意义的。

我的鳕鱼在下面:

#include #include int main() { int x=10; int y=25; int z=x+y; printf("Sum of x+y = %i", z); time_t start = clock(); for(int i=0;i<100000;i++)z=x+y; time_t stop = clock(); printf("\n\nArithmetic instructions take: %d",stop-start); start = clock(); for(int i=0;i<100000;i++)z=x&y; stop = clock(); printf("\n\nLogic instructions take: %d",stop-start); } 

一些结果:

 Arithmetic instructions take: 327 Logic instructions take: 360 Arithmetic instructions take: 271 Logic instructions take: 271 Arithmetic instructions take: 287 Logic instructions take: 294 Arithmetic instructions take: 279 Logic instructions take: 266 Arithmetic instructions take: 265 Logic instructions take: 296 

这些结果取自该计划的连续运行。

正如您可以看到的那样,逻辑运算符平均需要比算术运算符更长的时间。

好吧,让我们把它“测量”并将它炸掉,100k就是一点点

 #include #include #define limit 10000000000 int main() { int x=10, y=25, z; time_t start = clock(); for(long long i=0;i 

这会运行一段时间。 首先让我们尝试没有优化:

 thomas@TS-VB:~/src$ g++ -o trash trash.c thomas@TS-VB:~/src$ ./trash Arithmetic instructions take: 21910636 Logic instructions take: 21890332 

你看,两个循环大约需要同一时间。

用-S编译揭示了为什么(这里只显示.s文件的相关部分):

 // this is the assembly for the first loop .L3: movl 32(%esp), %eax movl 28(%esp), %edx addl %edx, %eax // <<-- ADD movl %eax, 40(%esp) addl $1, 48(%esp) adcl $0, 52(%esp) .L2: cmpl $2, 52(%esp) jl .L3 cmpl $2, 52(%esp) jg .L9 cmpl $1410065407, 48(%esp) jbe .L3 // this is the one for the second .L9: movl 32(%esp), %eax movl 28(%esp), %edx andl %edx, %eax // <<--- AND movl %eax, 40(%esp) addl $1, 56(%esp) adcl $0, 60(%esp) .L5: cmpl $2, 60(%esp) jl .L6 cmpl $2, 60(%esp) jg .L10 cmpl $1410065407, 56(%esp) jbe .L6 .L10: 

查看CPU指令集告诉我们,ADD和AND都将采用相同的循环次数 - > 2个循环将运行相同的时间量

现在进行优化:

 thomas@TS-VB:~/src$ g++ -O3 -o trash trash.c thomas@TS-VB:~/src$ ./trash Arithmetic instructions take: 112 Logic instructions take: 74 

循环已经过优化。 永远不需要计算的值,因此编译器决定不运行它

结论:如果你向森林射击3次,并且击中2只公猪和1只兔子,那并不意味着公猪的数量是兔子的两倍多

让我们从查看代码开始吧。 循环实际上没有做任何事情。 任何合理的编译器都会在第一次调用printf后看到你没有使用变量z ,所以扔掉它是完全安全的。 当然,编译器不必这样做,但任何合理的优化级别的合理编译器都会这样做。

让我们看一下编译器对您的代码所做的事情(带有-O2优化级别的标准clang):

  leaq L_.str(%rip), %rdi movl $35, %esi xorl %eax, %eax callq _printf 

这是第一个printf(“Sum of …”),注意生成的代码实际上没有添加任何东西,编译器知道xy的值,只计算它的总和并用35调用printf。

  callq _clock movq %rax, %rbx callq _clock 

呼叫时钟,将其结果保存在临时寄存器中,再次呼叫时钟,

  movq %rax, %rcx subq %rbx, %rcx leaq L_.str.1(%rip), %rdi xorl %eax, %eax movq %rcx, %rsi 

从头开始减去,为printf设置参数,

  callq _printf 

打电话给printf。

以等效方式移除第二个循环。 没有循环,因为编译器做了合理的事情 – 它注意到在循环中修改它后不使用z ,因此编译器会抛弃任何存储。 然后由于没有任何东西存储在其中,它也可以丢弃x+y 。 现在,由于循环体没有做任何事情,循环可以被丢弃。 所以你的代码基本上变成:

 printf("\n\nArithmetic instructions take: %d", clock() - clock()); 

现在,为什么这是相关的。 了解一些重要概念是相关的。 编译器不会将一个语句一次性地翻译成代码。 编译器会读取您的所有代码(或尽可能多的代码),尝试找出您实际意味着什么,然后生成代码,其行为“好像”它执行了所有这些语句。 语言和编译器只关心保留我们可以称之为可观察的副作用的东西。 如果计算值不可观察,则不需要计算它。 执行某些代码的时间不是我们关心的副作用,因此编译器不关心保留它,毕竟我们希望我们的代码尽可能快,所以我们希望有时间执行某些代码完全没有观察到。

第二部分为什么这是相关的。 如果你在没有优化的情况下编译它会花多长时间来测量它是没有用的。 这是在没有优化的情况下编译的代码中的循环:

 LBB0_1: cmpl $100000, -28(%rbp) jge LBB0_4 movl -8(%rbp), %eax addl -12(%rbp), %eax movl %eax, -16(%rbp) movl -28(%rbp), %eax addl $1, %eax movl %eax, -28(%rbp) jmp LBB0_1 LBB0_4: 

你以为你在这里测量addl指令。 但整个循环包含更多。 实际上,循环中的大部分时间都花在维护循环上,而不是实际执行指令。 大部分时间用于在堆栈上读取和写入值并计算循环变量。 您恰好测量的任何时间将完全由循环的基础设施主导,而不是您想要测量的操作。

你循环很少次。 我很确定你实际测量的大部分时间是花在clock()而不是你实际想要测量的代码上。 clock需要做一些工作,阅读时间往往相当昂贵。

然后我们回答你关心的实际指示的问题。 他们花费的时间相同。 这是与x86上的指令时序相关的所有内容的规范来源。

但。 推理个别指令非常困难,几乎没用。 在过去几十年中,几乎每个CPU都是超标量的。 这意味着它将同时执行许多指令。 事情花费多少时间的重要性在于指令之间的依赖性(如果那些输入是由先前的指令计算的话,在输入就绪之前就不能开始执行指令)而不是实际的指令。 虽然您可以在每纳秒的寄存器中进行数十次计算,但从主存储器获取数据可能需要数百纳秒。 因此,即使我们知道一条指令需要一个周期而你的CPU每纳秒执行两个周期(通常是那个周期),这意味着我们可以在100ns内完成的指令数可以在1之间(如果你需要等待)对于主内存)和12800(我不知道真正的确切数字,但我记得Skylake可以在每个周期退出64个浮点运算)。

这就是微基准测试不再严重的原因。 如果事情的微小变化可以影响结果一万二千,你可以很快看到为什么测量单个指令是无用的。 今天的大多数测量都是在大部分程序或整个程序上完成的。 我在工作中做了很多这样的事情,我有几种情况,改进算法改变了内存访问模式,即使算法可以在数学上certificate更快,整个程序的行为因为更改的内存访问模式等而受到影响。

抱歉这样一个漫无边际的答案,但我想知道为什么即使你的问题有一个简单的答案:“你的测量方法很糟糕”,也是一个真正的答案:“它们是相同的”,实际上有问题本身无法回答的有趣原因。

这只是几分钟的工作,宁愿展示裸机和其他类似的东西,但现在不值得花时间。

通过测试一些函数来查看调用约定是什么,并注意到添加它是生成

  400600: 8d 04 37 lea (%rdi,%rsi,1),%eax 400603: c3 retq 

为和

  400610: 89 f8 mov %edi,%eax 400612: 21 f0 and %esi,%eax 400614: c3 retq 

三个指令而不是两个,五个字节而不是四个,这些位如果信息都起作用并且不重要。 但是为了使它更公平,每个操作都会做同样的事情。

也希望这样做一个无数次循环紧密耦合而不是编译,因为最终可能会产生一些变化。 最后,协调试图使这个公平。

 .balign 32 nop .balign 256 .globl and_test and_test: mov %edi,%eax and %esi,%eax sub $1,%edx jne and_test retq .balign 32 nop .balign 256 .globl add_test add_test: mov %edi,%eax add %esi,%eax sub $1,%edx jne add_test retq .balign 256 nop 

来自你的

 #include #include unsigned int add_test ( unsigned int a, unsigned int b, unsigned int x ); unsigned int and_test ( unsigned int a, unsigned int b, unsigned int x ); int main() { int x=10; int y=25; time_t start,stop; for(int j=0;j<10;j++) { start = clock(); add_test(10,25,2000000000); stop = clock(); printf("%u %u\n",j,(int)(stop-start)); } for(int j=0;j<10;j++) { start = clock(); and_test(10,25,2000000000); stop = clock(); printf("%u %u\n",j,(int)(stop-start)); } return(0); } 

首先按预期运行第一个循环需要更长时间,因为它不在缓存中? 不应该花那么长时间,这样也没有意义,也许是其他原因......

 0 605678 1 520204 2 521311 3 520050 4 521455 5 520213 6 520315 7 520197 8 520253 9 519743 0 520475 1 520221 2 520109 3 520319 4 521128 5 520974 6 520584 7 520875 8 519944 9 521062 

但我们保持相当一致。 第二次运行,时间保持一定程度。

 0 599558 1 515120 2 516035 3 515863 4 515809 5 516069 6 516578 7 516359 8 516170 9 515986 0 516403 1 516666 2 516842 3 516710 4 516932 5 516380 6 517392 7 515999 8 516861 9 517047 

请注意,这是20亿个循环。 每条四条指令。 我的时钟每秒钟为4000000,每圈3.4ghz 0.8772个时钟或每个指令0.2193个时钟,这怎么可能? 超级计算器处理器。

还有很多工作要做,在这里,这只需要几分钟的时间,希望它足以certificate(正如其他人已经做过的那样),你不能真正看到这样的测试的差异。

我可以做一个类似于arm的更线性的演示以及我们可以读取时钟/定时器寄存器作为被测代码的一部分,因为时钟代码的调用都是被测代码的一部分,并且可以在这里变化。 希望这不是必要的,但结果更加一致,但使用sram,控制所有被测试的指令等等,你可以看到对齐差异你可以看到第一个循环读取的缓存成本但不是剩余的等等...(总共几个时钟,虽然我们在这里看到10毫秒,嗯,可能与x86系统相提并论,不知道基准测试x86几乎完全浪费时间,没有乐趣,结果不会转化为其他x86电脑那么好)

正如您在其他问题中指出的那样,作为副本关闭,我讨厌在这里使用链接,应该学习如何剪切和粘贴图片(TODO)。

 https://en.wikipedia.org/wiki/AND_gate https://en.wikipedia.org/wiki/Adder_(electronics) 

假设为数学/逻辑运算提供加法和且是相同的,我们只是试图测量它们之间的差异你是正确的AND更快没有进一步细节你可以看到一个只有一个阶段/门。 当一个完整的加法器需要三个级别,信封数学的后面,一旦输入改变而不是AND,就会有三倍的时间来解决信号....但是......虽然有一些例外,芯片并不是设计成的利用这个(很好的乘法和除以vs /和/ xor / etc,是的,他们是或更有可能)。 人们可以将这些简单的操作设计为一个时钟周期,在时钟上,组合逻辑的输入(实际的AND或ADD)被锁存,在下一个时钟周期,结果从另一端锁存并开始其到达的时间。注册文件或核心到存储器等...在设计的某个时刻,你将合成到您正在使用的代工厂/工艺的可用门,然后对其进行时序分析/关闭并寻找长杆帐篷。 非常不可能(不可能)添加是一个长杆,无论是增加还是非常非常短的极点,但是你在那时确定你的最大时钟频率是多少,如果你想要一个4ghz的procerssor但结果是2.7你需要拿这些长杆并将它们变成两个或更多个时钟操作。 进行添加vs所需的时间和应该变化的时间应该更长,是如此之快,并且在噪声中它都在一个时钟周期内,所以即使你做了逻辑设计的function模拟,你也看不到你需要在使用晶体管和其他元件的pspice中实现一个和一个完整的加法器的差异,然后将步骤变化输入到输入中并观察需要多长时间来解决,或者从无线电小屋中的分立元件构建它们并尝试,尽管结果可能对您的范围来说太快,因此请使用pspice或其他。

考虑编写方程来解决你可以编写的东西,可能是一个很长的方程式,或者你可以将它分解为多个较小的方程式和中间变量

 this a = b+c+d+e+f+g; vs x=b+c; y=d+e; z=f+g; a=x+y; a=a+z; 

一个时钟对5个时钟,但如果这是帐篷中最长的一个,则5个时钟中的每个时钟都可以更快。 所有其他逻辑都快得多了。 (实际上x,y,z可以是一个时钟,然后是a = x + y + z,或者再做两个)

乘法和除法是不同的,因为逻辑以指数方式爆炸,没有任何魔法可以乘法或除法,它们必须像我们在铅笔和纸上做事一样工作。 你可以考虑使用二进制文件的快捷方式。 因为你只能在shifing并添加到累加器之前乘以0或1。 一个时钟的逻辑方程仍然以指数方式爆炸,然后你可以做并行的事情。 它烧掉了大量的芯片房地产,所以你可以选择乘法以及除以一个以上的时钟并将其隐藏在管道中。 或者您可以选择刻录大量的芯片房地产...查看编译时可以编写的某些臂芯的文档(编译/合成内核时)选择一个时钟或多个时钟乘以平衡芯片尺寸与性能。 x86我们不会购买IP并自己制作芯片所以它取决于英特尔如何做到这一点,并且非常可能通过微编码进行微编码,你可以调整事情的发生或者在alu类型操作中进行调整。

所以你可能会检测乘法或除法性能与添加/和这样的测试,但要么他们在一个时钟内完成它而你无法看到它,或者他们可能已经将它埋入管道的两个或更多个步骤中以便它平均出去正确,看到它你需要访问芯片SIM卡。 使用计时器和运行十亿次的东西很有趣,但要实际看到指令性能,你需要一个芯片SIM卡,需要调整代码以防止未经测试的代码影响结果。