与其他类型的循环相比,编译器会为do-while循环产生更好的代码吗?


89

zlib压缩库中有一条注释(Chromium项目中使用了它),这意味着在大多数编译器中,C语言中的do-while循环会生成“更好的”代码。这是它所在的代码段。

do {
} while (*(ushf*)(scan+=2) == *(ushf*)(match+=2) &&
         *(ushf*)(scan+=2) == *(ushf*)(match+=2) &&
         *(ushf*)(scan+=2) == *(ushf*)(match+=2) &&
         *(ushf*)(scan+=2) == *(ushf*)(match+=2) &&
         scan < strend);
/* The funny "do {}" generates better code on most compilers */

https://code.google.com/p/chromium/codesearch#chromium/src/third_party/zlib/deflate.c&l=1225

是否有证据表明大多数(或任何一种)编译器会生成更好(例如,效率更高)的代码?

更新: 原始作者之一Mark Adler在评论中提供了一些上下文


7
顺便提一下,这不是铬的一部分。从URL可以推断出,这是一个“第三方”项目,如果再仔细研究一下,您会发现该代码来自ZLib(一种广泛使用的通用压缩库)。

1
The funny "do {}" generates better code---比什么还好?比有趣的while()有趣,还是比无聊的常规{}好吗?
n。代词

@ H2CO3谢谢您的澄清,我已经对问题进行了编辑,以更具体地说明起源。
丹尼斯

42
该评论写于18年前的Borland和Sun C编译器时代。今天与编译器的任何相关性纯属偶然。请注意,与的这种特定用法(do而不只是a)while不能避免条件分支。
Mark Adler

Answers:


108

首先:

do-while环是不一样的一个while-loop或for-loop。

  • whilefor循环可能根本不会运行循环主体。
  • 一个do-while循环总是在循环体至少一次-它跳过了初始条件检查。

这就是逻辑上的区别。也就是说,并非每个人都严格遵守这一点。这是一个相当普遍的whilefor即使可以保证它永远循环至少一次使用循环。(特别是在具有foreach循环的语言中。)

因此,为避免比较苹果和橙子,我将假设循环将至少运行一次。此外,我不再赘述for循环,因为它们本质while上是具有一些语法糖的循环计数器循环。

因此,我将回答这个问题:

如果while保证一个循环至少循环一次,那么使用do-while循环可以提高性能。


A do-while跳过第一个条件检查。因此,要减少的分支少一,而要评估的条件少。

如果检查条件很昂贵,并且您知道可以保证至少循环一次,则可以 do-while循环可能会更快。

尽管这最多不过是微优化,但这是编译器不能总是做的:特别是当编译器无法证明循环将至少进入一次时。


换句话说,一个while循环:

while (condition){
    body
}

实际上与此相同:

if (condition){
    do{
        body
    }while (condition);
}

如果您知道您将始终循环至少一次,则if语句是多余的。


同样在汇编级别,这大致是不同循环如何编译为:

同时执行循环:

start:
    body
    test
    conditional jump to start

while循环:

    test
    conditional jump to end
start:
    body
    test
    conditional jump to start
end:

请注意该条件已重复。另一种方法是:

    unconditional jump to end
start:
    body
end:
    test
    conditional jump to start

...它将重复的代码折衷以换取额外的跳转。

无论哪种方式,它仍然比正常do-while循环更糟糕。

就是说,编译器可以执行他们想要的操作。并且,如果他们可以证明循环总是进入一次,那么就为您完成了工作。


但是对于问题中的特定示例,事情有点奇怪,因为它具有空循环的主体。由于没有主体,所以while和之间没有逻辑上的区别do-while

FWIW,我在Visual Studio 2012中对此进行了测试:

  • 对于空主体,它实际上确实为while和生成了相同的代码do-while。因此,这部分可能是编译器不那么强大的旧时代的残余。

  • 但是对于非空的正文,VS2012设法避免条件代码重复,但仍会产生额外的条件跳转。

因此,具有讽刺意味的是,尽管问题中的示例强调了为什么 do-while在一般情况下循环可能更快,但示例本身似乎并未对现代编译器带来任何好处。

考虑到评论的年代久远,我们只能猜测为什么会如此。当时的编译器很可能无法识别主体为空。(或者,如果他们这样做了,他们就不会使用这些信息。)


12
那么,少花时间检查病情是否有很大优势?我对此表示高度怀疑。运行循环100次,它变得完全无关紧要。

7
@ H2CO3但是,如果循环只运行一次或两次,该怎么办?从重复的条件代码中增加代码大小又如何呢?
Mysticial

6
@Mystical如果循环仅运行一次或两次,则该循环不值得优化。而且,增加的代码大小充其量不是一个可靠的参数。并非每个编译器都按照您演示的方式实现它。我已经为自己的玩具语言编写了一个编译器,而while循环的编译是通过无条件跳转到循环的开头来实现的,因此该条件的代码仅发出一次。

30
@ H2CO3“如果一个循环仅运行一次或两次,则该循环不值得优化。” - 我不敢苟同。它可能在另一个循环中。我自己高度优化的HPC代码就是这样。是的,do-while确实有所作为。
Mysticial

29
@ H2CO3我在哪里说过鼓励我?问题是do-while循环比while循环快。我回答了这个问题,说它可以更快。我没说多少。我没有说这是否值得。我不建议任何人开始转换为do-while循环。但是在我看来,简单地否认即使有很小的优化也是有可能的,这对那些关心并且对这些事情感兴趣的人是无益的。
Mysticial

24

是否有证据表明大多数(或任何一种)编译器会生成更好(例如,效率更高)的代码?

不多,除非您查看特定平台上的实际特定编译器实际生成程序集,其中包括一些特定优化设置。

几十年前(编写ZLib时)这可能值得担心,但如今肯定不是,除非您通过真正的分析发现这消除了代码的瓶颈。


9
好吧-这句话premature optimization在我脑海中浮现。
詹姆斯·斯内尔

完全是@JamesSnell。这就是评分最高的答案所支持/鼓励的。

16
我认为评分最高的答案不会鼓励过早的优化。我认为这表明效率上的差异是可能的,尽管可能微不足道或微不足道。但是人们对事物的理解有所不同,有些人可能将其视为在不需要时开始使用do-while循环的标志(我希望不是)。无论如何,到目前为止,我对所有的答案都很满意。他们为问题提供了有价值的信息,并引发了有趣的讨论。
丹尼斯

16

简而言之(tl; dr):

我对OP的代码中的注释的解释有些不同,我认为他们声称观察到的“更好的代码”是由于将实际工作移入了“条件”循环。但是,我完全同意,这是非常特定于编译器的,并且它们进行的比较虽然能够生成稍有不同的代码,但大部分都是毫无意义的,而且可能已过时,如下所示。


细节:

很难说原始作者对此do {} while代码产生更好的代码的评论是什么意思,但是我想推测的方向与此处提出的方向不同-我们认为do {} whileand while {}循环之间的差异很小(少了一个分支,神秘主义者说),但是这段代码中甚至有一些“ funnier”,这使所有工作都处于这种疯狂的条件下,而内部却空着(do {})。

我在gcc 4.8.1(-O3)上尝试了以下代码,它给出了一个有趣的区别-

#include "stdio.h" 
int main (){
    char buf[10];
    char *str = "hello";
    char *src = str, *dst = buf;

    char res;
    do {                            // loop 1
        res = (*dst++ = *src++);
    } while (res);
    printf ("%s\n", buf);

    src = str;
    dst = buf;
    do {                            // loop 2
    } while (*dst++ = *src++);
    printf ("%s\n", buf);

    return 0; 
}

编译后-

00000000004003f0 <main>:
  ... 
; loop 1  
  400400:       48 89 ce                mov    %rcx,%rsi
  400403:       48 83 c0 01             add    $0x1,%rax
  400407:       0f b6 50 ff             movzbl 0xffffffffffffffff(%rax),%edx
  40040b:       48 8d 4e 01             lea    0x1(%rsi),%rcx
  40040f:       84 d2                   test   %dl,%dl
  400411:       88 16                   mov    %dl,(%rsi)
  400413:       75 eb                   jne    400400 <main+0x10>
  ...
;loop 2
  400430:       48 83 c0 01             add    $0x1,%rax
  400434:       0f b6 48 ff             movzbl 0xffffffffffffffff(%rax),%ecx
  400438:       48 83 c2 01             add    $0x1,%rdx
  40043c:       84 c9                   test   %cl,%cl
  40043e:       88 4a ff                mov    %cl,0xffffffffffffffff(%rdx)
  400441:       75 ed                   jne    400430 <main+0x40>
  ...

因此,第一个循环执行7条指令,而第二个循环执行6条指令,即使它们应该执行相同的工作。现在,我真的无法确定这背后是否有一些编译器的智能,可能不是,这只是巧合,但我还没有检查它如何与该项目可能使用的其他编译器选项交互。


另一方面,在clang 3.3(-O3)上,两个循环都生成以下5条指令代码:

  400520:       8a 88 a0 06 40 00       mov    0x4006a0(%rax),%cl
  400526:       88 4c 04 10             mov    %cl,0x10(%rsp,%rax,1)
  40052a:       48 ff c0                inc    %rax
  40052d:       48 83 f8 05             cmp    $0x5,%rax
  400531:       75 ed                   jne    400520 <main+0x20>

这只是表明编译器是完全不同的,并且以比几年前某些程序员所预期的快得多的速度前进。这也意味着该评论是毫无意义的,并且可能在那里,因为没有人检查过它是否仍然有意义。


底线-如果您想优化为可能的最佳代码(并且知道它的外观),请直接在汇编中进行操作,并从等式中删除“中间人”(编译器),但要考虑到较新的代码编译器和较新的硬件可能会使此优化过时。在大多数情况下,最好让编译器为您完成这一级别的工作,并专注于优化大工作。

应该指出的另一点-指令计数(假设这是原始OP的代码所遵循的),绝不是衡量代码效率的好方法。并非所有指令都是相同的,并且其中一些指令(例如简单的reg-to-reg移动)确实很便宜,因为它们已由CPU优化。其他优化实际上可能会损害CPU内部优化,因此最终只能进行适当的基准测试。


看起来它保存了寄存器移动。mov %rcx,%rsi:)我可以看到重新排列代码可以做到这一点。
Mysticial

@Mystical,虽然您对微优化是正确的。有时,即使保存一条指令也不是一文不值的(如今,通过重新命名,几乎不需进行reg-to-reg移动)。
Leeor

直到AMD Bulldozer和Intel Ivy Bridge才实施迁移重命名。真是令人惊讶!
Mysticial

@Mysticial,请注意,这些大致是第一个实现物理寄存器文件的处理器。旧的无序设计只是将寄存器放置在重排序缓冲区中,而您不能这样做。
Leeor

3
看起来您对原始代码中的注释的解释与大多数情况有所不同,这很有意义。该评论说“有趣的做{} ..”,但没有说明比较的是哪个非有趣的版本。大多数人都知道do-while和while的区别,所以我猜想“有趣的do {}”并不适用于此,而是适用于循环展开和/或缺少额外的分配,如您所展示的这里。
Abel

10

while环通常编译为一个do-while循环具有初始分支的条件,即

    bra $1    ; unconditional branch to the condition
$2:
    ; loop body
$1:
    tst <condition> ; the condition
    brt $2    ; branch if condition true

do-while没有初始分支的循环编译是相同的。从中可以看出,while()初始分支的成本本质上效率较低,但是初始分支的成本仅支付一次。[与幼稚的实现方式比较,该方式while,每次迭代都需要条件分支和无条件分支。]

话虽如此,它们并不是真正可比的替代品。将一个while循环转换成一个do-while循环是很痛苦的,反之亦然。他们做不同的事情。在这种情况下的几个方法调用将完全主宰一切的编译器做了与while对抗do-while.


7

备注不是关于控制语句的选择(do vs. while),而是关于循环展开的!

如您所见,这是一个字符串比较函数(字符串元素可能长2个字节),可以使用单个比较而不是在快捷方式和表达式中编写四个比较来编写。

后者的实现肯定更快,因为它在每四个元素比较之后对字符串结束条件进行一次检查,而标准编码每次比较都会涉及一次检查。换句话说,每4个元素进行5个测试,而每4个元素进行8个测试。

无论如何,仅当字符串长度是4的倍数或具有前哨元素时,它才有效(以确保两个字符串在strend边界之外都不同)。风险很大!


这是一个有趣的发现,直到现在,每个人都忽略了这一点。但是编译器不会对此产生影响吗?换句话说,无论使用哪种编译器,它总是会更加高效。那么,为什么有提到编译器的注释呢?
丹尼斯

@Dennis:不同的编译器具有优化生成代码的不同方法。有些人可能会自己展开循环(某种程度上)或优化分配。在这里,编码器迫使编译器进入循环展开状态,从而使优化程度较低的编译器仍能保持良好的性能。我认为Yves对于他的假设是完全正确的,但是如果没有原始的编码人员,“搞笑”言论背后的真实想法仍然是一个谜。
亚伯

1
@Abel感谢您的澄清,我现在更好地理解了注释后面的(假定)含义。Yves无疑最接近解决评论背后的谜团,但是我将接受Mysticial的回答,因为我认为他回答了我的问题最好。原来我问错了一个问题,因为注释使我误以为是循环的类型,而这可能是在指条件。
丹尼斯

0

在这种情况下,关于while vs. do效率的讨论是完全没有意义的,因为没有任何内容。

while (Condition)
{
}

do
{
}
while (Condition);

是绝对等价的。

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.