Skip to content

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

Merged
ChristopherWirt merged 2 commits into
developfrom
cwirt/list-layout-coarse-splits
Nov 3, 2026
Merged

ChristopherWirt merged 2 commits into
developfrom
cwirt/list-layout-coarse-splits

Conversation

@ChristopherWirt

@ChristopherWirt ChristopherWirt commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #61.

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 holds 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, and one 176-row output array referenced 42 MB.

Change

  • The struct reader marks soft boundaries. These are boundaries that only some of its fields have. Each field registers into its own RowSplits, and the struct takes their union, marking any boundary not every field has as soft. A single-field struct registers exactly as before.
  • 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 runs before the existing sub-division of wide spans.
  • Hard boundaries are never merged. These are boundaries every field has, and those registered by layouts above the struct, such as top-level chunk ends. Files whose columns chunk alike split as before.

Effect

Full scan of one file, with Arrow conversion as JNI does it. Live native bytes are measured with 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

Tests

  • Struct reader (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 preserves shared and validity boundaries, for nullable and non-nullable structs.
  • merge_small_spans: unit cases plus 1,000 generated cases checking endpoints, hard boundaries, exact coverage, minimum spans at retained soft boundaries, and subsequent subdivision.
  • The nullable integration regression failed before the validity-boundary fix.
  • Checks: 836 vortex-layout library tests; all-target, all-feature Clippy for vortex-layout and vortex-file with -D warnings; pinned Rust formatting; Java Spotless; full ./gradlew build --no-parallel --max-workers=1 including JNI and Spark checks; patch whitespace. All passed. The full Rust workspace suite was not rerun locally; Linux musl CI passed the workspace tests.

Review follow-up

  • Compute the shared-boundary intersection once instead of searching every field for every boundary. The existing 256-column split-collection debug benchmark's warm median fell from about 338 ms to 3 ms.
  • Preserve inherited soft marks while keeping this struct's validity boundaries hard, including where they overlap a soft data-field boundary.
  • Move soft marks when appending split sets instead of cloning their buffer.

Not done here

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

🤖 Generated with Claude Code

@gitRasheed gitRasheed left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The merge logic looks right. A port of merge_small_spans held up under a few hundred thousand random and exhaustive cases: it always keeps the ends and the hard boundaries and never adds one. Two things before merge:

  1. The tests the description lists aren't in the commit: no merge_small_spans unit or property tests, and no scan_builder changes. The two new struct tests stop at register_splits, and the existing SplitBy tests use a stub that never marks anything soft, so nothing covers soft-boundary merging. Could you add the merge tests and one LayoutSubSplitting test over misaligned_struct_layout that checks the resulting boundaries?

  2. The shared-boundary check (struct_/reader.rs:547) binary-searches every field for each boundary of each field, so it's F² x B x log B per struct. Aligned wide structs pay the most: 1,000 fields with 50 chunks each is 50M searches, and SplitBy::Layout pays it too, then drops the soft marks. Counting how many fields have each row (concatenate, sort, soft when the count is under F) does it in one pass.

ChristopherWirt and others added 2 commits November 2, 2026 21:08
…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, 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]>
Compute the shared boundary intersection once, preserve validity boundaries, and move inherited soft marks without cloning their buffer. Add merge invariants, real-layout regression coverage, and a wider split-collection benchmark case.

Signed-off-by: Christopher Wirt <[email protected]>
@ChristopherWirt
ChristopherWirt force-pushed the cwirt/list-layout-coarse-splits branch from 30be3a4 to aab5de1 Compare November 2, 2026 21:19
@ChristopherWirt

ChristopherWirt commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks @gitRasheed, agreed on both. Pushed aab5de1 with the fixes:

  • Added the missing merge cases, 1,000 generated invariant cases, and LayoutSubSplitting coverage over the real misaligned struct layout. The integration tests check the final boundaries for both nullable and non-nullable structs. I've corrected the test list in the PR description too.
  • Replaced the repeated all-fields check with a shared-boundary intersection computed once. That removes the quadratic dependence on field count. On the existing 256-column debug benchmark, the warm split-collection median went from about 338 ms to 3 ms.

The review also caught a validity boundary becoming soft when it overlapped a data-field boundary. That's fixed, with a regression that failed before the change. Inherited child soft marks are preserved.

Checks passed: 836 layout tests, all-target/all-feature Clippy for layout and file with -D warnings, pinned Rust formatting, Spotless, and the full Java ./gradlew build --no-parallel --max-workers=1 including JNI and Spark checks. I haven't rerun the full Rust workspace suite locally.

The first Java run ran out of disk space. I freed the temporary build artifacts and ran the suites sequentially; no test assertions were changed.

Linux musl CI has now passed the Rust workspace tests too.

@gitRasheed gitRasheed left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks. The intersection is computed once now, and the real-layout LayoutSubSplitting tests cover the merge path. Good catch on validity boundaries going soft.

@ChristopherWirt
ChristopherWirt merged commit 00130bd into develop Nov 3, 2026
1 check passed
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.

List layout scans hold GiBs of native memory: tiny misaligned splits each decode whole column chunks

2 participants