feat(encoding): write sparse structural pages explicitly#7755
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
Important This PR touches the Lance format specification. Substantive changes to the format specification — the If this is a meaningful format change:
|
|
Already covered by #7754 |
Part of #7750 ## Summary - In Lance 2.3+, automatically choose sparse structural pages only when the structural encoding is implicit, the dense mini-block rep/def plan requires top-level page splitting or has a single row over budget, and the sparse writer supports the value path. - Keep within-budget pages on the dense path without constructing a sparse value block or sparse plan. - Preserve every explicit structural mode, Lance 2.2 behavior, and dense fallback/error behavior for dictionary, variable packed-struct, and other sparse-ineligible value paths. - Keep the policy decision separate from sparse serialization; this adds no wire-format fields. This is the policy/performance layer that turns the already-readable and explicitly-writable sparse format on automatically only for over-budget eligible pages. ## Integration - [PR3 #7754](#7754) absorbed the readable and explicitly writable sparse format from the closed [PR4 #7755](#7755). Its squash patch is replayed directly on the current `main` base before the policy commits in this PR. - [PR1 #7751](#7751) is merged; the rebase resolves the stack onto its `MiniBlockRepDefBudget` naming. ## 100M-row S3 benchmark Environment: `r7i.8xlarge` in `us-east-2b`, Lance V2.3, 100 files of 1,000,000 rows, 65,536-row write batches, and 1,024-row takes. The final committed harness is at `66c936391aa46bfcd14f7ac1c820878da68fcc0b` on `xuanwo/sparse-stack-5-auto-sparse-bench-final`, based on pre-rebase PR5 head `ed0ce68d7f4cceafe6a249c912f7f1da8c4fecd9`. The rebase consolidates #7754 and resolves #7751 naming without changing the auto-selection or dense-fallback policy; these measurements remain attributed to that exact benchmarked head and are not relabeled as current-head results. Cases: - `hnsw`: `List<UInt32>`; the first 1/7 of rows (14,285,714 of 100M) contain 32 consecutive values, followed by an empty tail. - `uniform`: `List<UInt32>`; one singleton non-empty row every 10,000 rows. - `deep`: `Struct<events: List<Struct<id: Int32, tags: List<Int32>, pair: FixedSizeList<Int32; 2>>>>`; non-empty every 4,096 rows with nullable top struct, event list, event struct, id, tags, pair, and pair items. `cold` is the first operation on a newly opened `Dataset`; `warm` is the immediate repeat on the same `Dataset`. Times are milliseconds. Bytes are total dataset object bytes. Every dataset has 102 objects, including 100 Lance data files. | Case | Mode | Bytes | Pages | Cold full scan | Cold random | Warm full scan | Warm random | Warm empty | Warm non-empty | | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | hnsw | sparse | 1,600,850,504 | 317 | 1,627.830 | 466.940 | 1,472.636 | 272.931 | 0.484 | 29.238 | | hnsw | mini-block | 1,663,361,256 | 328 | 1,720.362 | 484.619 | 1,655.289 | 273.812 | 0.466 | 20.664 | | hnsw | full-zip | 2,342,961,549 | 328 | 3,239.070 | 478.839 | 3,104.055 | 273.603 | 0.659 | 25.735 | | uniform | sparse | 117,091 | 100 | 99.990 | 77.346 | 76.518 | 3.408 | 0.548 | 28.299 | | uniform | mini-block | 689,601 | 1,600 | 111.844 | 137.503 | 606.852 | 123.301 | 93.404 | 94.822 | | uniform | full-zip | 496,906,995 | 1,600 | 1,589.645 | 1,205.110 | 650.542 | 359.496 | 72.292 | 58.997 | | deep | sparse | 1,144,900 | 300 | 159.361 | 77.122 | 135.065 | 9.309 | 0.884 | 25.580 | | deep | mini-block | 376,199,150 | 10,100 | 6,735.485 | 4,673.904 | 2,560.397 | 582.959 | 174.014 | 189.929 | | deep | full-zip | 994,111,217 | 10,100 | failed | failed | failed | failed | failed | failed | `deep/full-zip` wrote all 100M rows and produced the reported size/page/object counts, then its first full scan panicked at `primitive.rs:2588` by unwrapping a missing fixed full-zip decode task. Sparse and mini-block completed all reads. A separate 200K-row check at the exact PR4 base SHA `da06b79a7f5eda4f33e2d80990c46766095ba383` independently confirmed that this nested-null `deep/full-zip` read was already broken before this PR, returning `Structural validity has 6 entries for an array with 7 values`. The precise failure site varies with this decoder path, but explicit full-zip never enters PR5's automatic sparse candidate closure, so this is not an auto-sparse regression. The policy deliberately does not disguise that behavior as sparse support.
Part of #7750
Depends on #7754.
Summary
lance-encoding:structural-encoding=sparse, and reject the request for older file versions.ConstantLayoutboundary; explicit non-null constants emit sparse pages.miniblock/fullzipbehavior are unchanged.Coverage
Round trips cover nullable primitive and struct values, list, large-list, map, fixed-size-list, nested combinations, null versus empty lists, both validity polarities, every semantic position/count form, structural-only and all-null pages, page boundaries, scans, ranges, and discontiguous takes. Metadata assertions verify the selected page and set representations. Lance 2.2 default output is compared with explicit mini-block output, and explicit sparse requests are rejected before encoding.
Validation
cargo fmt --all -- --checkcargo test -p lance-encoding sparse:: --lib(25 passed)cargo test -p lance-encoding(455 passed, 5 ignored)cargo test -p lance-file(92 passed)cargo clippy --all --tests --benches -- -D warningsuv run mkdocs buildfromdocs/(passed with existing repository link warnings)