Предисловие: Я не эксперт по C ++ или ассемблер. Но я знаю немного о них, возможно, достаточно опасно.
Итак, я был взволнован и решил взглянуть на ассемблер, сгенерированный для Go, и проверил его на соответствие выводу для clang ++.
Краткий обзор высокого уровня
Позже здесь я рассмотрю вывод ассемблера для обоих языков в ассемблере x86-64. Фундаментальная «критическая секция» кода в этом примере представляет собой очень узкий цикл. По этой причине, это самый большой вклад в время, проведенное в программе.
Почему узкие циклы имеют значение, потому что современные процессоры могут выполнять инструкции, как правило, быстрее, чем соответствующие значения для кода, на который можно ссылаться (например, для сравнения) из памяти. Чтобы достичь невероятно высоких скоростей, которые они достигают, процессоры выполняют ряд приемов, включая конвейерную обработку, прогнозирование ветвлений и многое другое. Плотные циклы часто являются препятствием для конвейерной обработки, и реалистичное предсказание ветвления может быть лишь незначительно полезным, если между значениями существует зависимость.
По сути, цикл обхода состоит из четырех основных частей:
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, и должен делать меньше обращений к ОЗУ (за счет еще одного регистра с плавающей запятой). Можно предположить, что выполнение почти одних и тех же инструкций только в разных порядках должно привести к тому же затрачиваемому времени, но это не учитывает конвейерную обработку и прогноз ветвления.
Выводы Можно было бы испытать искушение вручную оптимизировать этот код и написать сборку, если это был критический, но небольшой цикл. Игнорируя очевидные причины (это более рискованно / более сложно / более подвержено ошибкам), следует также учитывать, что хотя сгенерированный Go код был быстрее для двух процессоров Intel x86-64, на которых я его тестировал, вполне возможно, что с AMD процессор вы бы получили противоположные результаты. Также возможно, что с Intel N + 1-го поколения вы получите другие результаты.
Мое полное расследование следует ниже:
Расследование
ПРИМЕЧАНИЕ Я постарался привести как можно более короткие примеры, включая усечение имен файлов и удаление лишнего пуха из списка сборки, так что ваши результаты могут немного отличаться от моих. Но в любом случае я продолжаю.
Итак, я запустил go build -gcflags -S main.go
, чтобы получить этот список сборок, и я только смотрю на iterateAndPlace.
"".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 }
Несколько интересных вещей, которые я сразу заметил:
- Он не генерирует никакого кода для строки 24,
prev = node
. Это потому, что стало понятно, что присвоение может быть обмануто: при обходе для получения node.next
он использует регистр CX, который является значением prev
. Вероятно, это хорошая оптимизация, которую компилятор SSA может понять, является избыточной.
- Оператор if для node.age изменен на после материала
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