В статье Наблюдение за выполнением конкурирующих задач в Go и Rust коллега cpmonster привёл весьма интересные результаты:
Программа на Rust показала намного большую производительность при вычислении членов возвратной последовательности, чем программа на Go: 367 млн. итераций в секунду против 44 млн.
Ну, в 1.5 раза… Ну, в 2 раза… Но семь гвардейцев за два дня — это слишком, тем более что тут «гвардейцев» больше восьми!
Или нет, не слишком? В общем, потенциал любопытства пересилил другие потенциалы и я провёл своё исследование.
Повторение — мать учения и основа научного метода
Для начала попробуем воспроизвести результаты. Нужны исходники, а также Go и Rust (у меня версии 1.18 и 1.61, соответственно).
Идём в папку go/src
и запускаем go run concgo.go s
:
Cycles per second 70,621,468
Теперь в папке rust
выполним cargo run s
:
Cycles per second 25,562,372
Надо же, производительность версии на Rust в три раза ниже, чем версии на Go!
А, нет — это же debug
, вот так надо: cargo run --release s
. Совсем другое дело:
Cycles per second 603,500,301
Да, всё повторилось, те же 8+ раз. У меня и до чтения рассматриваемой статьи сложилось мнение, что Rust «готовит» более быстрые «числодробилки», но полученный результат — это же настоящее «унижение» для Go. Да неужто все именно так?! Будем разбираться.
Куда смотреть?
Смотреть сюда:
Именно эти функции вычисления последовательности триплетов подвергаются испытаниям.
При запуске с ключом s
испытание происходит в функции count_cycles_per_sec(). Испытание, надо заметить, происходит «вне конкуренции» — т.е. в одном потоке. Что, конечно, сильно упрощает анализ.
Что пишут?
Сам автор статьи приводит такое соображение:
Еще одно важное различие между Go и Rust, на которое мне указал внимательный читатель первой версии этой статьи, заключается в том, что разработчики языка Rust в принципе отказались от использования сборщика мусора.
В принципе, да. Значения переменных в Go легко и зачастую незаметно «убегают в кучу», именно с этого я и начал свои эксперименты:
func BenchmarkIterate(b *testing.B) { for i := 0; i < b.N; i++ { iterate(random_triplet(), 1000000) } }
Нет, тут все чисто:
cpu: Intel(R) Core(TM) i5-3570 CPU @ 3.40GHz BenchmarkIterate BenchmarkIterate-4 79 14,405,887 ns/op 0 B/op 0 allocs/op PASS ok
Версия из комментариев:
А почему версия Go такая старая (1.14.2 выпущена 2020-04-08)?
На Go 1.18 результаты не лучше.
Несколько раз высказывались сомнения в корректности испытаний, по типу такого:
На самом деле сравнение не корректно. И все результаты фактически вытекают из этого.
Более корректно было бы сравнить горутины с async кодом, в идеале — наверное на голом tokio
Всё это интересно, но запуск в режиме go run concgo.go s
ведёт к тестированию всего лишь в одном потоке, так что феномен проявляется и может быть изучен без привлечения горутин и tokio.
Попытка номер 2
С ключами оптимизации у компилятора Go негусто, скорее есть ключи «деоптимизации» (-l -N
) — так что остаётся работать с исходными текстами.
Сравнение текстов показало, что тип Triplet
в Rust объявлен как кортеж (tuple):
type Triplet = (f64, f64, f64);
В то время как для Go используется массив:
type Triplet = [3]float64
В Go более близким к кортежу типом является структура:
type Triplet struct{ f0, f1, f2 float64 }
При помощи чудесного инструмента godbolt посмотрим, есть ли разница в ассемблерном коде для работы с такими определениями:
type Triplet = [3]float64 type Triplet2 struct{ f0, f1, f2 float64 } ... var t Triplet t[0] = 10. t[1] = 11. t[2] = 12. printTriplet(t) var t2 Triplet2 t2.f0 = 20. t2.f1 = 21. t2.f2 = 22. printTriplet2(t2)
Оказывается, разница есть:
MOVSD $f64.4024000000000000(SB), X0 MOVSD X0, (SP) MOVSD $f64.4026000000000000(SB), X0 MOVSD X0, 8(SP) MOVSD $f64.4028000000000000(SB), X0 MOVSD X0, 16(SP) CALL "".printTriplet(SB) MOVSD $f64.4034000000000000(SB), X0 MOVSD $f64.4035000000000000(SB), X1 MOVSD $f64.4036000000000000(SB), X2 CALL "".printTriplet2(SB)
То есть работа со структурой происходит через регистры, а с массивом фиксированной длины — через стек. Это, конечно, может повлиять на производительность!
Я сделал fork оригинального репозитория, в нём папочку go2/src
, переопределил Triplet
. Результат работает так:
210,349,179
Разница теперь всего в 3 гвардейца, уже не так обидно. Смотрим в ассемблер функций iterate()
:
Go встраивает вызов get_next_triplet()
, но не делает этого для is_convergent()
:
;*** concgo.go#76 > if is_convergent(triplet, next_triplet) && !prokukarek { 0x4ab357 0f10d9 MOVUPS X1, X3 0x4ab35a 0f10e2 MOVUPS X2, X4 0x4ab35d 0f10e8 MOVUPS X0, X5 0x4ab360 0f10c6 MOVUPS X6, X0 0x4ab363 e8b8feffff CALL main.is_convergent(SB)
А вот Rust полностью оснащает iterate.asm
встроенной вычислительной техникой. Внешними остались только вызовы типа call std::io::stdio::_print
, но они на скорость не влияют, так как последовательность не сходится и условие is_convergent(triplet, next_triplet)
никогда не выполняется.
Отсюда и разница.
Для дальнейшего повышения производительности версии Go функцию is_convergent()
можно встроить вручную:
//if is_convergent(triplet, next_triplet) && !prokukarek { if approx_eq(triplet.f0, next_triplet.f0) && approx_eq(triplet.f1, next_triplet.f1) && approx_eq(triplet.f2, next_triplet.f2) && !prokukarek { print_convergency(initial_triplet, step, triplet.f2) prokukarek = true }
Получилась папка go3/src
, запуск из нее:
Cycles per second 393,081,761
Все равно 1.5 гвардейца, и это при том, что все вычисления встроены, см. go3/src/iterate.asm.
В качестве вишенки на торте попробуем переопределить Triplet
в версии для Rust таким образом:
type Triplet = [f64; 3];
Будет ли разница? Нет. Ассемблер раз:
let applicant = triplet.0 + triplet.1 - triplet.2; movapd xmm0, xmm7 movapd xmm7, xmm8 movapd xmm8, xmm6 movapd xmm6, xmm0 addsd xmm6, xmm7 subsd xmm6, xmm8 movapd xmm1, xmm6 andpd xmm1, xmm9
Ассемблер два:
let applicant = triplet[0] + triplet[1] - triplet[2]; movapd xmm0, xmm7 movapd xmm7, xmm8 movapd xmm8, xmm6 movapd xmm6, xmm0 addsd xmm6, xmm7 subsd xmm6, xmm8 movapd xmm1, xmm6 andpd xmm1, xmm9
Некоторые размышления
- Путём небольшой модификации исходного кода разницу удалось свести от «Rust на голову быстрее Go» к «Rust заметно быстрее Go»
- Понятно, что речь идёт о конкретном вычислительном случае
- В данном случае бо́льшая часть проигрыша по производительность упирается в стратегию встраивания в Go: function should be simple enough, the number of AST nodes must less than the budget (80)
- С ходу возникает предложение завести директиву компилятора
//go:inline
, которая отменяла бы бюджетные ограничения - Такое предложение уже было сделано и висит в статусе FrozenDueToAge, первый комментарий гласит: «This proposal has basically no chance of being accepted» 🙂
- Видимо, более подходящим названием директивы было бы
//go:tryinline
- Но даже с учетом встраивания остается разница в полтора раза
ссылка на оригинал статьи https://habr.com/ru/post/668166/
Добавить комментарий