«Пробенчмаркать уже это всё наконец» – тестирование инструментов для обработки данных на Python. Часть 1
Это будет история о том, как наша команда придумала и приступила к реализации бенчмарка объективным, упорядоченным и унифицированным способом – через написание универсального инструмента.
В первой части публикации представим теоретическую часть задачи, предпосылки, а также первую попытку реализации универсального инструмента. Основные результаты сравнения опишем в следующей части.
В нашей работе часто приходится сталкиваться с задачами обработки больших данных. Традиционный метод обработки, который мы используем — библиотека Pandas. Она предоставляет приятные вещи (чтения форматов из коробки, фильтрации, агрегации, concat, join merge). Всё это позволяет абстрагироваться от технических трудностей, сразу приступая к самому алгоритмически интересному.
Но время идёт, и появляются другие решения — Polars, Koalas, ускорители Numba и Cython, Pandas 2.0 — целый ворох технологий, которые стремятся сделать обработку данных быстрее. И, вроде бы, не так сложно запустить одно и другое, померить время и посмотреть, кто лучше. Однако точечный бенчмарк не даёт полной картины.
У нас возникает потребность качественно проанализировать технологии, чтобы уже не отвлекаться на мысли о том, а что тут можно модернизировать. А ещё желательно, чтобы у нас при этом остался какой-то кусок кода, который можно:
- использовать для бенчмарка новых (или свежевыкопанных, или обновлённых) инструментов;
- пересчитывать на разных ресурсах;
- пересчитывать под конкретную задачу;
- использовать любому человеку и получить достоверный результат.
Теоретическое описание идеи
По классике, при выборе технологии необходимо максимально полно описать альтернативные инструменты, их сильные и слабые стороны. Также понятно, что каждый раз описывать примерные преимущества Pandas и иже с нею не очень-то полезно, хочется конкретики. Многие метрики невозможно качественно оценить на глаз, затратно или невозможно проанализировать теоретически. Поэтому предлагаем создать код, который будет сквозным образом воспроизводить объективный набор экспериментов и выдавать чисто метрики. Для этого рассмотрим, как бы выглядел план экспериментов, если бы мы воспроизводили его более или менее руками.
Получить такой план рассчитываем, «перемножив» два списка — список экспериментов и список альтернатив. Первый из них будет содержать алгоритмическое описание экспериментов (открыть файл, сгруппировать по такой-то колонке, отфильтровать и т.д.), каждый из которых будет давать на выходе некие метрики (время и оперативная память).
Второй — список альтернатив — содержит все доступные технологии и их возможные (или, лучше, имеющие смысл) комбинации/реализации.
Их условное «перемножение» даст нам матрицу, в каждой клетке которой будет находиться значение метрик эксперимента, проведённого для конкретной альтернативы — как, например:
{среднее время работы}{группировки по *условию*}{на pandas + Cython}.
И далее мы бы стали проводить серии испытаний и заполнять матрицу. Как только заполним матрицу до конца, сможем сказать, что исследование исчерпано, и дальнейшее принятие решения будем считать обоснованным — ну, то есть как только заполним, а потом разберём и проанализируем полную картину.
Первый из обозначенных списков — список экспериментов — по нашему представлению, должен содержать множество из пересечения двух составляющих: конкретные данные, на которых будет проводиться исследование, и операции над данными, которые хочется проделать.
Есть ещё метрики, но тут всё довольно-таки тривиально: в основном, когда мы говорим о метриках, имеем в виду скорость, так как именно она очень чувствительна в нашей работе, и ещё занимаемая оперативная память. У них можно посчитать среднее значение на выборке испытаний, потому что скорость, например, довольно сильно «гуляет» от эксперимента к эксперименту. Кроме того, будем просматривать обычное и стандартное отклонения, чтобы убедиться, что среднее значение не оторвано от реальности.
Данные
Начнём с рассмотрения данных, которые были бы максимально похожи на то, с чем приходится работать. Для публикации решили взять на Kaggle набор датасетов big-sales-data.
Мы остановились на датасете Books_rating.csv весом в 2,9 гигабайт и размером в 3 000 000 строк.
Содержание датасета:
Данные эти качественно отличаются от того, что мы видим в наших «боевых» русскоязычных базах, однако существенные отличия есть и с публичными русскоязычными датасетами, а объём этих данных всё-таки определяет доступный размах. Собственно, признак, по которому наборы данных могут принципиально отличаться друг от друга — это как раз объём. Его станем оценивать в строчках, хотя это и не до конца честно.
Для анализа того, как поведёт себя инструмент на данных разной длины, станем пилить этот датасет на файлики поменьше – с определённой длиной в строчках. Из модуля предполагается проделывать все тесты над рядом файлов, которые лежат в указанной папке.
Операции и альтернативы
Кроме данных, мы должны учитывать какой-то набор операций, а также набор альтернатив. На данный момент излагаем только эскизный этап, наши наброски по этому поводу. Поэтому для начала в качестве операций возьмём чтение из файлов формата csv и операцию groupby, а в качестве альтернатив – модули pandas и polars, имея в виду, что потом эти списки расширятся.
Практическая реализация (попытка номер раз)
Для большей абстрактности решения был придуман подход, при котором мы абстрагируемся от проверяемой альтернативы.
Суть в том, чтобы написать для каждой исследуемой альтернативы особые файлы-обёртки, в которых интерфейс будет унифицирован и подогнан под необходимые эксперименты. Далее из тестового модуля мы думали абстрагировано их перебирать, пробегать сколько надо раз, замерять метрики и т. д. Результаты всех прогонов должны были помещаться в словарь, который потом можно разбирать и анализировать. Ничего сложного, казалось бы. Посмотрим на реализацию.
Модуль slicer: просто на всякий случай
Модуль slicer нарезает большой датафрейм на датафреймы поменьше, а потом смотреть всё в динамике по длине.
Основной код модуля выглядит так:
Из интересного, что можно рассказать об этой функции: когда она запускалась в промежуточном варианте (там ещё не было минимального порога), она упала где-то на одной тридцатой всех файлов. Это произошло, потому что она заполнила всю — вообще всю — память на ноутбуке. Очень удобно, что всё это она складывает в одной папке, и это можно было быстро удалить. Но, судя по тому, как быстро удалилось это всё, память ноутбука где-то там всё ещё хранит отпечаток размноженного много-много раз big-sales-data.
В общем, модуль рекомендуем применять с осторожностью. Если вдруг попробуете, используйте минимальный порог — он появился там именно после этой истории.
Модули-оболочки: идейное ядро
Для начального примера их было реализовано две — для Pandas и Polars. Операций было имплементировано также немного: open_csv, groupby и длина датафрейма. Мы рассматриваем это как первую эскизную версию, поэтому этих опций будет пока достаточно. Главное, что они включают в себя варьирования по обоим критериям: по альтернативам и по операциям. Вот так выглядит оболочка для Pandas:
А вот такая оболочка написана для Polars:
Как видите, они практически идентичны: взятые операции достаточно просты, и их реализации на разных модулях не имеют различий в коде.
Подобные оболочки можно положить в массив и легко прогонять снаружи — унифицировано и абстрагировано от внутренней реализации. При этом разница реализаций будет проявляться лишь через посчитанные метрики. Как раз то, что нужно.
Файл main: дурная голова ногам покоя не даёт
Всю красоту запускает код, написанный в файле main.py (очень оригинально). Код его довольно длинный, поэтому посмотрим на него по частям. Начнём с импортов:
Импортируем встроенные os, time и resources. Далее подключаем наш собственный drawer, чтобы сразу после теста посмотреть, чего насчиталось, и data_model, который, по сути, производит абстракцию над ранее нарезанными данными.
В конце импортируем собственные модули-оболочки — все, которые хотим проанализировать. Для примера у нас их всего две.
Чтобы единожды запустить функцию и получить от неё время и память, написали функцию run_single:
Она берёт объект функции и kwargs для неё, после чего запускает и возвращает время и память.
Чтобы её пробегать n-ное число раз, применяем функцию run_many:
Она запустит функцию n раз и на списке из n штук подсчитает метрики при помощи следующего кода:
Здесь считаются среднее значение, отклонение и стандартное отклонение для затраченного времени и задействованной памяти компьютера.
Чтобы проделать это для всего интерфейса оболочек, написали такой код:
В этом коде явно прописано, как проходится интерфейс оболочки, и как собираются kwargs под каждую функцию. Это не очень хорошо, уже настораживает. Но подождите.
Модуль data_model: пора поговорить о просчётах плана
В общем, на волне повсеместной объективизации и абстракции мы допустили одну существенную ошибку: попытались абстрагироваться от данных и от операций по отдельности. И у нас бы всё получилось, если бы данные не были по сути своей частью операции — по крайней мере, вещами одного порядка.
В самом начале рассуждения мы обозначали данные частью списка экспериментов. Наравне с операциями. Вот надо было их там и оставить, судя по всему)
Сейчас, на этапе сублимации опыта, угадывается мысль, что итерировать данные лучше было бы не из главного модуля, а из оболочек. Тогда составление разных оболочек было бы единственной формой вот этой регулярной модификации, о которой говорили в начале — а инвариант одинаковости данных в функциях (как и инвариант однозначности функций) соблюдался бы самим пользователем, который дописывает оболочки.
В общем, по итогу код, «владеющий» конкретикой данных, был вынесен в модуль data_model, основной код которого можно видеть ниже:
Только вот это всё равно ничего особенно не спасает: изменяемая часть кода оказывается по разные стороны от нашего main, а значит, и менять его будет до определённой степени неудобно.
Так мы слоника не продадим. Но использовать имеющийся код до некоторой степени всё-таки можно.
Модуль drawer: или его эскиз
В модуле drawer мы реализовали отрисовку графиков. Работали частично из терминала Unix, и при первых попытках нарисовать что-нибудь обнаружилось, что из терминала нет возможности запускать фигуру matplotlib. Она всё-таки рисуется в графической оболочке, а терминал идентифицирует себя как kitty, у него лапки.
И хотя, в общем-то можно было решить это просто сохранением графика в файл, но для отладки нашли интересное решение – милую библиотечку uniplot, которая печатает довольно приятные графики прямо в консоль. Это несколько удобнее при отладке кода, а функционально получается почти то же самое.
По итогу код модуля drawer сейчас присутствует с вшитой uniplot, потом поменяем на хороший чёткий график matplotlib:
Перебираем словарик, вылавливаем оттуда среднее время открытия по модулям Polars и Pandas. Сейчас от этого получается вот такой пиксельный график:
В общем, это вся реализация к текущему моменту времени. Подводя итоги первой итерации, стоит перечислить положительные и отрицательные итоги.
Положительные:
- предложен подход, который обнаруживает большую объективность, наглядность и воспроизводимость по сравнению с точечным бенчмарком, который применяется сейчас;
- подход реализован в коде, проверен на двух операциях, одной метрике и некоторых данных.
Отрицательные:
- допущена структурная ошибка (но, ошибка выявлена, мы её осознали и будем поправлять);
- текущая реализация отличается существенной прототипичностью – неполная реализация модулей-обёрток, эскизное состояние модуля drawer, использование графиков uniplot – все эти вещи не позволяют говорить о применении инструмента в текущей версии.
С полным кодом того, чего пока есть, вы можете ознакомиться в репозитории.
Выводы
Таким образом, в первой части публикации мы описали идею нашего будущего бенчмарка, выбрали данные для исследования, представили код и результаты первой версии практической реализации универсального инструмента.
О том, как мы «поднимем» Spark на локальном компьютере, переделаем структуру и добавим другие оболочки альтернатив, собираемся написать в следующей части публикации.
Спасибо за внимание, и, если у вас есть для нас советы/замечания/предложения, оставьте их, пожалуйста, в комментариях.
Ждём 2ю часть))
Благодарим за интерес 👍