Memory Layout
Зачем свой формат в памяти
Традиционные структуры данных (ArrayList в Java, Vec в Rust, list в Python) хранят объекты поштучно — каждый элемент может лежать в произвольном месте кучи. Для аналитики это катастрофа: SIMD-инструкции процессора работают с непрерывными блоками, а кэш-линейка (64 байта) загружает мусор вместо полезных данных.
Apache Arrow определяет формат колоночных данных в памяти — фиксированную раскладку буферов, одинаковую для любого языка и рантайма. Один и тот же массив Arrow можно передать из Python в Rust без сериализации — просто через указатель.
Arrow — это не формат файлов. Это стандарт представления данных в оперативной памяти. IPC и Feather — сериализация этого представления на диск или в сеть, но каноническая форма Arrow живёт в RAM.
Validity Bitmap
Любой Arrow-массив может содержать NULL-значения. Вместо sentinel-значений (NaN, -1, пустая строка) Arrow использует отдельный validity bitmap — по одному биту на каждый элемент массива.
Правила:
- Бит
1— значение валидно, бит0— NULL - Биты упакованы least-significant bit first (LSB): бит 0 элемента i находится в байте
i / 8, позицияi % 8 - Размер bitmap:
ceil(N / 8)байт, дополненный нулями до кратности 8 байт - Если массив не содержит NULL — bitmap может отсутствовать (null_count = 0)
Бит 0 (LSB) ←————————————————→ Бит 7 (MSB)
Проверка NULL для элемента i:
is_valid = (bitmap[i / 8] >> (i % 8)) & 1
Операция O(1) — никакого сканирования, никаких хэш-таблиц. Один сдвиг и одно побитовое AND.
Массивы фиксированной ширины
Простейший случай — примитивные типы: Int32, Int64, Float64, Boolean. Каждый элемент занимает одинаковое количество байт.
Буферная раскладка массива Int32:
- Validity bitmap —
ceil(N / 8)байт, padded до 64 байт - Data buffer —
N × 4байт (4 байта на Int32), padded до 64 байт
Доступ к элементу i:
value = *(int32_t*)(data_buffer + i * 4)
O(1) — прямое вычисление адреса. Нет индексов, нет B-деревьев, нет хэш-таблиц. Процессор загружает одну кэш-линейку (64 байта = 16 элементов Int32) — соседние элементы уже в кэше.
Массивы переменной ширины
Строки и бинарные данные не имеют фиксированного размера. Arrow использует три буфера:
- Validity bitmap — как для фиксированных типов
- Offsets buffer — массив из
N + 1смещений (Int32 для Utf8/Binary, Int64 для LargeUtf8/LargeBinary) - Data buffer — конкатенация всех значений без разделителей
Значение элемента i — это срез data[offsets[i]..offsets[i+1]]. Длина = offsets[i+1] - offsets[i].
Offset для NULL-элемента равен offset следующего элемента — длина строки 0. Байты в data buffer не определены (не обязательно пустые). Всегда проверяйте validity bitmap перед чтением.
Выравнивание и padding
Arrow требует выравнивания каждого буфера по 64 байтам. Это не произвольное число:
- Кэш-линейка x86/ARM: 64 байта
- AVX-512 регистр: 64 байта
- Выравнивание устраняет unaligned access penalties и позволяет SIMD обрабатывать данные без копирования
Правила padding:
- Начало каждого буфера выровнено по 64 байтам
- Конец каждого буфера дополнен нулями до кратности 64 байт
- Между буферами — padding до следующей 64-байтовой границы
Base Address (aligned to 64B)
Адрес начала массива. Выровнен по границе 64 байт (младшие 6 бит адреса = 0).Next Buffer (aligned to 64B)
Следующий буфер начинается ровно на границе 64 байт. Никаких промежутков.Arrow спецификация рекомендует 64-байтовое выравнивание (и большинство имплементаций следуют этому), но минимальное требование — 8 байт. Если вы пишете собственный producer — используйте 64 байта. Если читаете чужие данные — будьте готовы к 8-байтовому выравниванию.
O(1) Random Access
Вся конструкция Arrow оптимизирована под одну операцию: прямой доступ к элементу по индексу за O(1).
Для фиксированного типа (Int32):
address = base + buffer_offset + i * element_size
Для переменного типа (Utf8):
start = offsets[i]
end = offsets[i + 1]
value = data[start..end]
Никаких linked list, B-tree, hash table. Два умножения и одно сложение — даже без branch prediction miss.
Сравнение с другими форматами:
| Формат | Random Access | Сканирование | NULL-проверка |
|---|---|---|---|
| Arrow | O(1) — pointer arithmetic | SIMD-friendly, 64B aligned | O(1) — bitmap bit check |
| Parquet | O(page) — нужно декодировать страницу | Требует decompress + decode | Repetition/Definition levels |
| JSON | O(N) — линейный поиск | Сериализация/десериализация | Проверка типа значения |
| CSV | O(N) — поиск разделителя | Парсинг каждой строки | Сравнение со строкой-маркером |
Boolean Array: особый случай
Boolean в Arrow — не массив байтов. Каждое значение занимает 1 бит, упакованный так же, как validity bitmap (LSB-first).
Буферная раскладка:
- Validity bitmap —
ceil(N / 8)байт - Data buffer —
ceil(N / 8)байт (значения, не байты)
Array: [true, false, true, true, NULL, false, true, true]
Validity: 0b 1110_1111 = 0xEF (бит 4 = 0 → NULL)
Data: 0b 1110_1101 = 0xED (бит 4 undefined)
Boolean массив из 1 миллиона элементов: 125 KB данных + 125 KB bitmap = 250 KB. Тот же массив в Java (boolean[]) — 1 MB (1 байт на элемент). Arrow экономит 4× памяти на boolean-колонках.
Ключевые выводы
- Validity bitmap хранит NULL-маску по 1 биту на элемент (LSB-first), проверка NULL — O(1) побитовая операция
- Фиксированная ширина (Int32, Float64) — один data buffer, доступ за O(1) через
base + i × size - Переменная ширина (Utf8, Binary) — offsets buffer (N+1 элементов) + data buffer, доступ за O(1) через
data[offsets[i]..offsets[i+1]] - 64-байтовое выравнивание обеспечивает SIMD-совместимость и совпадение с кэш-линейкой процессора
- Boolean упакованы по 1 биту — не 1 байт, что даёт 8× плотность хранения