После недавних статей (№10xd34df00d, №2chapuza, №3picul) сравнивающих скорость работы реализаций упрощенной утилиты wc у меня оставался только один вопрос — Как простая реализация на Haskell оказалась быстрее простой реализации на C ?!
2020-02-27: Подтверждены результаты и выводы для ghc 8.8.3
и на текстах Шекспира (в конце под спойлером).
Кратко напомню ТЗ
Для текстового файла размером 1.8Гб (1_871_822_210 байт) необходимо посчитать количество строк, слов и символов. С рядом упрощений: только 8-битная кодировка символов, конец строк только в стиле Unix, границами слов считаются пробелы и все символы меньше Char(14)
, чтение только из файла (допустимо отображение в память).
Region,Country,Item Type,Sales Channel,Order Priority,Order Date,Order ID,Ship Date,Units Sold,Unit Price,Unit Cost,Total Revenue,Total Cost,Total Profit
Sub-Saharan Africa,South Africa,Fruits,Offline,M,7/27/2012,443368995,7/28/2012,1593,9.33,6.92,14862.69,11023.56,3839.13
Middle East and North Africa,Morocco,Clothes,Online,M,9/14/2013,667593514,10/19/2013,4611,109.28,35.84,503890.08,165258.24,338631.84
Australia and Oceania,Papua New Guinea,Meat,Offline,M,5/15/2015,940995585,6/4/2015,360,421.89,364.69,151880.40,131288.40,20592.00
Sub-Saharan Africa,Djibouti,Clothes,Offline,H,5/17/2017,880811536,7/2/2017,562,109.28,35.84,61415.36,20142.08,41273.28
Europe,Slovakia,Beverages,Offline,L,10/26/2016,174590194,12/4/2016,3973,47.45,31.79,188518.85,126301.67,62217.18
Asia,Sri Lanka,Fruits,Online,L,11/7/2011,830192887,12/18/2011,1379,9.33,6.92,12866.07,9542.68,3323.39
Sub-Saharan Africa,Seychelles ,Beverages,Online,M,1/18/2013,425793445,2/16/2013,597,47.45,31.79,28327.65,18978.63,9349.02
Sub-Saharan Africa,Tanzania,Beverages,Online,L,11/30/2016,659878194,1/16/2017,1476,47.45,31.79,70036.20,46922.04,23114.16
Sub-Saharan Africa,Ghana,Office Supplies,Online,L,3/23/2017,601245963,4/15/2017,896,651.21,524.96,583484.16,470364.16,113120.00
Sub-Saharan Africa,Tanzania,Cosmetics,Offline,L,5/23/2016,739008080,5/24/2016,7768,437.20,263.33,3396169.60,2045547.44,1350622.16
Asia,Taiwan,Fruits,Offline,M,2/9/2014,732588374,2/23/2014,8034,9.33,6.92,74957.22,55595.28,19361.94
Middle East and North Africa,Algeria,Cosmetics,Online,M,2/18/2011,761723172,2/24/2011,9669,437.20,263.33,4227286.80,2546137.77,1681149.03
Asia,Singapore,Snacks,Online,C,1/28/2013,176461303,2/7/2013,7676,152.58,97.44,1171204.08,747949.44,423254.64
Australia and Oceania,Papua New Guinea,Clothes,Offline,L,6/20/2011,647164094,7/14/2011,9092,109.28,35.84,993573.76,325857.28,667716.48
Asia,Vietnam,Personal Care,Online,M,4/4/2010,314505374,5/6/2010,7984,81.73,56.67,652532.32,452453.28,200079.04
Sub-Saharan Africa,Uganda,Personal Care,Online,M,6/19/2014,539471471,7/21/2014,451,81.73,56.67,36860.23,25558.17,11302.06
Sub-Saharan Africa,Zimbabwe,Office Supplies,Offline,C,3/28/2011,953361213,4/8/2011,9623,651.21,524.96,6266593.83,5051690.08,1214903.75
Sub-Saharan Africa,Ethiopia,Cosmetics,Online,M,7/7/2011,807785928,7/25/2011,662,437.20,263.33,289426.40,174324.46,115101.94
Europe,France,Cosmetics,Online,M,12/7/2015,324669444,1/18/2016,5758,437.20,263.33,2517397.60,1516254.14,1001143.46
Ранее полученные результаты
"Зачётное" время конечно зависит от машины, компилятора и опций сборки. Поэтому я буду использовать только значения полученные локально и собственноручно на престарелом i7-4600U CPU @ 2.10GHz. В том числе поэтому я использовал не последние версии компиляторов — мне так было удобнее и от этого ничего принципиально не меняется. На всякий упомяну, что для исключения влияния обмена с диском файл с данными размещался в /dev/shm/
(tmpfs).
Для начала результаты "попавшие в зачёт", с учетом ограниченных возможностей Haskell-компилятора (не умеет автовекторизацию, оптимизацию для конкретной модели CPU):
Реализация | Сборка | Результат в секундах |
---|---|---|
Системная утилита wc |
- | 19.416 |
на Haskell | ghc 8.0.2, -O2 | 2.811 |
простая на С | gcc 8, -Ofast | 3.067 |
оптимизированная переносимая на С | gcc 8, -Ofast | 0.637 |
Не удивительно, что оптимизированная человеком (мной) С-версия существенно быстрее простой реализации на Haskell. Но меня заинтересовало почему простая реализация на С всё-таки уступает "Хаскелю". Конкретно в этих моих тестах с использованием не последней версии ghc
результаты отличаются незначительно (2.811 < 3.067), но при использовании актуальной версии компилятора Haskell отношение получается примерно 2:3 в пользу Haskell. Всё это сподвигло меня на выяснения причин.
Тем не менее, для полной картины сначала стоит показать остальные результаты. Включая получаемые как просто включением оптимизации под конкретный процессор, так и ручным кодированием с использование интринсиков AVX2:
Реализация | Сборка | Результат в секундах |
---|---|---|
на Haskell | ghc 8.0.2, -O2 -mavx2 | 2,789 |
простая на С | gcc 8, -Ofast -march=haswell | 2.914 |
простая на С | clang 8, -Ofast -march=haswell | 1.124 |
оптимизированная переносимая на С | gcc 8, -Ofast -march=haswell | 0.634 |
оптимизированная AVX2 на С | gcc 8, -Ofast -march=haswell | 0.269 |
Здесь стоит отметить, что включение AVX2 не повлияло на результат реализации на Haskell, а вот clang сделал автовекторизацию (что легко увидеть в CompilerExplorer), за счёт чего скорость увеличилась более чем вдвое. В свою очередь, оптимизированная вручную версия с AVX2 по скорости ожидаемо приближается к пропускной способности памяти.
Смотрим внутрь кода из-под Haskell
Собственно я не собирался глубоко лезть к дракону в ж анализировать результат работы компилятора. Мне было интересно посмотреть на самый внутренний цикл, в котором происходит посимвольная обработка и понять почему вдруг Хаскель оказался быстрее.
Поигравшись с опциями я пришел к выводу, что получить самый подходящий для анализа код позволяет опция -ddump-asm
. Из исходника wc.hs генерируется примерно 24К ассемблерного кода. По-быстрому увидеть глазами нужный цикл не получилось, но grep cmp
позволил быстро локализовать релевантные инструкции сравнения: cmpq $33,%rbx; cmpq $32,%rbx; cmpq $10,%rbx; cmpb $4,%bl
. После этого было легко найти искомый цикл.
Достаточно быстро я понял в чём дело и набросал на С
примерный аналог генерируемого Хаскель-компилятором цикла обработки. Рисовать блок схему мне показалось излишним и менее выразительным чем просто привести код на C:
static _Bool process_chunk(const unsigned char *text, const size_t bytes,
_Bool prev) {
result.chars += bytes;
for (size_t i = 0; i < bytes;) {
if (text[i] > ' ') {
// под-цикл по "словам"
result.words += !prev;
prev = 1;
while (++i < bytes && text[i] > ' ')
;
} else {
// под-цикл по пробелам
prev = 0;
do {
_Bool non_space = text[i] != ' ' && text[i] - 9 > 4;
result.words += !prev && non_space;
result.lines += text[i] == '\n';
prev = non_space;
}
while (++i < bytes && text[i] <= ' ');
}
}
return prev;
}
Этот код упрощен ради демонстрации сути Haskell-цикла, но работает корректно без лишних накладных расходов и налогов на абстракцию. Давайте посмотрим насколько это будет быстро, добавив для наглядности "зачётные" показатели:
Реализация | Сборка | Результат в секундах |
---|---|---|
на С по мотивам Haskell | gcc 8, -Ofast | 1.331 |
на Haskell | ghc 8.0.2, -O2 | 2.811 |
простая на С | gcc 8, -Ofast | 3.067 |
оптимизированная переносимая на С | gcc 8, -Ofast | 0.637 |
Совершенно "внезапно" сгенерированный Haskell-компилятором код, но реализованный на С оказался вдвое быстрее! На всякий случай, ещё раз напомню, что новый ghc
вероятно покажет результат немного лучше.
Теперь можно проанализировать почему реализация на Haskell оказалось быстрее простой реализации на C, а заодно ещё раз посмотреть на тестовые данные (под спойлером в самом начале):
- Следуя простейшим правилам
ghc
стал преобразовывать decision tree по значению символов, одновременно пытаясь минимизировать переходы. В результате разделил обработку на два суб-цикла: для символов "больше пробела" и "меньше или равно пробелу". - Суб-цикл по символам "больше пробела" не меняет состояния, не имеет внутренних зависимостей по данным и поэтому выполняется существенно быстрее второго под-цикла. Чем больше средняя длина слов, тем быстрее будет работать такой код, а одно "слово" в 1.8 Гб будет идеальным вариантом ;)
- Если посмотреть на тестовые данные, то там подавляющее большинство символов больше пробела. Более того, "пробельные" символы идут по-одному (фактически это только пробелы и переводы строк). Поэтому предсказание переходов работает очень хорошо — только по одному промаху на каждое слово.
Если немного присмотреться к результатам, то легко увидеть что средняя длина слова 1871822210 / 44774631 = 41.8
символов, а строка в среднем состоит из 44774631 / 15000000 = 2.98
слов.
Фактически тестовые данные подобраны крайне удачны именно для обработки кодом сгенерированным Хаскель-компилятором. Если взять текст с более-менее случайным распределением пробелов, то предсказание переходов будет ошибаться и результат будет значительно хуже. Проверим?
Получим 1.8 Гб случайных данных (3068561 * 610 = 1871822210
):
$ dd if=/dev/urandom of=/dev/shm/rand.dat bs=3068561 count=610
Стоит пояснить, что получаемый "случайный мусор" с точки зрения задачи является абсолютно корректными данными и при этом его статистические показатели гораздо ближе к тому, что мы называем "текстом" в контексте задачи. Средняя длина слова составляет 1871822210 / 210195110 = 8.9
, а остальные параметры не важны. Конечно, такой "текст" отличается по массе параметров от естественного языка и не является идеальным тестовым набором. Но очевидно, что этот "текст" гораздо ближе к здравому смыслу чем первоначальные данные со средней длиной слова в 41 символ. Теперь посмотрим каковы будут итоговые "зачётные" показатели на таких данных:
Реализация | Сборка | Результат в секундах |
---|---|---|
на С по мотивам Haskell | gcc 8, -Ofast | 1.331 => 3.532 |
на Haskell | ghc 8.0.2, -O2 | 2.811 => 4.812 |
простая на С | gcc 8, -Ofast | 3.062 => 3.071 |
Вариант на С по мотивам Хаскель-кода замедлился сильнее всего — это следствие намеренного упрощения кода ради наглядности и демонстрации проблемы. Реализация на Хаскель замедлилась не столь сильно, но теперь она существенно медленнее самого простого кода на C. А скорость наивной реализации на С ожидаемо не изменилась.
Получается что реализация на Haskell оказалась быстрее благодаря "удачности" тестовых данных, которые просто маскировали недостаток. Соответственно, результаты показанные в первой статье не отражает реального положения дел, а самая простая реализация на C всё-таки оказалась быстрее ;)
В комментариях была высказана критика:
- Использования относительно старой версии Хаскель-компилятора и отсутствие результатов при использовании LLVM.
- Отсутствие результатов для актуальных версий gcc и clang.
- Искусственность, неестественность теста на случайных данных.
- Утверждение о том, что на "Шекспире" реализация на Haskell не замедлялась и (следовательно) результаты и выводы не верны.
- Отсутствие результатов системной
wc
на случайных данных.
Как видите я решил устранить эти замечания и окончательно поставить все точки над всеми Ё. Для этого было проведено два забега на другой машине с i7-7820HQ CPU @ 2.90GHz под управлением 64-битной версии Fedora 31.
Для первого теста был взят текст произведений Шекспира объемом 5_777_367
байт. Этот текст был "зачитан до дыр" для получения объема порядка 1.8Гб:
$ wget http://www.gutenberg.org/files/100/100-0.txt
$ for i in `seq 1 333`; do cat 100-0.txt >> test.txt; done
В результате получился текстовый файл размером 1_923_863_211
байт. Давайте посмотрим насколько Хаскель и C любят Шекспира:
Реализация | Сборка | Результат в секундах |
---|---|---|
Системная утилита wc |
- | 12.687 |
на С по мотивам Haskell | gcc 9.2.1, -Ofast | 4.947 |
на С по мотивам Haskell | clang 9.0.1, -Ofast | 4.689 |
на Haskell | ghc 8.8.3, -O3 | 6.339 (!) |
на Haskell | ghc 8.8.3, -O3 -fllvm | 4.893 (!) |
простая на С | gcc 9.2.1, -Ofast | 2.445 |
простая на С | clang 9.0.1, -Ofast | 2.347 |
Результаты для случайных данных из /dev/urandom
размером 1_871_822_210
байт:
Реализация | Сборка | Результат в секундах |
---|---|---|
Системная утилита wc |
- | 16.878 |
на С по мотивам Haskell | gcc 9.2.1, -Ofast | 3.322 |
на С по мотивам Haskell | clang 9.0.1, -Ofast | 3.344 |
на Haskell | ghc 8.8.3, -O3 | 3.976 |
на Haskell | ghc 8.8.3, -O3 -fllvm | 2.513 |
простая на С | gcc 9.2.1, -Ofast | 2.376 |
простая на С | clang 9.0.1, -Ofast | 2.244 |
Последняя версия ghc
8.8.3 не показала видимых различий от 8.0.2, но подключение LLVM (в данном случае 9.0.1) действительно дало ускорение. Тем не менее, простая реализация на C по-прежнему быстрее, а в случае естественного текста существенно быстрее. "Вильям Шекспир" не только подтвердил корректность вчерашнего теста на данных из /dev/urandom
, но и оказался тяжелее.
Отдельно стоит отметить, что совершенно не подтвердилась информация от 0xd34df00d, о том что реализация на Haskell обрабатывала тексты Шекспира с той же скоростью что и тестовые данные из первой статьи. Напротив, замедление налицо и оно больше чем на случайных данных.
Таким образом, все результаты и выводы еще раз подтверждены, а сомнения полностью развеяны.
Дисклаймер: Этот статья написана не для того чтобы показать "тормознутось" Хаскеля или "превосходство" C (ибо у каждого языка свое назначение), но чтобы обратить внимание: чуть менее чем все подобные "хайповые" бенчмарки и сравнения содержат достаточно недочётов чтобы не принимать их всерьёз. При этом всё же уместно напомнить тезис, что языки без zero cost abstraction никогда не смогут конкурировать по скорости кода с теми, где zero cost abstraction есть.
0xd34df00d
Прям не ожидал такой серии статей, любо-дорого читать!
У меня есть опыт, когда апгрейд ghc на одну мажорную версию вперёд (т. е. ЕМНИП 8.2 > 8.4) улучшил производительность одной кодовой базы на 30%. А у вас 8.0, это очень старая версия. Я своё не зря собирал 8.8.
Она, увы, показывает то, что генерирует родной ghc'шный кодген, а я во всех дальнейших экспериментах использовал LLVM.
Снимаю шляпу. Я грепал по
cmp.*$32
(чтобы найти сравнения с пробелом), но что-то адекватное у меня не нагрепалось за то время, что я был готов на это потратить.Другое дело, что я искал в дизасме готового бинарника, а не в дампе.
Я бы даже сказал «намного лучше».
Взял вашу версию «на C по мотивам Haskell», схоронил в файл
main3.c
, запустил на той же машине, где все прочие эксперименты:У gcc ещё хуже.
Время работы хаскель-версии на этой машине, напомню, 1.45 с.
Ничего не хочу сказать, но обычный человекочитаемый текст, на который чаще всего и натравливают wc, сгенерирован не случайным образом, и имеет характеристики, несколько отличные от случайного.
yleo Автор
Вероятно у меня будет время чтобы добавить результаты от более новых компиляторов из Fedora 31 на чуть более современно процессоре.
yleo Автор
// промазал
Antervis
смею предположить, что распределение длины реальных слов должно быть ближе к Максвелловскому, а распределение длины случайного числа точно экспонента. Однако медианная длина слова в ваших тестовых данных тоже шибко далека от реальности
так что оба хороши. С длиной строк так же, но там менее важно — средняя строка должна быть достаточно длинной чтобы не париться по поводу миспредиктов. Я бы конечно подставлял литературный текст, а не генерировал,но это от леничтобы быть максимально точным.yleo Автор
Скорость хаскель-кода зависит только от средней длины слов, а картина распределения как и длина не играет роли (с поправкой что слово не может быть длиннее строки). Видимо это не очевидно и (соответственно) должно быть явно разъяснено в статье. Поэтому я намерено ушел от поиска "идеального" текста в качестве тестовых данных. По той же причине оба замечания беспочвенны.
Использование данных
/dev/urandom
абсолютно оправдано и неплохо иллюстрирует недостаток хаскель-кода, тогда как первоначальные данные этот недостаток маскируют — это главное. Случайные данные не являются идеальным тестовым набором, но использование "идеального текста" (какой он и почему именно такой?) будет скорее перфекционизмом.Antervis
yleo Автор
Вы несете чушь, потому что 9 знаков — это среднее значение случайной величины.
yleo Автор
Вот интересно, какова логика минусующих?
Даже если (вдруг, внезапно) бранч-предиктор адаптируется на 9 знаков, то для любой другой длины (а она случайна) он ошибётся.
С пробелами ошибок будет меньше, ибо в большинстве случаев они будут одиночными. Однако, будут и не одиночные проблемные символы, в то время как в исходном тестовом наборе они всегда одиночные (и предиктор действительно будет это угадывать).
VolCh
"Вы несёте чушь" не всем могло понравиться
Antervis
Да и какая же это «чушь» если она подтвердилась вашими же экспериментами, кроме как для реализаций, которые компиляторы смогли векторизовать (о чем я тоже упоминал)?
yleo Автор
Замечательно ;)
Тогда покажите, пожалуйста, как средняя длина слов получилась 8.9, а не 42?
Ну и какова средняя длина цепочек единичных бит (the runs of ones) в данных из
/dev/urandom
?technic93
8.9 Это отношение пробельных символов ко всем.
yleo Автор
Нет, подумайте.
Для данных из
/dev/urandom
соотношение не-пробельных символов к пробельным (пробел и все что меньше 14) будет равно(256-14-1)/(1+14) = 16,06(6)
.technic93
А я посчитал — численно
yleo Автор
Не понял что вы посчитали и как.
Поясните.
technic93
Сгенерировал случайный массив где вероятность нулей 1/N и вероятность единиц (N-1)/N. Получил что средняя длина слова из единиц N. Распределение длин слов экспоненциальное.
yleo Автор
Да, но чем меньше N чем хуже у бранч-предиктора с угадыванием переходов. Поэтому исходные тестовые данные с 40-буквенными словами искажали реальное положение дел, о чем собственно и написано в тексте статьти.
Короче, я уже примерно совсем не понимаю о чем разговор.
В статье говориться:
Позволяют ли случайные данные что-то предсказывать бранч-предиктору?
Позволяют ли случайные данные (со средней длинной слова 8.9) предиктору предсказывать переход на 9 символе?
technic93
Дискуссия уже далеко ушла от кода и перешла на статистику. К выводу статьи у меня вопросов нету.
Не понятно почему у вас все же 8.9 получается а у меня другое. Об этом были мои комментарии.
Я не уверен что бранч предиктор достаточно умный чтобы находить периоды во входных данных. Но если да, то как минимум дисперсия важна.
yleo Автор
А теперь понятно, а то я всё смотрел на это через предсказатель переходов.
Короче, по вашей наводке (спасибо) я понял что немного продолбал пока возился с
/dev/urandom
. Сначала при экспериментах я чистил выхлоп для получения ASCII-текста (посредствомtr
). Получил подтверждение гипотезы, перенес результаты в текст. Но после решил убрать "чистку" для упрощения, замерив результаты заново. Но вот цифры в выражении1871822210 / 210195110 = 8.9
не обновил :(technic93
Окей, спасибо за развернутые ответы.
yleo Автор
На всякий — все результаты и выводы подтвердились для ghc 8.8.3, LLVM-бакендом и на текстах Шекспира.
См под спойлером в конце статьи.
technic93
Условный Шекспир это хорошо для 16го века, но нам в 21ом актуально логи условного nginx)
powerman
Только мне кажется, что Вы придираетесь? :)
technic93
Это же шутка была, я даже смайлик поставил в конце)
IvanBulb
Где реализация на GPU?
каким образом за 0.2 сек читается файл на 1.8ГБ?)))
нормальным временем для данной задачи будет только время РАВНОЕ чтению файла с диска)
все остальное — мартышкин труд кодировщиков средней руки)))
надо бы Короткевича позвать — как раз для него тема))
0xd34df00d
А зачем? И почему не на FPGA?
Из tmpfs у меня (и из эквивалентных механизмов у других авторов), как написано, наверное, в каждой статье.
Вопрос в том, сколько времени вы готовы на это потратить. И yleo, и picul очень хорошо постарались сделать версии с другим алгоритмом, с broadword programming, с SSE/AVX2, и так далее. Но это уже другой класс задач.
Надо бы мне уже написать статью про моё впечатление от реакций на бенчмарки, где хаскель-код оказывается на уровне (или обгоняет) алгоритмически эквивалентный код на С. За последние полгода я узнал много интересного.
viirtus
Файл размещался в tmpfs (читай в RAM), о чём указано в статье.
yleo Автор
Все результаты и выводы подтвердились для ghc 8.8.3, LLVM-бакендом и на текстах Шекспира.
См под спойлером в конце статьи.