Skip to content

Navigation Menu

Sign in
Sign up

perf(vindex): optimize IVF index build reads - #800

Open
jerry-024 wants to merge 8 commits into
apache:main from
jerry-024:codex/ivf-sparse-read-add-clean
Open

perf(vindex): optimize IVF index build reads #800
jerry-024 wants to merge 8 commits into
apache:main from
jerry-024:codex/ivf-sparse-read-add-clean

Conversation

@jerry-024

@jerry-024 jerry-024 commented Sep 10, 2026
edited
Loading

Copy link
×ばつ 768 vectors in 10 Parquet files and one index shard. IVF-PQ options: cosine, `nlist=4096`, `pq.m=192`, 262,144 training rows, 16 Rayon threads. Current HEAD used 8-way row-group parallelism with a 768 MiB in-flight budget; upload remained serial. | Revision | Total build time | Runs | Read budget | | --- | ---: | ---: | ---: | | `main` (`78994db`, PR merge base) | 353.892s | 1 | 256 MiB default | | PR HEAD (`28d6670`) | 150.501s median | 3 | 768 MiB | The observed end-to-end reduction is 57.5%. The current-HEAD trial times were 160.165s, 150.501s, and 141.969s; its median sparse sample read was 7.858s, Full Scan/Add was 89.761s, and peak RSS was 4994 MiB. The two rows use the same host, dataset, direct-OSS path, disabled Paimon local cache, index options, and `paimon-vindex-core` 0.5.0. They were not collected in the same run and use different read budgets, so the 57.5% figure is an observed end-to-end comparison rather than an isolated measurement of this PR's code alone. ### Recall verification An end-to-end diagnostic was run on the 10M index with 100 warm serial queries, `top_k=100`, and `nprobe=128`: | Revision | recall@100 | NDCG | | --- | ---: | ---: | | `main` (`78994db`) | 0.7931 | 0.8346 | | `28d6670` | 0.7915 | 0.8322 | The observed deltas are -0.0016 recall and -0.0024 NDCG. This 100-query run is a regression check, not a statistically conclusive quality study. ### Tests - [x] Unit tests for deterministic, exact, short training ranges and range-count fallback. - [x] Unit tests for sparse row-ID validation across disjoint ranges. - [x] Unit tests for Parquet offset-index capability checks and selected row-group reads. - [x] `cargo +1.94.0 test -p paimon table::vindex_index_build_builder::tests --lib` (23 passed). - [x] Parquet module tests (52 passed in the direct-OSS benchmark preparation). - [x] `cargo +1.94.0 clippy -p paimon --lib --tests -- -D warnings`. - [x] Direct-OSS IVF-PQ build comparison with the PR's merge-base `main` revision. - [x] Three 10M-row direct-OSS IVF-PQ builds on current HEAD. - [x] End-to-end recall@100 diagnostic for `main` and current HEAD. ### API and Format No public API or storage-format changes. ### Documentation No documentation changes are required; this is an internal index-build optimization. " data-view-component="true"> Copy Markdown
Contributor

Purpose

Reduce IVF index build I/O and temporary-disk usage without materially changing training-sample quality. The build reads deterministic, short sparse training ranges when data files support precise Parquet page selection, then streams the full scan directly into the index writer.

Brief change log

  • Plan deterministic stratified training ranges of at most 128 rows; use the sparse path for at most 4096 ranges and otherwise fall back to the original full-scan path.
  • Use Parquet offset indexes to read only the vector pages needed for IVF training and schedule selected row groups concurrently under the shared read budget.
  • Probe eligible vector files concurrently and skip unrelated partial-column files before format checks.
  • Stream the full data scan through a bounded read/add pipeline instead of writing and rereading a raw-vector temporary file.
  • Validate sparse and full-scan row IDs before adding vectors.
  • Preserve the temporary-file fallback when sparse reads are unsupported or IVF training-size autotuning fails.
  • Extend index-build diagnostics with per-phase I/O, read-budget, sampling-seed, and pipeline timing metrics.

10M direct-OSS build verification

Environment: Intel Xeon 6982P VM (8 cores / 16 threads, 64 GiB RAM), direct OSS VPC endpoint, Paimon local cache disabled, paimon-vindex-core 0.5.0, Rust 1.94.0. Dataset: 10M ×ばつ 768 vectors in 10 Parquet files and one index shard. IVF-PQ options: cosine, nlist=4096, pq.m=192, 262,144 training rows, 16 Rayon threads. Current HEAD used 8-way row-group parallelism with a 768 MiB in-flight budget; upload remained serial.

Revision Total build time Runs Read budget
main (78994db, PR merge base) 353.892s 1 256 MiB default
PR HEAD (28d6670) 150.501s median 3 768 MiB

The observed end-to-end reduction is 57.5%. The current-HEAD trial times were 160.165s, 150.501s, and 141.969s; its median sparse sample read was 7.858s, Full Scan/Add was 89.761s, and peak RSS was 4994 MiB.

The two rows use the same host, dataset, direct-OSS path, disabled Paimon local cache, index options, and paimon-vindex-core 0.5.0. They were not collected in the same run and use different read budgets, so the 57.5% figure is an observed end-to-end comparison rather than an isolated measurement of this PR's code alone.

Recall verification

An end-to-end diagnostic was run on the 10M index with 100 warm serial queries, top_k=100, and nprobe=128:

Revision recall@100 NDCG
main (78994db) 0.7931 0.8346
28d6670 0.7915 0.8322

The observed deltas are -0.0016 recall and -0.0024 NDCG. This 100-query run is a regression check, not a statistically conclusive quality study.

Tests

  • Unit tests for deterministic, exact, short training ranges and range-count fallback.
  • Unit tests for sparse row-ID validation across disjoint ranges.
  • Unit tests for Parquet offset-index capability checks and selected row-group reads.
  • cargo +1.94.0 test -p paimon table::vindex_index_build_builder::tests --lib (23 passed).
  • Parquet module tests (52 passed in the direct-OSS benchmark preparation).
  • cargo +1.94.0 clippy -p paimon --lib --tests -- -D warnings.
  • Direct-OSS IVF-PQ build comparison with the PR's merge-base main revision.
  • Three 10M-row direct-OSS IVF-PQ builds on current HEAD.
  • End-to-end recall@100 diagnostic for main and current HEAD.

API and Format

No public API or storage-format changes.

Documentation

No documentation changes are required; this is an internal index-build optimization.

@jerry-024 jerry-024 changed the title (削除) feat: optimize IVF index build reads (削除ここまで) (追記) perf(vindex): optimize IVF index build reads (追記ここまで) Sep 10, 2026

@JingsongLi JingsongLi 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.

Found two reproducible regressions in the sparse training path: sampling can omit an entire data distribution, and the offset-index capability check can enable a second effectively full source read.

Comment on lines +91 to +95
let gap = skipped_rows / gap_count
+ usize::from(
(gap_index + gap_count - gap_extra_offset) % gap_count < skipped_rows % gap_count,
);
cursor = checked_add_offset(cursor, gap, "training gap")?;

@JingsongLi JingsongLi Sep 10, 2026

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.

[P2] Preserve representative training samples across the shard

These fixed gaps permanently exclude contiguous regions; the seed only shifts the remainder allocation by approximately one row. For a 100,000-row shard with the default sample ratio of 1 and 65,536 retained vectors, the last sampled row is 99,468, so a newly appended distribution in the final 500 rows is never trained.

I reproduced this using the actual planner and vindex 0.4 trainer: 90,000 vectors [0], then 9,500 [1], then 500 [100], with IVF-SQ, L2, and nlist=nprobe=1. Querying [100] returns 10/10 results from the final cluster with the previous full-stream reservoir sampling, but 0/10 with these ranges. The SQ bounds are trained only on the older distributions, so the new vectors are clamped to the old upper bound even though every vector is subsequently added and every list is searched. Please retain the existing reservoir path until representative sampling is preserved, and add a recall regression test for data clustered by append order.

@JingsongLi JingsongLi Sep 11, 2026

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.

Verified at 28d6670: both original tail-distribution cases (100,000 rows with a final 500-row cluster, and 1,000,000 rows with a final 10,000-row cluster) now return 10/10 correct-cluster results. The specific fixed-gap regression described above is addressed. A separate small-sample case still fails and is documented on the current planner line here: #800 (comment)

Comment on lines +162 to +168
has_usable_offset_index(
Box::new(input.reader().await?),
file_size,
index_column,
&local_ranges,
)
.await

@JingsongLi JingsongLi Sep 10, 2026

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.

[P2] Check actual page savings before enabling the extra source pass

An offset index being present does not mean these ranges avoid reading pages. With the default 100,000-row shard, 65,536 training rows, 128-dimensional vectors, and default Zstd/page settings, the 64 sample ranges leave gaps too small to skip pages or survive the reader's existing 1 MiB range coalescing. A tracking FileRead measured exactly 47,788,791 data bytes for both the sample and the full read, excluding metadata; the offset-index check still returned true. This fixture fits the default writer-buffer and file-size limits.

The subsequent full scan therefore doubles source data reads relative to the previous single source scan, including remote reads when the files are on OSS. This measures bytes, not overall wall time versus the saved local spill. Please estimate selected page ranges after coalescing and fall back, or adapt the sample plan, when the sample would read essentially the whole projection.

@JingsongLi JingsongLi Sep 11, 2026

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.

Rechecked at 28d6670: this remains reproducible with the current 512 short, jittered ranges. For the same default 100,000-row shard with 128-dimensional vectors, the sample and full scan still each fetch exactly 47,788,791 data bytes, excluding metadata. The latest concurrency and selected-page budget changes do not prevent the extra effectively full source pass. The sparse-path gate at writer.rs:173 still only checks offset-index availability; please include actual page savings after range coalescing, or fall back when the sample reads essentially the entire projection.

@JingsongLi JingsongLi 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.

Rechecked at 28d6670. The previous 100,000-row and 1,000,000-row tail-distribution cases now pass. Two additional reproducible issues remain in small-sample planning and sparse-read admission; the source-read amplification also remains, with updated measurements in its existing inline thread. Validation: 24 index-build tests and 53 Parquet tests passed, plus four isolated verification cases.

source: None,
});
}
let range_count = training_rows.div_ceil(MAX_IVF_TRAINING_RANGE_ROWS);

@JingsongLi JingsongLi Sep 11, 2026

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.

[P2] Keep small training samples distributed across the shard

When training_rows <= 128, this calculation produces just one contiguous range. A realistic incremental shard with 1,000 new rows and train.sample-ratio=0.1 therefore trains on 100 adjacent rows, whereas the previous implementation sampled every tenth row across the whole shard.

Using the actual planner and vindex 0.4 trainer, I reproduced this with 450 vectors [0], 450 [1], and 100 [100], IVF-SQ, L2, and nlist=nprobe=1. For snapshot 1, bucket 0, and an empty partition, the new range is [385,484], which excludes the entire final distribution. Querying [100] returns 10/10 results from the final cluster with the baseline, but 0/10 with this plan: SQ clamps those vectors to the older upper bound and returns rows 450–459. Please retain multiple strata for small samples, or fall back to the original sampling path, and cover this incremental-shard case in a recall regression test.

Comment on lines +758 to +763
let selected_compressed_bytes = selection
.scan_ranges(page_locations)
.into_iter()
.try_fold(dictionary_bytes, |total, range| {
total.checked_add(range.end.checked_sub(range.start)?)
})?;

@JingsongLi JingsongLi Sep 11, 2026

Copy link
×ばつ 128 floats, default Zstd/page settings, and one selected row every 4,096 rows. With a 20,971,520-byte budget, all four groups were admitted and simultaneously retained 25,390,984 bytes of owned read buffers. Allocation ownership and release were tracked with `Bytes::from_owner` and `Drop`; these counts exclude decoded Arrow arrays, and all tracked buffers were released on completion. Please account for the coalesced allocations when calculating admission costs, or retain conservative full-column admission when they cannot be estimated." data-view-component="true"> Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Include retained coalesced buffers in sparse read admission

scan_ranges sums the selected pages before ArrowFileReader merges byte ranges separated by at most 1 MiB. Its returned Bytes slices retain the larger coalesced allocations, but the new concurrent row-group admission uses only the smaller selected-page estimate. This leaves live source buffers out of the memory estimate used to increase parallelism.

I verified this with four valid Parquet row groups of 16,384 rows ×ばつ 128 floats, default Zstd/page settings, and one selected row every 4,096 rows. With a 20,971,520-byte budget, all four groups were admitted and simultaneously retained 25,390,984 bytes of owned read buffers. Allocation ownership and release were tracked with Bytes::from_owner and Drop; these counts exclude decoded Arrow arrays, and all tracked buffers were released on completion. Please account for the coalesced allocations when calculating admission costs, or retain conservative full-column admission when they cannot be estimated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@JingsongLi JingsongLi JingsongLi left review comments

At least 1 approving review is required to merge this pull request.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

AltStyle によって変換されたページ (->オリジナル) /