Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В статье [ссылка] было заявлено, что производительность Haskell кода превзошла код на С++. Что сразу вызвало интерес, т.к. и то и другое может генерироваться LLVM компилятором, значит либо Наskell может давать больше хинтов компилятору, либо что-то не так с С++ реализацией. Далее мы разберём, как череда случайностей в действиях автора привела к неправильным выводам, которые описываются таблицей ниже (под катом).
Предисловие
Недавно на хабре появилась очередная статья от 0xd34df00d про оптимизацию хаскель кода. Сравнивается в таких случаях естественно с неоспоримым лидером в производительности — С/C++. Затем последовал разбор этой статьи от yleo о том какой асм код действительно лучше, и в чём кроется различие реализаций на разных ЯП (рекомендую к прочтению). Ещё раньше (около полутора месяцев назад), была опубликована предыдущая статья из серии "Хаскель vs С/C++", и я проделал похожий разбор, но вместо того чтобы опубликовать его на Хабр — увы, отложил в долгий ящик. Бурные дискуссии в комментариях на этой неделе, побудили меня вернутся к прошлой теме. Сегодня я его наконец-то достал тот markdown документ из ящика, стряхнул пыль, дописал, и предоставляю его на ваше обозрение.
Введение
Напомню, что задача была про подсчёт расстояния Левенштейна [вики], и вот такие результаты были показаны в оригинальной статье:
Реализация | Отн. время |
---|---|
С clang 9 | 103% |
С gcc 9.2 | 125% |
C++ gcc 9.2 | 163% |
C++ clang 9 | 323% |
Остановимся только на С/С++, т.к. другие бенчмарки были написаны, как заметили в комментариях, по методу "Пишем одной рукой, иногда закрывая надолго глаза". Они были добавлены как "бонус", и в рамках одной статьи их полноценно разобрать невозможно. Тем не менее, всё равно выражаю большой респект тому человеку, который в одиночку написал реализации больше, чем на 10ке языков.
Что подозрительно
Во-первых, сразу бросается в глаза, что С++ версия гораздо медленнее Си, что, на самом деле, странно. Далее мы найдём, где потерялся zero-cost, а в другой части, надеюсь, покажем, как именно можно использовать мета-программирование С++, чтобы обходить Си. К тому же, на clang плюсовая версия оказалась медленнее в 3 (!) раза, хотя сишный код почти такой-же по скорости как хаскель+ллвм, что ожидаемо, т.к. сlang и llvm — это один проект.
Череда случайностей
Если проследить, то дело было так: автор написал наивный код на плюсах и скомпилировал gcc и clang. Последний оказался в два раза медленнее, и автор его отбросил. Далее он проделал пару попыток оптимизировать код (подробнее ниже), но gcc было абсолютно фиолетово на эти изменения. После этого автор принялся за Хаскель и написал код, который делает примерно то же самое, что и плюсовый, за исключением неких, как окажется потом, важных перестановок инструкций.
Дьявол кроется в деталях
Нюанс std::min({...})
Деталь номер один, которую заметил сам автор и множество людей в комментах, это использование std::min.
Мне таки удалось воспроизвести ускорение в случае C++.
Так, если вместоstd::min({delCost, insCost, substCost})
написатьstd::min(substCost, std::min(delCost, insCost))
,
то время работы под clang — уменьшается до 0.840 секунд
(ура, быстрее всех остальных вариантов и почти хаскель).
Смотрим на хаскель версию:
A.unsafeWrite v1 (j + 1) $ min (substCost + substCostBase) $ 1 + min delCost insCost
Как ни странно, тут как раз и есть два раза вызов функции min
от двух аргументов и в том же порядке! (надеюсь, на таком уровне я понимаю хаскель правильно). Таким образом, автор, после исправления C++ версии, сам получает, что один llvm равен второму llvm. Собственно, это я ожидал с самого начала. К сожалению, предположение, что "Наskell может давать больше хинтов компилятору" не подтвердилось. Но судьба сложилась так, что изначально автор статьи "Быстрее чем С++; медленне, чем PHP" проверил эту замену только на гцц, а этому компилятору от этого ни холодно, ни жарко. Как видно в бенчмарке ниже:
Компилятор | Время оригнала | Время исправленного (2) |
---|---|---|
haskell/llvm | 910ms | - |
gcc 9.2 | 1211ms | 1211ms |
clang 9 | 1915ms | 852ms |
В реализации stdlib от gcc я не нашел каких-то специализаций для std::min
в случае трёх элементов, хотя это не должно быть проблемой сделать на С++. На данный момент минимум там находится путём создания массива на стеке и его обхода алгоритмом std::min_element
. В простых случаях, как уже было замечено в комментариях, разницы нет, и компилятор умеет сам выкидывать массив на стеке и генерировать оптимальный код:
f(int, int, int):
cmp esi, edi
mov eax, edx
cmovg esi, edi
cmp esi, edx
cmovle eax, esi
ret
Примечание: cmov*
= conditional move (условие: g
— greater, le
— less equal, и т.д.).
Но что интересно, в случае, когда вовлечены указатели, это не так, и clang, в отличии от gcc, зачем-то кладёт данные на стек (туда указывает rsp регистр):
fptr(int*, int*, int*):
mov eax, dword ptr [rdi]
mov dword ptr [rsp - 12], eax
mov ecx, dword ptr [rsi]
mov dword ptr [rsp - 8], ecx
cmp ecx, eax
cmovle eax, ecx
mov ecx, dword ptr [rdx]
cmp ecx, eax
cmovle eax, ecx
ret
Что косвенно объясняет, почему clang более чувствителен к этому изменению в исходном коде. Также в случае без initializer_list
компилятор генерирует оптимальный asm уже при -O1
, а с ним нужно вовлечь больше оптимизаций (-O2
), чтобы добиться того же asm выхлопа. Таким образом, std::min(std::initializer_list)
не совсем зеро-кост, тут создателям стд-либы, возможно, стоит подумать над перегрузками для некоторых эвристик.
Вынесите s1[i]
Деталь номер два, которую я обнаружил — это другая оптимизация из Хаскель, которую потеряли в С++.
Да вынесите вы наконец уже s1[i]
за рамки цикла! (я)
Опять же, по роковой случайности, гцц на неё почти по-барабану, а автор то тестировал на гцц и, соответственно, забыл внести её в итоговое сравнение. Итак, это вынос s1[i]
за тело цикла, который присутствовал уже на нулевой итерации хаскель кода
let s1char = s1 `BS.index` i
let go j | j == n = pure ()
-- Тело цикла
После применения этой оптимизации к коду на С++, мы получаем результаты быстрее, чем хаскель при сборке компилятором clang. Т.е. хаскель + llvm всё же добавляет какой-то оверхед, или ему не хватает -march=native
. Самое забавное то, что, оставив строку с std::min
без изменений, эта версия работает быстрее, чем если применить оба изменения! Значит, компилятор как-то не очень предсказуемо переставляет инструкции во время оптимизации, и некоторые решения "волей случая" оказываются быстрее, но это мы обсудим подробнее дальше.
Компилятор | Оригнал | Исправленный (1) | Оба исправления (3) |
---|---|---|---|
haskell/llvm | 910ms | - | - |
gcc 9.2 | 1211ms | 1195ms | 1195ms |
clang 9 | 1915ms | 742ms | 831ms |
Допилить напильником
C++ вариант приведен для сравнения.
Его можно оптимизировать ещё немного,
но тогда это получится C с плюсовым main'ом, что не так интересно.
Как мы уже увидели, даже маленькие исправления могут быть абсолютно непредсказуемы в зависимости от компилятора, Поэтому, я всё же попробую чуть-чуть допилить код, потому что это не сложно:
size_t lev_dist(const std::string &s1, const std::string &s2) {
const auto m = s1.size();
const auto n = s2.size();
std::vector<int> v0;
v0.resize(n + 1);
std::iota(v0.begin(), v0.end(), 0);
auto v1 = v0;
for (size_t i = 0; i < m; ++i) {
v1[0] = i + 1;
char c1 = s1[i];
for (size_t j = 0; j < n; ++j) {
auto substCost = c1 == s2[j] ? v0[j] : (v0[j] + 1);
v1[j + 1] = std::min(substCost, std::min(v0[j + 1], v1[j]) + 1);
}
std::swap(v0, v1);
}
return v0[n];
}
Тут я перешёл на 32-битный int
в векторе и чуть упростил подсчёт результата — сначала ищем минимум, потом инкремент (что опять же уже присутствует в хаскель коде).
Компилятор | Оригинал | Допиленный (3b) |
---|---|---|
haskell/llvm | 910ms | - |
gcc 9.2 | 1210ms | 831ms |
clang 9 | 1915ms | 741ms |
Ура, теперь GCC тоже ускорился. Также я пробовал заменить счётчик j
на указатели, что внезапно замедлило GCC. В то же время скорость clang осталась на своём максимуме.
Осознаем результаты
LVVM == LLVM
Во-первых, мы получили, что если написать С++ код так же как Haskell код, то результат одинаковый при использовании clang-9. К тому же, на моём процессоре Skylake C++ версия оказывается даже быстрее. Код, который я бенчмаркал, будет находится на гитхабе, и можно будет проверить данный тезис на архитектуре Haswell, которую в основном использовал автор.
Итак, приходим к выводу, что вместо сравнений языков, по факту проводилось сравнение компилятора GCC и LLVM.
В оригинальной статье было детально разобрано, как на хаскеле написать код, заточенный под llvm, и обойтись без ffi, за что автору спасибо.
GCC vs CLANG
Во-вторых, до этого момента сложилось впечатление, что старичок gcc уже ни на что не годится. Поэтому, ставьте свою генту пересобираться clang-ом и читайте дальше.
Отдельно отмечу, что в Си версии исходного кода, предоставленной автором, была директива компилятора, которая выбирает лучший код в зависимости от компилятора (#if !defined(__GNUC__) || defined(__llvm__)
), что объясняет относительную разницу между Си реализациями и С++ реализациями, и соответственно, делать выводы о соотношении Си и С++ по приведённой автором таблице нельзя.
clang не осиливает (либо не пытается) убрать ветвления. (Голос из зала)
Попробуем понять, чем вызвана разница между GCC и LLVM. Для этого посмотрим, что там наворотил компилятор в asm. С gcc все более-менее ясно: один внутренний цикл, который на основе команд cmov* делает min (аналогично тому, что мы видели листинге выше). Я беру версию (3), это та, что с двумя исправлениями, и С++ выглядит так:
for (size_t j = 0; j < n; ++j) {
auto delCost = v0[j + 1] + 1;
auto insCost = v1[j] + 1;
auto substCost = c1 == s2[j] ? v0[j] : (v0[j] + 1);
v1[j + 1] = std::min(substCost, std::min(delCost, insCost));
}
Ассемблер, который я ради не сильно знакомых с ним читателей решил прокомментировать, получается таким:
.L42:
inc rcx // j++
mov rdi, QWORD PTR [r12+rcx*8] // загрузить v0[j+1]
xor edx, edx // обнулить %edx
cmp r10b, BYTE PTR [r11-1+rcx] // c1 == s2[j]
setne dl // результат в последнем байте %rdx
lea r9, [rdi+1] // стало v0[j+1] + 1
add rdx, QWORD PTR [r12-8+rcx*8] // добавить v0[j]
lea rsi, [rax+1] // %rax это v1[j]
cmp rdi, rax // сравнить v0[j+1] и v1[j] до += 1
mov rax, r9
cmovg rax, rsi // на основе сравнения выбрать результат после += 1
cmp rax, rdx // меньшее %rax, %rdx
cmovg rax, rdx
mov QWORD PTR [r8+rcx*8], rax // v1[j+1] = ...
cmp rbx, rcx // loop
jne .L42
Тут компилятор сам сделал оптимизацию, которая упоминалась в оригинальной статье — вместо загрузки v1[j]
на каждой итерации мы передаем его через %rax
.
Что же касается LLVM, то тут какая-то лапша из переходов, которую полностью приводить не буду. Отмечу лишь для примера, что во одном из кусков во внутреннем цикле имеется конструкция, частично похожая на предыдущую:
.LBB1_40: # in Loop: Header=BB1_36 Depth=2
mov qword ptr [r14 + 8*rsi + 8], rax
mov rdx, qword ptr [rbx + 8*rsi + 16]
inc rdx
inc rax
xor ebp, ebp
cmp cl, byte ptr [r13 + rsi + 1]
setne bpl
add rbp, qword ptr [rbx + 8*rsi + 8]
cmp rax, rdx
jg .LBB1_41
lea rdi, [rsi + 2]
cmp rax, rbp
jle .LBB1_44
jmp .LBB1_43
Примечание: jmp, j*
= jump (условие: jg
— greater, jle
— less equal, и т.д.).
Тоже загружаем данные из v0[j+1]
, v0[j]
, делаем cmp
для s1[i]
, но потом у нас идёт набор из cmp + jump во всех вариациях. Оставшуюся лапшу так детально комментировать не буду, но вполне ожидаемо, что на однотипных данных (а это то, что делал автор) бранч предиктор рулит, и такой код работает быстрее, как заметили в комментариях. Давайте попробуем на других данных — двух случайных строках.
Компилятор | str a — str a, str a — str b | random-random x2 |
---|---|---|
gcc 9.2 | 1190 ms | 1190 ms |
clang 9 | 837 ms | 1662 ms |
Как и ожидалось, в GCC не меняется результат ни на одну миллисекунду, а LLVM замедляется в 2 (!) раза, потому что бранч предиктор больше не работает.
Итак, приходим к основному тезису статьи. По факту были сравнены две реализации алгоритмов: одна основана на условных переходах (jump), другая — на операциях условного копирования (cmov).
Одна реализация работает лучше на однотипных данных, другая — на случайных.
Естественно, компилятор не может знать заранее, какие данные будут у программы в реальной жизни. Для того, чтобы решить эту задачу, существует PGO (Кстати, тут языки с JIT могут заиграть новыми красками). Я проверил это в нашем случае и получил, что GCC после PGO выдает результат наравне с самой быстрой версией clang. Какие данные ближе к реальной задаче — это предмет отдельной дискуссии, которую мы оставим для последующих изысканий. Мне кажется, что хоть мы и будем в реальности сравнивать близкие строки, выбор в алгоритме между удалить/заменить/вставить всё же будет случайный, а ветка, когда не надо делать ничего, может быть обработана отдельной эвристикой.
Выводы
- Бенчмарки порождают холливары, а холливары — новые бенчмарки
- Никакой дискриминации нет, LLVM генерирует хороший код для всех
- Мало того, что GCC и LLVM дают разную скорость в зависимости от задачи, так еще и в зависимости от входных данных
- Бенчмарки без четкого технического задания и полноценного набора входных тестовых данных не имеют смысла
- Автору надо прикручивать обратно ffi. На самом деле нет, т.к. у него есть другой алгоритм, о чём, надеюсь, узнаем в других сериях
- Не спешите бежать на новый язык или компилятор на основе бенчмарков в интернете
Надеюсь, в следующей части будут рассмотрены детально эти два подхода и разобраны их плюсы и минусы в более реальных ситуациях, а так же предложены способы ускорения этого алогоритма.
Где я мог обмануть
Для полной корректности выводов надо было провести ещё и следующий эксперимент: Убирать оптимизации из Хаскель версии и проверять, стало ли оно медленнее, тогда можно было бы более полно проверить тезис об "умности" компиляторов и, в частности, о влиянии алиасинга. Но эту задачку я оставлю любителям ФП или Rust (Блин, я же сам в числе последних).
P.S. Альтернативное решение
Первый способ решить задачу — это проверить, решил ли её уже кто-то другой
(Мой препод по матану)
Напомню, что задача — это поиск редакционного расстояния, т.е. минимального числа вставок, удалений и замен символов, которые надо произвести, чтобы из строки s1 получить строку s2. Статья об этом уже была на хабре. В данной заметке мы рассмотрели способы оптимальной реализации алгоритма Вагнера-Фишера, который требует O(n*m) времени (два вложенных for). Хотя по ссылке выше есть и алгоритм Хиршберга, который работает за линейное время, о чём также упоминал автор обсуждаемой здесь статьи, но это уже тема для другой заметки.
Аппендикс
Методика бенчей
- Флаги компилятора:
-O3 -march=native -std=gnu++17
. - Процессор: Intel i5-8250U (да, ноут)
- ОС: Ubuntu 19.04 / Linux 5.0.0
- Первый прогон для разгона турбо-буст, далее берём минимум из пяти подряд. Отклонения в рамках 1-2%.
- Между запусками разных реализаций 1с прохлаждаемся (да, ноут)