Skip to content

Native: column frames for to_arrow, to_columns, to_dataframe and to_polars, read by the decoder the JVM wheel uses (refs #312) - #331

Draft
tae898 wants to merge 6 commits into
release/26.11.1from
perf/312-native-columnar-frames
Draft

tae898 wants to merge 6 commits into
release/26.11.1from
perf/312-native-columnar-frames

Conversation

@tae898

@tae898 tae898 commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

For users the values do not change: to_arrow, to_columns, to_dataframe and to_polars of the native wheel return what they returned, now read from typed column frames in the library (the layout the JVM wheel's ColumnBatcher writes) by the same Python decoder as the JVM wheel, without one Python dict per row. A few behaviours are made the same on both wheels on purpose, and I list each below. This is a draft: the CI evidence is not in yet, and I make no speed claim (the measurement is the read-path profile study and a later A/B).

Refs #312 (part of #315). The base is release/26.11.1 for 26.11.1.post1. #326 (the cherry-picks of #320 and #321) is still open, so tests/test_cross_wheel_golden.py and the #321 version of columns.py are not in this base. This branch deletes columns.py (nothing calls it any more), which will conflict with #326 in that file only: take the deletion. The golden test must pass on both wheels once #326 is in; I will rerun after it merges.

How it works:

  • arcadedb_n_next_col (ArcadeNative.java, encoder in the new ColumnFrames.java, signature in _native/lib.py) returns one frame of the cursor. A frame ends at batch_size rows or when what it will hold reaches about 1 MiB, counted as the larger of the values read and rows times columns times 9 bytes, so a schemaless result with another property in every row does not allocate gigabytes for a frame. No Arrow C ABI export.
  • src/arcadedb/_columnar.py holds the one decoder and the merge of frames for both wheels. results.py keeps its JPype transport and the text of its json columns; _native/colframes.py is the native glue.
  • Frame types: i8, f8, b1, dt/dtu, f4v/f8v, str, dec. What a fixed layout cannot carry without changing a value is a per-column json frame column: nested lists and maps, records, BigInteger, a mixed column, a ragged vector, a DATE or time-zone-aware value in to_dataframe, and an integer beyond 2**53 next to a float. Nothing falls back to the JSON row path.
  • The first reply of a query is still JSON for the row APIs, and the caller already holds those rows as dicts. A columnar read writes them as one more frame in Python (colframes.py), by the rules of the library's encoder and through the same decoder, so the cursor keeps no rows and a column has one type however a result splits between the first reply and frames. The JSON reply now names the properties that were float[] or double[] (fv, dv), because a list of numbers in a row cannot say so. This frame goes away with the JSON reply.

Behaviour that is now the same on both wheels (the old behaviour was not kept):

  • A vector column is a column of declared arrays (float[], double[]) at any result size. A LIST of doubles whose values fit float32 was a float32 matrix in to_columns for 256 rows or fewer on native and a list above that, and a list on the JVM wheel; it is a list of lists everywhere now.
  • The type of a column belongs to the whole result, not to where a batch ends. A batch that lacks the column or holds only nulls for it takes the type of the others (to_columns and to_arrow). float32 next to float64 vectors of one size unify to a fixed-size list of double, and a dimension that changes (inside a batch or between two) gives list<double> on both wheels (the widening is exact); never text.
  • An integer beyond 2**53 next to a float stays exact: a json column, so to_columns holds the exact ints and floats and to_arrow has no common numeric type and gives exact text. The JVM ColumnBatcher used to widen it to a double and lose digits.
  • A result with no rows and columns=[...] returns those columns (empty lists in to_columns, null-typed columns in to_arrow). Without columns it is {} or an empty table.
  • to_columns arrays are writable (the decoder copies once; they were read-only for a single frame).
  • to_dataframe: a vector cell is a numpy float32 array (a row of the to_columns matrix, no Python list per row) or None for a null vector (never a vector of NaN), and a DATETIME column is datetime64[us]. to_polars gives Array(Float32, n) for a vector column, null where the vector is null. to_columns keeps its matrix, where a null vector is a row of NaN, as before. All of this is in the docstrings of both wheels.
  • batch_size below 1 raises ValueError; a batch_size of 2**31 or more is clamped to what a C int holds (it used to wrap to zero on native and loop forever). An empty frame from an open cursor is an ArcadeDBError, so a library bug cannot loop, and a library older than the wheel raises an error that says so instead of an AttributeError.

Differences from the JSON route that stay: a float[] column in to_arrow is fixed_size_list<float32> (it was list<double>, the same values) and a column of only nulls is a string column (it was the Arrow null type).

What stays: the old JSON read path (_drain_dicts, loads_tagged, arcadedb_n_next_bin) stays in this PR for to_list, to_json_list, row iteration and the first reply. Retiring it for the row APIs is a later PR, once these frames are measured. No path materialises the table twice: frames are decoded and merged once.

Not changed: to_columns still promotes an int column with nulls to float64 with NaN (the documented pandas rule). So an int column that has a null in one frame next to another frame of integers beyond 2**53 is rounded in to_columns while to_arrow keeps int64. That is the rule of the API, not a frame boundary effect, and I did not change it here.

Tests (CI only; locally I compiled every changed Python file, ran pyflakes, black, isort and bandit, and called the decoder and the first-reply frame logic directly on hand-built data):

  • tests/test_column_frames.py, both wheels: the decoder on hand-built frames, then one stored table through every columnar API, plus result sizes 255 to 513, huge and invalid batch sizes, empty results with columns, writable arrays, declared and list vectors at 10, 300 and 3000 rows, double arrays after rows read past the first reply, a property that is numeric from row 30,000 on with batch_size=25_000, exact integers next to floats at 10 and 300 rows, a changing vector dimension, two cursors at once, a cursor closed early and 300 closed unread.
  • tests/test_native_column_frames_reference.py, native only: the old assembly is copied into the test as a private reference and each case compares the two tables column by column with NaN equal to NaN and None equal only to None (arrays are compared as arrays, vector cells must be numpy float32); a frame budget test for a result with another property in every row; an empty frame is an error; a huge batch_size.

What I could not verify: no Java compile and no native build here (CI builds the library), so the Java is read by eye only. I did not construct a query that fails in the middle of a stream, so that path is covered by reading the code, not by a test.

🤖 Generated with Claude Code

tae898 and others added 3 commits November 9, 2026 18:58
…olars, read by the decoder the JVM wheel uses (refs #312)

The native library answers a read of a cursor as one column frame of about 1 MiB (arcadedb_n_next_col, ColumnFrames.java), in the layout that ColumnBatcher writes for the JVM wheel: int64, float64, bool bytes, epoch milliseconds or microseconds, float32 vectors, Arrow strings (int32 offsets and a UTF-8 blob), DECIMAL as text, and a tagged JSON column for what no fixed layout carries without changing a value (nested values, records, a mixed column, an integer beyond 2**53 next to a float). arcadedb._columnar holds the one decoder and the merge of frames that both wheels call; results.py keeps only its JPype transport and the text of its json columns.

The first reply of a query is still JSON for the row APIs, and the cursor keeps those rows (at most 256, until its next read by row) so that a columnar read replays them into its first frame. A result that the first reply held whole has no cursor: its dicts go through the old rules once, as one more chunk. The old JSON read path stays for to_list, to_json_list and row iteration in this commit.

Tests: tests/test_column_frames.py (the decoder on hand-built frames, and the stored table through every columnar API, both wheels), tests/test_native_column_frames_reference.py (the old route copied into the test as the reference, native only).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… keeps the rest for the next call

With batch_size=64 the first frame took 64 of the 256 replayed rows and the cursor then dropped the other 192 (test_to_arrow_multi_batch: 108 rows of 300). The library now puts back the unread tail of the head after each frame. Also: the reference test named a property "by" (an SQL keyword), and the dataframe test expected None where pandas 3 shows nan for a missing string.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tae898 and others added 3 commits November 9, 2026 21:31
… belong to the whole result, bounded frames

The first reply of a query stays what the caller already holds as dicts and is written as one more frame by Python (colframes.py) by the rules of the library's encoder, so the cursor keeps no rows (the replay kept up to 256 live Result objects per open cursor and read them after the snapshot the JSON reply had taken). The JSON reply names the properties that were float[] or double[] (fv, dv): a vector column is a declared array, not a list that happens to fit float32, at any result size. columns.py, with its size-dependent guess, is gone.

Frames: the row limit is clamped to a C int (a batch_size of 2**31 or more wrapped to zero and looped), a frame ends by the larger of the values read and rows x columns x 9 bytes (a schemaless result with a property per row allocated gigabytes against a 1 MiB estimate), an empty frame from an open cursor is an error, a NULL reply and a library older than the wheel are errors with a message.

Types: float32 next to float64 or another dimension unify to a list of float (never text), a frame of only nulls takes the type of the others, an integer beyond 2**53 next to a float stays exact in a json column on both wheels (ColumnBatcher too), an empty result with columns= returns those columns on both wheels, to_columns arrays are writable, to_dataframe vectors are numpy float32 cells and datetimes datetime64[us] on both wheels (documented).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…n to_dataframe

A dimension that changes inside a frame makes a json column (list<double>), so the same change between two frames now gives list<double> too (it gave list<float>); float32 next to float64 of one size is fixed_size_list<double>. In to_dataframe a vector cell is a numpy float32 array or None for a null, on both wheels (a NaN row, which to_columns keeps in its matrix, read as a vector of NaN where the old route had a missing value); to_arrow and to_polars already kept the null.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…atever the writer put there

The decoder set no value for the null rows of a vector column and read what the frame held, which is zeros in a hand-built frame and NaN only where the writer chose NaN; a zero row reads as a real zero vector. decode_columns_frame now sets the null rows to NaN itself, so the single-frame matrix, the matrix after a merge with other frames and the first-reply frame agree, and the array stays writable.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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