Copilot commented on code in PR #95:
URL: 
https://github.com/apache/paimon-vector-index/pull/95#discussion_r3951092938


##########
core/benches/ann_bench.rs:
##########
@@ -999,8 +999,48 @@ fn run_query_case(
         ("DISKANN", DiskAnnRawVectorEncoding::F16) => "f16",
         _ => "none",
     };
+    // The original passes above retain their first-pass I/O and recall 
semantics.
+    // Optional CI timing starts after those complete sweeps have warmed each 
reader.
+    let steady_min_ms: u64 = read_env("ANN_STEADY_MIN_MS", 0)?;
+    if steady_min_ms > 0 && !storage.latency.is_zero() {
+        return Err("ANN_STEADY_MIN_MS requires local_ssd_warm_cache".into());
+    }
+    let mut steady_sequential_queries = 0usize;
+    let mut steady_batch_queries = 0usize;
+    let mut steady_latencies = Vec::new();
+    let mut steady_sequential_elapsed = Duration::ZERO;
+    let mut steady_batch_elapsed = Duration::ZERO;
+    if steady_min_ms > 0 {
+        let minimum = Duration::from_millis(steady_min_ms);
+        let started = Instant::now();
+        while started.elapsed() < minimum {
+            for query in dataset.queries.chunks_exact(config.d) {
+                let query_started = Instant::now();
+                std::hint::black_box(reader.search(query, search)?);
+                steady_latencies.push(query_started.elapsed());
+            }

Review Comment:
   The steady-state loop stores a latency `Duration` for every sequential query 
across all iterations until `ANN_STEADY_MIN_MS` elapses. If searches are very 
fast, this vector can grow large (potentially hundreds of thousands to millions 
of entries), increasing memory usage and pushing extra work into the percentile 
calculation. Consider bounding memory by collecting latencies for only one 
sweep, sampling (e.g., every Nth query), or using a fixed-size 
histogram/streaming quantile approach to estimate P95 without retaining all 
measurements.



##########
tools/benchmark_pr.py:
##########
@@ -0,0 +1,382 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Compare two disposable checkouts using the candidate's ANN benchmark 
driver."""
+
+import argparse
+import csv
+import hashlib
+import json
+import math
+import os
+from pathlib import Path
+import platform
+import shutil
+import signal
+import statistics
+import subprocess
+import time
+
+
+INDEXES = ("IVF_FLAT", "IVF_SQ", "IVF_PQ", "IVF_RQ", "DISKANN")
+WORKLOAD = {
+    "ANN_DATASET_NAME": "ci-clustered-v1",
+    "ANN_N": "10000",
+    "ANN_TRAIN_N": "4096",
+    "ANN_NQ": "2048",
+    "ANN_D": "64",
+    "ANN_K": "10",
+    "ANN_NLIST": "64",
+    "ANN_NPROBE": "8",
+    "ANN_PQ_M": "8",
+    "ANN_RQ_BITS": "4",
+    "ANN_CLUSTERS": "32",
+    "ANN_NOISE_DIMENSIONS": "64",
+    "ANN_SEED": "42",
+    "ANN_DISKANN_L_SEARCHES": "100",
+    "ANN_DISKANN_MEMORY_BUDGET_BYTES": str(256 * 1024 * 1024),
+    "ANN_DISKANN_BUILD_DISTANCE": "full_precision",
+    "ANN_DISKANN_RAW_VECTOR_ENCODING": "f32",
+    "ANN_STORAGE_CASES": "local_ssd_warm_cache",
+    "RAYON_NUM_THREADS": "2",
+    "ANN_STEADY_MIN_MS": "1000",
+}
+# These identify the workload, not measured results. Never compare unlike 
cases.
+CASE_FIELDS = (
+    "dataset", "index", "storage", "n", "train_n", "nq", "d", "k", "nlist",
+    "nprobe", "pq_m", "rq_bits", "diskann_build_distance",
+    "diskann_raw_vector_encoding", "l_search", "steady_min_ms",
+)
+METRICS = (
+    ("recall_at_10", "Batch Recall@10", "recall"),
+    ("steady_sequential_qps", "Warm sequential QPS", "higher"),
+    ("steady_batch_qps", "Warm batch QPS", "higher"),
+    ("steady_sequential_p95_us", "Warm sequential P95 (µs)", "lower"),
+    ("build_ms", "Build (ms)", "lower"),
+    ("peak_rss_bytes", "Process peak RSS up to build completion (MiB)", 
"lower"),
+    ("file_bytes", "Index size (MiB)", "lower"),
+)
+
+
+def command_output(command, cwd=None):
+    return subprocess.check_output(command, cwd=cwd, text=True, 
timeout=30).strip()
+
+
+def run_checked(command, *, deadline, timeout, **kwargs):
+    remaining = deadline - time.monotonic()
+    if remaining <= 0:
+        raise TimeoutError("Benchmark total time budget exhausted; stopping 
before next process")
+    limit = min(timeout, remaining)
+    process = subprocess.Popen(command, start_new_session=os.name == "posix", 
**kwargs)
+    try:
+        returncode = process.wait(timeout=limit)
+    except subprocess.TimeoutExpired as error:
+        # Cargo can leave rustc children alive if only the parent is killed.
+        if os.name == "posix":
+            os.killpg(process.pid, signal.SIGKILL)
+        else:
+            process.kill()
+        process.wait()
+        raise TimeoutError(
+            f"{command[0]} exceeded {limit:.1f}s process/remaining total 
budget"
+        ) from error
+    if returncode:
+        raise subprocess.CalledProcessError(returncode, command)
+
+
+def numeric_field(row, field, path, *, integer=False):
+    raw = row.get(field)
+    if raw is None or not raw.strip():
+        raise ValueError(f"{path}: missing or empty metric/parameter {field}")
+    try:
+        value = int(raw) if integer else float(raw)
+    except (ValueError, OverflowError) as error:
+        raise ValueError(f"{path}: invalid {field}={raw!r}; expected a 
number") from error
+    if not math.isfinite(value) or value < 0:
+        raise ValueError(f"{path}: invalid {field}={raw!r}; expected finite 
nonnegative value")
+    return value
+
+
+def read_sample(path, index):
+    with path.open(newline="") as source:
+        rows = list(csv.DictReader(source))
+    if len(rows) != 1:
+        raise ValueError(f"{path}: expected exactly one case, found 
{len(rows)}")
+    row = rows[0]
+    if row.get("index") != index:
+        raise ValueError(f"{path}: expected index {index}")
+    for field in CASE_FIELDS:
+        if not row.get(field):
+            raise ValueError(f"{path}: missing workload field {field}")
+    for field, _, _ in METRICS:
+        numeric_field(row, field, path)
+    if not 0 <= float(row["recall_at_10"]) <= 1 or numeric_field(row, "nq", 
path, integer=True) <= 0:
+        raise ValueError(f"{path}: invalid recall or query count")
+    if float(row["steady_sequential_qps"]) <= 0 or 
float(row["steady_batch_qps"]) <= 0:
+        raise ValueError(f"{path}: throughput must be positive")
+    minimum = numeric_field(row, "steady_min_ms", path, integer=True)
+    if minimum <= 0:
+        raise ValueError(f"{path}: steady_min_ms must be positive")
+    for mode in ("sequential", "batch"):
+        elapsed = numeric_field(row, f"steady_{mode}_ms", path)
+        queries = numeric_field(row, f"steady_{mode}_queries", path, 
integer=True)
+        if elapsed < minimum or queries < int(row["nq"]) or queries % 
int(row["nq"]):
+            raise ValueError(f"{path}: incomplete steady_{mode} measurement")
+    return row
+
+
+def metric_value(row, field):
+    value = float(row[field])
+    if field in ("peak_rss_bytes", "file_bytes"):
+        return value / (1024 * 1024)
+    return value
+
+
+def delta_text(base, candidate, direction):
+    if direction == "recall":
+        return f"{(candidate - base) * 100:+.2f} pp"
+    if base == 0:
+        return "0.0%" if candidate == 0 else "n/a (base=0)"
+    return f"{(candidate / base - 1) * 100:+.1f}%"
+
+
+def summarize(samples, rounds):
+    results = {}
+    for index in INDEXES:
+        sides = samples[index]
+        if any(len(sides[side]) != rounds for side in ("base", "candidate")):
+            raise ValueError(f"{index}: incomplete samples")
+        cases = {
+            tuple(row[field] for field in CASE_FIELDS)
+            for rows in sides.values() for row in rows
+        }
+        if len(cases) != 1:
+            raise ValueError(f"{index}: workload differs between samples")
+        result = {}
+        for field, label, direction in METRICS:
+            entry = {"label": label, "direction": direction}
+            for side in ("base", "candidate"):
+                values = [metric_value(row, field) for row in sides[side]]
+                entry[side] = {
+                    "median": statistics.median(values),
+                    "min": min(values), "max": max(values), "samples": values,
+                }
+            entry["delta"] = delta_text(
+                entry["base"]["median"], entry["candidate"]["median"], 
direction
+            )
+            result[field] = entry
+        results[index] = result
+    return results
+
+
+def alert_level(entry):
+    base, candidate = entry["base"]["median"], entry["candidate"]["median"]
+    if entry["direction"] == "recall":
+        loss = round((base - candidate) * 100, 6)
+        yellow, red = 1, 3
+    else:
+        loss = round((1 - candidate / base) * 100, 6) if base else 0

Review Comment:
   `alert_level()` currently treats any drop vs base as a regression for all 
non-recall metrics, but it ignores the `direction` value (`higher` vs `lower`). 
For `lower` metrics (e.g., build time, RSS, size), regressions are increases, 
not decreases, so the current formula will fail to flag worse results. Update 
the computation to branch on `entry['direction']` so that `higher` flags 
decreases and `lower` flags increases (while still keeping the recall 
percentage-point logic).



##########
core/benches/ann_bench.rs:
##########
@@ -1096,7 +1139,9 @@ fn exact_ground_truth(dataset: &Dataset, k: usize) -> 
Vec<Vec<i64>> {
                         .then_with(|| left.1.cmp(&right.1))
                 });
             }
-            distances.into_iter().map(|(_, row)| row).collect()
+            // Borrow the retained top-k slice: consuming the Vec can reuse its
+            // original N-vector allocation for every ground-truth row.

Review Comment:
   The comment describes allocation reuse behavior that doesn’t match what this 
code does: `collect()` here still allocates a fresh `Vec<i64>` for the 
ground-truth row, and iterating (rather than consuming) doesn’t by itself reuse 
the original `distances` allocation. Please update the comment to reflect the 
actual intent/behavior (e.g., avoiding an extra move vs borrowing), or adjust 
the implementation if allocation reuse is the goal.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to