feat(layout): add a paged layout encoding to bound flatbuffer tables - #9448
feat(layout): add a paged layout encoding to bound flatbuffer tables#9448wsulais wants to merge 3 commits into
Conversation
|
Thanks for the contribution! While I think this is an ok solution I think a better solution is fixing flatbuffer verifier. There's no reason that the verifier has to run at all in principle. However, the code generated by flatbuffers uses unchecked access which means that without upfront validation you can access invalid memory address. Having option in flatbuffer generator or having an alternative flatbuffer binding generator would a lot better long term solution |
|
Thanks for clarifying! I think there are three separate ceilings here:
(2) is the case where paging is necessary rather than preferable.
Not a contrived geometry: block size comes from a byte target with So on the 2.7B-row artifact specifically, your fix opens it as-is and paging is an improvement rather than a requirement. Aside from that, what paging buys that a verifier fix doesn't: the layout's own footer contribution goes from ~34 MB to approximately nothing, and subtrees are fetched on descent instead of at open. On the path, as I read it there are three:
(3) needs no codegen change and no format change, and is the same verify-on-descent idea as this PR applied to verification instead of to the file. I suggest that we keep this design for those use cases (e.g. LiDAR scans) and pursue one of the paths in a different PR. |
Merging this PR will degrade performance by 11.8%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | WallTime | words_gather_scalar[65536] |
8.2 µs | 9.4 µs | -12.47% |
| ❌ | WallTime | words_gather_dispatch[1024] |
8 ns | 9 ns | -11.11% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing wsulais:feat/hierarchical-layout-pages (fc9979b) with develop (b825c4f)
Footnotes
-
89 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
fc9979b to
c3844ef
Compare
A layout is a single recursive flatbuffer, verified on open against `max_tables` (1,000,000 by default). Each chunk layout costs one table, so past the limit the writer produces a file the default reader cannot open: the write reports success and the error appears at open. Add a `vortex.paged` encoding that reports no inline children and holds its subtree in a segment as a nested `Layout` flatbuffer, resolved on descent. Each page is verified in its own right, so the table and depth limits apply per page rather than per file. Opt in via `ChunkedLayoutStrategy::with_page_size` and `WriteStrategyBuilder::with_page_size`; off by default, since a reader without the encoding registered can traverse a paged layout tree but cannot scan it. No flatbuffer schema change. `LayoutChildren` stays synchronous: the descent happens in the reader, whose evaluation methods already return futures. Each page carries its subtree's chunk boundaries so scan planning, which is synchronous, needs no page reads and split boundaries stay identical to the inline layout. The uniform case is kept symbolic, since one integer per chunk would otherwise dominate the footer. Measured on a 2,714,867,981-point file that 0.83.0 writes and then cannot open: 1,215,246 inline layout nodes become 1,264, the footer shrinks from 53.5 MB to 19.6 MB, the file grows 0.001%, and it opens in 54 ms. Assisted-by: Claude Opus 5 (claude-opus-5) via Claude Code 2.1.232 Signed-off-by: Wael Sulais <waelsulais@mailbox.org>
A page's nested `Layout` flatbuffer was interned into a dictionary of its own, carried in the page's metadata, because `FooterSerializer` builds the file's dictionary while serializing the root layout — after the page segments have been written. Thread one `LayoutContext` through the write instead. `LayoutWriterContext` carries it, `FooterSerializer` already accepted one, and the file writer now hands the same context to both, so a page indexes into the file's single dictionary and stores no copy. On the read side `LayoutBuildContext` and `LayoutDeserializeArgs` carry the layout read context, so a paged layout resolves its subtree against the footer's dictionary rather than against its own metadata. Worth about 32 bytes per page. For 13,312 chunks the serialized layout drops from 125.9 kB to 73.6 kB at page_size 8, 10.1 kB to 6.7 kB at 128, and 3.4 kB to 2.8 kB at 1024; the file-size overhead of paging at page_size 8 falls from 11.3% to 9.0%, and at 1024 to 0.08%. Also ports the page-size sweep used for those numbers as an ignored test. Assisted-by: Claude Opus 5 (claude-opus-5) via Claude Code 2.1.232 Signed-off-by: Wael Sulais <waelsulais@mailbox.org>
`PagedReader` issued its segment request while constructing an evaluation future rather than when that future is polled. Callers build those futures without knowing whether they will await them: `ZonedReader::pruning_evaluation` builds its data child's pruning future eagerly and drops it unawaited when its own zone map already pruned the range. Pages were therefore read for ranges that were then discarded. Move the request inside the future. A pruning future that is dropped unawaited now issues no segment request, where it previously requested every page intersecting the row range. In-memory timings do not move, because `open_buffer` makes a segment request nearly free. What this avoids is a round trip per pruned page against remote storage. Assisted-by: Claude Opus 5 (claude-opus-5) via Claude Code 2.1.232 Signed-off-by: Wael Sulais <waelsulais@mailbox.org>
c3844ef to
d755591
Compare
Refs #9447. Draft, for design feedback — the unresolved questions in that issue could change the shape of this.
Problem
A layout is a single recursive flatbuffer, verified on open against
VerifierOptions::max_tables(1,000,000 by default). Each chunk layout costs one table, so past the limit the writer produces a file the default reader cannot open, and the error appears at open rather than at write.Change
A
vortex.pagedlayout encoding. It reports no inline children and holds one segment containing its subtree as a nestedLayoutflatbuffer, resolved on descent. Each page is verified separately, somax_tablesandmax_depthapply per page.Opt-in, off by default:
No flatbuffer schema change. Three existing properties make that possible: layout encodings are an open registry with ids resolved at read time via
Footer.layout_specs;Layout.metadatais opaque per encoding; andLayoutReader's evaluation methods already return futures.LayoutChildrenstays synchronous.child()cannot await a segment fetch, but a page reports no children, so the descent happens in the reader instead.Results
2,714,867,981 rows, 22 leaf columns, 49,152-row blocks. Same data, block size and encodings in both arms; only the layout differs. The paged file was verified row-identical to the inline one through Arrow at three ranges, including the last 60,000 rows.
Too many tables.Page size sweep, 65,536 rows × 13
i32columns at 64-row blocks, 13,312 chunks.root tablesandmax/pageare the smallest table budget each message verifies under, by bisection. Each arm is asserted to read every row back and to select the same rows under a filter.max/pageispage_size + 1at every setting, soroot ≈ chunks/page_size + 3×columns. Below page_size 32 the per-page encoding dictionary dominates and the file grows measurably.Split boundaries
batchesis 1024 in every arm, including inline.register_splitsis synchronous, so a page cannot fetch its subtree to answer it. The writer promotes the subtree's chunk boundaries to the page instead. One integer per chunk would dominate the footer — 26 kB of a 35 kB layout at page_size 128 — so the uniform case is symbolic:That took page_size 128 from 35.4 kB to 10.1 kB. Non-uniform subtrees use explicit offsets and stay exact.
A page answers
register_splitswithout touching its segment, giving a whole subtree the benefitis_indivisiblegives a single child. A test asserts zero segment requests during planning.One encoding dictionary for the file
FooterSerializerbuilds the file's dictionary while serializing the root layout — after the page segments are written — so a page initially carried a dictionary of its own. The second commit threads oneLayoutContextthrough the write instead:LayoutWriterContextcarries it,FooterSerializeralready accepted one, and the file writer hands the same context to both.LayoutBuildContextandLayoutDeserializeArgsgained alayout_read_ctxso the read side resolves a page against the footer's dictionary.Worth ~32 bytes per page. For 13,312 chunks the serialized layout drops from 125.9 kB to 73.6 kB at page_size 8, 10.1 kB to 6.7 kB at 128, and 3.4 kB to 2.8 kB at 1024. The page-size table above reflects this; paging's file-size overhead at page_size 1024 is now 0.08%.
Tests
12 new, 393 passing across
vortex-layoutandvortex-file.Not included
write_pageis generic; onlyChunkedLayoutStrategycalls it.Limitations
ForeignLayout::dyn_new_readererrors). Hence opt-in and off by default.display_treeanddepth_first_traversalstop at a page.ChunkedLayoutStrategygained a public field, breaking struct-literal construction.ChunkedLayoutStrategycollects every chunk layout before paging any. Measured cost of paging is +5% peak RSS on an identical 100 M-row write, 1,565 MB against 1,642 MB.FlatReaders and 13,321 array futures, one per chunk in the file, where a paged layout builds 105.ZonedReader::pruning_evaluationconstructs its data child's pruning future eagerly, soChunkedReaderwalks every chunk in the range building a reader — andfilter_evaluationan array future, each issuing asegment_source.request()— before the zone map has pruned anything. Pruning stops those futures being polled, not being constructed; a page collapses them behind one deferred future.SplitBy::RowCountgives identical counts, so this is the evaluation path, notregister_splits. Deferring construction inChunkedReaderwould give the inline path the same win; filed separately as Selective filter cost scales with total chunk count, not with rows selected #9449.open_buffer. The read-amplification argument for larger pages is untested against object storage.Notes
Built on
develop.cargo fmt --checkandcargo clippy --all-targetsare clean for both crates, though the workspace rustfmt config wants nightly for several options and I ran stable.Can split into "the encoding" and "wire it into the file writer" if that reviews better.
AI assistance disclosure
Developed with agentic AI assistance — Claude Opus 5 (
claude-opus-5) via Claude Code 2.1.232, recorded as anAssisted-by:trailer on the commit. Written test-first. Every number above comes from a reproducible run rather than an estimate; claims I could not attribute are marked as such.