Skip to content

Add stream_arrow: read Arrow data through the C Data Interface - #115

Open
haoxu0 wants to merge 3 commits into
ml-explore:mainfrom
haoxu0:arrow-c-stream
Open

haoxu0 wants to merge 3 commits into
ml-explore:mainfrom
haoxu0:arrow-c-stream

Conversation

@haoxu0

@haoxu0 haoxu0 commented Sep 15, 2026

Copy link
Copy Markdown

Adds dx.stream_arrow(data): a stream over Arrow data, handed in through the Arrow C Data Interface.

I hit this training with MLX and wanting a HuggingFace dataset as the input. There is no way to feed one to an mlx-data pipeline today: you convert to JSONL and lose the column projection, or build the stream yourself outside mlx-data and lose the pipeline ops. But a HF dataset is already Arrow — the cache is Arrow IPC and Dataset.data is a pyarrow.Table — so the conversion is the only reason it does not work, and it should not need to exist.

import mlx.data as dx
from datasets import load_dataset

ds = load_dataset("...", split="train")
dset = dx.stream_arrow(ds.data.to_batches(1024))

Parquet, which is what #114 asked about, arrives through the same door:

import pyarrow.parquet as pq

dset = dx.stream_arrow(pq.ParquetFile("train.parquet").iter_batches(1024))

The implementation is straightforward. We reuse the arrow abi.h interface. And implement the ArrowStream similar to CsvStream.

One Sample per record batch, not per row

A batch's column already is a contiguous typed buffer, so it is adopted through the existing Array(type, shape, shared_ptr<void>); Array is unmodified. Emitting one sample per row would mean splitting those buffers and letting Batch reassemble them — over 200,000 rows × 3 columns, 22.8 ms via to_pylist(), 33.5 ms via per-row buffer reads, against 0.02 ms for whole columns.

The consequence, stated in the docstring: the leading dimension is Arrow's batch size, not one this stream chose. Set it at the reader (iter_batches(batch_size=1024)); .batch(n) on top re-batches. This is the part that does not look like the rest of mlx-data, where every stream yields a row, and it is question 1 below.

Ownership is one aliasing shared_ptr per column, pointing at that column's buffer while holding the whole batch, so the batch's release callback fires exactly once, when the last column Array is dropped.

Verification

Built against main at 2f431e9: full cmake build including the Python bindings, 0 errors and no new warnings; 24 Python tests pass (10 new, 14 existing); tests/test_arrow_stream.cpp checks values, slice offset, and that release fires exactly once. That test builds its own ArrowArrayStream out of plain C, so it needs neither Arrow nor a test framework, and it is the only place ownership is checked — counting release calls needs a producer you control, which the Python suite cannot reasonably hand-roll.

It is the first entry in tests/CMakeLists.txt (previously # coming soon), behind a new MLX_BUILD_TESTS that is off by default. Off by default plus a CI that builds through setup.py develop and runs only the Python suite would have meant the test never actually ran here, so linux_build_on_commit now configures it explicitly and runs ctest. Linux only — the test is plain C, so there is nothing platform-specific for the mac job to add.

I did not trust a suite that passed first try, so I reverted the offset arithmetic to void* values = base;: both offset tests fail and the offset-0 case still passes. Arrow's offset is in values, not bytes, and a sliced batch has a non-zero one — ignoring it silently returns the wrong rows, which is the easiest thing to get wrong in this interface.

Open questions

Nulls. Genuinely separate — huggingface datasets can use None because its rows are Python objects; a Sample has no equivalent.

Adds `dx.stream_arrow(data)`, a stream over anything exposing the Arrow PyCapsule
interface -- pyarrow Table / RecordBatch / RecordBatchReader /
ParquetFile.iter_batches, and also polars, DuckDB, Lance and pyiceberg, none of
which need a special case.

No new dependency. The Arrow C Data Interface is a header, not a library:
`mlx/data/core/arrow/abi.h` is vendored verbatim from the spec and its only
include is <stdint.h>. Nothing is linked, nothing is found by CMake, and the
build is unchanged when Arrow is absent -- so this needs none of the
find_package / MLX_HAS_* machinery the other optional readers use.

One Sample per record batch, not per row. Arrow already stores a column as one
contiguous typed buffer, so a batch's column *is* an Array and is adopted
through the existing `Array(type, shape, shared_ptr<void>)` constructor rather
than copied. `Array` is not modified. Emitting rows instead would mean splitting
those buffers into values and letting Batch reassemble them, undoing work Arrow
already did: measured in Python over 200,000 rows x 3 columns, row-at-a-time is
22.8 ms and taking the columns whole is 0.02 ms.

The consequence callers need to know, and which the docstring states: the
leading dimension of every array is Arrow's batch size, so `.batch(n)` on top of
this re-batches and gives back the saving. Set the size where the reader is
created, e.g. `iter_batches(batch_size=1024)`.

Ownership is one aliasing shared_ptr per column pointing at that column's
buffer while holding the whole batch, so the batch's release callback fires
exactly once, when the last column Array is dropped -- not per column, and not
while a column is still referenced.

What it refuses rather than approximating:

  - boolean, because Arrow's is a bitmap, one bit per value, and adopting the
    buffer would read 8 values as 1
  - strings and nested types, which need more than one buffer and do not fit a
    single Array
  - a column with nulls, because a Sample has nowhere to record which values are
    absent
  - reset(), because an ArrowArrayStream is consumed once and the interface has
    no rewind

Arrow's `offset` is in values, not bytes, and a sliced batch has a non-zero one;
ignoring it silently returns the wrong rows, so both tests cover it.

Tests: `python/tests/test_arrow.py` (10 cases against real pyarrow) and
`tests/test_arrow_stream.cpp`, which builds its own ArrowArrayStream so it needs
neither Arrow nor a test framework, and counts release calls to check ownership.
The latter is the first entry in tests/CMakeLists.txt, behind a new
MLX_BUILD_TESTS option that is off by default.

Verified by reverting the offset arithmetic: both the C++ and the Python slice
test fail, and the offset-0 test still passes.
MLX_BUILD_TESTS is off by default, and CI builds through `python setup.py
develop` and runs only the Python suite, so tests/test_arrow_stream.cpp would
never have run upstream. Configure it explicitly in linux_build_on_commit and
run it under ctest.

Linux only: the test builds its own ArrowArrayStream out of plain C, so there is
nothing platform-specific for the mac job to add.
It argues for the design rather than documenting the class, and the argument
belongs in the PR and the commit message, not in a header a reader consults for
how to use the type.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant