Arrow Memory Layout: буферы, bitmap и RecordBatch
Три буфера Arrow-массива
Каждая колонка (массив) в Arrow состоит из комбинации трёх типов буферов:
- Validity Bitmap — битовая маска: 1 = значение присутствует, 0 = NULL
- Data Buffer — сами значения, уложенные непрерывно
- Offset Buffer — используется только для типов переменной длины (строки, списки)
Не каждый тип использует все три буфера. Числовые массивы обходятся двумя (validity + data), строковые — тремя (validity + offsets + data).
Fixed-Width типы: числа и boolean
Для типов фиксированного размера (Int32, Float64, Boolean) layout прямолинеен:
Ключевой момент: позиция NULL в data buffer не пуста. Там может быть 0 или мусор. Определяет null именно validity bitmap, не значение в буфере. Это позволяет выделить буфер один раз и не сдвигать данные при появлении NULL.
Alignment и padding
Arrow требует, чтобы каждый буфер был выровнен по 64 байт (размер cache line). Если данные не кратны 64, в конце добавляется padding. Для массива из 5 значений Int32 (20 байт) буфер занимает 64 байт — 44 байта padding.
Variable-Width типы: строки и бинарные данные
Строковые типы (Utf8, LargeUtf8, Binary) добавляют третий буфер — offset buffer, который хранит начало каждого значения в data buffer.
Формула длины i-го значения: offsets[i+1] - offsets[i]. Для NULL-значений длина 0 (offset не сдвигается). Offset buffer всегда содержит N+1 элементов, где N — количество значений.
Utf8 vs LargeUtf8
Utf8 использует int32 offsets — максимальный суммарный размер данных 2 GB. LargeUtf8 использует int64 offsets — до 2^63 байт. DataFusion по умолчанию работает с Utf8, переключение на LargeUtf8 требуется только для колонок с огромным суммарным объёмом текста.
Null Handling: дизайн-решение Arrow
В Arrow NULL — не значение, а свойство позиции. Это принципиальное отличие от SQL (где NULL — специальное значение) и от Pandas (где NaN используется для NULL в числовых колонках).
Преимущества подхода Arrow:
- Любой тип может быть nullable без изменения data layout
- Null-check — побитовая операция, не сравнение с sentinel
- Агрегации (SUM, AVG) пропускают NULL через bitmap mask, не проверяя каждое значение
// Проверка null в arrow-rs
let array: Int32Array = /* ... */;
// Побитовая проверка -- O(1) per element
if array.is_null(2) {
// позиция 2 -- NULL
}
// Количество null -- popcount на bitmap
let null_count = array.null_count(); // быстрая побитовая операция
Если в массиве нет NULL, validity bitmap может отсутствовать (null_count = 0). Arrow оптимизирует этот случай — нет overhead на bitmap для полностью заполненных колонок.
RecordBatch: группа колонок
RecordBatch объединяет несколько Arrow-массивов (колонок) одинаковой длины под общей схемой:
RecordBatch — неизменяемая структура. Фильтрация, проекция и другие операции создают новый RecordBatch, ссылающийся на подмножество буферов оригинала (zero-copy slice).
Потоковая обработка в DataFusion
DataFusion обрабатывает данные как поток RecordBatch. Каждый оператор (FilterExec, ProjectionExec, HashJoinExec) получает RecordBatchStream, трансформирует каждый batch и передаёт следующему оператору.
// Концептуальная модель потока
trait RecordBatchStream {
fn schema(&self) -> SchemaRef;
async fn next(&mut self) -> Option<Result<RecordBatch>>;
}
Типичный размер batch — 8192 строк. Это баланс между:
- Достаточно строк для SIMD-векторизации
- Достаточно мало, чтобы batch помещался в L2 cache (~256 KB)
- Низкая латентность первого результата (потоковая модель)
Буферы на практике: arrow-rs
В Rust-реализации Arrow (arrow-rs) создание массивов выглядит так:
use arrow::array::{Int32Array, StringArray, RecordBatch};
use arrow::datatypes::{Schema, Field, DataType};
use std::sync::Arc;
// Создание колонок
let ids = Int32Array::from(vec![1, 2, 3, 4]);
let names = StringArray::from(vec!["Иван", "Мария", "Пётр", "Анна"]);
let salaries = Int32Array::from(vec![Some(80000), Some(95000), None, Some(72000)]);
// Схема
let schema = Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("name", DataType::Utf8, false),
Field::new("salary", DataType::Int32, true), // nullable
]);
// RecordBatch
let batch = RecordBatch::try_new(
Arc::new(schema),
vec![Arc::new(ids), Arc::new(names), Arc::new(salaries)],
).unwrap();
assert_eq!(batch.num_rows(), 4);
assert_eq!(batch.num_columns(), 3);
Обратите внимание: Field::new("salary", DataType::Int32, true) — третий аргумент true означает nullable. Arrow валидирует соответствие: если поле non-nullable, а в данных есть NULL, конструктор вернёт ошибку.
Итоги
- Arrow-массив состоит из буферов: validity bitmap + data buffer (+ offset buffer для строк)
- Fixed-width типы: два буфера, доступ по индексу за O(1)
- Variable-width типы: три буфера, offset buffer определяет границы значений
- NULL — свойство позиции (bitmap), не значение в data buffer
- RecordBatch = схема + набор колонок одинаковой длины
- DataFusion обрабатывает данные как поток RecordBatch (~8192 строк на batch)