Skip to content

feat(ivf): add balanced k-means clustering for IVF partitioning - #3260

Open
DoubleCore wants to merge 14 commits into
antgroup:mainfrom
DoubleCore:issue-2870-kmeans
Open

DoubleCore wants to merge 14 commits into
antgroup:mainfrom
DoubleCore:issue-2870-kmeans

Conversation

@DoubleCore

Copy link
Copy Markdown

Implements #2870.

Fixes: #2870.

Adds BalancedKMeansCluster, a reusable capacity-constrained trainer for
IVF partitioning (issue antgroup#2870), plus a new optional ivf_train_type
value "balanced_kmeans".

Unlike a price-based auction solver, the balancing step uses a
regret-greedy capacitated assignment: every vector keeps only its top-C
nearest clusters (no dense n x k distance matrix), the n mod k clusters
with the highest unconstrained nearest-centroid demand receive
ceil(n/k) seats, and vectors are placed most-constrained first - in
decreasing order of their regret - each into its nearest cluster that
still has a free seat, with an on-demand full rescan for the rare vector
whose candidates are all full. Each cluster therefore holds exactly
floor(n/k) or ceil(n/k) vectors, no cluster is empty while k <= n, and
results are deterministic for a given seed regardless of thread count.

- src/impl/cluster/balanced_kmeans_cluster.{h,cpp}: the trainer, its
  metric-aware fixed-centroid Assign (used by the IVF build-time bucket
  assignment) and a BalancedKMeansStats observability handle.
- IVF integration: ivf_train_type "balanced_kmeans" parses and
  serializes, unknown trainer names now fail with INVALID_ARGUMENT,
  GNO-IMI rejects the combination, defaults and existing kmeans/random
  behavior are unchanged, and the first bucket assignment of IVF::Build
  balances through a new IVFPartitionStrategy::ClassifyDatasForBuild
  hook.
- Tests: src/impl/cluster/balanced_kmeans_cluster_test.cpp (exact
  balance for divisible, indivisible, skewed and duplicated data,
  k == 1, k == n, invalid arguments, cross-thread determinism, Assign),
  parameter tests for parsing/serialization/rejection, and functional
  tests in tests/test_ivf.cpp checking bucket balance through GetStats,
  search, serialization round-trip and error paths.
- tools/cluster_balance_bench: seeded reproducible comparison of the
  two trainers on uniform, skewed, indivisible and duplicated datasets,
  reporting cluster-size statistics, loss, training time and peak memory
  against a dense n x k distance matrix.
- docs: English and Chinese IVF pages describe the trainer, its
  complexity and its balance/quality trade-off.

Signed-off-by: DoubleCore <851678303@qq.com>
Assisted-by: OpenCode:qwen3.8-flash
@pull-request-size pull-request-size Bot added the size/XXL 1000+ changed lines label Nov 3, 2026
@vsag-bot

vsag-bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Merger | Merge Readiness

Head: ebadeaab | Base: 6002c527 | Target: main

Updated: 2026-11-06 07:29:49 UTC

Status: BLOCKED

Merger decision: HOLD (shadow assessment; no merge executed).

  • Conversations: 44 unresolved; resolution required
  • GitHub merge state: BLOCKED

Next owner: Author. Required next step: Resolve the GitHub merge conditions, then reassess.

Merge reliability: 0.00 / 1.00 (WITHHELD)

Scoring basis Points Evidence
Required CI evidence 0.27 / 0.40 2/3 required check groups satisfy the branch policy from the expected source; neutral/skipped are not execution evidence
Branch review policy 0.00 / 0.20 GitHub review decision: REVIEW_REQUIRED; review conditions are not verified
Current AI review 0.40 / 0.40 Current head/base, complete static file coverage, correct verdict and zero P0/P1 findings

Evidence subtotal: 0.67; applicable ceiling: 0.00. Final score = min(subtotal, ceiling). Policy: merge-reliability-v2.

  • Ceiling 0.95: Static analysis and check outcomes do not prove correctness; v2 has an uncalibrated 0.95 policy ceiling.
  • Ceiling 0.00: GitHub merge conditions are BLOCKED; hard gates cannot be offset by other evidence.
  • Ceiling 0.50: 44 unresolved review threads remain.
  • Ceiling 0.85: The current AI review reports 2 residual risks; see its review for context.
Merge condition State Evidence
PR state PASS OPEN
Rules PASS Active rulesets and branch protection read
Conflicts PASS MERGEABLE
Merge method PASS squash
Merge commit freshness PENDING Waiting for GitHub to refresh the merge commit for the current head and base. Only current head checks are shown.
DCO PASS success
Mergify Merge Protections PASS success
PR CI Gate PENDING Waiting for required result from the expected source
Human review PENDING GitHub: REVIEW_REQUIRED; approvals >= 1; Code Owner approval required; stale approvals dismissed
Conversations BLOCKED 44 unresolved; resolution required
GitHub merge state BLOCKED BLOCKED

AI review: COMPLETE. Static review: patch is correct; CI is reported separately.

Review findings: 0; P0/P1 findings in that review: 0; coverage: 24/24 changed files. Findings are not a count of currently unresolved threads.

CI summary: 1 neutral, 5 success. Check source: ebadeaab. Skipped/neutral checks are not evidence that tests executed.

Next action: Merge commit freshness: Waiting for GitHub to refresh the merge commit for the current head and base. Only current head checks are shown.; PR CI Gate: Waiting for required result from the expected source; Human review: GitHub: REVIEW_REQUIRED; approvals >= 1; Code Owner approval required; stale approvals dismissed; Conversations: 44 unresolved; resolution required; GitHub merge state: BLOCKED

CI check details
Check Source Result Required
ci/circleci: path-filtering/filter StatusContext success no
ci/circleci: pull-request-check StatusContext success no
DCO CheckRun (App 1861) success yes
Mergify Merge Protections CheckRun (App 10562) success yes
Mergify Merge Queue CheckRun (App 10562) neutral no
Summary CheckRun (App 10562) success no

This is an uncalibrated evidence score, not a probability, correctness guarantee or approval. Zero means the assessment is withheld or lacks positive evidence, not a proven-bad patch. Automatic merging is disabled. New commits or base changes invalidate earlier evidence; never authorize a merge from this score alone.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T13:38:18.798632Z a08dcb7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@vsag-bot
vsag-bot self-requested a review November 3, 2026 13:18
@vsag-bot

vsag-bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Reviewer | Review complete

Automated pull request review completed.
Review decision: Approved. No actionable findings were identified in this complete static review. CI and merge requirements remain separate.

Model: gpt-6.1-sol; reasoning effort: high.
Review scope: high (3374 changed lines across 24 files).

No actionable inline findings were found.
Review coverage checks completed; this does not prove absence of defects.
Static review only; compilation and runtime tests are outside this review's scope.
File coverage: 24/24; observed passing test runs: 0.
Review: #3260 (review)

@vsag-bot vsag-bot added kind/feature Brand-new functionality or capabilities 引入全新的功能、新特性或新能力 module/index Index algorithms and implementations 索引算法与实现 labels Nov 3, 2026
@mergify

mergify Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 All 3 merge protections satisfied — ready to merge.

Show 3 satisfied protections

🟢 Require kind label

  • label~=^kind/

🟢 Require version label

  • label~=^version/

🟢 Require linked issue for feature/bug PRs

  • body~=(?im)(?:^|[\s\-\*])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:#\d+|[\w.\-]+/[\w.\-]+#\d+|https?://github\.com/[\w.\-]+/[\w.\-]+/issues/\d+)

@vsag-bot

vsag-bot commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Jev-driven classification

Applied: kind/feature, module/index. Existing labels were preserved.

Option probabilities
Option Probability Result
kind/feature 98.0% Applied
module/index 98.0% Applied
module/api 80.0% Not applied
area/tools 78.0% Not applied
area/testing 76.0% Not applied
area/docs 67.0% Not applied
module/simd 18.0% Not applied
area/ci 17.0% Not applied
area/dependencies 11.0% Not applied
area/examples 11.0% Not applied
module/datacell 10.0% Not applied
area/bindings-python 4.0% Not applied
module/attr 4.0% Not applied
kind/enhancement 2.0% Not applied
area/bindings-node 2.0% Not applied
area/docker 2.0% Not applied
kind/bug 0.0% Not applied
kind/cleanup 0.0% Not applied
kind/documentation 0.0% Not applied
kind/improvement 0.0% Not applied
No kind 0.0% Not applied

Model: jev-1.13.0. Selection threshold: 85%. Probabilities are model estimates, not guarantees.

@mergify mergify Bot added area/docs Website and repository documentation 网站与仓库文档 area/tools Command-line, evaluation, and developer tools 命令行、评测与开发工具 area/testing Tests, fixtures, and test infrastructure 测试、夹具与测试基础设施 labels Nov 3, 2026

@vsag-bot vsag-bot 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.

Reviewer | Code review

Automated pull request review completed.

Model: gpt-6.1-sol; reasoning effort: high.
Review scope: high (2247 changed lines across 20 files).
Submitted 5 inline comments.
Review coverage checks completed; this does not prove absence of defects.
Static review only; compilation and runtime tests are outside this review's scope.
File coverage: 20/20; observed passing test runs: 0.

Reviewed commit a08dcb7.

auto buckets =
partition_strategy_->ClassifyDatas(vectors, num_element, buckets_per_data_, nullptr);
initial_bucket_assignment
? partition_strategy_->ClassifyDatasForBuild(

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.

[P2] [P2] Train residual quantizers with the balanced assignment

This changes storage assignment, but BucketDataCell::Train still calls ClassifyDatas, so residual quantizers learn a different distribution. For L2 data [0,0,0,100] with k=2, balanced centroids are 0 and 50. SQ8 trains on residuals [0,0,0,50], while Build must encode one zero as -50. That residual clips to 0, reconstructing the point as 50 and producing incorrect distances even when all buckets are scanned. Residual training must account for the balanced assignments used during Build.

float
metric_distance(MetricType metric, const float* x, const float* c, int32_t dim) {
if (metric == MetricType::METRIC_TYPE_IP or metric == MetricType::METRIC_TYPE_COSINE) {
return clamp_distance(1.0F - FP32ComputeIP(x, c, dim));

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.

[P2] [P2] Normalize cosine rows before comparing assignment regrets

ClassifyDatasForBuild passes raw vectors here. Normalized centroids preserve each row's nearest-centroid order, but raw row norms change the regrets compared across vectors. With centroids (1,0)/(0,1) and vectors (100,99)/(1,0), both raw regrets equal 1, so the first vector takes centroid 0 and forces (1,0) into centroid 1. Normalized cosine costs give (1,0) priority instead. Normalize cosine inputs before computing costs so vector magnitude does not distort placement and recall.

}
const float dist =
metric_distance(metric, x, centroids + j * static_cast<uint64_t>(dim_), dim_);
if (dist < best) {

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.

[P2] [P2] Allow saturated distances to select an available seat

metric_distance clamps overflow to K_MAX_DISTANCE, which is also the initial best value. For Assign with two zero vectors and finite centroids -1e20/+1e20 in one dimension, every distance clamps to this value. Candidate lists retain only their default bucket 0; after its seat fills, the fallback rejects bucket 1 because its distance is not strictly smaller than best and throws 'ran out of capacity'. Use a distinct uninitialized sentinel or explicitly accept the first available cluster, including in candidate construction.

false,
1e-6F,
vsag::KMeansInitMethod::KMEANS_PLUS_PLUS,
seed);

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.

[P3] [P3] Make the seeded baseline reduction reproducible

This leaves KMeansCluster::Run's final deterministic_reduction argument at false. With multiple workers, block centroid sums merge in completion order, so floating-point centroids, subsequent labels, and reported loss can vary despite identical arguments and seed. This contradicts the tool's reproducibility contract. Enable deterministic reduction for the reproducible comparison or explicitly qualify the reported results.

}
actual_n = total;
if (kind == "indivisible" and total % k == 0) {
actual_n = total - 1;

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.

[P3] [P3] Handle the single-point indivisible dataset

The parser accepts --dataset indivisible --n 1 --k 1, but this subtraction changes actual_n from 1 to 0. run_bench then passes an empty dataset to KMeansCluster::Run, which throws an uncaught invalid-argument exception. Handle k=1 explicitly or reject this unsupported dataset combination before generating it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a08dcb7ec5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +239 to +243
const auto labels = cls.Assign(this->bucket_count_,
centroids.data(),
static_cast<const float*>(datas),
static_cast<uint64_t>(count),
this->metric_type_);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Train residual quantizers with balanced bucket assignments

With balanced_kmeans, use_residual: true, and SQ8 base codes, Build now uses capacity-constrained labels, but BucketDataCell::Train still computes training residuals using nearest-centroid ClassifyDatas (src/datacell/bucket_datacell.h:638). For 90 zero vectors and ten vectors equal to 100 with two buckets, the centroids become 0 and 20: SQ8 trains on residuals in [0, 80], while Build generates residual -20 for 40 zero vectors. SQ8 clamps those residuals, reconstructing the affected zeros as 20. Without exact reorder, the resulting distance errors persist even when every bucket is scanned. Train the residual codec using assignments consistent with balanced Build, or reject this combination.

Useful? React with 👍 / 👎.

Comment thread tools/cluster_balance_bench/README.md Outdated
Comment on lines +23 to +24
The binary is part of the tools build (`-DENABLE_TOOLS=ON`, requires
`-DENABLE_CXX11_ABI=ON`, Linux):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Move the benchmark guide into the canonical website docs

The new benchmark's build and CLI guide lives in this standalone README, while a repository-wide rg search for cluster_balance_bench finds only brief mentions in the English and Chinese website sources. Root AGENTS.md requires user-facing documentation under docs/docs/{en,zh}/src/ and says, “Tool-level READMEs should stay as local pointers to those canonical pages.” Publish the benchmark guide in those website sources and make this README point there rather than establishing a separate documentation source.

AGENTS.md reference: AGENTS.md:L101-L103

Useful? React with 👍 / 👎.

}
}
for (auto& future : futures) {
future.wait();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate exceptions from candidate-scan workers

If an allocation inside scan_block fails, GeneralEnqueue stores the exception in its future, but wait() never observes it. Assignment then proceeds with the failed rows' placeholder distances and cluster IDs and can return success with incorrect labels. For example, failing the worker allocations when assigning [10,10,0,0] to centroids [0,10] returns labels [0,0,1,1] and loss 400 instead of reporting the allocation failure. Retrieve each future's result, drain all outstanding tasks, and propagate any recorded exception before consuming the candidate buffers.

Useful? React with 👍 / 👎.

}
const float dist =
metric_distance(metric, x, centroids + j * static_cast<uint64_t>(dim_), dim_);
if (dist < best) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Select a free cluster when distances reach the clamp

When finite inputs overflow the FP32 distance calculation, metric_distance clamps the result to K_MAX_DISTANCE, which is also the initial value of best. This strict comparison therefore never selects a cluster if every remaining distance reaches that bound, despite seats being available. For example, Assign with centroids [-1e20,1e20] and vectors [0,0] throws “balanced assignment ran out of capacity” for the second vector. Select the first feasible cluster even when its distance equals the sentinel so the advertised finite-input handling preserves the capacity guarantee.

Useful? React with 👍 / 👎.

Comment on lines +276 to +277
vsag::KMeansInitMethod::KMEANS_PLUS_PLUS,
seed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enable deterministic reductions in the seeded benchmark

With multiple workers and more than one 1,024-vector reduction block, this call leaves KMeansCluster::Run's final deterministic_reduction argument at its default false. The CPU implementation consequently merges floating-point centroid sums in worker-completion order (src/impl/cluster/kmeans_cluster.cpp:345-354), so a fixed seed does not determine the resulting centroids and subsequent assignments. This undermines the tool's advertised reproducible clustering comparison. Pass deterministic_reduction=true, as the existing seeded reproducibility test does.

Useful? React with 👍 / 👎.

@vsag-lgtm vsag-lgtm 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.

Automated review @ a08dcb7e — 4 inline comment(s).

Comment on lines +271 to +385
if (dist < best_dist[c - 1]) {
uint32_t pos = c - 1;
while (pos > 0 and best_dist[pos - 1] > dist) {
best_dist[pos] = best_dist[pos - 1];
best_id[pos] = best_id[pos - 1];
--pos;
}
best_dist[pos] = dist;
best_id[pos] = j;
}
}
for (uint32_t j = 0; j < c; ++j) {
cand_dist[i * static_cast<uint64_t>(c) + j] = best_dist[j];
cand_id[i * static_cast<uint64_t>(c) + j] = best_id[j];
}
++local_demand[best_id[0]];
}
std::lock_guard<std::mutex> lock(demand_mutex);
for (uint32_t j = 0; j < k; ++j) {
// Exact integer sums are order-independent, so any merge
// schedule produces the same demand vector.
demand[j] += local_demand[j];
}
};

std::vector<std::future<void>> futures;
for (uint64_t i = 0; i < count; i += block_size) {
const uint64_t begin = i;
const uint64_t end = std::min(i + block_size, count);
if (thread_pool_->IsCurrentThread()) {
scan_block(begin, end);
} else {
futures.emplace_back(thread_pool_->GeneralEnqueue(scan_block, begin, end));
}
}
for (auto& future : futures) {
future.wait();
}

// Step 2: capacity targets. Exactly n mod k clusters hold ceil(n / k)
// vectors. The seats go to the clusters that the unconstrained nearest
// pass drew most, which keeps the constrained assignment closest to the
// unconstrained one. Ties break by cluster id, so the targets are
// deterministic.
const uint64_t base = count / static_cast<uint64_t>(k);
const uint64_t extra = count % static_cast<uint64_t>(k);
Vector<uint64_t> remaining(k, base, allocator_);
{
Vector<uint32_t> cluster_order(k, 0U, allocator_);
std::iota(cluster_order.begin(), cluster_order.end(), 0U);
std::sort(cluster_order.begin(), cluster_order.end(), [&](uint32_t a, uint32_t b) {
if (demand[a] != demand[b]) {
return demand[a] > demand[b];
}
return a < b;
});
for (uint64_t i = 0; i < extra; ++i) {
++remaining[cluster_order[i]];
}
}

// Step 3: regret-greedy placement. A vector's regret is the extra cost of
// losing its best candidate; the vectors that lose the most when pushed
// are placed first, while their best seats are still free. The order is
// a total order (regret, then nearest distance, then vector id), so the
// result does not depend on the thread count.
Vector<uint64_t> point_order(count, 0ULL, allocator_);
std::iota(point_order.begin(), point_order.end(), 0ULL);
auto regret = [&](uint64_t i) {
if (c >= 2) {
return cand_dist[i * static_cast<uint64_t>(c) + 1] -
cand_dist[i * static_cast<uint64_t>(c)];
}
return 0.0F;
};
std::sort(point_order.begin(), point_order.end(), [&](uint64_t a, uint64_t b) {
const float ra = regret(a);
const float rb = regret(b);
if (ra != rb) {
return ra > rb;
}
const float da = cand_dist[a * static_cast<uint64_t>(c)];
const float db = cand_dist[b * static_cast<uint64_t>(c)];
if (da != db) {
return da < db;
}
return a < b;
});

// Total remaining capacity equals the number of unplaced vectors at every
// point of this loop, so the on-demand rescan always finds a cluster with
// a free seat.
for (uint64_t i : point_order) {
const uint64_t row = i * static_cast<uint64_t>(c);
uint32_t chosen = k;
for (uint32_t j = 0; j < c; ++j) {
const uint32_t id = cand_id[row + j];
if (remaining[id] > 0) {
chosen = id;
break;
}
}
if (chosen == k) {
// Every remembered candidate is full: recompute distances against
// the clusters that still have seats instead of keeping the full
// row. Rare for c > 1, and bounded work even when it repeats.
const float* x = datas + i * static_cast<uint64_t>(dim_);
float best = K_MAX_DISTANCE;
for (uint32_t j = 0; j < k; ++j) {
if (remaining[j] == 0) {
continue;
}
const float dist =
metric_distance(metric, x, centroids + j * static_cast<uint64_t>(dim_), dim_);
if (dist < best) {

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.

[critical] Strict < comparisons in the candidate insertion (line 271) and fallback rescan (line 385) treat a clamped distance equal to K_MAX_DISTANCE as unselected. If any vector-to-centroid distance overflows and is clamped to K_MAX_DISTANCE (or if the input maximum is reached), the candidate list keeps the default cluster 0 and the fallback never updates best, so chosen stays k and the function throws balanced assignment ran out of capacity. Use <= in both places (or a value-typed uninitialized sentinel instead of the valid max float) so clamped distances still participate in the selection and the capacity guarantee does not depend on avoiding overflow.

std::uniform_int_distribution<uint64_t> dis(0, count - 1);
selected_idx = dis(gen);
} else {
std::uniform_real_distribution<double> prob_dis(0.0, total_weight);

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.

[warning] std::uniform_real_distribution<double>(0.0, total_weight) has undefined behavior when total_weight is +inf or NaN. total_weight sums distances clamped to FLT_MAX; for very large n / extreme magnitudes that sum can overflow double, making the distribution bounds invalid. Guard with std::isfinite(total_weight) and, on overflow, fall back to the uniform random pick used for the total_weight <= 0.0 case.

}
}
for (auto& future : futures) {
future.wait();

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.

[warning] future.wait() only blocks until the task finishes; it does not propagate exceptions stored in the future. If a worker throws (e.g., an allocation failure inside scan_block or metric_distance), the exception is swallowed and balanced_assign continues with uninitialized/placeholder candidate data, returning wrong labels and silently corrupted loss. Iterate the futures with future.get() (rethrowing the first failure) or explicitly collect and rethrow after all tasks complete.

// trained partition; anything else (including a build that brings fewer
// vectors than buckets, where the trainer has already placed one vector per
// bucket best it can) takes the plain routing.
const bool balance_buckets = this->ivf_partition_strategy_param_->partition_train_type ==

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.

[suggestion] balance_buckets requires buckets_per_data == 1. That means if a user enables balanced_kmeans but sets n_probe > 1 (so buckets_per_data_ > 1), the index silently falls back to plain nearest-centroid routing for the first Build assignment. The behavior is internally consistent but surprising from a user's perspective; consider either documenting this limitation in the IVF docs or emitting a logger::warn trace when partition_train_type is BalancedKMeansTrainer and buckets_per_data != 1, so the balance guarantee does not appear to be silently dropped.

- train residual quantizers with the capacity-constrained build assignment
- normalize cosine rows before comparing assignment regrets
- use infinity as the candidate sentinel so saturated distances stay selectable
- propagate worker exceptions instead of waiting on failed futures
- make the seeded baseline reduction reproducible; keep n=1,k=1 datasets valid

Signed-off-by: DoubleCore <DoubleCore@users.noreply.github.com>
@vsag-bot
vsag-bot self-requested a review November 4, 2026 16:55
@mergify mergify Bot added the module/datacell Data cells, vector I/O, and quantization 数据单元、向量 I/O 与量化 label Nov 4, 2026
@DoubleCore

Copy link
Copy Markdown
Author

本轮评审已处理,新提交 e81e314f(上一条 head a08dcb7e 已过期)。

已修

  1. [P2][P1] 残差量化器训练与 balanced 分配不一致 — BucketDataCell::Train 与 RaBitQSplitBucketDataCell::Train 改用 ClassifyDatasForBuild,让残差学习覆盖与初次 Build 相同的容量约束分配;新增 BuildAssignedPartitionStrategy 回归用例(Build 把首行放进少座位桶时残差不再被截断)。
  2. [P2][P2][critical] 饱和距离无法选中座位 — 候选缓冲与回退重扫的初值从 K_MAX_DISTANCE 改为 +inf,clamp 到 K_MAX_DISTANCE 的真实距离仍可被选中。
  3. [P2] cosine 行未归一化 — Assign() 先按 metric 归一化输入行再比较分配代价,头文件契约同步说明。
  4. [P2] 工作线程异常被 future.wait() 吞掉 — 改为 future.get() 收集首个异常,全部块结束后 rethrow_exception;新增 WorkerFailAllocator 用例验证异常能穿透。
  5. [P3] --dataset indivisible --n 1 --k 1 被减成空集 — 仅在 k > 1 时做不可整除调整。
  6. [P3] seeded baseline 不可复现 — bench 基线显式传 deterministic_reduction=true。

未处理(如实说明)

  • [P1] 把 benchmark 指南并入官网 docs:需要同步改 docs/docs/{en,zh}/src/SUMMARY.md 与站点导航,属文档结构变更,留给维护者确认落点后再动,避免与其它文档 PR 冲突。
  • [warning] uniform_real_distribution<double>(0.0, total_weight) 在 total_weight=+inf/NaN 时 UB:该路径只在权重含非有限值时触发,本次已在校验阶段拒绝非有限输入;若要再加一层 clamp 需要先确认 k-means++ 权重来源是否可能溢出。
  • [suggestion] buckets_per_data == 1 限制与 n_probe > 1 的交互:属设计取舍(balanced 分配需单一归属),已在 balance_buckets 校验处报错,未改行为。

验证:clang-format 人工核对、C++ 声明/定义/调用点交叉核对(ClassifyDatasForBuild 为 IVFPartitionStrategy 已声明的虚函数,默认实现回退到 ClassifyDatas),新增 3 个单测。未运行 CMake 全量构建(roaringbitmap FetchContent 在本机下载挂起)。

@vsag-bot vsag-bot 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.

Reviewer | Code review

Automated pull request review completed.
Review decision: Comment only. No approval was submitted.

Model: gpt-6.1-sol; reasoning effort: high.
Review scope: high (2412 changed lines across 23 files).
Submitted 2 inline comments.
Review coverage checks completed; this does not prove absence of defects.
Static review only; compilation and runtime tests are outside this review's scope.
File coverage: 23/23; observed passing test runs: 0.

Reviewed commit e81e314.

if (thread_pool_->IsCurrentThread()) {
scan_block(begin, end);
} else {
futures.emplace_back(thread_pool_->GeneralEnqueue(scan_block, begin, end));

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.

[P2] [P2] Drain submitted scans when task submission fails

If GeneralEnqueue or futures.emplace_back throws after a scan has been queued, execution skips the draining loop below. Futures backed by packaged_task do not join on destruction, so workers can continue using cand_dist, cand_id, demand, and demand_mutex after those objects are destroyed. This turns an allocation failure or rejecting custom thread pool into use-after-free during training or Build. Guard submission as well as worker completion, and ensure every submitted task is joined before unwinding.

float* dest = sums.data() + static_cast<uint64_t>(label) * static_cast<uint64_t>(dim_);
++sizes[label];
for (int32_t d = 0; d < dim_; ++d) {
dest[d] += src[d];

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.

[P2] [P2] Use a stable accumulator for large cluster means

Accumulating an entire cluster into one float loses contributions once the running sum becomes large. For dim=1, k=1, and 2^25 copies of 1.0F, the sum stops increasing at 2^24 and the returned centroid becomes 0.5 instead of 1.0. This valid input is also reachable with an explicitly enlarged train_sample_count; the existing trainer's 1024-vector block reduction preserves this example. Use double accumulation or a stable deterministic reduction so large clusters retain correct means.

@vsag-lgtm vsag-lgtm 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.

Automated review @ e81e314f — 4 inline comment(s).

Comment thread src/datacell/bucket_datacell.h Outdated
// Residuals must cover the same capacity-constrained assignments used by
// the initial Build; nearest-centroid training can otherwise clip the
// residuals of vectors that balancing moves to another bucket.
auto buckets = strategy_->ClassifyDatasForBuild(data_ptr, count, 1, nullptr);

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.

[warning] The comment above promises "the same capacity-constrained assignments used by the initial Build", but the training batch is not the build batch, so that guarantee does not hold.

IVF::Train routes the sampled dataset here: sample_train_data(...) limits the set to train_sample_count_ (default MAX_TRAIN_COUNT) and bucket_->Train(data_ptr, sample_count) is called with that subset (src/algorithm/ivf/ivf_build.cpp:159-166). The build assignment, in contrast, runs on the entire first Add batch (src/algorithm/ivf/ivf_build.cpp:199-204). BalancedKMeansCluster::Assign is capacity-constrained, so its labels depend on the batch: with n_train != n_build the per-cluster targets ceil(n/k) / floor(n/k) differ and, via the regret ordering, a vector near a capacity boundary can end up in a different bucket than at build time. For such a vector this quantizer records the residual range against a centroid that encode never uses for it - i.e. exactly the clipping this change is meant to remove. Note also that the mismatch runs in both directions: balanced residuals are typically larger than nearest-centroid residuals, so many ranges become looser than needed and encode cannot produce them.

The Train(); Add(); API path is a second mismatch: Add calls add(base, false), so initial_bucket_assignment is always false and every vector is routed by nearest centroid, while the residual ranges here were trained from balanced labels.

No test covers either case - the new UT uses BuildAssignedPartitionStrategy, which returns bucket 1 for every row - so CI cannot detect the divergence. src/datacell/rabitq_split_bucket_datacell.cpp:1368 has the same call and the same exposure.

Suggestion: use the balanced assignment only when the datacell is trained on the exact batch that will later be assigned, and keep plain ClassifyDatas otherwise (or state the sampling caveat in the comment).


Vector<float> normalized_datas(allocator_);
if (metric == MetricType::METRIC_TYPE_COSINE) {
normalized_datas.resize(count * static_cast<uint64_t>(dim_));

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.

[warning] For METRIC_TYPE_COSINE this materializes a second full copy of the input (count * dim_ * 4 bytes) and normalizes every row into it.

Both datacell call sites that now reach Assign through ClassifyDatasForBuild already hold normalized rows: BucketDataCell::Train normalizes into train_data_buffer (src/datacell/bucket_datacell.h:631-635) and RaBitQSplitBucketDataCell::Train into normalized_data (src/datacell/rabitq_split_bucket_datacell.cpp:1357-1363). There the copy is pure duplication and Normalize runs a second time over the same rows. On the IVF::add path the caller passes raw user vectors, so a normalization is genuinely required - but the extra dataset-sized allocation remains, and it scales with the whole initial batch (10M x 768 float rows is ~28 GiB), which is at odds with this function's documented bounded-memory behaviour.

Suggestion: normalize block-wise inside balanced_assign (the candidate scan already runs in 1024-row blocks and the loss pass can do the same), or let callers declare their rows already normalized so the copy is skipped. The former also removes the double normalization in both Train paths.

if (metric == MetricType::METRIC_TYPE_COSINE) {
normalized_datas.resize(count * static_cast<uint64_t>(dim_));
for (uint64_t i = 0; i < count; ++i) {
Normalize(datas + i * static_cast<uint64_t>(dim_),

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.

[note] Normalize divides by the row norm without a zero-norm guard, so a zero vector becomes NaN in normalized_datas. clamp_distance maps NaN to K_MAX_DISTANCE, so such rows are ranked after every non-degenerate row and each one contributes FLT_MAX to the accumulated loss, making Stats().loss unusable for any batch containing zero vectors. Before this change the same row compared as 1 - 0 = 1.0 and stayed finite. Labels remain valid because balance is still enforced, so this is informational unless Stats().loss is consumed.

1e-6F,
vsag::KMeansInitMethod::KMEANS_PLUS_PLUS,
seed,
/*deterministic_reduction=*/true);

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.

[note] This makes the baseline arm deterministic while the production path is not: the IVF k-means trainer calls KMeansCluster::Run(k, data, n, iter) and keeps the default deterministic_reduction = false (src/algorithm/ivf/ivf_nearest_partition.cpp:117-122), and the balanced arm has no equivalent switch. Since the table prints both time columns side by side, any runtime difference introduced by the deterministic reduction makes the printed comparison non-like-for-like. Please confirm the difference is negligible, or state the asymmetry in the benchmark output.

Document the buckets_per_data limitation and format the new regression tests.

Signed-off-by: DoubleCore <doublecore@users.noreply.github.com>
Assisted-by: OpenCode:ark-code-latest
@vsag-bot
vsag-bot self-requested a review November 4, 2026 17:08
@DoubleCore

Copy link
Copy Markdown
Author

Validation for 50c0c97: C++ was not compiled or tested locally (CMake/build was deliberately not run); only static symbol checks were performed. clang-format 20.1.8 and git diff --check passed. This PR does not touch tools/dataset_profiler, so its Python pytest command was not applicable. C++ test or CI success is not claimed.

@vsag-bot vsag-bot 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.

Reviewer | Code review

Automated pull request review completed.
Review decision: Comment only. No approval was submitted.

Model: gpt-6.1-sol; reasoning effort: high.
Review scope: high (3080 changed lines across 23 files).
Submitted 1 inline comment.
Review coverage checks completed; this does not prove absence of defects.
Static review only; compilation and runtime tests are outside this review's scope.
File coverage: 23/23; observed passing test runs: 0.

Reviewed commit 50c0c97.

Comment thread src/datacell/bucket_datacell.h Outdated
[](uint64_t sample, uint64_t sample_count, uint64_t population_count) {
const uint64_t step = population_count / sample_count;
const uint64_t remainder = population_count % sample_count;
return sample * step + sample * remainder / sample_count;

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.

[P2] [P2] Avoid periodic aliasing when sampling PQ residuals

This fixed-stride sampler can discard an entire residual population. For an L2 balanced index with one bucket, residual PQ/PQFS, train_sample_count=131072, and 131072 scalar rows alternating 0 and 100, both routes agree and build_samples becomes 65536. This expression selects only rows 2*i, so the centroid is 50 but every training residual is -50. All codewords then represent -50, collapsing both input values to the same reconstruction and corrupting distances and recall. The previous 65536-row prefix contained both residual values. Use representative sampling that avoids periodic aliasing, or preserve the existing prefix when no extra population is needed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. When nearest_samples == 0, Build residuals now retain the original 65,536-row prefix; fixed-stride sampling is used only when a nearest-only residual population must share that prefix. I also updated the PQ/PQFS regression to use 131,072 alternating 0/100 rows, so it covers this exact aliasing case and checks reconstruction of the +50 residual. Validation: per task constraints, I did not compile or run C++ tests locally. Static review, clang-format 20.1.8 --dry-run --Werror, and git diff --check passed. No Python code was changed, so the dataset_profiler Python tests were not applicable.

Signed-off-by: DoubleCore <851678303@qq.com>
Assisted-by: OpenCode:ark-code-latest

@vsag-lgtm vsag-lgtm 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.

Automated review @ bac458bb (increment 7ab5577c5a5e..bac458b, event SHA b583615 is an ancestor) - 2 inline comment(s): 1 warning, 1 note.

Comment thread src/datacell/bucket_datacell_test.cpp Outdated
Comment on lines +979 to +981
// The sole centroid is 50, so alternating rows have residuals -50 and +50.
// Both Build and nearest routing agree. Fixed-stride sampling used to select
// only even rows, filling PQ's 65,536-row prefix with just the -50 residual.

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.

[warning] This regression test cannot reach the sampling code it is meant to lock in, so the fix in bac458bb is currently unverified.

FixedCentroidPartitionStrategy (src/datacell/bucket_datacell_test.cpp:90) does not override HasDistinctBuildAssignment(), whose IVFPartitionStrategy base implementation returns false (src/algorithm/ivf/ivf_partition_strategy.h:79). BucketDataCell::Train therefore sets train_nearest = false and takes the single-route else branch (src/datacell/bucket_datacell.h:721); the whole prefix_limited block - including the new guard const bool sample_build = sample_prefix && nearest_samples != 0; (src/datacell/bucket_datacell.h:708) and the sample_index stride sampling - never executes for this strategy.

Consequences:

  1. The comment above is inaccurate: no fixed-stride sampling happens here. PQ simply truncates the first 65,536 rows of the single-route residual buffer.
  2. The aliasing scenario fixed by bac458bb (dual routing, pq/pqfs, count > 65536, zero rows where Build and nearest assignments disagree) has no regression coverage. Reverting the guard to plain sample_prefix would still leave this test green, with the build prefix collapsing onto every-even-row residue sampling again.

To cover the path, the strategy must return true from HasDistinctBuildAssignment() while ClassifyDatasForBuild and ClassifyDatas agree (so nearest_rows stays empty) on a pq/pqfs bucket with count > 65536. The sibling test "samples nearest residuals within the PQ prefix" uses MixedResidualPartitionStrategy, whose data always contains disagreeing rows, so it only exercises sample_build == true.

return false;
}
}
if (k >= 10000) {

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.

[note] Implicit coupling worth tracking: the magic 10000 duplicates the private KMeansCluster::THRESHOLD_FOR_HGRAPH = 10000ULL (src/impl/cluster/kmeans_cluster.h:103), which selects the deterministic BLAS path when k < THRESHOLD_FOR_HGRAPH (src/impl/cluster/kmeans_cluster.cpp). I verified the two agree today, so this guard and the en/zh ivf.md passages are accurate as of this push. Nothing ties them together at compile or test time, though: if the library threshold ever changes, the tool keeps rejecting k >= 10000 and silently diverges from the real HGraph switch point, voiding the reproducibility guarantee the docs advertise. Consider exposing the constant (public constexpr or accessor) or cross-checking it at tool startup.

Signed-off-by: DoubleCore <851678303@qq.com>
Assisted-by: OpenCode:ark-code-latest
@vsag-bot
vsag-bot self-requested a review November 5, 2026 06:33
@DoubleCore

Copy link
Copy Markdown
Author
  1. Residual prefix regression coverage — Fixed in ccacd7b30a1cd20b3ac7fb6309744535ea1fe6d4. The PQ/PQFS regression now uses a test-only dual-route fixed-centroid strategy whose Build and nearest assignments agree. It also asserts two classification requests, confirming that the dual-route path runs with nearest_samples == 0 and exercises the original-prefix guard. No production strategy or default behavior was changed.

  2. HGraph threshold coupling — Fixed in ccacd7b30a1cd20b3ac7fb6309744535ea1fe6d4. The existing internal KMeansCluster::THRESHOLD_FOR_HGRAPH constant is now visible to the benchmark without changing its value or the algorithm. The benchmark guard and diagnostic use that constant directly, and a static_assert ties the documented 10000 value in the en/zh IVF docs to the implementation.

Validation: per the local task constraints, I did not run CMake, compile C++, or execute C++ tests. Static symbol and control-flow review, clang-format 20.1.8 --dry-run --Werror, and git diff --check passed.

@vsag-bot vsag-bot 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.

Reviewer | Code review

Automated pull request review completed.
Review decision: Comment only. No approval was submitted.

Model: gpt-6.1-sol; reasoning effort: high.
Review scope: high (3106 changed lines across 24 files).
Submitted 1 inline comment.
Review coverage checks completed; this does not prove absence of defects.
Static review only; compilation and runtime tests are outside this review's scope.
File coverage: 24/24; observed passing test runs: 0.

Reviewed commit ccacd7b.

Comment thread src/datacell/bucket_datacell.h Outdated
const bool sample_build = sample_prefix && nearest_samples != 0;
for (uint64_t i = 0; i < build_samples; ++i) {
const uint64_t source =
sample_build ? sample_index(i, build_samples, count) : i;

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.

[P2] [P2] Avoid periodic aliasing when sampling Build residuals

The fixed stride can remove an entire Build-residual population when nearest-route extras exist. For one-dimensional L2 residual PQ/PQFS, use k=2, n=131072, train_sample_count=n, and rows repeating [0,100,100,100]. Balanced training yields centroids 50 and 100, with 32768 rows of value 100 forced into centroid 50. Here build_samples and nearest_samples are both 32768, so sample_index selects only Build rows 4*i: their residual is always -50. Every nearest extra has residual 0, so the codebook never sees the +50 residual needed by a quarter of the corpus and encodes those vectors as reconstructed value 50 instead of 100. Use sampling that avoids periodic aliasing for both populations; the nearest_samples == 0 special case does not cover this case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in a33a9ee.

Replaced fixed-stride indexing for both Build rows and nearest-route extras with fixed-seed reservoir sampling. The existing residual training buffer serves as the reservoir, so each population keeps its exact build_samples/nearest_samples budget, has no duplicate source rows, and needs no additional sampling allocation. The nearest_samples == 0 lone-prefix path remains unchanged.

Added PQ/PQFS regression coverage using repeating [0,100,100,100] rows. It inspects the quantizer input directly and asserts both -50 and +50 Build residuals, exact population sizes, and per-population source uniqueness; existing lone-prefix coverage remains.

Validation: clang-format 20.1.8 --dry-run --Werror and git diff --check passed; a static reviewer-scenario model confirmed that the old stride selected only -50 Build residuals. C++ was not compiled and unit tests were not run locally.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in a33a9ee. Both Build and nearest-extra populations now use fixed-seed reservoir sampling directly in the existing residual training buffer, replacing the fixed stride without duplicate rows, changing either sample budget, or adding sampling allocations. The nearest_samples == 0 lone-prefix path is unchanged. Added PQ/PQFS regression coverage using the [0,100,100,100] routes and inspecting the actual quantizer input: Build residuals must contain both -50 and +50, each population has exactly 32768 rows, and source-row IDs verify uniqueness within each population. Validation performed: clang-format 20.1.8 dry-run, git diff --check, static text checks, and a Python algorithm model (not a C++ test). No C++ compilation or C++ test execution was performed locally.

@vsag-lgtm vsag-lgtm 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.

Incremental review of bac458bb..ccacd7b3 (event SHA 50c0c979 is an ancestor of head): no new findings in this push; previously raised review thread(s) remain unresolved, so not approving.

Signed-off-by: DoubleCore <851678303@qq.com>
Assisted-by: OpenCode:ark-code-latest
@DoubleCore

Copy link
Copy Markdown
Author

Review follow-up: a33a9ee replaces periodic residual sampling for both Build and nearest-extra populations with deterministic in-buffer reservoir sampling. Budgets and memory bounds stay unchanged, and the lone-prefix path is preserved. Added direct PQ/PQFS regression assertions for both -50/+50 Build residuals, population counts, and source uniqueness.

Validation completed: clang-format 20.1.8 dry-run, git diff --check, and static reviewer-scenario checks. No local C++ compilation or unit-test execution was performed.

@DoubleCore

Copy link
Copy Markdown
Author

Review follow-up in a33a9ee: Build and nearest-extra residuals now use fixed-seed reservoir sampling in the existing training buffer, removing periodic aliases without extra sampling allocations. The new PQ/PQFS regression directly checks quantizer input for both Build residual signs, exact population sizes, and source-row uniqueness; lone-prefix coverage remains. clang-format 20.1.8, diff/static checks, and a Python algorithm model passed. C++ compilation and C++ tests were not run locally.

vsag-bot
vsag-bot previously approved these changes Nov 5, 2026

@vsag-bot vsag-bot 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.

Reviewer | Code review

Automated pull request review completed.
Review decision: Approved. No actionable findings were identified in this complete static review. CI and merge requirements remain separate.

Model: gpt-6.1-sol; reasoning effort: high.
Review scope: high (3252 changed lines across 24 files).
No actionable inline findings were found.
Review coverage checks completed; this does not prove absence of defects.
Static review only; compilation and runtime tests are outside this review's scope.
File coverage: 24/24; observed passing test runs: 0.

Reviewed commit a33a9ee.

@vsag-lgtm vsag-lgtm 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.

Automated review @ a33a9eea — 2 inline comment(s).

Comment thread src/datacell/bucket_datacell_test.cpp Outdated
Comment on lines +1116 to +1128
for (uint64_t i = 0; i < nearest_samples; ++i) {
const uint64_t offset = (build_samples + i) * dim;
const float residual = allocator->captured_values[offset];
const float source_value = allocator->captured_values[offset + 1];
REQUIRE(source_value >= 0.0F);
REQUIRE(source_value < static_cast<float>(count));
REQUIRE(source_value == std::floor(source_value));
const auto source = static_cast<uint64_t>(source_value);
REQUIRE_FALSE(nearest_seen[source]);
nearest_seen[source] = true;
// Only the forced 100 rows differ between Build and nearest routing.
REQUIRE(source % 4 == 1);
REQUIRE(residual == 0.0F);

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.

[warning] The nearest-extras reservoir path is never exercised by this regression test

With count = 131072 and exactly 32,768 mismatched rows, nearest_samples becomes min(32768, 65536 - 32768) == 32768, which equals nearest_rows.size(). The second call for_each_sample(sample_prefix, nearest_samples, nearest_rows.size(), ...) therefore executes the reservoir loop for (uint64_t i = sample_count; i < population_count; ++i) (src/datacell/bucket_datacell.h:715) zero times, so every nearest assertion in this block only validates the plain initial fill.

Only the Build population actually goes through the new reservoir code, even though the fix is described as replacing fixed-stride sampling for both the Build and the nearest-extra populations. Re-introducing stride-based selection for the nearest population alone (the aliasing problem this commit targets) would keep this test green.

Consider adding a case where the mismatched population exceeds the 32,768 budget, for example count = 131072 with every row mismatching so that nearest_samples = 32768 < nearest_rows.size() = 131072, and assert on the sampled nearest half (uniqueness plus both residual signs, as done for Build) so the reservoir behaviour is locked in for that population too.

return DefaultAllocator::Allocate(size);
auto* pointer = DefaultAllocator::Allocate(size);
if (size == capture_size) {
captured_pointers.emplace_back(pointer);

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.

[suggestion] Captured values depend on deallocation order and on an accidental byte-size match

captured_values is overwritten by every deallocation of a buffer whose byte size equals capture_size, so the assertions at the end of the test read whichever matching buffer happened to be freed last. In the new case capture_size is 65536 * 2 * sizeof(float) == 524288 bytes, and build_buckets / nearest_buckets are 131072 * sizeof(BucketIdType) == 524288 bytes as well, so the two classification arrays are tracked and captured too.

The residual assertions currently observe the training buffer only because train_data_buffer is the first local declared in Train and therefore destroyed last; reordering local declarations or changing the sampling budget would silently make these assertions compare against bucket IDs reinterpreted as floats. Consider making the assumption explicit, for example by keeping only the most recently allocated matching pointer (the training buffer is also the last such allocation) and asserting captured_pointers.size() == 1 at capture time.

Signed-off-by: DoubleCore <doublecore@users.noreply.github.com>
Assisted-by: OpenCode:ark-code-latest
@DoubleCore

Copy link
Copy Markdown
Author

Addressed both new review comments in commit 403ce4f46877bc0cafde9ca0685e01237d2b7104.

  • Nearest-extras reservoir: the regression now creates 65,536 mismatched rows, exceeding the unchanged 32,768 nearest sample budget. The sampled nearest half must have unique source rows and contain both negative and positive residuals. Existing Build assertions, both 32,768 sample budgets, the rejected-allocation check, and the lone-prefix case remain unchanged.
  • Training-buffer capture: the test allocator now retains only the most recently allocated matching pointer and requires captured_pointers.size() == 1 when capturing. Earlier same-sized classification allocations therefore cannot overwrite the training values according to deallocation order.

Validation: clang-format 20.1.8 --dry-run --Werror passed for src/datacell/bucket_datacell_test.cpp; git diff --check passed; static scenario checks confirmed 65,536 mismatches, nearest population greater than its budget, uniqueness-compatible routing, both residual signs in the full population, and only -100 residuals under the old stride. C++ was not compiled and C++ unit tests were not run locally.

@vsag-bot vsag-bot 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.

Reviewer | Code review

Automated pull request review completed.
Review decision: Comment only. No approval was submitted.

Model: gpt-6.1-sol; reasoning effort: high.
Review scope: high (3267 changed lines across 24 files).
Submitted 1 inline comment.
Review coverage checks completed; this does not prove absence of defects.
Static review only; compilation and runtime tests are outside this review's scope.
File coverage: 24/24; observed passing test runs: 0.

Reviewed commit 403ce4f.

}
// Each nearest extra is written before its normalized source row is overwritten
// by the Build residual.
uint64_t nearest_offset = count;

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.

[P2] [P2] Account for scalar quantizers' sampling of appended residuals

SQ8/SQ4 also subsample: ScalarQuantizationTrainer takes 100000 rows with stride 2147483647 % train_count. For a 131072-row corpus with 65530 differing routes, train_count=196602 makes that stride 1, so every appended nearest residual is skipped. A concrete L2 case is k=2, train_sample_count=131072, and ordered data containing 65536 zeros, 65530 ones, then six values of 20000. Balanced centroids are approximately 0 and 2.831; the sampled Build prefix contains only zero/negative residuals, giving an upper bound of zero. Later Add(1) routes to centroid zero and its residual +1 is clipped to zero, corrupting distances. The previous nearest-only training includes the +1 population. Apply population-aware sampling to scalar quantizers as well.

Signed-off-by: DoubleCore <doublecore@users.noreply.github.com>
Assisted-by: OpenCode:ark-code-latest
@vsag-bot
vsag-bot self-requested a review November 5, 2026 09:46
@DoubleCore

Copy link
Copy Markdown
Author

Addressed discussion_r4478684635 in 7ac69e0e5896ea96409264145d99d2db0cc8b58d.

  • SQ8/SQ4 now use the existing population-aware reservoir path, splitting their existing 100,000-row budget between Build and nearest-only residuals. The quantizer receives at most 100,000 rows, so its fixed-stride sampler cannot discard the appended population; the full 196,602-row temporary is also avoided.
  • Added the exact SQ8/SQ4 L2 regression: 131,072 rows (65,536 zeros, 65,530 ones, six 20,000 values), old stride 1, 50,000 sampled +1 nearest residuals, rejected full-size allocation, and subsequent Add(1) distance. Existing PQ reservoir and rejected-allocation tests remain unchanged.

Validation: clang-format 20.1.8, git diff --check, and static symbol/boundary checks passed. C++ was not compiled locally and tests were not run; only static/source and formatting checks were performed.

@vsag-bot vsag-bot 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.

Reviewer | Code review

Automated pull request review completed.
Review decision: Comment only. No approval was submitted.

Model: gpt-6.1-sol; reasoning effort: high.
Review scope: high (3372 changed lines across 24 files).
Submitted 1 inline comment.
Review coverage checks completed; this does not prove absence of defects.
Static review only; compilation and runtime tests are outside this review's scope.
File coverage: 24/24; observed passing test runs: 0.

Reviewed commit 7ac69e0.

const auto quantizer_name = this->GetQuantizerName();
prefix_limited = quantizer_name == QUANTIZATION_TYPE_VALUE_PQ or
quantizer_name == QUANTIZATION_TYPE_VALUE_PQFS;
scalar_limited = quantizer_name == QUANTIZATION_TYPE_VALUE_SQ8 or

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.

[P2] [P2] Include uniform scalar quantizers in residual sampling

sq8_uniform and sq4_uniform also use ScalarQuantizationTrainer's 100000-row sampler, but this condition sends them through the unsampled dual-buffer branch. For the ordered 131072-row population in the new scalar test, the resulting 196602 residual rows give sampler stride 1: it reads only the Build prefix and drops every appended +1 nearest-route residual. Both uniform trainers then learn an upper bound of zero, so later Add vectors with that residual are clipped and become indistinguishable from zero residuals. Include both uniform quantizers in the population-budgeting path.

Signed-off-by: DoubleCore <doublecore@users.noreply.github.com>
Assisted-by: OpenCode:ark-code-latest
@vsag-bot
vsag-bot self-requested a review November 5, 2026 10:49
@DoubleCore

Copy link
Copy Markdown
Author

Addressed vsag-bot review discussion_r4478974261 in commit ebadeaab2f1c06fdaa5f97ac8d1b1f03a2e33933.

  • Added sq8_uniform and sq4_uniform to the existing 100000-row population-budgeting/sampled dual-buffer residual path used by sq8/sq4. Other quantizers and the PQ lone-prefix behavior are unchanged.
  • Extended the existing BucketDataCell samples scalar build and nearest residual populations generator to both uniform types. Its existing assertions now cover the 100000-row captured training buffer, the appended nearest-residual half containing +1 rows, and the subsequent Add/Query regression for all four scalar types.

Validation performed:

  • Statically checked the quantizer names against inner_string_params.h, the bucket factory, and both uniform quantizers' NameImpl() definitions.
  • ./scripts/format/format-cpp.sh — passed with clang-format 20.
  • clang-format --dry-run --Werror src/datacell/bucket_datacell.h src/datacell/bucket_datacell_test.cpp — passed.
  • git diff --check and git diff HEAD^ HEAD --check — passed.

No local CMake/C++ build or C++ test execution was performed in this environment. This comment does not claim that CI is green.

@vsag-bot vsag-bot 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.

Reviewer | Code review

Automated pull request review completed.
Review decision: Approved. No actionable findings were identified in this complete static review. CI and merge requirements remain separate.

Model: gpt-6.1-sol; reasoning effort: high.
Review scope: high (3374 changed lines across 24 files).
No actionable inline findings were found.
Review coverage checks completed; this does not prove absence of defects.
Static review only; compilation and runtime tests are outside this review's scope.
File coverage: 24/24; observed passing test runs: 0.

Reviewed commit ebadeaa.

@vsag-lgtm vsag-lgtm 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.

Incremental review @ ebadeaab (increment a33a9eea..ebadeaab, includes 403ce4f/7ac69e0e; event SHA 403ce4f is an ancestor of head): scalar residual population budgeting, reservoir sampling and the new regression tests verified; the uniform-variant coverage added in ebadeaa matches the SQ8UniformQuantizer/SQ4UniformQuantizer names and the trainer's 100,000-row cap. No new findings.

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

Labels

area/docs Website and repository documentation 网站与仓库文档 area/testing Tests, fixtures, and test infrastructure 测试、夹具与测试基础设施 area/tools Command-line, evaluation, and developer tools 命令行、评测与开发工具 kind/feature Brand-new functionality or capabilities 引入全新的功能、新特性或新能力 module/datacell Data cells, vector I/O, and quantization 数据单元、向量 I/O 与量化 module/index Index algorithms and implementations 索引算法与实现 size/XXL 1000+ changed lines version/1.2 蚂蚁开源轻训营专属

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Balanced K-Means Clustering for IVF Partitioning

5 participants