当我测试C中移位和乘法之间的时间差异时,没有差异。为什么?


28

有人告诉我,二进制移位比乘以2 ^ k要有效得多。因此,我想尝试一下,并使用以下代码对此进行了测试:

#include <time.h>
#include <stdio.h>

int main() {
    clock_t launch = clock();
    int test = 0x01;
    int runs;

    //simple loop that oscillates between int 1 and int 2
    for (runs = 0; runs < 100000000; runs++) {


    // I first compiled + ran it a few times with this:
    test *= 2;

    // then I recompiled + ran it a few times with:
    test <<= 1;

    // set back to 1 each time
    test >>= 1;
    }

    clock_t done = clock();
    double diff = (done - launch);
    printf("%f\n",diff);
}

对于这两个版本,打印输出约为440000,给定或接受10000。两个版本的输出之间(至少在视觉上)没有显着差异。所以我的问题是,我的方法是否有问题?是否应该甚至在视觉上有所区别?这与我的计算机,编译器或其他体系结构有关吗?


47
谁教你的,显然是错误的。自从1970年代以来,对于通常使用的体系结构上的通常使用的编译器,这种信念就一直不成立。对您测试此声明非常有帮助。我听说过,出于天真的缘故,这种关于JavaScript的荒谬说法。
埃里克·利珀特

21
回答此类问题的最佳方法是查看编译器生成的汇编代码。编译器通常可以选择生成其生成的汇编语言的副本。对于GNU GCC编译器,这是'-S'。
查尔斯·格兰特

8
应该指出的是,使用查看之后gcc -S,for的代码test *= 2实际上被编译为shll $1, %eax 使用调用时gcc -O3 -S甚至没有循环。两个时钟呼叫相隔一条线:callq _clock movq %rax, %rbx callq _clock

6
“有人告诉我,二进制移位比乘以2 ^ k要有效得多”;我们被教会了很多错误的东西(或者至少是过时的)。聪明的编译器将对两者使用相同的移位操作。
约翰·博德2014年

9
在进行此类优化时,请务必始终检查生成的汇编代码,以确保您正在测量自己认为要测量的内容。关于SO的大量“为什么我会看到这些时间”问题最终导致编译器完全淘汰了操作,因为未使用结果。
罗素·博罗戈夫

Answers:


44

就像在另一个答案中所说的那样,大多数编译器会自动优化乘法以通过移位来完成。

优化时,这是一条非常通用的规则:大多数“优化”实际上会误导编译的含义,甚至会降低性能。

仅在发现性能问题并衡量出问题所在后才进行优化。(而且我们编写的大多数代码不会经常执行,因此我们无需打扰)

优化的最大缺点是“优化”代码的可读性通常差很多。因此,在您要进行乘法运算时,请务必进行乘法运算。如果要移动位,请进行位移。


20
始终使用语义正确的操作。如果要操作位掩码,或将小整数放置在较大的整数中,则shift是合适的操作。
2014年

2
从实践上来说,是否需要在高级软件应用程序中优化与移位运算符的乘法?似乎,由于编译器已经进行了优化,因此唯一有用的知识就是在非常低的级别(至少在编译器之下)进行编程。
NicholasFolk

11
@NicholasFolk不。做最简单的事情。如果您直接编写程序集可能会很有用...或者如果您正在编写优化的编译器,那么它可能会很有用。但是,在这两种情况之外,这是一种使您不知所措并且使下一个程序员(知道斧头的谋杀者,知道您居住的地方)的恶作剧,诅咒您的名字并考虑从事一项业余爱好。

2
@NicholasFolk:无论如何,CPU体系结构几乎总是掩盖或渲染这种级别的优化。谁在乎是否仅从内存中获取参数并将其写回将占用100个以上的时间,即可节省50个周期?当内存以(或接近)CPU的速度运行时,这样的微优化才有意义,但今天还不那么多。
TMN 2014年

2
因为我厌倦了看到那句名言的10%,并且因为它触及了这里的钉子:“毫无疑问,效率的高低会导致滥用。程序员浪费了大量时间来思考或担心。关于非关键部分程序的速度,以及这些对效率的尝试,实际上在考虑调试和维护时会产生严重的负面影响,我们应该忘掉效率低下的问题,例如大约97%的时间:过早的优化是造成问题的根源万恶..
cHao 2014年

25

编译器识别常量,并在适当的时候将乘法转换为移位。


编译器识别出2 ....的幂的常数,并转换为移位。并非所有常量都可以转换为移位。
quick_now 2014年

4
@quickly_now:它们可以转换为班次和加法/减法的组合。
Mehrdad 2014年

2
编译器优化器的一个经典错误是将除法转换为右移,这对于正的红利有效,而对于负的红利则减1。
2014年

1
@quickly_now我相信“适当时”一词涵盖了一些常量不能重写为班次的想法。
法拉普2014年

21

移位是否比乘法快取决于CPU的体系结构。在奔腾和更早的日子里,移位通常比乘法快,这取决于被乘数中1位的数量。例如,如果被乘数为320,则为101000000,即两位。

a *= 320;               // Slower
a = (a<<7) + (a<<9);    // Faster

但是如果您有两个以上的位...

a *= 324;                        // About same speed
a = (a<<2) + (a<<7) + (a<<9);    // About same speed

a *= 340;                                 // Faster
a = (a<<2) + (a<<4) + (a<<7) + (a<<9);    // Slower

在带有单周期乘法但没有桶形移位器PIC18之类的微控制器上,如果移位多于1位,则乘法速度更快。

a  *= 2;   // Exactly the same speed
a <<= 1;   // Exactly the same speed

a  *= 4;   // Faster
a <<= 2;   // Slower

请注意,那是 旧版Intel CPU相反

但这还不是那么简单。如果我没记错的话,由于其超标量架构,奔腾能够同时处理一个乘法指令或两个移位指令(只要它们彼此不依赖)。这意味着,如果您想将两个变量乘以2的幂,则移位可能会更好。

a  *= 4;   // 
b  *= 4;   // 

a <<= 2;   // Both lines execute in a single cycle
b <<= 2;   // 

5
+1“移位是否快于乘法取决于CPU的体系结构。” 感谢您实际上进入了历史,并表明大多数计算机神话确实具有一定的逻辑基础。
法拉普

11

您的测试程序遇到了一些问题。

首先,您实际上并没有使用的值test。在C标准中,没有办法确保test事情的价值。优化器完全可以自由删除它。一旦将其删除,您的循环实际上为空。唯一可见的效果是set runs = 100000000,但是runs使用。因此,优化器可以(并且应该!)删除整个循环。轻松解决:还可以打印计算出的值。请注意,充分确定的优化程序仍然可以优化循环(它完全依赖于编译时已知的常数)。

其次,您执行两个互相抵消的操作。允许优化器注意到这一点并取消它们。再次留下一个空循环,并将其删除。这是一个很难修复的问题。您可以切换到unsigned int(因此溢出不是未定义的行为),但是当然会导致结果为0。简单的事情(例如test += 1)很容易让优化器找出来,而且确实如此。

最后,您假设test *= 2实际上将被编译为一个乘法。这是一个非常简单的优化;如果位移位更快,则优化器将改用它。为了解决这个问题,您必须使用类似特定于实现的程序集内联。

或者,我想,只要检查一下您的微处理器数据表,看看哪个更快。

当我检查gcc -S -O3使用4.9版编译程序的程序集输出时,优化器实际上可以查看上述每个简单的变体,以及更多其他变体。在所有情况下,它都删除了循环(为分配了一个常量test),剩下的唯一事情就是对clock(),转换/减法和的调用printf


1
还要注意,优化器可以(并且将)优化对常量的操作(甚至在循环中),如sqrt c#vs sqrt c ++所示,其中,优化器可以用实际总和替换将值求和的循环。为了克服这种优化,您需要使用在运行时确定的功能(例如命令行参数)。

@MichaelT是的。这就是我的意思,“请注意,经过充分确定的优化程序仍然可以优化循环(它完全依赖于编译时已知的常数)”。
derobert

我明白您的意思,但是我不认为编译器会删除整个循环。您可以通过简单地增加迭代次数来轻松验证该理论。您会看到增加迭代次数确实会使程序花费更长的时间。如果将循环完全删除,则不是这种情况。
DollarAkshay

@AkshayLAradhya我不能说您的编译器在做什么,但是我再次确认gcc -O3(现在是7.3)仍然完全删除了循环。(如果需要,请确保切换到long而不是int,否则由于溢出会将其优化为无限循环)。
德罗伯特

8

我认为对于提问者来说,做出更具差异性的答案会更有帮助,因为我在问题以及某些答案或评论中看到了一些未经审查的假设。

移位和乘法所产生的相对运行时间与C无关。当我说C时,我并不是说特定实现的实例,例如那个或那个版本的GCC,而是语言。我的意思不是荒谬,而是要使用一个极端的例子进行说明:您可以实现一个完全符合标准的C编译器,并且乘法需要一个小时,而移位则需要毫秒(或者相反)。我不了解C或C ++中的任何此类性能限制。

您可能不关心论证中的这种技术性。您的意图可能只是测试移位与乘法的相对性能,然后选择C,因为C通常被认为是一种低级编程语言,因此人们可能希望其源代码能够更直接地转换为相应的指令。这样的问题非常普遍,我认为一个很好的答案应该指出,即使在C语言中,您的源代码也不能像您在给定实例中所想象的那样直接转换为指令。我在下面给出了一些可能的编译结果。

在这里,提出了质疑在现实世界软件中替代这种等效性的有用性的评论。您可以在对问题的评论中看到一些评论,例如Eric Lippert的评论。这与您通常会从经验丰富的工程师那里得到的响应相吻合。如果您将生产代码中的二进制移位用作乘法和除法的总括方式,人们很可能会对您的代码感到畏缩,并会产生某种程度的情感反应(“为了天堂,我听说过有关JavaScript的荒谬说法”)。除非对新手程序员有更好的了解,否则可能对新手程序员没有意义。

这些原因主要是这种优化的可读性和无效性的综合体现,您可能已经在比较它们的相对性能时发现了它们。但是,我不认为如果将shift替换为乘法是这种优化的唯一示例,那么人们不会有那么强烈的反应。像您这样的问题经常以各种形式和背景出现。我认为,至少在某些时候,更高级的工程师实际上会如此强烈地做出反应,这是当人们在整个代码库中自由地采用此类微优化时,有可能造成更大范围的危害。如果您在像Microsoft这样的大型代码公司工作,您将花费大量时间阅读其他工程师的源代码,或尝试在其中找到某些代码。甚至可能是您自己的代码,几年后,尤其是在某些最不适当的时候,您将试图弄清这些代码,例如,当您接到传呼机上的电话后必须修复生产中断时周五晚上值班,准备和朋友一起度过一个快乐的夜晚……如果您花大量时间阅读代码,您将欣赏它尽可能可读。想像一下您最喜欢的小说,但是出版商决定发行使用abbrv的新版本。所有ovr th plc bcs和svs spc。这类似于其他工程师可能会对您的代码产生的反应,如果您对它们进行了这样的优化。正如其他答案所指出的那样,最好清楚地说明您的意思,

即使在这些环境中,您也可能会发现自己正在解决一个面试问题,希望您知道这一点或其他等效问题。知道它们并不坏,一个好的工程师会意识到二进制移位的算术效果。请注意,我并不是说这会造就一个好的工程师,但是我认为一个好的工程师会知道。特别是,您可能仍会找到一些经理,通常在面试循环快要结束时,他会大笑您,以期希望在编码问题中向您透露这个聪明的工程“技巧”,并证明他/她也曾经是或曾经是精明的工程师之一,而不是“仅仅是”一位经理。在这种情况下,只要让自己印象深刻并感谢他/她的启发性采访。

为什么看不到C的速度差异?最可能的答案是,它们都导致了相同的汇编代码:

int shift(int i) { return i << 2; }
int multiply(int i) { return i * 2; }

都可以编译成

shift(int):
    lea eax, [0+rdi*4]
    ret

在没有优化的GCC上,即使用标志“ -O0”,您可能会得到以下信息:

shift(int):
    push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    sal eax, 2
    pop rbp
    ret
multiply(int):
    push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    add eax, eax
    pop rbp
    ret

如您所见,将“ -O0”传递给GCC并不意味着它不会产生什么样的代码。特别要注意的是,即使在这种情况下,编译器也避免了使用乘法指令。您可以重复相同的实验,但要移位其他数字,甚至要乘以不是2的幂的数字。在您的平台上,您可能会看到移位和加法的组合,但没有乘法。如果乘法和移位确实具有相同的代价,那么在所有这些情况下,编译器显然避免使用乘法似乎有点巧合,不是吗?但是我并不是要提供假设作为证据,所以让我们继续前进。

您可以使用上面的代码重新运行测试,看看现在是否注意到速度差异。即便如此,您仍未测试移位与乘法的关系,正如您通过没有乘法所看到的那样,而是在特定实例中由GCC针对移位和乘法的C运算使用特定标志集生成的代码。因此,在另一个测试中,您可以手动编辑汇编代码,而在代码中使用“ imul”指令执行“ multiply”方法。

如果您想击败一些编译器的聪明才智,则可以定义一个更通用的shift和乘法方法,并最终得到以下结果:

int shift(int i, int j) { return i << j; }
int multiply(int i, int j) { return i * j; }

这可能会产生以下汇编代码:

shift(int, int):
    mov eax, edi
    mov ecx, esi
    sal eax, cl
    ret
multiply(int, int):
    mov eax, edi
    imul    eax, esi
    ret

即使在GCC 4.9的最高优化级别上,我们最终也终于有了汇编指令中最初开始进行测试时可能期望的表达式。我认为它本身可以成为性能优化中的重要一课。我们可以看到在编译器能够应用的智能方面,用变量代替代码中的具体常量所产生的差异。像移位乘法替换这样的微优化是编译器通常可以很容易地自己完成的一些非常低级的优化。其他对性能影响更大的优化则需要了解代码意图这通常是编译器无法访问的,或者只能通过某种启发式方法来猜测。那就是您作为软件工程师加入的地方,并且通常不涉及使用移位代替乘法。它涉及一些因素,例如避免对产生I / O并可能阻塞进程的服务进行冗余调用。如果您进入硬盘或进入远程数据库以获取一些其他数据,这些数据可能是从内存中已有的数据中获取的,那么等待所花费的时间将超过一百万条指令的执行量。现在,我认为我们与您的原始问题相去甚远,但我想将其指出给发问者,特别是如果我们假设有人刚刚开始掌握代码的翻译和执行,

那么,哪一个会更快?我认为这是您选择实际测试性能差异的一种好方法。通常,某些代码更改的运行时性能很容易使您感到惊讶。现代处理器采用了许多技术,软件之间的交互也可能很复杂。即使您应该在一种情况下进行某些更改而获得有益的绩效结果,但我认为得出这样的变化总是可以带来绩效收益的结论还是很危险的。我认为一次进行这样的测试很危险,说“好吧,现在我知道哪个更快!” 然后不加选择地将相同的优化应用于生产代码,而无需重复进行测量。

那么,如果移位比乘法快怎么办?当然有迹象表明这是正确的。正如您在上面看到的那样,GCC似乎认为(即使没有优化)避免直接乘法而采用其他指令也是一个好主意。在英特尔64和IA-32架构优化参考手册会给你的CPU指令的相对成本的想法。http://www.agner.org/optimize/instruction_tables.pdf是另一个更注重指令延迟和吞吐量的资源。请注意,它们不是绝对运行时的良好预测器,而是指令相对于彼此的性能。在一个紧密的循环中,正如您的测试在模拟一样,“吞吐量”的度量标准应该是最相关的。它是执行给定指令时通常会占用执行单元的周期数。

那么,如果移位没有比乘法快呢?如前所述,现代架构可能非常复杂,诸如分支预测,缓存,流水线和并行执行单元之类的事情可能使得有时很难预测两个逻辑上等效的代码的相对性能。我真的很想强调这一点,因为在这里我对大多数此类问题的答案不满意,并且人们阵营直截了当地说,移位快于乘法是不正确的(不再)。

不,据我所知,我们不是在1970年代发明的,也没有在任何时候突然消除乘法器和移位器的成本差异的时候发明的。在许多架构上,就逻辑门而言,当然就逻辑运算而言,通用乘法仍然比采用桶形移位器的移位复杂得多。如何将其转换为台式计算机上的整体运行时可能有点不清楚。我不确定如何在特定处理器中实现它们,但是这里是对乘法的解释:整数乘法的速度真的与现代CPU上的加法速度相同吗?

这里是桶形移位器的解释。我在上一段中引用的文档通过代理CPU指令对操作的相对成本提供了另一种观点。英特尔的工程师经常会遇到类似的问题:英特尔开发人员专区论坛为核心2双核处理器中的整数乘法和加法提供时钟周期

是的,在大多数现实场景中,几乎可以肯定在JavaScript中,为性能而利用这种等效性可能是徒劳的。但是,即使我们强制使用乘法指令,然后看到运行时也没有差异,这更多是由于我们使用的成本指标的性质(准确地说),而不是因为没有成本差异。端到端运行时是一个指标,如果这是我们关心的唯一指标,那么一切都很好。但这并不意味着乘法和移位之间的所有成本差异都已消失。而且我认为以暗示或其他方式将其传达给提问者肯定不是一个好主意,因为提问者显然才刚刚开始了解现代代码的运行时间和成本所涉及的因素。工程总是要权衡取舍。对现代处理器进行了哪些折衷以显示出我们最终用户看到的执行时间的询问和解释,可能会得出更加不同的答案。而且我认为,如果我们希望看到更少的工程师检入经过微优化的代码,从而消除可读性,则需要一个比“根本不再是事实”更具差异性的答案,因为它需要对这种“优化”的性质有更全面的了解,发现它的各种变身,而不是简单地将某些特定实例称为过时的。


6

您看到的是优化器的效果。

优化器的工作是使生成的编译后的代码更小或更快速(但很少同时出现……但是就像很多事情一样……这取决于代码是什么)。

在原理中,对乘法库的任何调用,或者甚至经常使用硬件乘法器,都比仅进行按位移位要慢。

所以...如果天真的编译器为操作* 2生成了对库的调用,那么它的运行当然会比按位移位*慢。

然而,优化器可以检测模式并弄清楚如何使代码更小/更快/什么。您所看到的是编译器检测到* 2与移位相同。

出于兴趣,我今天只是在为* 5之类的某些操作查看生成的汇编器...实际上不是在查看而是其他内容,并且在此过程中,我注意到编译器已将* 5转换为:

  • 转移
  • 转移
  • 添加原始号码

因此,我的编译器的优化程序足够聪明(至少对于某些小常数而言),可以生成内联移位并添加而不是调用通用乘法库。

编译器优化程序的艺术是一个完全独立的主题,充满了魔力,并且在整个地球上大约有6个人真正地正确理解了:)


3

尝试使用以下方法计时:

for (runs = 0; runs < 100000000; runs++) {
      ;
}

编译器应认识到,test在每次循环迭代后,的值均未更改,并且最终值test未使用,因此完全消除了循环。


2

乘法是移位和加法的组合。

在您提到的情况下,我认为编译器是否对其进行优化并不重要-“乘以x2”可以实现为:

  • x向左移动一位。
  • 添加xx

这些都是基本的原子操作。一个并不比另一个快。

将其更改为“乘以x四”,(或任意2^k, k>1),这有点不同:

  • x向左移动两个位置。
  • 添加xx并调用它y,添加yy

在一个基本的架构,它的简单地看到,转变为更有效-以一个对两个操作,因为我们不能添加yy直到我们知道什么y是。

尝试使用后者(或任何一个2^k, k>1),并使用适当的选项来防止您将它们优化为实现上的同一事物。O(1)与中的重复加法相比,您应该发现转换速度更快O(k)

显然,在被乘数不是2的幂的情况下,移位和加法的组合(其中一个的数目不为零)是必须的。


1
什么是“基本原子操作”?难道不能说移位可以将运算并行地应用于每个位,而最左边的位又取决于其他位吗?
Bergi 2014年

2
@Bergi:我猜他的意思是shift和add都是单机指令。您必须查看指令集文档以查看每个指令的周期计数,但是是的,加法通常是多周期操作,而移位通常在单个周期中执行。
TMN 2014年

是的,可能是这样,但是乘法也是单条机器指令(尽管当然可能需要更多的周期)
Bergi 2014年

@Bergi,那也取决于拱门。与32位加法(或适用的x位)相比,您认为哪个拱门移位的周期更少?
OJFord

我不知道任何特定的体系结构,不(并且我的计算机工程课程已经淡出),可能两条指令都花费不到一个周期。我可能在考虑微码甚至是逻辑门,在这种情况下转移可能会更便宜。
Bergi 2014年

1

有符号或无符号值乘以2的幂等于左移,并且大多数编译器都会进行替换。无符号值的除法或编译器可以证明的无符号值永远不会为负,这等同于右移,并且大多数编译器都会进行这种替换(尽管有些不够复杂,无法证明有符号值不能为负)。 。

但是,应注意,潜在负号的除法并不等同于右移。类似的表达式(x+8)>>4不等于(x+8)/16。在99%的编译器中,前者将映射值从-24到-9到-1,-8到+7到0以及+8到+23到1 [四舍五入的数字几乎对称地大约为零]。后者将-39映射到-24到-1,-23映射到+7到0,以及+8映射到+23到+1(非常不对称,并且可能不是预期的)。请注意,即使不期望值是负数,使用>>4也会产生比/16除非编译器可以证明值不能为负数更快的代码。


0

我刚刚签出的更多信息。

在x86_64上,MUL操作码具有10个周期的延迟和1/2个周期的吞吐量。MOV,ADD和SHL的延迟为1个周期,吞吐量为2.5、2.5和1.7个周期。

乘以15至少需要3个SHL和3个ADD op,可能还需要几个MOV。

https://gmplib.org/~tege/x86-timing.pdf


0

您的方法有缺陷。循环增量和条件检查本身要花费大量时间。

  • 尝试运行一个空循环并测量时间(称为base)。
  • 现在添加1个移位操作并测量时间(称为s1)。
  • 接下来添加10个移位操作并测量时间(称为s2

如果一切正常的话base-s2应该是的10倍base-s1。否则,这里会发生其他事情。

现在,我自己亲自尝试了一下,并弄清楚了,如果循环引起问题,为什么不将其完全删除。所以我继续这样做:

int main(){

    int test = 2;
    clock_t launch = clock();

    test << 6;
    test << 6;
    test << 6;
    test << 6;
    //.... 1 million times
    test << 6;

    clock_t done = clock();
    printf("Time taken : %d\n", done - launch);
    return 0;
}

结果就在那里

在1毫秒内完成1百万次换档操作?

我对64乘以相同的运算并得到相同的结果。因此,编译器可能会完全忽略该操作,因为其他人提到test的值从未更改。

移位运算符结果

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.