Skip to content

feat(ipc): Skip unchanged dictionaries in ArrowIpcWriterWriteArrayViewWithDictionaries - #979

Draft
rustyconover wants to merge 12 commits into
apache:mainfrom
rustyconover:feat/ipc-writer-skip-unchanged-view-dictionaries
Draft

rustyconover wants to merge 12 commits into
apache:mainfrom
rustyconover:feat/ipc-writer-skip-unchanged-view-dictionaries

Conversation

@rustyconover

@rustyconover rustyconover commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #928 and #968. This branch is based on #968 as rebased onto #928 (feat/ipc-writer-write-array-view-with-dictionaries-on-928), so until those merge, the diff here includes their commits. Only the last commit, "Skip unchanged dictionaries in ArrowIpcWriterWriteArrayViewWithDictionaries", is new. I'll rebase once they land; it's a draft until then.

ArrowIpcWriterWriteArrayViewWithDictionaries() (#968) writes every dictionary before every RecordBatch. Its input is borrowed, so it can't do what ArrowIpcWriterWriteArrayStream() does (#928): keep the previous array alive and compare dictionaries by buffer address. A producer that writes batches one at a time with the same dictionary therefore sends that dictionary again with every batch. In a file, where a dictionary ID may be written only once, it can't write a second batch with dictionaries at all.

The writer now keeps a copy of each dictionary this function writes, keyed by dictionary ID, and compares the next batch's values with it:

  • First it checks whether the borrowed values are physically identical to the copy (ArrowArrayViewCompare(..., NANOARROW_COMPARE_IDENTICAL, ...)). This is the common case, e.g. when the producer builds each batch's dictionary the same way, and it needs no copy.
  • Otherwise it copies the values into the same canonical form as the saved copy (zero offset, buffers sized to the values, cleared bitmap padding bits, computed null count) and compares the two. A slice, an unknown null count or garbage in padding bits therefore doesn't make an unchanged dictionary look changed.
  • A dictionary whose values are unchanged isn't written. A changed one is written in full, as before (in a file that returns ENOTSUP, as before).
  • A dictionary whose values contain a nested dictionary that was written is written again even if its own values are unchanged, since a reader resolves a DictionaryBatch against the nested dictionaries current at that point.
  • Values that can't be copied (ArrowArrayAppendStorageFromArrayView() doesn't support dense unions) are written every time, as before.
  • ArrowIpcWriterWriteDictionaryBatch() drops the saved copy for its ID, so a dictionary written that way is never skipped by mistake, and ArrowIpcWriterWriteSchema() drops all of them.

ArrowIpcWriterWriteArrayStream() is unchanged. The function's documentation now describes this, including the cost: a copy of each dictionary that is written.

Cost: 1000 batches of 2048 rows, dictionary<int32, string> with 10,000 values, written to a memory buffer (Release, Linux aarch64; best of 3):

#968 this PR
Dictionary unchanged 126 ms, 258.5 MB 23 ms, 8.6 MB
Dictionary grows by 16 values a batch 205 ms, 458.3 MB 531 ms, 458.3 MB
Unchanged, 100 values 6.4 ms, 11.0 MB 5.1 ms, 8.4 MB

A dictionary that changes on every batch costs a copy per batch. The follow-up, #980, writes a dictionary that only grew as a delta, which brings that row to 346 ms and 9.2 MB.

Tests (writer_test.cc):

  • WriteArrayViewWithDictionariesSkipsUnchanged: separately built arrays with dictionaries [foo, bar], [foo, bar], [foo, baz], [foo, baz] write two DictionaryBatches. Read back with nanoarrow and Arrow C++.
  • WriteArrayViewWithDictionariesComparesValues: an int64 dictionary [1, null, 3], then the same values as a slice of a longer array with an unknown null count and padding bits set, write one DictionaryBatch.
  • WriteArrayViewWithDictionariesNested: when only a nested dictionary changes, both it and the dictionary that refers to it are written (IDs 1, 0, 1, 0 over three batches).
  • WriteArrayViewWithDictionariesAfterWriteDictionaryBatch: a dictionary written with ArrowIpcWriterWriteDictionaryBatch() in between isn't mistaken for the saved copy.
  • WriteArrayViewWithDictionariesUncopyableValues: a dense union dictionary is written for every batch.
  • WriteArrayViewWithDictionariesInFile now writes a second, unchanged batch to the file, checks that a changed dictionary returns ENOTSUP, and reads the file back with Arrow C++.

Against #968's writer.c, the first three new tests and the updated file test fail.

ctest passes (Linux aarch64, Debug, 878 tests with NANOARROW_BUILD_TESTS_WITH_ARROW), and 403 tests under ASan/UBSan with clang. The writer tests are clean under valgrind. The changed files are formatted with clang-format 16.0.6, the pre-commit version.

This PR's code and description were written by AI (Claude Code) and reviewed by a human maintainer.

🤖 Generated with Claude Code

rustyconover and others added 9 commits October 4, 2026 11:42
ArrowIpcEncoderSetMessageMetadata() was ignored by
ArrowIpcEncoderEncodeSimpleDictionaryBatch(); encode it like the schema and
record batch messages do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The IPC Field for a dictionary-encoded column describes the dictionary value
type, so its children must come from schema->dictionary rather than from the
index schema. This replaces the ENOTSUP for dictionary values with children
(e.g., struct dictionaries whose children are themselves dictionary-encoded).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add ArrowIpcEncoderSetDictionaryReplacement() to declare the
DICTIONARY_REPLACEMENT feature in an encoded Schema message. The writer
enables it in stream mode, where a dictionary ID may be written more than
once, and the reader no longer rejects streams that declare it (the decoder
already replaces dictionaries).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On main, a file could contain exactly one DictionaryBatch, so a schema
with more than one dictionary-encoded field couldn't be written, and the
footer schema couldn't resolve its dictionary IDs when finalizing the
file. Record the dictionary encodings of the footer schema and track the
dictionary IDs written to the file: a second batch for the same ID
returns EINVAL because IPC files don't support dictionary replacement
(deltas remain unsupported when writing files).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When dictionary values contain dictionary-encoded children, a reader needs
the nested dictionaries before it can decode the DictionaryBatch that
references them. Keep assigning IDs in schema order but write each
dictionary after the dictionaries nested in its values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ArrowIpcWriterWriteArrayStream() wrote every dictionary before every
record batch. Skip a dictionary when it has the same length, offset,
null count, and buffer addresses (recursively) as the corresponding
dictionary of the previous array, which is the case when a producer
shares one dictionary among the arrays of a stream (e.g., the nanoarrow
IPC reader).

Comparing addresses is only safe while both arrays are alive, so the
writer keeps the previous array (if it has dictionaries) until the next
one is written. ArrowIpcWriterWriteDictionaryBatch() only sees a
borrowed view that it can't keep alive and always writes the dictionary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Write the integration files back using ArrowIpcWriterWriteArrayStream(),
which writes dictionaries (writing the arrays with
ArrowIpcWriterWriteArrayView() alone does not), and enable the write
roundtrip for the dictionary, nested dictionary, shared dictionary, and
extension (whose dict-extension column is dictionary-encoded) files.
Because the reader shares dictionaries among the batches it decodes,
these roundtrips also exercise skipping unchanged dictionaries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a repeated file dictionary ID

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Oct 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.04265% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.59%. Comparing base (d01fdbb) to head (57c4810).

Files with missing lines Patch % Lines
src/nanoarrow/ipc/writer.c 81.40% 4 Missing and 33 partials ⚠️
src/nanoarrow/ipc/encoder.c 75.00% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #979      +/-   ##
==========================================
+ Coverage   78.45%   78.59%   +0.13%     
==========================================
  Files         106      106              
  Lines       17064    17255     +191     
  Branches     2021     2087      +66     
==========================================
+ Hits        13388    13562     +174     
+ Misses       2432     2423       -9     
- Partials     1244     1270      +26     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…message-metadata

# Conflicts:
#	src/nanoarrow/ipc/writer.c
@rustyconover
rustyconover force-pushed the feat/ipc-writer-skip-unchanged-view-dictionaries branch from 8c765f2 to 3aa0b89 Compare October 20, 2026 03:48
rustyconover and others added 2 commits October 20, 2026 18:24
…message-metadata

# Conflicts:
#	src/nanoarrow/ipc/writer.c
#	src/nanoarrow/ipc/writer_test.cc
…wWithDictionaries

ArrowIpcWriterWriteArrayViewWithDictionaries() wrote every dictionary before
every RecordBatch: its input is borrowed, so it couldn't compare a dictionary
with the previous batch's by address as ArrowIpcWriterWriteArrayStream() does.
The writer now keeps a copy of each dictionary it writes and compares the next
batch's values with it, writing only dictionaries that changed (and those whose
values contain a nested dictionary that was written). In a file, batches whose
dictionaries are unchanged can now be written this way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rustyconover
rustyconover force-pushed the feat/ipc-writer-skip-unchanged-view-dictionaries branch from 3aa0b89 to 57c4810 Compare October 20, 2026 22:34

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.

2 participants