О кложурных редьюсерах
В последнее время я немного подавлен работой. Делаю одну задачу уже много месяцев, при этом она довольно тупая и бестолковая. Омрачает то, что в процессе я нахожу баги, порой довольно серьезные. Чувствую себя так, словно заставили идти через поле коровьих лепешек.
Часть багов связана с инфраструктурой и очередями задач. Пересказывать их довольно скучно, и вряд ли вам будут интересно. Но один бажок на тему Кложи и параллельных вычислений мне запомнился, и сейчас его расскажу.
Как вы знаете, Кложа хороша своими коллекциями – это прямо алмаз, одно из
немногих утешений в айти. Вместе с коллекциями прилагаются штук триста функций:
всякие map, reduce и так далее, включая экзотику.
Начиная с какой-то версии в Кложу завезли параллельные map и reduce. Пакет
называется clоjure.core.reducers. Сигнатуры параллельных функций почти
идентичны оригиналам. Идея в том, что ты такой хоп – заменил reduce на
r/reduce, и вычисления раскидались по ядрам. Нашлись коллеги, которые, не
читая документации, так и сделали – а я пожал их плоды.
Есть огромных файл, где каждая строка – джейсончик. Нужно построить мапу {id ->
entity}, чтобы быстро дергать из нее сущность по айдишке. Скажем, в Питоне это
делается так:
{entity["id"]: entity
for entity in file.read_lines()
}
В Кложе это тоже решается тремя строчками. Но коллега использовал не простой reduce, а который параллельный. Логика такова: коллекция большая, пусть колбасится параллельно. И вот я вызываю функцию, которая строит эту мапу, и замечаю – в ней нет половины ключей. Пропали. Что такое?
Дебажил я передебажил и выяснил вот что.
В документаци clоjure.core.reducers написано, что коллекции не всегда
обрабатываются параллельно. Критериев несколько, например коллекция мала и нет
смысла ее делить. Но главный критерий таков: коллекция не должна быть ленивой. А
те коллекции, что коллеги читают из файла, ленивы. Это нормально, потому что
файл огромный и читать в память все разом нельзя. Но получается, что вся
параллельность идет псу под хвост – параллельные r/map и r/reduce сводятся к
последовательным аналогам.
Другими словами, параллельной обработки никогда не было. Вызовы есть, но эффекта нет. Никто этого не замерял и не проверял.
Почему же у меня возникла ошибка? Дело в том, что я передал вектор – не ленивую
коллекцию. Она попадала под критерии параллельности, и произошло вот что. Пакет
clоjure.core.reducers использует подход ForkJoin. На этапе Fork коллекция
бьется на части, и каждая часть обрабатывается в своем потоке. Получаются,
скажем, два словаря {id -> entity} для каждой части. Далее наступает фаза
Join – их нужно объединить. Функция r/reduce принимает дополнительную
функцию для сборки финального результата, но ее не передали. А если ее нет,
вызывается редуцирующая функция. Она приняла два словаря и неправильно их
обработала, в результате чего пропала половина данных. Нужно было передать туда
функцию merge, но никто не знал, для чего это в принципе.
Когда я это исправил, нашел еще один косяк у себя самого. Когда записи
индексируют по id, с объединением проблем нет, потому что ключи уникальны. Но
представим, что выполняется группировка по какому-то рейтингу. В этом случае
результат такой:
{"a" [1 2 3], "b" [5 4 1]}
Если дать эту задачу на параллельное вычисление, они могут вернуть что-то такое:
{"a" [1 2], "b" [5]}
{"a" [2 3], "c" [11]}
То есть и в первом потоке был такой ключ, и во втором. Если тупо объединить словари, получится вот что:
{"a" [2 3], "b" [5], "c" [11]}
, то есть из “a” пропадет 1. Ошибки не будет, все пройдет молча, и догнать причину будет сложно. Правильная функция объединения будет такой:
(partial merge-with into)
С ней получим результат {"a" [1 2 3], "b" [5], "c" [11]}.
Ради интереса прошелся по коду коллег: почти везде используются ленивые коллекции. Это значит, что параллельные вычисления банально не работают. Я хотел поправить, но плюнул: давайте-ка сами.
В сухом остатке:
-
человек подключил либу для параллельных вычислений
-
вычисления всегда протекали последовательно
-
при попытке вычислить что-то параллельно получали ошибочный результат. Исключения нет, все тихо, ищи сам.
В Гарри Поттере была фраза: “не доверяй тому, что мыслит, если не знаешь, где у него мозги”. Я бы перефразировал: не доверяй быстрым библиотекам, если не понимаешь, за счет чего достигается скорость. Реалии таковы, что в погоне за скоростью срезают углы. Это ни хорошо ни плохо, это факт, который нужно знать.
Внедряя параллельные вычисления, уделите хотя бы толику внимания тому, какие сюрпризы вас ожидают.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter