Я автор aiochlite — асинхронного клиента ClickHouse на aiohttp. Клиент читает результаты запросов в бинарном формате RowBinaryWithNamesAndTypes. Сам RowBinary-декодер написан на Python без собственного модуля расширения; единственная обязательная зависимость библиотеки — aiohttp.
В таком декодере каждое числовое поле читалось отдельным вызовом. Функция, выбранная по типу колонки, звала метод reader, тот — struct.unpack_from, дальше отдельно сдвигалось смещение. На 200 тысячах строк с десятью числовыми колонками получается два миллиона проходов по этой цепочке. Payload при этом уже лежал в памяти — ни сеть, ни сервер к делу не относились.
Первое, что здесь напрашивается, — переписать горячий участок на Cython, C или Rust. Иногда это и правда лучший вариант. Но прежде чем городить сборку модуля расширения, я решил посмотреть, сколько полезной работы вообще приходится на один вызов.
Первым делом я попробовал объединять соседние поля фиксированной ширины в один формат struct — распаковывать группу одним вызовом вместо вызова на каждое поле. На десятиколоночной схеме это дало кратное ускорение. А на смешанной, где числа перемежаются строками, измеримого выигрыша не оказалось вовсе. Это оказался самый полезный результат за всю историю с декодером. В итоге цикл декодирования стал компилироваться под конкретную схему запроса. Смешанная схема ускорилась примерно в 1.7 раза, схема из десяти числовых колонок — в 8.3. Точные цифры и методика будут ниже.
В CPython многие операции стандартной библиотеки уже реализованы на C, поэтому вопрос обычно не «Python или C», а сколько раз код ходит между ними и сколько работы делается за один переход. Ускорение пришло из двух разных мест. Если все колонки фиксированной ширины, struct получает сразу всё тело ответа: для каждой строки больше не нужен отдельный Python-вызов unpack_from. Если в строке есть String или контейнер, отдать всё разом нечего, и там убирается другое — диспетчеризация по типам, потому что цикл генерируется по схеме заранее. Python быстрее C нигде не стал: в нём остались построение плана и преобразование значений.
Дальше — как это устроено сейчас, методика замера и места, где приём перестаёт работать.
Что в данном случае означает pure Python
Под pure Python я понимаю библиотеку, которая поставляется как .py-файлы и не требует компиляции собственного модуля расширения. Это не значит, что каждый байт обрабатывается интерпретатором: модуль struct входит в стандартную библиотеку CPython и распаковывает бинарные значения в скомпилированном коде. И не значит, что весь код написан заранее: дальше декодер сам собирает исходник под схему и компилирует его через compile(). Выполняет его всё тот же интерпретатор.
Cython работает иначе: превращает Python-подобный код в C и собирает модуль расширения. К сравнению с ним я вернусь в конце, когда будет видно, что именно осталось на стороне Python.
Как устроен RowBinaryWithNamesAndTypes
Разберём ClickHouse RowBinaryWithNamesAndTypes. При стандартном текстовом кодировании типов его заголовок выглядит так:
VarUInt количество колонок N String × N имена колонок String × N типы колонок row × M значения строк
String здесь означает длину в виде VarUInt, после которой идут байты строки. Типы в заголовке можно передавать и в компактном бинарном кодировании, если на сервере включена соответствующая настройка. Дальше я рассматриваю стандартный текстовый вариант.
После заголовка значения идут подряд. Числа имеют фиксированную ширину и записываются в little-endian. У String, Array, Map и других составных типов размер определяется непосредственно из payload. У строки нет отдельного префикса с общей длиной, разделителей между строками тоже нет.
Одна деталь про String: в ClickHouse это произвольная последовательность байтов, валидный UTF-8 не гарантируется. Декодер, который сразу зовёт .decode("utf-8"), на такой колонке упадёт. Возвращать str, а не bytes — осознанный контракт, но ограничение реальное: колонку с произвольными двоичными данными таким декодером не прочитать.
Чтобы найти начало следующей колонки, декодер должен знать тип текущей и правильно продвинуть смещение.
Наивный декодер
Базовый вариант сначала строит функцию чтения для каждого типа, а затем применяет эти функции к каждой строке:
readers = [_reader_for_type(ch_type) for ch_type in column_types] while not reader.eof: yield [read(reader) for read in readers]
Сама идея правильная: типы разбираются один раз, а не для каждой строки. Проблема проявляется уровнем ниже. Чтение одного UInt64 может выглядеть примерно так:
def read_uint64(self) -> int: value = struct.unpack_from("<Q", self._data, self._pos)[0] self._pos += 8 return value
Для каждой ячейки происходят как минимум:
вызов функции Python, выбранной для типа;
вызов метода
reader;переход в
struct.unpack_from;создание кортежа с результатом;
извлечение первого элемента;
обновление смещения. В строке из десяти числовых колонок эта последовательность повторяется десять раз. На ста тысячах строк — миллион раз. Полезная работа остаётся небольшой, но диспетчеризация на уровне Python выполняется для каждой ячейки.
Один формат struct на несколько полей
struct умеет распаковывать не только одно значение. Последовательность UInt64, UInt32, Float64 можно описать форматом <QId и получить сразу три значения Python:
import struct ROW = struct.Struct("<QId") def decode_row(data: bytes, offset: int = 0) -> tuple[int, int, float]: first, second, third = ROW.unpack_from(data, offset) return first, second, third payload = ROW.pack(42, 1_700_000_000, 3.5) assert decode_row(payload) == (42, 1_700_000_000, 3.5)
Префикс < здесь обязателен. Он задаёт little-endian и стандартные размеры без выравнивания. Без него struct переходит в нативный режим, где размеры и выравнивание зависят от платформы и ABI: на типичной 64-битной Linux-машине QId займёт 24 байта вместо 20. Такое представление уже не совпадает с RowBinary: на коротком буфере unpack_from упадёт, а на достаточно длинном смещения могут разойтись без немедленной ошибки.
Здесь всё ещё создаются объекты Python и результирующий кортеж. Но на группу полей приходится один вызов unpack_from и один результирующий кортеж вместо отдельной цепочки на каждое поле.
Повторный разбор строки формата здесь ни при чём: модульные функции struct кэшируют форматы, а объект Struct компилирует свой один раз при создании, так что "<Q" и без того не разбирался бы заново. Экономия в другом — меньше вызовов и меньше промежуточных кортежей, из которых каждый раз доставали единственный элемент. Операций при этом не становится в десять раз меньше, и преобразование сложных значений никуда не исчезает.
План декодирования строится по схеме
Объединять можно только соседние поля, размер которых известен заранее. Для примитивов соответствие прямое:
ClickHouse |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Некоторые типы тоже имеют фиксированное представление, но требуют дополнительного преобразования. Например, DateTime передаётся как UInt32, а после распаковки превращается в datetime. DateTime64 передаётся как знаковое 64-битное целое с количеством тиков заданной точности. Decimal32 и Decimal64 также можно распаковать как целые числа, а масштаб применить позже.
Не для каждого типа фиксированной ширины в struct есть код, который сразу возвращает нужное значение. У Decimal128, Decimal256, UUID, IPv6 и FixedString такого кода нет. Но ширина у них всё равно постоянная, поэтому они идут через код вида 16s — struct отдаёт сырые байты, а конвертер уже собирает из них нужный объект. Для объединения важна только предсказуемая ширина, а не то, возвращает ли struct готовое число.
Поэтому элемент плана декодирования содержит две части:
код формата для
struct;необязательный конвертер, который применяется к уже распакованному значению. Условно схема
UInt64, DateTime('UTC'), Float64
превращается в
format: <QId converters: [(1, unix_timestamp_to_datetime)]
Сначала Struct.unpack_from возвращает три значения, затем конвертер заменяет только элемент с индексом 1.
Такой план строится один раз после чтения заголовка. Разбирать описание типа вроде Array(Decimal(10, 2)) для каждой строки результата не требуется.
Быстрый путь для строк фиксированной ширины
Самый простой случай — строка, в которой все поля имеют фиксированную ширину. Тогда один объект Struct описывает её целиком. А вся последовательность строк после заголовка — это тот же формат, повторённый M раз. Для такого случая у Struct есть iter_unpack:
unpacked = unpacker.iter_unpack(memoryview(data)[start:])
iter_unpack двигает смещение и распаковывает повторяющиеся строки внутри C-кода. Отдельного unpack_from на каждую строку больше нет, хотя кортежи и объекты Python по-прежнему создаются построчно. Явный цикл на Python возвращается только там, где нужны конвертеры:
for values in unpacked: row = list(values) for idx, convert in conv_slots: row[idx] = convert(row[idx]) append(row)
Конвертеры лежат в conv_slots парами «индекс — функция», поэтому трогаются лишь те позиции, которым преобразование действительно нужно. Если конвертеров нет вовсе, как в обычной числовой схеме, fetch_rows() забирает весь результат одним list(unpacked) без отдельного цикла по полям.
Смешанные схемы: цикл, скомпилированный под схему
В реальных схемах поля фиксированной ширины чередуются со String, Array, Nullable и другими типами переменной длины. У Nullable перед значением находится флаг null, а само значение может отсутствовать, поэтому его нельзя считать обычным полем фиксированной ширины. Одним форматом такая строка не описывается:
[UInt64] [String] [DateTime, Float64, Int32] └──── один Struct ───────┘
Напрашивается собрать по такой схеме список функций: на группу фиксированных полей — своя, на String — своя. Так и было сделано сначала, и здесь же вылезла проблема. Каждая группа — отдельная функция Python, и её вызов надо чем-то оплатить; группа из двух-трёх полей не окупала себя. А главное, диспетчеризация никуда не девалась: цикл по-прежнему шёл по списку функций и звал их по очереди.
Но схема известна заранее — она приходит в заголовке ответа. Значит, по ней можно сразу сгенерировать исходный текст цикла, который читает ровно эти колонки, и скомпилировать его:
code = compile(source, f"<rowbinary:{','.join(types)}>", "exec")
Отдельно про безопасность exec. Строка типа, пришедшая от сервера, напрямую в исходник не подставляется. По ней генератор только выбирает заранее написанный шаблон и конвертер. В сам код попадают коды формата struct, числовые параметры вроде длины FixedString и сгенерированные имена переменных.
Я это проверил: в скомпилированном объекте у декодера для схемы Decimal128(2), FixedString(8), Enum8('x\' = 1), DateTime64(3, 'Europe/Moscow'), String нет ни одной строковой константы, а все имена сгенерированы: v0, _s0, _c1. Текст типов виден лишь в имени «файла», которое подставляется в compile() ради читаемых трейсбеков и не исполняется. Тип, который генератор не умеет встраивать, попадает в цикл вызовом заранее собранной функции чтения, и в текст кода из него тоже ничего не переносится. А тип, неизвестный и этой функции, отвергается до генерации.
Затем exec выполняет этот code object в пространстве имён, куда заранее сложены Struct, конвертеры и вспомогательные функции: сгенерированный код обращается к ним по именам — _s0, _varint, _c2. Вот вывод генератора для схемы UInt64, String, DateTime('UTC'), Float64, Int32, как есть:
def _decode(data, pos, end): rows = [] append = rows.append while pos < end: p = pos _e = p + 8 if _e > end: break v0, = _s0(data, p) p = _e if p >= end: break _l = data[p] p += 1 if _l > 0x7F: _l, p = _varint(data, p - 1, end) if _l < 0: break _e = p + _l if _e > end: break v1 = data[p:_e].decode() p = _e _e = p + 16 if _e > end: break v2, v3, v4 = _s2(data, p) p = _e v2 = _c2(v2) append([v0, v1, v2, v3, v4]) pos = p return rows, pos
Диспетчеризации по типам здесь уже нет — типы кончились на этапе генерации. Группа из трёх последних колонок распаковывается одним вызовом _s2, но собственной функции у неё больше нет: она развернулась прямо в тело цикла, и минимальная длина группы стала не нужна. Конвертер применяется только к v2 — единственной колонке, которой он требуется. Длина строки читается однобайтовым путём, а на полноценный LEB128 код уходит только если старший бит выставлен.
Проверок границ (if _e > end: break) тоже стало меньше: соседней группе фиксированных полей хватает одной на всю группу.
Колонка, которую генератор не умеет встраивать, весь этот путь не отменяет: она читается своим замыканием внутри того же цикла, остальные остаются встроенными. Полностью на чтение по одному полю декодер переключается, только если встроить не удалось ни одной колонки. Сейчас туда попадает вложенность глубже четырёх уровней: каждый следующий уровень заметно раздувает генерируемый код, и дальше это перестаёт окупаться.
Сколько это даёт
Приём нужно мерить отдельно от клиента целиком, иначе в результат попадут сеть, работа сервера и создание обёрток над строками. Payload заранее лежит в памяти. Я сравниваю два пути одного декодера — чтение по одному полю и оптимизированный путь — и полностью материализую результат внутри таймера.
Равенство выдачи проверяется до замеров, порядок путей чередуется между сериями, предыдущий результат освобождается перед следующим, gc на время измерения выключен. Оба декодера пересобираются каждую серию со сбросом кэшей модуля: иначе кэш конвертеров приходит тёплым с прошлой серии и замер показывает попадания там, где у одного запроса были бы промахи. Ниже — медианы по одиннадцати сериям после двух прогревочных.
Что в таблицы не входит: время построения самого декодера. Оба собираются до старта таймера, поэтому ни разбор схемы, ни compile(), ни exec в цифры не попали — здесь измеряется только горячий проход по готовому payload. Это и не сравнение версий aiochlite, и не полное время запроса; стоимость сборки посчитана отдельно сразу после таблиц.
Окружение: AMD Ryzen 7 9800X3D, Linux 6.6 (WSL2), CPython 3.14.5, ClickHouse 26.3.17.110, aiochlite 1.7.0, коммит 31e76b0, BENCH_ROUNDS=11 (по умолчанию в скрипте 7). Абсолютные значения на другой машине будут другими.
Три схемы по 200 тысяч строк:
Схема | по одному полю | оптимизированный путь | Ускорение |
|---|---|---|---|
| 199.18 мс | 108.28 мс | 1.84x |
| 231.60 мс | 133.23 мс | 1.74x |
| 228.74 мс | 27.67 мс | 8.27x |
Первая целиком фиксированной ширины, во второй в середине стоит String, третья широкая и без единого конвертера.
Средняя строка здесь для меня главная. Это та самая схема, на которой промежуточный вариант со списком функций и сегментами не давал ничего: серии перекрывались полностью: в одних прогонах результат был чуть выше 1.00x, в других — чуть ниже. Значит, стоимость вызова сегментов на такой строке не была основной — основной была диспетчеризация, и убрал её уже генератор.
Три точки на трёх схемах ещё не показывают зависимость, поэтому ширину строки я померил отдельно, при прочих равных: N одинаковых колонок UInt64. Здесь я оставил 100 тысяч строк: прогон девяти схем по одиннадцать серий на 200 тысячах получался слишком долгим. Абсолютные миллисекунды с таблицей выше не сопоставимы: там другой объём и другие типы. Этот прогон нужен только ради формы зависимости от числа колонок.
Колонок | по одному полю | оптимизированный путь | Ускорение |
|---|---|---|---|
2 | 24.74 мс | 3.74 мс | 6.62x |
3 | 34.62 мс | 4.12 мс | 8.40x |
4 | 42.05 мс | 4.46 мс | 9.43x |
5 | 52.30 мс | 5.23 мс | 10.00x |
6 | 62.61 мс | 5.56 мс | 11.26x |
7 | 72.22 мс | 6.38 мс | 11.33x |
8 | 81.07 мс | 6.91 мс | 11.73x |
9 | 93.18 мс | 7.84 мс | 11.89x |
10 | 104.17 мс | 8.84 мс | 11.79x |
Здесь видно и то, чего не видно в первой таблице. Чтение по одному полю растёт линейно — примерно 10 мс на каждую добавленную колонку, как и ожидается от цепочки вызовов на ячейку. Текущий путь растёт тоже, но примерно на 0.6 мс: iter_unpack всё равно создаёт по объекту Python на каждое поле, и от этого никуда не деться.
Отсюда же видно, откуда разрыв между 1.84x в первой таблице и 10.00x здесь: в первой схеме есть DateTime, его преобразование остаётся на обоих путях и разбавляет выигрыш. По той же причине широкая числовая схема из первой таблицы, где конвертеров нет, уходит в 8.27x. Отдельным экспериментом вклад конвертеров я не измерял.
Коэффициент растёт и выходит на плато. Отдельные приросты между соседними строками читать не стоит, они шумят от прогона к прогону; воспроизводится форма. Линейной зависимости ускорения от числа колонок здесь не видно, и это согласуется с ожидаемым: накладные расходы на вызов раскидываются сразу на всю строку, а работа на каждое значение остаётся, struct всё равно создаёт объекты и складывает их в кортежи. Выделение памяти отдельно я не мерил, так что это объяснение кривой, а не её разбор.
На смешанных схемах есть ещё одно ограничение: если поля фиксированной ширины перемежаются строками и массивами, распаковкой дело не ограничивается. Заметная часть стоимости лежит в полях переменной длины — там на каждое значение приходится длина, срез и decode, и склеивать тут нечего.
Скрипт замера — benchmarks/decode_paths.py; он сравнивает оба пути на одном payload и печатает разброс по сериям. Ссылка ведёт на main, потому что сам скрипт появился в репозитории уже после релиза 1.7.0. Измеряемый им код — ровно тот, что в теге.
Сколько стоит сама кодогенерация
Раз она из таблиц исключена, её надо посчитать отдельно: иначе видно ускорение, но не цену подготовки декодера. Медиана по сотням повторов:
Схема | холодная сборка | схема уже в кэше |
|---|---|---|
| 75.5 мкс | 1.01 мкс |
| 231.7 мкс | 0.43 мкс |
При первом построении схемы выполняются:
разбор схемы и выбор шаблонов для каждой колонки;
генерация исходного текста;
compile(). Результат этих шагов кэшируется на 256 схем. Для повторного запроса остаются:execготового code object, чтобы получить свежий объект функции;сборка конвертеров. Конвертеры пересобираются потому, что у них своё состояние на запрос: каждый помнит преобразованные значения. Кэшировать их вместе с кодом — значит оставить эту память жить между запросами. Вторая строка таблицы дешевле первой в тёплом состоянии именно поэтому: конвертеров в ней нет, остаётся один
exec.
Для оценки окупаемости считать надо экономию относительно старого пути. На смешанной схеме разница между путями — 98.37 мс на 200 тысяч строк, то есть около 0.49 мкс на строку. Холодные 75.5 мкс окупаются примерно на 150 строках. Оценка грубая: у пути с чтением по одному полю тоже есть своя подготовка, и в 75.5 мкс она не учтена. Но порядок понятен — на выдаче в сотню строк с новой для процесса схемой кодогенерация съедает почти весь свой выигрыш, на тысячах уже нет. Схем в приложении обычно немного, и после прогрева подготовка декодера для такой схемы занимает около микросекунды на запрос.
Почему Cython или C-расширение всё ещё могут быть быстрее
Всё описанное выше сокращает накладные расходы, но не устраняет фундаментальные ограничения построчного декодирования:
на каждую строку по-прежнему создаётся список или кортеж;
каждое число становится отдельным объектом Python;
datetime,Decimal,UUIDи вложенные структуры требуют дополнительных объектов;типы переменной длины всё равно приходится разбирать последовательно. Скомпилированное расширение может держать весь цикл внутри C, Cython или Rust: читать смещение, проверять границы, разбирать сразу несколько строк или колонку целиком и возвращать результат только после крупной операции. А если результат удаётся оставить в колоночном буфере вроде NumPy или Arrow, объект Python на каждую ячейку и вовсе не понадобится. Поэтому у хорошо спроектированного C-, Cython- или Rust-расширения здесь остаётся пространство для дальнейшего ускорения.
Но сам по себе Cython скорости не гарантирует. Если расширение зовут из Python на каждое поле и оно возвращает объекты Python, повторяя ту же мелкую диспетчеризацию, преимущество скомпилированного кода толком не используется.
Решает здесь не столько язык, сколько то, где находится горячий цикл:
Плохо масштабируется: Python loop → compiled function → Python object Python loop → compiled function → Python object Python loop → compiled function → Python object Лучше: Python → compiled function, обрабатывающая группу полей → Python tuple Ещё лучше для bulk workloads: Python → compiled function, обрабатывающая блок или колонку → contiguous buffer
Отдельно про event loop. Само декодирование синхронное: внутри разбора одного блока нет await, и пока он идёт, цикл событий управления не получает. В stream() блоков много, и между ними декодер ждёт следующий кусок ответа, так что переключения там есть. А вот fetch() собирает весь ответ и разбирает его одним проходом, и вот он держит цикл событий до конца.
Ни склейка, ни кодогенерация точек переключения не добавляют и не убирают — они только сокращают время между ними. Если важна задержка соседних задач, помогает stream() или явная отдача управления между порциями, за которую придётся заплатить частью пропускной способности.
Ещё несколько оптимизаций вокруг горячего участка
Крупное убрал, дальше пошла мелочь: срезы буфера, лишний вызов на каждую строку, создание datetime из целого числа.
bytes и memoryview для разных операций
memoryview не обязателен для чтения без копирования. bytes тоже поддерживает протокол буфера, и unpack_from(data, offset) читает из него без среза и без копирования. Разница между ними на этой операции в пределах шума:
Операция |
|
|---|---|
| разница в пределах шума |
срез + | путь через |
Абсолютные значения здесь мало полезны: они заметно меняются между машинами и даже окружениями на одной машине. Гораздо устойчивее воспроизводится соотношение; померить у себя можно так:
.venv/bin/python -m timeit -s 'import struct; S=struct.Struct("<QId"); b=S.pack(42,1,3.5)*50; mv=memoryview(b)' 'S.unpack_from(b, 20)' .venv/bin/python -m timeit -s 'import struct; S=struct.Struct("<QId"); b=S.pack(42,1,3.5)*50; mv=memoryview(b)' 'S.unpack_from(mv, 20)' .venv/bin/python -m timeit -s 'b=b"hello world"*3; mv=memoryview(b)' 'b[2:20].decode()' .venv/bin/python -m timeit -s 'b=b"hello world"*3; mv=memoryview(b)' 'mv[2:20].tobytes().decode()'
memoryview позволяет избежать копирования при взятии среза — на этом держатся int.from_bytes для 128-битных значений, UUID и FixedString. Но «не копирует» и «быстрее» — не одно и то же: выигрыш зависит от размера среза и от того, что делается со срезом дальше. На коротких значениях создание представления само стоит заметно, и копия маленького bytes может оказаться дешевле.
Поэтому reader держит две ссылки на один payload:
self._raw = data self._data = memoryview(data)
Сам payload не дублируется, memoryview лишь ссылается на существующий буфер. Заведён он под конкретные операции — 128-битные значения, UUID и FixedString; на коротких срезах он проигрывает копии bytes.
Когда дублирование дешевле общей функции
Общая вспомогательная функция добавляет вызов Python на каждую строку, и на большом результате это может стоить дороже, чем несколько продублированных строк кода. Поэтому цикл, отдающий списки, и цикл, отдающий кортежи, в декодере выписаны по отдельности, хотя различаются одной строкой.
Здесь я сознательно нарушил DRY. Дублировать такой код имеет смысл только после профилирования и вместе с тестами, которые не дают двум веткам разъехаться по поведению.
Кэширование преобразованных значений
Преобразование сырого целого в datetime, timedelta или Decimal может быть дороже самой распаковки. Если значения повторяются, кэш эту стоимость убирает. Но промах не бесплатен, и обе стороны сделки оказались крупнее, чем я ожидал.
Замер на схеме UInt64, DateTime('UTC'), Decimal(18, 2), 200 тысяч строк, те же условия, что и выше:
Кардинальность | С кэшем | Без кэша | Изменение времени |
|---|---|---|---|
200 меток времени и 100 цен на 200 тысяч строк | 23.55 мс | 117.86 мс | −80.0% |
Все значения различны | 166.44 мс | 115.72 мс | +43.8% |
По этим замерам видно три вещи.
Кэш окупается только там, где есть что кэшировать. Выигрыш тем больше, чем большую долю работы занимает преобразование. Здесь через конвертер идут две колонки из трёх, поэтому оно доминирует и экономия доходит до 80%. На широкой схеме с одним конвертером из десяти колонок эффект будет меньше, но насколько именно — нужно мерить.
Промахи тоже стоят денег. Полный промах добавил 44% — при том, что раньше на этой же схеме я мерил около 11%. Сам поиск в кэше дешевле не стал — просто остальной декодер ускорился, и та же стоимость поиска стала занимать большую долю времени. Ускорив одну часть, легко сделать заметной соседнюю.
Одного лимита размера недостаточно. Самый неприятный результат за всю историю: lru_cache на 4096 записей при 20 тысячах различных значений оказался медленнее, чем отсутствие кэша, — 224 мс против 174. Объяснение простое: за границей размера каждый поиск промахивается, а каждая вставка кого-то вытесняет, и к стоимости преобразования добавляется стоимость обслуживания кэша.
Поэтому в декодере я выбрал другую политику: заполнить кэш до лимита и очистить его целиком. На четырёх режимах, которые я мерил, она уступила вытеснению в одном: 100 тысяч различных значений в случайном порядке, то есть кардинальность чуть выше границы при равномерных обращениях. Зато при смене рабочего набора она обошла все остальные проверенные политики, а колонка, отсортированная по времени, именно так и выглядит. Это результат на четырёх нагрузках, а не доказанное свойство политики: на своей нагрузке порядок вполне может оказаться другим. Цифры по всем режимам лежат в benchmarks/README.md.
Вторая причина ограничивать кэш — память. Он живёт столько же, сколько запрос, а в stream() это весь результат целиком — при том, что сами строки вызывающий код успевает выбросить. Без границы три миллиона строк с уникальными метками времени давали пик в 1033 МиБ, с границей — 24 МиБ (это пик по tracemalloc, а не RSS процесса).
Итог зависит от данных: для монотонно растущих отметок времени или уникальных Decimal кэш может только мешать, для округлённого времени и повторяющихся цен — сильно помогать. Скрипт замера — benchmarks/converter_cache.py; свою нагрузку здесь лучше померить.
Что сломало мои первые замеры
Отдельный бенчмарк декодера отвечает на вопрос «сколько». Но по ходу работы я несколько раз получал правдоподобные цифры и делал из них неправильный вывод, так что к методике добавились три пункта.
Проверять надо не только удачную схему. У меня разброс между схемами вышел от 1.84x до 8.27x, и обе цифры честные — просто в одной схеме есть колонка с конвертером, а в другой нет. Выбери я для статьи любую одну, вывод получился бы совсем другим.
Состояние между сериями — на этом я сам обжёгся. Если декодер собран один раз и переиспользуется, его кэш конвертеров приходит в следующую серию тёплым, и замер показывает попадания там, где у одного запроса были бы одни промахи. Из-за этого один из замеров с высокой кардинальностью сначала выглядел как выигрыш, хотя на деле кэш только замедлял декодирование. Всё, что кэширует, должно пересобираться внутри цикла замера.
И без записанного окружения — CPU, ОС, версии Python и сервера, коммит кода — цифры нельзя ни перепроверить, ни сравнить со своими.
Сравнивать разные клиенты сложнее. Совпадать у них должны формат передачи, режим чтения (потоковый или с буферизацией), тип возвращаемых строк, уровень материализации и объём работы внутри таймера. Сравнение с настройками по умолчанию показывает, какой результат пользователь получит без дополнительной настройки, но не доказывает преимущество одной архитектуры парсера над другой.
Что из этого следует
Pure Python не означает, что каждый низкоуровневый шаг должен выполняться кодом на Python. Быстрые скомпилированные примитивы в CPython уже есть; здесь мне помогло давать им больше работы за один вызов: построить план декодирования один раз по схеме, склеить соседние поля фиксированной ширины, а в пределе скомпилировать под схему весь цикл.
Дальше начинается то, что этим не лечится: объект на каждое значение, конвертеры, поля переменной длины. Мне это стало видно только после того, как я измерил каждую часть отдельно.
Самым полезным оказался замер схемы, на которой склейка не дала выигрыша. Подкручивать размер группы дальше было бесполезно: мешала диспетчеризация, оставшаяся в цикле. Именно этот неудачный замер и подсказал следующий шаг. Тот же разбор нужен и перед своим модулем расширения: сначала понять, какая работа осталась в Python, и только потом переносить крупный цикл в C или Rust.
Похожие решения
В июле 2026 ClickHouse выпустил @clickhouse/rowbinary. Проблема там та же — диспетчеризация на каждую ячейку, а решение устроено так: кроме обычного универсального декодера пакет поставляет примитивы чтения по типам и инструкцию SKILL.md. По ней coding agent пишет парсер под конкретную схему запроса. Обычного кодогенератора с фронтендом и бэкендом авторы не писали: генерацией занимается агент, а пакет даёт ему кирпичи и описание, как их складывать.
Там тоже генерируется парсер под схему, но в другой момент. Там парсер пишется заранее и становится обычным кодом приложения: его хранят в репозитории, тестируют и правят вместе со схемой, зато на генерацию можно потратить сколько угодно времени. Здесь парсер собирается в рантайме по заголовку ответа, кэшируется на время жизни процесса и исчезает вместе с ним. Хранить и синхронизировать нечего, но и генерация должна быть быстрой: платит за неё первый запрос новой схемы, и растянуть эту плату не на что.
Где посмотреть код
Основная реализация — aiochlite/converters/rowbinary.py: по схеме собирается либо один Struct для последовательности строк после заголовка, либо исходный текст цикла, который затем компилируется; конвертеры применяются к нужным позициям после распаковки. В репозитории также есть тесты для вложенных типов, Nullable, DateTime64, пропуска неиспользуемых значений и повреждённых payload.
Установить библиотеку можно из PyPI:
pip install aiochlite

