Skip to content

perf(scan): merge narrow splits at boundaries only some fields of a struct have - #11128

Merged
robert3005 merged 10 commits into
vortex-data:developfrom
quantcast:cwirt/upstream-soft-split-boundaries
Nov 11, 2026
Merged

robert3005 merged 10 commits into
vortex-data:developfrom
quantcast:cwirt/upstream-soft-split-boundaries

Conversation

@ChristopherWirt

@ChristopherWirt ChristopherWirt commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Problem: A scan splits a file wherever any column's chunk ends, and a split decodes the whole chunk of every column it overlaps. When a struct's fields chunk at unrelated rows, as list elements and dictionary-encoded columns of a wide nested file do, the union of their boundaries leaves spans of a few dozen rows. Successive splits then each decode the same chunks of the other fields. On a 180 MB list-layout file, a full scan held 3.6 GiB of native memory.

Fix: The struct reader marks boundaries that only some of its fields have as soft. SplitBy::LayoutSubSplitting merges spans that end at a soft boundary until they reach 8,192 rows. Boundaries that every field has are never merged, and neither are boundaries from layouts above the struct, so files whose columns chunk alike split as before.

Result: full scan of one file, with Arrow conversion as JNI does it. Live native bytes come from a counting allocator:

Splits Peak live Time Bytes requested
List layout, 180 MB, before 139 (smallest 70 rows) 3.57 GiB 1.7 s 6.2 GB
List layout, 180 MB, after 60 (smallest 8,192 rows) 1.10 GiB 0.6 s 0.99 GB
Default layout, 111 MB, before 59 224 MiB 0.2 s
Default layout, 111 MB, after 48 224 MiB 0.2 s

Changes

  • RowSplits gains soft marks. Each struct field registers into its own RowSplits, and the struct takes their union. Any boundary that not every field has is marked soft.
    • The shared-boundary intersection is computed once. On the existing 256-column split-collection benchmark, the warm median falls from about 338 ms to 3 ms.
    • The struct's validity child is classified like any field, so a nullable struct merges the same way at any depth.
    • Soft marks inherited from nested structs are kept.
    • A single-field struct without validity registers exactly as before.
    • SplitBy::Layout collects positions only (BoundaryMode::PositionsOnly), so it skips the classification.
  • SplitBy::LayoutSubSplitting merges spans ending at a soft boundary until they reach DEFAULT_MIN_SPLIT_ROWS (8,192, the writer's row block), or max_rows if that is smaller. It never merges a span past max_rows. The merge runs before the existing sub-division of wide spans.

Tests

  • Struct reader, over real layouts written with RepartitionStrategy:
    • fields chunked every 1,000 and 3,000 rows mark only the unshared boundaries soft;
    • the same struct under a top-level chunked layout keeps the chunk end hard;
    • LayoutSubSplitting merges soft boundaries and keeps shared boundaries, for nullable and non-nullable structs.
  • merge_small_spans: unit cases plus 1,000 generated cases. They check endpoints, hard boundaries, exact coverage, minimum span length at kept soft boundaries, and the sub-division that follows.
  • The wider split-collection benchmark case, and *_sub_splitting variants that measure the default SplitBy.

On the fork (quantcast#67, same change), these passed: 836 vortex-layout library tests; Clippy for vortex-layout and vortex-file (all targets, all features, -D warnings); pinned rustfmt; the JNI and Spark builds; git diff --check.

This branch cherry-picks that change onto develop without conflicts. On the port, with the follow-up commits: 750 vortex-layout tests; Clippy for vortex-layout and vortex-file (all targets, all features, -D warnings); pinned rustfmt; git diff --check.

Update on 2026-11-09

Merged upstream develop (1c60cac79) cleanly in b7ebcd336, including the existing ty version pin and renamed-rule fix that address the Python lint failure.

Compared missing_stats[16] on the updated PR against the same upstream commit, using the repository's bench profile and locked dependencies on this macOS ARM64 host. Both builds passed. Six runs per version were interleaved in base/PR/PR/base order, each with 10,000 samples of 100 calls. Median of the per-run medians:

Version Time
Upstream develop 770.5 ns
Updated PR 780.9 ns

The local difference is 1.35%, with overlapping per-run median ranges (upstream 765.5–791.7 ns; PR 772.6–792.2 ns). This does not reproduce the earlier 17.96% CodSpeed simulation regression. The pruning implementation, benchmark fixture and lockfile are identical to the current upstream baseline; the PR changes split collection. Native walltime is supporting evidence and does not replace the CI CPU simulation.

Reproduction after building each checkout with cargo bench -p vortex-layout --bench zone_map_prune --no-run --locked:

<benchmark-binary> --bench 'missing_stats::16$' --color never --sample-count 10000 --sample-size 100

Fresh CI and CodSpeed workflows are awaiting maintainer approval. No additional tests, lint or formatting checks were run for this merge.

Not done here

  • The minimum is a row count. Rows with many list elements are larger than rows with few, so a minimum in bytes would be more precise.
  • The minimum isn't tuned beyond 8,192 rows. An earlier variant used less memory at 16,384.

AI assistance

Disclosed per the AI policy: AI tools helped write and review this change. Claude Code prepared this upstream port, issue and PR description at my request. I am the responsible contributor.

🤖 Generated with Claude Code

ChristopherWirt and others added 2 commits November 3, 2026 10:41
…truct have

A scan splits wherever any column's chunk ends, and a split decodes the
whole chunk of every column it overlaps. Where a struct's fields chunk at
unrelated rows, as list elements and dictionary-encoded columns of a wide
nested file do, the union holds spans of a few dozen rows, and successive
splits each decode the same chunks of the other fields.

The struct reader now marks the boundaries only some of its fields have as
soft. SplitBy::LayoutSubSplitting merges spans ending at soft boundaries up
to DEFAULT_MIN_SPLIT_ROWS (8,192, or max_rows if smaller). Boundaries every
field has, its validity boundaries, and those layouts above the struct
register, such as top-level chunk ends, are never merged, so files whose
columns chunk alike split as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Christopher Wirt <[email protected]>
@robert3005 robert3005 added the action/bench-sql Run SQL benchmarks without Vortex Compact on this PR label Nov 4, 2026
@codspeed

codspeed Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

2 warnings

⚠️ Walltime results may be unreliable

Some benchmarks used the Walltime instrument outside CodSpeed Macro Runners, which can make the results inconsistent.

Learn more about Macro Runners

See affected benchmarks

⚠️ 2 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 1914 untouched benchmarks
🆕 7 new benchmarks
⏩ 50 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
🆕 CPU Simulation cold_misaligned_sub_splitting[(16, 64)] N/A 455.9 µs N/A
🆕 CPU Simulation cold[(16, 8)] N/A 213.2 µs N/A
🆕 CPU Simulation warm_sub_splitting[(16, 8)] N/A 602.1 µs N/A
🆕 CPU Simulation warm[(16, 8)] N/A 124 µs N/A
🆕 CPU Simulation cold[(24, 4)] N/A 283.2 µs N/A
🆕 CPU Simulation warm_sub_splitting[(24, 4)] N/A 819.6 µs N/A
🆕 CPU Simulation warm[(24, 4)] N/A 163.7 µs N/A

Comparing quantcast:cwirt/upstream-soft-split-boundaries (7157b3b) with develop (cb613ac)

Open in CodSpeed

Footnotes

  1. 50 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. ↩

@ChristopherWirt

Copy link
Copy Markdown
Contributor Author

The CodSpeed regression is from soft-boundary classification running under SplitBy::Layout, which never reads the marks. I've added a BoundaryMode (PositionsOnly / WithSoftMarks) on RowSplits. Layout now requests PositionsOnly, so StructReader takes the original path. Only LayoutSubSplitting pays for classification.

I'll also add a LayoutSubSplitting variant to split_collection so the cost on the default path shows up.

SplitBy::Layout never reads soft marks but paid for per-field collection,
intersection and classification in StructReader. RowSplits now carries a
BoundaryMode; Layout requests PositionsOnly and takes the original path.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Signed-off-by: Christopher Wirt <[email protected]>
@vercel

vercel Bot commented Nov 4, 2026

Copy link
Copy Markdown

@ChristopherWirt is attempting to deploy a commit to the Vortex Team on Vercel.

A member of the Team first needs to authorize it.

ChristopherWirt and others added 3 commits November 4, 2026 18:51
…x_rows

StructReader's WithSoftMarks path now skips fields whose boundaries match
the previous field's (the common aligned-columns case), intersects with a
linear merge instead of binary searches, and collects straight into the
parent RowSplits, removing RowSplits::append and remove_soft. Soft marks
are deduplicated before they are stored, and fields with no boundaries in
the range no longer empty the shared set.

merge_small_spans keeps a soft boundary where dropping it would leave a
span wider than max_rows, which subdivide_large_spans would otherwise
re-split at a row no column chunks at.

Also fixes the struct reader tests, which collected in PositionsOnly mode
after the previous commit and so saw no soft marks; they now reuse
register_boundaries and return VortexResult.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Christopher Wirt <[email protected]>
Validity boundaries were kept hard only at their own struct; a parent
struct still marked them soft when its other fields lacked them, so
whether they merged depended on nesting depth. Validity now takes part
in the shared-boundary intersection as another child, which is
consistent at every depth and removes the special case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Christopher Wirt <[email protected]>
@ChristopherWirt

Copy link
Copy Markdown
Contributor Author

Follow-up commits:

  • 8a2dadeaf: the soft-mark path skips fields whose boundaries match the previous field's and intersects with a linear merge. merge_small_spans no longer merges a span past max_rows. It also fixes two struct reader tests that the previous commit broke.
  • bd225d350: adds the warm_sub_splitting and cold_misaligned_sub_splitting benches.
  • 7b7766cd3: validity boundaries are now classified like any other child's, so a nested nullable struct merges the same way as a top-level one.

Locally (divan), LayoutSubSplitting warm with 256 columns went from 334 µs to 29 µs. Layout is 18 µs.

@robert3005 robert3005 added the changelog/performance A performance improvement label Nov 6, 2026

@robert3005 robert3005 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution! I think this can be slightly simplified.

Comment thread vortex-file/benches/split_collection.rs Resolved
@ChristopherWirt

Copy link
Copy Markdown
Contributor Author

@robert3005 CodSpeed now reports warm_sub_splitting[(32, 4)] at 1.1 ms; the other reported split-collection cases are below 1 ms. Is that small overage acceptable, or would you prefer I reduce this fixture further?

@robert3005

Copy link
Copy Markdown
Contributor

If you could trim it down a bit further that would be great

@robert3005
robert3005 enabled auto-merge (squash) November 11, 2026 10:45
@robert3005
robert3005 merged commit b2d08cc into vortex-data:develop Nov 11, 2026
72 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action/bench-sql Run SQL benchmarks without Vortex Compact on this PR changelog/performance A performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants