Repository navigation
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
Conversation
…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>
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
For users the values do not change:
to_arrow,to_columns,to_dataframeandto_polarsof the native wheel return what they returned, now read from typed column frames in the library (the layout the JVM wheel'sColumnBatcherwrites) 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.1for26.11.1.post1. #326 (the cherry-picks of #320 and #321) is still open, sotests/test_cross_wheel_golden.pyand the #321 version ofcolumns.pyare not in this base. This branch deletescolumns.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 newColumnFrames.java, signature in_native/lib.py) returns one frame of the cursor. A frame ends atbatch_sizerows 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.pyholds the one decoder and the merge of frames for both wheels.results.pykeeps its JPype transport and the text of itsjsoncolumns;_native/colframes.pyis the native glue.i8,f8,b1,dt/dtu,f4v/f8v,str,dec. What a fixed layout cannot carry without changing a value is a per-columnjsonframe column: nested lists and maps, records, BigInteger, a mixed column, a ragged vector, a DATE or time-zone-aware value into_dataframe, and an integer beyond 2**53 next to a float. Nothing falls back to the JSON row path.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 werefloat[]ordouble[](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):
float[],double[]) at any result size. A LIST of doubles whose values fit float32 was a float32 matrix into_columnsfor 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.to_columnsandto_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) giveslist<double>on both wheels (the widening is exact); never text.jsoncolumn, soto_columnsholds the exact ints and floats andto_arrowhas no common numeric type and gives exact text. The JVMColumnBatcherused to widen it to a double and lose digits.columns=[...]returns those columns (empty lists into_columns, null-typed columns into_arrow). Withoutcolumnsit is{}or an empty table.to_columnsarrays are writable (the decoder copies once; they were read-only for a single frame).to_dataframe: a vector cell is a numpyfloat32array (a row of theto_columnsmatrix, no Python list per row) orNonefor a null vector (never a vector of NaN), and a DATETIME column isdatetime64[us].to_polarsgivesArray(Float32, n)for a vector column, null where the vector is null.to_columnskeeps its matrix, where a null vector is a row of NaN, as before. All of this is in the docstrings of both wheels.batch_sizebelow 1 raisesValueError; abatch_sizeof 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 anArcadeDBError, so a library bug cannot loop, and a library older than the wheel raises an error that says so instead of anAttributeError.Differences from the JSON route that stay: a
float[]column into_arrowisfixed_size_list<float32>(it waslist<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 forto_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_columnsstill 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 into_columnswhileto_arrowkeeps 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 withcolumns, 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 withbatch_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 numpyfloat32); a frame budget test for a result with another property in every row; an empty frame is an error; a hugebatch_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