Apache Arrow — спецификация колоночного представления данных в оперативной памяти плюс набор библиотек, которые это представление читают и пишут. Что такое Apache Arrow: таблицы в памяти — это описание того, как значения лежат в буферах, как отдельно хранится признак пропуска и почему две программы могут обменяться таблицей без копирования. Формат задаёт раскладку байтов, библиотека PyArrow даёт к ней доступ из Python, а IPC переносит те же буферы между процессами.
Формат в памяти, библиотека PyArrow и IPC — разные уровни
Разговор про Arrow сбивается, когда три разных предмета называют одним словом. Спецификация формата описывает, как устроены буферы: где лежат значения, где битовая карта пропусков, в каком порядке байты. Библиотека — это код, который создаёт такие буферы и умеет их читать; в Python это PyArrow, в других языках свои реализации. Протокол обмена описывает, как те же буферы упаковать в поток или файл, чтобы передать другому процессу.
Документация формата на arrow.apache.org описывает Arrow как колоночный формат для аналитических задач в памяти. В том же документе сказано, что значения в массиве могут быть семантически пустыми независимо от того, примитивный это тип или вложенный. Это утверждение относится к уровню формата: признак пропуска существует для любого типа, и реализации обязаны его учитывать.
PyArrow — отдельный уровень. Это библиотека-обёртка, которая предоставляет объекты Array, ChunkedArray, Table, Schema и функции чтения-записи. Она реализует спецификацию, но сама спецификацией не является. Когда в тексте говорят «Arrow не копирует данные», речь идёт о свойстве формата: если две библиотеки договорились о раскладке, они могут смотреть в одни и те же байты. Конкретная библиотека может этого и не делать.
IPC — третий уровень. Документация формата указывает, что формат IPC по умолчанию использует little-endian. Это правило сериализации: при передаче между процессами и машинами байты укладываются в младшим-вперёд порядке. Это правило распространяется на сериализованные потоки и файлы IPC; метаданные схемы указывают порядок байтов их данных.
Parquet — колоночный формат файла для хранения таблиц. Arrow описывает представление данных в памяти. Читатель файла может декодировать Parquet в Arrow-таблицу; способ и объём загрузки зависят от выбранного инструмента и столбцов.
Практический вывод из разделения уровней: вопрос «поддерживает ли инструмент Arrow» надо уточнять. Инструмент может читать и писать IPC, может принимать Arrow-таблицу в памяти через API, может использовать Arrow внутри для обмена между движками. Три ответа означают три разные вещи.
| Уровень | Что описывает | Где живёт | Кто отвечает |
|---|---|---|---|
| Формат в памяти | Раскладку буферов, bitmap, порядок байтов | Оперативная память процесса | Спецификация Arrow |
| Библиотека | Объекты Array, Table, Schema, чтение и запись | Код в процессе | PyArrow и аналоги |
| IPC | Упаковку буферов в поток или файл | Канал, файл, сокет | Спецификация IPC |
| Parquet | Колоночный файл со сжатием и row groups | Диск и объектное хранилище | Спецификация Parquet |
Разделение объясняет и типовой сценарий обмена. Движок читает Parquet с диска, собирает Arrow-таблицу в памяти, отдаёт её другой библиотеке через Arrow C Data Interface, та считает результат и возвращает новую таблицу. Диск, память и канал участвуют в одной цепочке, и на каждом переходе действуют свои правила.
Array, ChunkedArray, Table и схема типов
Array — одномерная последовательность значений одного типа с известной длиной. Внутри неё лежат буферы: буфер значений фиксированной ширины, буфер битовой карты пропусков и, для переменных типов вроде строк, буфер смещений. Длина массива хранится отдельно от буферов.
ChunkedArray — последовательность нескольких Array с одинаковым типом. Она появляется естественным образом: чтение большого файла идёт порциями, и каждая порция становится отдельным массивом. Логически ChunkedArray выглядит как один столбец, физически это список кусков. Различие важно для копирования: несколько кусков требуют непрерывного представления при передаче в библиотеку, которая такого требования придерживается.
Table — именованный набор столбцов с общей схемой. Каждый столбец таблицы представлен ChunkedArray. Схема описывает имена полей, их типы и признак допустимости пропуска. Схема — часть данных: две таблицы с одинаковыми значениями и разными типами столбцов дадут разные результаты при объединении или записи.
Типы в Arrow заданы явно и не совпадают один в один с типами языков. Целое число имеет ширину и знак: int8, int16, int32, int64 и беззнаковые варианты. Число с плавающей точкой имеет ширину. Текстовые значения представлены типом utf8, а произвольные последовательности байтов — типом binary. Отдельно существуют типы для дат, времени, интервалов, десятичных чисел, списков, структур и словарей. Схема фиксирует выбранный вариант, и при обмене обе стороны должны его понимать.
У примитивного массива с пропусками есть дополнительный буфер — битовая карта; у Union и run-end encoded представлений устройство отличается. Документация формата описывает нумерацию битов как least-significant bit, то есть бит с младшим значением идёт первым. Для массива без пропусков реализация может не выделять карту вовсе; в документации сказано, что представление такого отсутствующего буфера зависит от реализации, и в C++ он может быть обозначен нулевым указателем. Так реализация может сэкономить память, сохраняя смысл формата.
Схема типов решает и вопрос совместимости. Два инструмента договорятся об обмене, если их представления типов совпадают. Целое int32 и целое int64 — разные типы, и обмен между ними потребует преобразования. utf8 и binary — тоже разные типы. Проверка схемы перед обменом дешевле, чем отладка испорченных значений после него.
| Объект | Что содержит | Число буферов | Типичный источник |
|---|---|---|---|
| Array | Значения одного типа, длина, карта пропусков | Один или несколько | Порция чтения, результат вычисления |
| ChunkedArray | Список Array одного типа | По числу кусков | Столбец таблицы после чтения файла |
| Table | Столбцы и схема | По числу столбцов и кусков | Результат чтения или вычисления |
| Schema | Имена, типы, признак допустимости пропуска | Метаданные | Описание таблицы |
Полный разбор [10, null, 30] с validity bitmap 0x05
Возьмём учебный пример: массив nullable int32 со значениями 10, пропуск, 30. Длина равна трём, число пропусков равно одному. Логические слоты значений выглядят как 10, 0, 30 — в слоте пропуска лежит ноль, и этот ноль не является значением. Битовая карта определяет, содержит ли слот действительное значение.
Битовая карта для трёх элементов занимает один байт. Биты идут от младшего к старшему, и для нашего массива они равны 1, 0, 1: первый элемент присутствует, второй пропущен, третий присутствует. В шестнадцатеричной записи первый байт карты равен 0x05. Проверка: младший бит даёт единицу, следующий бит даёт ноль, третий бит даёт единицу, сумма равна 1 + 4 = 5.
Буфер значений в little-endian для int32 содержит три четырёхбайтовых слота: 0a000000 для десяти, 00000000 для слота пропуска, 1e000000 для тридцати. Число 30 в шестнадцатеричной записи — 1e. Порядок байтов младшим-вперёд означает, что младший байт значения идёт первым.
Чтение массива идёт по двум буферам одновременно. Для позиции с индексом i проверяется бит i в карте. Если бит равен единице, значение берётся из буфера значений. Если бит равен нулю, результат чтения — пропуск, и содержимое слота значений игнорируется. Именно поэтому ноль в слоте пропуска не превращается в значение ноль.
Ошибка, которую этот пример показывает наглядно: если проигнорировать битовую карту, из массива 10, пропуск, 30 получится 10, 0, 30, и смысл пропуска изменится. Среднее по такому массиву посчитается по трём числам вместо двух. Фильтр «значение равно нулю» ошибочно выберет строку, в которой был пропуск. Поэтому чтение буфера значений нужно согласовывать с битовой картой.
Вот полный учебный разбор в виде текстового блока, который можно сохранить рядом с кодом:
Массив: nullable int32, логические значения [10, null, 30]
length = 3
null_count = 1
validity bits = [1, 0, 1]
первый байт bitmap = 0x05
Буфер значений, little-endian, три слота по 4 байта:
слот 0: 0a 00 00 00 -> 10
слот 1: 00 00 00 00 -> пропуск, содержимое не читается
слот 2: 1e 00 00 00 -> 30
Проверка чтения:
индекс 0: бит 1 -> значение 10
индекс 1: бит 0 -> пропуск
индекс 2: бит 1 -> значение 30
Такой разбор полезен как эталон при отладке. Если библиотека возвращает 10, 0, 30, значит, битовая карта не была прочитана или была прочитана с неверным порядком битов. Если возвращается 10, пропуск, 30, но карта построена с обратным порядком битов, ошибка проявится на массивах, где пропуски стоят в разных позициях.
Расчёт 12 байт слотов и 1 байта bitmap без padding и metadata
Полезные слоты значений в нашем примере занимают 3 × 4 = 12 байт. Битовая карта для трёх бит занимает 1 байт. Эти два числа описывают только полезную нагрузку двух буферов и не описывают полный размер массива в памяти.
Что остаётся за рамками расчёта. Метаданные описывают тип, длину, число пропусков и ссылки на буферы. Выравнивание требует размещать буферы по адресам, кратным 8 или 64 байтам, как рекомендует документация формата. Padding дополняет буфер до длины, кратной 8 или 64 байтам. Для массива из трёх элементов накладные расходы могут превышать полезную нагрузку, и это нормально: формат рассчитан на большие массивы, где накладные расходы размываются.
Рекомендация выравнивания в документации сформулирована как совет реализациям выделять память по адресам, кратным 8 или 64 байтам, и дополнять буфер до длины, кратной 8 или 64 байтам. Причина практическая: выровненный доступ к памяти быстрее, а векторные инструкции работают с выровненными адресами эффективнее. Padding не несёт данных и не должен интерпретироваться как значения.
Отсюда следует правило оценки памяти. Для массива из n значений фиксированной ширины w полезная нагрузка значений равна n × w байт. Битовая карта занимает ceil(n / 8) байт, если она выделена. К этому добавляются метаданные и padding. Для больших n доля накладных расходов мала; для массивов из нескольких элементов она может быть больше самой нагрузки.
| Компонент | Формула для n = 3, w = 4 | Значение | Входит в полезную нагрузку |
|---|---|---|---|
| Слоты значений | n × w | 12 байт | Да |
| Битовая карта | ceil(n / 8) | 1 байт | Да |
| Метаданные | Зависит от реализации | Не фиксировано | Нет |
| Padding | До кратности 8 или 64 | Не фиксировано | Нет |
Практический смысл расчёта — в оценке порядка величин. Если массив содержит миллион значений int32, полезная нагрузка значений составит около четырёх мегабайт, а карта пропусков — около ста двадцати пяти килобайт. Накладные расходы на метаданные и padding в этом масштабе несущественны. Если массив содержит три значения, те же накладные расходы могут доминировать, и хранить такие массивы по отдельности невыгодно.
Отдельно стоит помнить, что массив без пропусков может не иметь битовой карты вовсе. Документация формата указывает, что массив с нулевым числом пропусков может не выделять карту, и представление такого отсутствующего буфера зависит от реализации. В расчёте памяти для такого массива строка с картой просто исчезает.
Когда zero-copy возможен и когда нужна копия
Обмен без копирования возможен, когда две стороны смотрят в одни и те же байты и одинаково понимают их раскладку. Условий несколько, и все они должны выполняться одновременно.
Первое условие — совпадение раскладки. Обе стороны должны использовать один и тот же формат буферов, одну и ту же ширину типов, один и тот же порядок байтов. Если одна сторона ожидает int64, а данные лежат как int32, потребуется преобразование. Если одна сторона ожидает little-endian, а данные пришли в другом порядке, потребуется перестановка байтов.
Второе условие — непрерывность там, где её требует принимающая сторона. Документация интеграции Arrow с pandas прямо говорит, что несколько кусков всегда потребуют копирования из-за требования непрерывности в pandas. ChunkedArray из нескольких Array не станет одним непрерывным блоком без переупаковки. Однокусковый массив может быть передан без копирования, если остальные условия выполнены.
Третье условие — совместимость типов. Документация Arrow по интеграции с pandas указывает, что преобразования без копирования, при которых не требуется ни выделение памяти, ни вычисления, возможны только в ограниченном наборе случаев. Это честная формулировка: общий формат создаёт возможность, а конкретная пара типов и конкретная библиотека определяют, реализуется ли она.
Четвёртое условие — владение памятью. Буфер должен оставаться действительным, пока на него смотрят обе стороны. Если исходный объект освобождает память, вторая сторона получит висячую ссылку. Поэтому обмен обычно сопровождается соглашением о времени жизни: либо буфер удерживается ссылкой, либо копируется.
| Ситуация | Копирование | Причина |
|---|---|---|
| Один Array, совпадающие типы, совпадающий порядок байтов | Обычно возможно без копии | Раскладка совпадает |
| ChunkedArray из нескольких кусков в pandas | Требуется | Непрерывность в pandas |
| Разные ширины целых типов | Требуется | Нужно преобразование значений |
| Разные представления nullable-типов | Требуется | Нужно согласование признака пропуска |
| Данные читаются из Parquet | Требуется | Формат файла преобразуется в формат памяти |
Практический приём: перед обменом проверяйте схему и число кусков. Если схема совпадает и кусок один, шансы на обмен без копирования высоки. Если кусков много, стоит рассмотреть объединение в один массив заранее, если это оправдано задачей. Если типы различаются, копирование неизбежно, и его лучше запланировать, чем обнаружить по росту памяти.
Отдельный сценарий — обмен между движками через Arrow C Data Interface. Статья DuckDB о совместной работе с Arrow описывает интеграцию, при которой запрос выполняется в DuckDB, а результат передаётся в Arrow потоком без дополнительного копирования. Там же описан приём передачи данных между движками: если операция не поддерживается в одном движке, данные передаются в другой, и обратно, без затрат на повторную сериализацию. Это иллюстрация того, как общий формат памяти позволяет разделить работу между инструментами.
Pandas nullable-типы и Arrow-типы могут различаться
Pandas исторически использовал NumPy-массивы, и признак пропуска в них выражался по-разному для разных типов. Для чисел с плавающей точкой пропуск обозначался значением NaN. Для целых чисел собственного признака пропуска не было, и столбец с пропусками превращался в столбец с плавающей точкой. Для строк использовался объектный тип. В обычных примитивных массивах Arrow признак пропуска хранится в отдельном буфере, и это расхождение приходится согласовывать.
Современный pandas умеет хранить данные на Arrow-бэкенде. Документация pandas по PyArrow указывает, что читатели могут возвращать данные на PyArrow-бэкенде при указании параметра dtype_backend=»pyarrow». Там же сказано, что функции чтения предоставляют параметр engine, который может направить чтение в PyArrow для ускорения работы с источником ввода-вывода. Это два разных параметра с разным действием: один задаёт представление результата, другой выбирает движок чтения.
Тонкость, которую документация pandas подчёркивает отдельно: указание строкового типа через ArrowDtype и указание строкового типа через StringDtype с Arrow-бэкендом дают разные результаты. В документации сказано, что одно не эквивалентно другому. Практический смысл: два столбца, которые выглядят как строковые, могут иметь разные типы, и операция, работающая с одним, может повести себя иначе с другим.
Различие проявляется в трёх местах. Первое — запись и чтение: столбец с одним строковым типом может сохраниться и восстановиться иначе, чем столбец с другим. Второе — сравнение и объединение: типы должны совпадать, иначе потребуется приведение. Третье — обмен с внешними системами: Arrow-тип передаётся в схему, и принимающая сторона увидит именно его.
Проверка типов перед обменом выглядит так: посмотрите на схему Arrow-таблицы и на типы столбцов pandas. Если столбец pandas имеет Arrow-бэкенд, его тип соответствует Arrow-типу, и обмен может пройти без преобразования. Если столбец использует NumPy-бэкенд, соответствие нужно проверять отдельно для каждого типа, и для целых чисел с пропусками преобразование почти наверняка потребуется.
| Что сравниваем | NumPy-бэкенд в pandas | Arrow-бэкенд в pandas |
|---|---|---|
| Целое с пропуском | Требует замены типа или отдельной маски | Хранит пропуск в битовой карте |
| Строка | Объектный тип или строковый тип pandas | Arrow-тип utf8 или large_utf8 |
| Признак пропуска | Разный для разных типов | Битовая карта у обычных примитивных массивов |
| Обмен с Arrow-таблицей | Часто требует копирования | Часто проходит без копирования |
Практический совет: если задача состоит в обмене данными между библиотеками, задавайте Arrow-бэкенд на входе, при чтении. Тогда преобразование произойдёт один раз, на границе чтения, и дальнейшие операции будут работать с согласованными типами. Если задавать бэкенд в середине цепочки, преобразование может произойти несколько раз.
Отличить Arrow в памяти от Parquet на диске
Arrow и Parquet часто упоминают вместе, и это порождает представление, что это два режима одного формата. Это два разных формата с разными задачами. Arrow описывает данные в оперативной памяти, Parquet описывает файл на диске.
Parquet хранит данные колоночно, сжимает их и разбивает на row groups. Сжатие и разбиение нужны для экономии места и для чтения только нужных частей файла. Arrow хранит данные в памяти в том виде, в котором их удобно обрабатывать: без сжатия, с выровненными буферами, с прямым доступом к значениям по индексу.
Отсюда следуют практические различия. Файл Parquet можно читать частично: движок пропускает row groups, которые не нужны для запроса, и читает только нужные столбцы. Уже построенная Arrow-таблица предоставляет доступ к столбцам и срезам; возможность отбора при загрузке определяется источником и API чтения. Сжатие Parquet может уменьшать объём хранения. Представление Arrow даёт прямой доступ к значениям; итоговые размер и скорость зависят от данных, кодеков и операции.
Статья DuckDB о совместной работе с Arrow описывает, как эти свойства складываются в цепочку. Фильтры и проекции проталкиваются в сканирование, и читаются только нужные столбцы и row groups. В примере с набором данных о поездках такси из двадцати столбцов читаются четыре. Arrow-таблица при этом собирается уже из отобранных данных, и лишние байты в память не попадают.
| Свойство | Arrow в памяти | Parquet на диске |
|---|---|---|
| Назначение | Обработка в оперативной памяти | Хранение и передача файлов |
| Сжатие | Отсутствует | Применяется |
| Частичное чтение | Доступ к столбцам и срезам; загрузка зависит от API | Через row groups и столбцы |
| Выравнивание буферов | Рекомендуется | Не является свойством формата |
| Пропуски | Битовая карта в буфере | Кодируются в структуре файла |
| Порядок байтов | Задаётся форматом IPC | Задаётся форматом файла |
Практическое правило выбора: Parquet — для хранения и передачи между системами, Arrow — для обработки в памяти и для обмена между библиотеками внутри одного процесса или между процессами на одной машине. Типовая цепочка выглядит так: файл Parquet читается, преобразуется в Arrow-таблицу, обрабатывается одной или несколькими библиотеками, результат записывается обратно в Parquet.
Отдельно стоит помнить про порядок байтов. Документация формата указывает, что формат IPC по умолчанию использует little-endian. Это правило действует при передаче данных между процессами. Файл Parquet имеет собственные правила кодирования, и при чтении значения преобразуются в представление памяти.
Частые вопросы
Чем Arrow отличается от Parquet?
Это два формата с разными задачами. Arrow описывает данные в оперативной памяти: выровненные буферы, битовые карты пропусков, прямой доступ по индексу. Parquet описывает файл на диске: сжатие, row groups, статистика для пропуска ненужных частей. Типовая цепочка читает Parquet, преобразует в Arrow-таблицу, обрабатывает и записывает обратно.
Что такое PyArrow и нужен ли он отдельно?
PyArrow — библиотека, которая реализует спецификацию Arrow для Python и предоставляет объекты Array, ChunkedArray, Table, Schema. Она устанавливается отдельно от pandas и от других библиотек. Многие инструменты используют PyArrow внутри, поэтому он часто оказывается в зависимостях, даже если вы его не устанавливали явно.
Почему при преобразовании в pandas данные копируются?
Документация интеграции Arrow с pandas указывает, что несколько кусков всегда потребуют копирования из-за требования непрерывности в pandas. Однокусковый массив может быть передан без копирования, если совпадают типы и порядок байтов. Преобразования без копирования возможны только в ограниченном наборе случаев, и конкретную пару типов надо проверять.
Что означает bitmap 0x05 в примере с [10, null, 30]?
Битовая карта хранит по одному биту на элемент, от младшего к старшему. Для массива 10, пропуск, 30 биты равны 1, 0, 1. В шестнадцатеричной записи первый байт равен 0x05, потому что единицы стоят в позициях с весом 1 и 4. Если карту проигнорировать, массив прочитается как 10, 0, 30, и смысл пропуска изменится.
Как понять, что обмен прошёл без копирования?
Проверьте совместимость раскладки, типов и времени жизни буферов. Для обычного преобразования в NumPy-представление pandas важны также отсутствие пропусков и один кусок. Условия зависят от конкретного API; факт использования общих буферов проверяйте средствами выбранной библиотеки.
