在 C++ 中遍历链表比 Go 慢。

7

编辑:经过一些反馈,我创建了一个新的示例,应该更容易复现。

我正在用C++编写一个涉及大量链表迭代的项目。为了进行基准测试,我将代码重写成了Go。令人惊讶的是,即使在向clang++传递了-O标志后,我发现Go实现始终比C++实现快约10%。可能我只是在C++中缺少一些明显的优化,但我已经尝试了各种调整,并且一直在苦思冥想。

这里有一个简化版本,在C++和Go中具有相同的实现,其中Go程序运行得更快。它所做的就是创建一个带有3000个节点的链表,然后计算迭代此列表1,000,000次需要多长时间(在C++中为7.5秒,在Go中为6.8秒)。

C++:

#include <iostream>
#include <chrono>

using namespace std;
using ms = chrono::milliseconds;

struct Node {
    Node *next;
    double age;
};

// Global linked list of nodes
Node *nodes = nullptr;

void iterateAndPlace(double age) {
    Node *node = nodes;
    Node *prev = nullptr;

    while (node != nullptr) {
        // Just to make sure that age field is accessed
        if (node->age > 99999) {
            break;
        }

        prev = node;
        node = node->next;
    }

    // Arbitrary action to make sure the compiler
    // doesn't optimize away this function
    prev->age = age;
}

int main() {
    Node x = {};
    std::cout << "Size of struct: " << sizeof(x) << "\n"; // 16 bytes

    // Fill in global linked list with 3000 dummy nodes
    for (int i=0; i<3000; i++) {
        Node* newNode = new Node;
        newNode->age = 0.0;
        newNode->next = nodes;
        nodes = newNode;
    }

    auto start = chrono::steady_clock::now();
    for (int i=0; i<1000000; i++) {
        iterateAndPlace(100.1);
    }

    auto end = chrono::steady_clock::now();
    auto diff = end - start;
    std::cout << "Elapsed time is :  "<< chrono::duration_cast<ms>(diff).count()<<" ms "<<endl;
}

Go:

package main
import (
    "time"
    "fmt"
    "unsafe"
)

type Node struct {
    next *Node
    age float64
}

var nodes *Node = nil

func iterateAndPlace(age float64) {
    node := nodes
    var prev *Node = nil

    for node != nil {
        if node.age > 99999 {
            break
        }
        prev = node
        node = node.next
    }

    prev.age = age
}

func main() {
    x := Node{}
    fmt.Printf("Size of struct: %d\n", unsafe.Sizeof(x)) // 16 bytes

    for i := 0; i < 3000; i++ {
        newNode := new(Node)
        newNode.next = nodes
        nodes = newNode
    }

    start := time.Now()
    for i := 0; i < 1000000; i++ {
        iterateAndPlace(100.1)
    }
    fmt.Printf("Time elapsed: %s\n", time.Since(start))
}

我的Mac的输出:

$ go run minimal.go
Size of struct: 16
Time elapsed: 6.865176895s

$ clang++ -std=c++11 -stdlib=libc++ minimal.cpp -O3; ./a.out
Size of struct: 16
Elapsed time is :  7524 ms

Clang版本:

$ clang++ --version
Apple LLVM version 8.0.0 (clang-800.0.42.1)
Target: x86_64-apple-darwin15.6.0
Thread model: posix

编辑: UKMonkey提出节点在Go中可能是连续分配的,但在C ++中不一定。为了测试这一点,我使用向量在C++中进行了连续分配,但这并未改变运行时间:

// Fill in global linked list with 3000 contiguous dummy nodes
vector<Node> vec;
vec.reserve(3000);
for (int i=0; i<3000; i++) {
    vec.push_back(Node());
}

nodes = &vec[0];
Node *curr = &vec[0];
for (int i=1; i<3000; i++) {
    curr->next = &vec[i];
    curr = curr->next;
    curr->age = 0.0;
}

我检查了结果链表确实是连续的:

std::cout << &nodes << " " << &nodes->next << " " << &nodes->next->next << " " << &nodes->next->next->next << "\n";
0x1032de0e0 0x7fb934001000 0x7fb934001010 0x7fb934001020
2个回答

4
前言:我不是C++专家或汇编专家。但我知道一点点,足以让我有些危险。
因此,我被激发了起来,并决定查看生成的Go汇编代码,并检查它与clang++的输出是否相符。
高层次总结:
在这里,我将通过x86-64汇编语言查看两种语言的汇编输出。这个示例中最基本的“关键部分”是一个非常紧密的循环。因此,它是程序中花费时间最长的部分。
为什么紧密循环很重要呢?因为现代CPU通常可以比代码引用的相关值(例如比较)从内存中加载更快地执行指令。为了实现他们所达到的惊人速度,CPU执行许多技巧,包括流水线、分支预测等。紧密循环经常是流水线的噩梦,而实际上,如果存在值之间的依赖关系,分支预测可能只能略微有所帮助。
从根本上讲,遍历循环有四个主要部分:
1. If `node` is null, exit the loop.
2. If `node.age` > 999999, exit the loop.
3a. set prev = node
3b. set node = node.next

每个都由几个汇编指令表示,但Go和C++输出的块的顺序不同。 C++实际上按顺序执行:3a,1,2,3b。Go版本按顺序执行3, 2, 1。(它在第一个循环中从段2开始以避免在空值检查之前发生赋值)
实际上,clang++输出的指令比Go少一些,应该做更少的RAM访问(代价是多了一个浮点寄存器)。人们可能会想象,几乎以不同的顺序执行相同的指令应该结束于相同的时间,但这没有考虑到流水线和分支预测。
要点:如果这是一个关键但较小的循环,则可以尝试手动优化此代码并编写汇编代码。忽略明显的原因(风险更大/更复杂/更容易出错),还必须考虑到,虽然我用两个Intel x86-64处理器测试时,Go生成的代码更快,但对于AMD处理器,您可能会得到相反的结果。使用N+1th gen Intel也可能会得到不同的结果。
我的完整调查如下:
"".iterateAndPlace STEXT nosplit size=56 args=0x8 locals=0x0
    00000 (main.go:16)   TEXT    "".iterateAndPlace(SB), NOSPLIT, $0-8
    00000 (main.go:16)   FUNCDATA    $0, gclocals·2a5305abe05176240e61b8620e19a815(SB)
    00000 (main.go:16)   FUNCDATA    $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
    00000 (main.go:17)   MOVQ    "".nodes(SB), AX
    00007 (main.go:17)   MOVL    $0, CX
    00009 (main.go:20)   JMP 20
    00011 (main.go:25)   MOVQ    (AX), DX
    00014 (main.go:25)   MOVQ    AX, CX
    00017 (main.go:25)   MOVQ    DX, AX
    00020 (main.go:20)   TESTQ   AX, AX
    00023 (main.go:20)   JEQ 44
    00025 (main.go:21)   MOVSD   8(AX), X0
    00030 (main.go:21)   MOVSD   $f64.40f869f000000000(SB), X1
    00038 (main.go:21)   UCOMISD X1, X0
    00042 (main.go:21)   JLS 11
    00044 (main.go:21)   MOVSD   "".age+8(SP), X0
    00050 (main.go:28)   MOVSD   X0, 8(CX)
    00055 (main.go:29)   RET

如果你失去了上下文,我会在这里贴出原始代码及其行号:

16  func iterateAndPlace(age float64) {
17      node := nodes
18      var prev *Node = nil
19
20      for node != nil {
21          if node.age > 99999 {
22              break
23          }
24          prev = node
25          node = node.next
26      }
27
28      prev.age = age
29  }

我注意到了一些有趣的事情:
  1. 在第24行中,它没有生成任何代码prev = node。这是因为它意识到可以通过遍历来获得node.next并使用CX寄存器(即prev的值)进行赋值。这可能是SSA编译器可以实现的一个不错的优化。
  2. 检查节点年龄的if语句重新排序为在node=node.next语句之后,第一次迭代跳过该语句。在这种情况下,您可以将其视为更像do..while循环。总体而言,这只会微小地改变第一次迭代。但也许这已经足够了?
所以让我们跳到C++汇编中,您可以通过以下命令获得: clang++ -S -mllvm --x86-asm-syntax=intel -O3 minimal.cpp.
.quad   4681608292164698112     ## double 99999
# note I snipped some stuff here
__Z15iterateAndPlaced:                  ## @_Z15iterateAndPlaced
## BB#0:
    push    rbp
Lcfi0:
    .cfi_def_cfa_offset 16
Lcfi1:
    .cfi_offset rbp, -16
    mov rbp, rsp
Lcfi2:
    .cfi_def_cfa_register rbp
    mov rcx, qword ptr [rip + _nodes]
    xor eax, eax
    movsd   xmm1, qword ptr [rip + LCPI0_0] ## xmm1 = mem[0],zero
    .p2align    4, 0x90
LBB0_2:                                 ## =>This Inner Loop Header: Depth=1
    mov rdx, rax
    mov rax, rcx
    movsd   xmm2, qword ptr [rax + 8] ## xmm2 = mem[0],zero
    ucomisd xmm2, xmm1
    ja  LBB0_3
## BB#1:                                ##   in Loop: Header=BB0_2 Depth=1
    mov rcx, qword ptr [rax]
    test    rcx, rcx
    mov rdx, rax
    jne LBB0_2
LBB0_3:
    movsd   qword ptr [rdx + 8], xmm0
    pop rbp
    ret

这真的很有趣。总体来说,生成的汇编代码非常相似(忽略汇编器列举语法时的细微差异)——它对不分配“prev”变量进行了类似的优化。此外,C++ 似乎消除了每次比较时加载 99999 的需要(Go 版本在每次比较之前都会加载它)。
为了复制实验结果,在一个 x86-64 的 Darwin Mac 上使用了以下版本(在 OSX High Sierra 上)。
$ go version
go version go1.9.3 darwin/amd64

$ clang++ --version
Apple LLVM version 9.0.0 (clang-900.0.39.2)
Target: x86_64-apple-darwin17.4.0

有趣的是,与 Go 版本相比,C++ 版本创建了一个堆栈帧 push rbp / pop rbp。有没有人成功地强制编译器内联迭代并放置函数?由于它被调用了 1,000,000 次,这可能会有所不同。 - kiloalphaindia
阅读了几次__main后,我比较确定C++会生成迭代并放置代码的代码(可能是为了可以被其他代码调用),实际上会将其内联。代码在我的 _main 中,没有调用和堆栈推送。 - Crast
1
好的。那么我来总结一下:性能差异与所使用的编程语言无关,而是由于Go编译器在这个特定的用例中进行了更好的优化。 - kiloalphaindia
根据Fanto的回答,gcc胜过go和clang。 - Deduplicator

2
我认为问题出在使用clang编写的代码上。 我的结果是:
使用clang:6097毫秒
使用gcc:5106毫秒
使用go:5219毫秒
因此,我已经对其进行了反汇编,并发现在不访问age字段的情况下生成的代码在clang和gcc中是相同的,但是当您访问age字段时,从clang生成的代码比从gcc生成的代码稍微差一些。
这是从clang生成的代码:
enter image description here

从gcc生成的代码如下:
enter image description here

最后一个是go版本:
enter image description here

可以看到,所有的代码几乎都是相同的,但是在clang版本中,开头的前两个mov指令使指令数量比gcc版本更多,因此会稍微降低性能。而且,我认为对性能影响最大的是clang版本中的uncomisd指令,因为它会进行内存重定向。 抱歉我的英语不好,希望您能理解。

嘿,谢谢回复!不幸的是,我无法复制您的结果;对我来说,gcc 的运行速度与 clang++ 一样慢。您是否运行类似于 gcc -std=c++11 -stdlib=libc++ -lstdc++ minimal.cpp -O3; ./a.out 的命令? - rampatowl
@fanto 关于“但在clang版本中,开头的前两个mov使得指令数量比gcc版本多”--这两个mov实际上将%rdx设置为引用rax的地址,以便在退出循环时您有一个“prev”的引用。因此,实际上只有一个额外的指令,尽管这可能足以产生差异。 - Crast
我使用以下编译器进行编译:g++ --std=c++11 -O3 -lstdc++。 - Fanto

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接