- PVSM.RU - https://www.pvsm.ru -

Объяснение: почему wc на Haskell оказался «быстрее» аналога на С

Объяснение: почему wc на Haskell оказался «быстрее» аналога на С - 1

После недавних статей (№1 [1]0xd34df00d [2], №2 [3]chapuza [4], №3 [5]picul [6]) сравнивающих скорость работы реализаций упрощенной утилиты wc [7] у меня оставался только один вопрос — Как простая реализация на Haskell оказалась быстрее простой реализации на C ?!

Кратно напомню ТЗ

Для текстового файла размером 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 [8] - 19.416
на Haskell [9] ghc 8.0.2, -O2 2,811
простая на С [10] gcc 8, -Ofast 3.067
оптимизированная переносимая на С [11] gcc 8, -Ofast 0.637

Не удивительно, что оптимизированная человеком (мной) С-версия существенно быстрее простой реализации на Haskell. Но меня заинтересовало почему простая реализация на С всё-таки уступает "Хаскелю". Конкретно в этих моих тестах с использованием не последней версии ghc результаты отличаются незначительно (2,811 < 3.067), но при использовании актуальной версии компилятора Haskell отношение получается примерно 2:3 в пользу Haskell. Всё это сподвигло меня на выяснения причин.

Тем не менее, для полной картины сначала стоит показать остальные результаты. Включая получаемые как просто включением оптимизации под конкретный процессор, так и ручным кодированием с использование интринсиков AVX2:

Реализация Сборка Результат в секундах
на Haskell [9] ghc 8.0.2, -O2 -mavx2 2,789
простая на С [10] gcc 8, -Ofast -march=haswell 2.914
простая на С [10] clang 8, -Ofast -march=haswell 1.124
оптимизированная переносимая на С [11] gcc 8, -Ofast -march=haswell 0.634
оптимизированная AVX2 на С [12] gcc 8, -Ofast -march=haswell 0.269

Здесь стоит отметить, что включение AVX2 не повлияло на результат реализации на Haskell, а вот clang сделал автовекторизацию (что легко увидеть в CompilerExplorer [13]), за счёт чего скорость увеличилась более чем вдвое. В свою очередь, оптимизированная вручную версия с AVX2 по скорости ожидаемо приближается к пропускной способности памяти.

Смотрим внутрь кода из-под Haskell

Собственно я не собирался глубоко лезть к дракону в ж анализировать результат работы компилятора. Мне было интересно посмотреть на самый внутренний цикл, в котором происходит посимвольная обработка и понять почему вдруг Хаскель оказался быстрее.

Поигравшись с опциями я пришел к выводу, что получить самый подходящий для анализа код позволяет опция -ddump-asm. Из исходника wc.hs [9] генерируется примерно 24К ассемблерного кода [14]. По-быстрому увидеть глазами нужный цикл не получилось, но grep cmp позволил быстро локализовать релевантные инструкции сравнения: cmpq $33,%rbx; cmpq $32,%rbx; cmpq $10,%rbx; cmpb $4,%bl. После этого было легко найти искомый цикл [15].

Достаточно быстро я понял в чём дело и набросал на С примерный аналог генерируемого Хаскель-компилятором цикла обработки. Рисовать блок схему мне показалось излишним и менее выразительным чем просто привести код на 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 [16] gcc 8, -Ofast 1.331
на Haskell [9] ghc 8.0.2, -O2 2.811
простая на С [10] gcc 8, -Ofast 3.067
оптимизированная переносимая на С [11] gcc 8, -Ofast 0.637

Совершенно "внезапно" сгенерированный Haskell-компилятором код, но реализованный на С оказался вдвое быстрее! На всякий случай, ещё раз напомню, что новый ghc вероятно покажет результат немного лучше.

Теперь можно проанализировать почему реализация на Haskell оказалось быстрее простой реализации на C, а заодно ещё раз посмотреть на тестовые данные (под спойлером в самом начале):

  • Следуя простейшим правилам ghc стал преобразовывать decision tree по значению символов, одновременно пытаясь минимизировать переходы. В результате разделил обработку на два суб-цикла: для символов "больше пробела" и "меньше или равно пробелу".
  • Суб-цикл по символам "больше пробела" не меняет состояния, не имеет внутренних зависимостей по данным и поэтому выполняется существенно быстрее второго под-цикла.
  • Если посмотреть на тестовые данные, то там подавляющее большинство символов больше пробела. Более того, "пробельные" символы идут по-одному (фактически это только пробелы и переводы строк). Поэтому предсказание переходов работает очень хорошо — примерно по одному промаху на каждое слово и строку.

Если немного присмотреться к результатам, то легко увидеть что средняя длина слова 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 / 7313229 = 255.95, средняя длина слова 1871822210 / 210195110 = 8.9, количество слов в строке 210195110 / 7313229 = 28.74. Эти значения можно вывести по теории вероятности, но для проверки стоило сверить реально получаемые значения с теоретическими. Такой тестовый набор представляется мне гораздо ближе к реальности (по средней длине слов и строк), чем первоначальные тестовые данные.

Теперь посмотрим каковы будут итоговые "зачётные" показатели на таких данных:

Реализация Сборка Результат в секундах
на С по мотивам Haskell [16] gcc 8, -Ofast 1.331 => 3.532
на Haskell [9] ghc 8.0.2, -O2 2.811 => 4.812
простая на С [10] gcc 8, -Ofast 3.062 => 3.071

Вариант на С по мотивам Хаскель-кода замедлился сильнее всего — это следствие намеренного упрощения кода ради наглядности и демонстрации проблемы. Реализация на Хаскель замедлилась не столь сильно, но теперь она существенно медленнее самого простого кода на C. А скорость наивной реализации на С ожидаемо не изменилась.

Получается что реализация на Haskell оказалась быстрее просто благодаря "удачности" тестовых данных. Соответственно, результаты показанные в первой статье [1] не отражает реального положения дел, а самая простая реализация на C всё-таки оказалась быстрее ;)

Приятных холиваров в комментариях!

Дисклаймер: Этот статья написана не для того чтобы показать "тормознутось" Хаскеля или "превосходство" C (ибо у каждого языка свое назначение), но чтобы обратить внимание: чуть менее чем все подобные "хайповые" бенчмарки и сравнения содержат достаточно недочётов чтобы не принимать их всерьёз. При этом всё же уместно напомнить тезис, что языки без zero cost abstraction никогда не смогут конкурировать по скорости кода с теми, где zero cost abstraction есть.

Автор: Леонид Юрьев

Источник [17]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/programmirovanie/347780

Ссылки в тексте:

[1] №1: https://habr.com/ru/post/489136/

[2] 0xd34df00d: https://habr.com/ru/users/0xd34df00d/

[3] №2: https://habr.com/ru/post/489548/

[4] chapuza: https://habr.com/ru/users/chapuza/

[5] №3: https://habr.com/ru/post/489898/

[6] picul: https://habr.com/ru/users/picul/

[7] wc: https://ru.wikipedia.org/wiki/Wc

[8] Системная утилита wc: https://github.com/coreutils/coreutils/blob/master/src/wc.c

[9] на Haskell: https://gist.github.com/erthink/b7936acf3c397064c0b5d2852532bd50

[10] простая на С: https://gist.github.com/erthink/2a8972a7e630f3157f445ed7e5ff9d87

[11] оптимизированная переносимая на С: https://gist.github.com/erthink/a93124374bb7cb8aa9dc731ed2cfdf73

[12] оптимизированная AVX2 на С: https://gist.github.com/erthink/ee5d8429ad58272168cc1e1007b3af38

[13] CompilerExplorer: https://godbolt.org/z/LszLUz

[14] 24К ассемблерного кода: https://gist.github.com/erthink/f75ba3cbb63032b04b89a7410f296ef2

[15] искомый цикл: https://gist.github.com/erthink/f75ba3cbb63032b04b89a7410f296ef2#file-wc-hs-lst-L368-L455

[16] на С по мотивам Haskell: https://gist.github.com/erthink/a7d35192c1c8596d48a12e6fff459db1

[17] Источник: https://habr.com/ru/post/489958/?utm_source=habrahabr&utm_medium=rss&utm_campaign=489958