Go против Rust — производительность вне конкуренции

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В статье Наблюдение за выполнением конкурирующих задач в 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           70621468

Теперь в папке 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. Да неужто все именно так?! Будем разбираться.


Куда смотреть?


Смотреть сюда:


  • Go: iterate()
  • Rust: iterate()

Именно эти функции вычисления последовательности триплетов подвергаются испытаниям.


При запуске с ключом 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      14405887 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. Результат работает так:


210349179

Разница теперь всего в 3 гвардейца, уже не так обидно. Смотрим в ассемблер функций iterate():


  • go2/src/iterate.asm
  • rust/iterate.asm

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          393081761

Все равно 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/


Интересные статьи

Интересные статьи

На Хабре существует немало статей, посвящённых повышению производительности программ за счёт параллельных вычислений и использования векторных команд. Я решил дополнить этот список и рассказать о то...
В эпоху контейнеров («эпоху Docker») Java все еще жив, борется за это или нет. Java всегда славилась своей производительностью, в основном из-за уровней абстракции между кодом и реальной машиной,...
Вы когда-нибудь видели аттракцион “Пьяный велосипед”? Принцип этого аттракциона - управление наоборот. То есть когда ты поворачиваешь руль налево – колесо поворачивается ...
В 1С Битрикс есть специальные сущности под названием “Информационные блоки, сокращенно (инфоблоки)“, я думаю каждый с ними знаком, но не каждый понимает, что это такое и для чего они нужны
В первой части мы превратили наш сайт в Progressive Web App. Там же было сказано, что совсем недавно, 6 февраля 2019 года, Google предоставили простую возможность выкладывать PWA в Google Pla...