Ускоряем драйвер ClickHouse | Артур Суилин

Ускоряем драйвер ClickHouse

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 массивы, процесс пошёл бы намного бодрее. Но драйвер этого не умеет, поэтому я его немного доработал, чтобы такая возможность появилась.

Инсталляция

  1. Если уже установлен пакет clickhouse-driver, удалить его: pip uninstall clickhouse-driver
  2. Инсталлировать из 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 и прочих продвинутых типов (поддержка может быть добавлена в будущем).

Ограничения на чтение никак не мешают функционированию драйвера, просто для некоторых типов данных чтение ускоряется, а для некоторых – работает, как обычно.

Related

Previous