ClickHouse – самая быстрая в мире аналитическая СУБД. Для тех, кто с ним ещё не знаком, очень рекомендую попробовать, пересаживаться обратно на MySQL или Postgress потом не захочется.
Обычно данные хранятся в ClickHouse в сыром, неагрегированном виде, и агрегируются на лету при выполнении SQL запросов. Но при решении data science задач часто возникает необходимость выгрузки именно сырых данных, для дальнейшей их обработки в памяти (например, для обучения модели по этим данным). Если выгружать данные в текстовый файл с помощью родного клиента ClickHouse, всё происходит достаточно шустро – “ClickHouse не тормозит”™. Но если пользоваться драйвером для Python, то процесс выгрузки затягивается надолго. Почему?
Дело не в том, что драйвер плохо написан – драйвер как раз отличный. Проблема
лежит глубже. ClickHouse отдаёт данные в виде бинарного потока, каждый
элемент которого соответствует машинному представлению числа на x86 процессоре.
Если работать с этими данными на низкоуровневом языке, таком как С++ (как
в родном клиенте), проблем с быстродействием не будет. Если колонка, например,
имеет тип Int32
, то на клиента приедет фактически готовый к использованию
массив чисел с типом int32_t
.
Но Python представляет все числа, как объекты. Это означает, что драйвер проходит по загруженным данным, преобразует каждое число в объект, и потом уже из этих объектов собирает питоновский массив (состоящий из указателей). Такая операция называется boxing, и при больших объемах данных она отнимает значительное время. Собственно, в ходе загрузки данных через Python-драйвер основное занятие CPU это переупаковка чисел из машинного представления в объекты.
В то же время в data science принято работать c numpy массивами (pandas тоже работает через numpy), которые содержат числа в машинном представлении, как в С. То есть, сначала мы долго упаковывали числа в объекты, а потом, при конвертировании из Python массива в numpy массив будем распаковывать объекты обратно в числа (unboxing). Очевидно, что промежуточное объектное представление здесь только мешает, и если бы драйвер умел выгружать данные сразу в numpy массивы, процесс пошёл бы намного бодрее. Но драйвер этого не умеет, поэтому я его немного доработал, чтобы такая возможность появилась.
Инсталляция
- Если уже установлен пакет clickhouse-driver, удалить его:
pip uninstall clickhouse-driver
- Инсталлировать из github версию с ускоренным чтением:
pip install git+https://github.com/Arturus/clickhouse-driver.git
Использование
При создании объекта Client
надо включить новую опцию numpy_columns=True
,
а при выполнении запросов включать опцию columnar=True
:
client = Client('localhost', database='db', settings=dict(numpy_columns=True))
data = client.execute(query, columnar=True)
В data будет содержаться набор колонок. Колонки, представляющие собой числа или timestamp, будут numpy-массивами, остальные колонки (например, строки) будут стандартными Python массивами. В numpy формат конвертируются следующие типы Clickhouse: Int8/16/32/64, UInt8/16/32/64, DateTime.
Полученные данные часто преобразуются в pandas DataFrame с именами колонок,
соответствующими именам колонок в БД. Чтобы не делать это каждый раз вручную,
в класс Client добавлен метод query_dataframe()
:
df = client.query_dataframe('SELECT a,b FROM table')
Результатом будет DataFrame с двумя колонками, a и b.
Benchmarks
Замерялась скорость выполнения запроса SELECT x1,x2,...,xn FROM table
на таблице со
100 млн. записей (реальные данные из Logs API Яндекс.Метрики), engine=MergeTree. Запросы выполнялись на локальном
ClickHouse c дефолтными настройками драйвера.
Запрос | Время, numpy | Время, standard | Ускорение | Memory, numpy | Memory, standard |
---|---|---|---|---|---|
4 колонки Int8 | 0.34 s | 5.8 s | ×17 | 0.82 Gb | 3.3 Gb |
2 колонки Int64 | 1.38 s | 12 s | ×8.7 | 2.61 Gb | 9.7 Gb |
1 колонка DateTime | 12.1 s | 7.1 m | ×35 | 1.16 Gb | 4.8 Gb |
Использование numpy ускоряет чтение на порядок. Особенно заметно ускорение на типе DateTime, потому что работа c временем на уровне Питоновских datetime-объектов происходит очень медленно. Фактически, без использования numpy время выполнения запроса, включающего колонку со временем, выходит за рамки разумного.
В последних двух колонках – объём памяти, занимаемый процессом после выполнения запроса. Видно, что использование numpy не только ускоряет загрузку данных, но и уменьшает объём требуемой памяти примерно в 4 раза.
Ограничения
- Поддерживается только чтение в numpy массивы. Запись возможна только в режиме
numpy_columns=False
. - numpy массивы не используются при чтении nullable колонок и колонок-массивов. Впрочем, код чтения массивов тоже немного оптимизирован и теперь работает быстрее, чем в обычном драйвере.
- Также numpy не используется при чтении enums, decimal и прочих продвинутых типов (поддержка может быть добавлена в будущем).
Ограничения на чтение никак не мешают функционированию драйвера, просто для некоторых типов данных чтение ускоряется, а для некоторых – работает, как обычно.