feat(#4085): split a sparse-vector top-K into parallel RID ranges - #5518
Conversation
Wires the dispatch SparseVectorScoringPool was built for. A query over one index is scored as several RID ranges concurrently and merged, instead of one traversal on the caller's thread. Measured on an 18-worker box, SPLADE-shaped corpus, INT8, 59 terms, k=10: 1M docs, single query: 26.2 ms p50 serial -> 7.6 at 8 ranges (3.5x), 4.5 at 16 (5.9x) 500k docs, single query: 13.4 -> 4.0 (3.4x) -> 2.4 (5.6x) Splitting is not free and the gating is most of this commit. A range prunes against its own top-K watermark rather than the global one, so it does more work than its share - 1.16x total CPU at 2 ranges, 1.89x at 8. On an idle machine that buys latency for nothing; on a busy one it takes throughput from other queries. A query therefore claims the workers it wants up front, and the claim is refused once enough queries are in flight to keep the pool busy without help. Sizing it from pool activity instead was tried and measured worse: a query runs on its caller's thread, so at 16 concurrent clients the pool reads idle at every sampling instant, a third of queries split anyway, and throughput dropped 14%. With the in-flight gate, 500k/16 clients: 829 vs 869 qps serial (-4.6%), 2 of 8289 queries split; at 4 clients, 468 vs 290 qps (+61%) with p50 13.6 -> 4.1 ms. Also refuses to split when the caller holds uncommitted page changes (a worker's own transaction context cannot see them), and when the caller is already a pool worker - a nested fan-out on a bounded queue deadlocks rather than degrading, since the outer tasks hold every worker while waiting on inner tasks nothing is left to run. Results are the serial ones, ties included: RidScoreMinHeap now breaks ties on RID ascending so which of several equally-scored documents survives no longer depends on heap layout, and the merge ranks the same way. Scores can differ by an ulp between the two shapes because MaxScore sums a document's terms in an order that follows the pruning split - documented on the test that asserts it. The caller scores one range itself rather than blocking on all of them. topKGrouped stays serial: merging grouped results needs the per-group worsts, not a flat best-k, and that is its own piece of work. New knobs: arcadedb.sparseVectorScoringMaxPartitions (0 = adaptive, 1 = off), arcadedb.sparseVectorScoringMinPostingsForPartitioning. Tests: ParallelRangeTopKTest (range/serial equivalence at 2..16 ranges, tie determinism, empty and out-of-bounds ranges, engine-level equivalence, no nested split, no worker leak, in-flight suppression) and MultiBucketSealedSegmentFanoutTest, which covers multi-bucket over sealed segments - previously untested, and passing only because LocalDatabase creates a thread context as a side effect.
LSMSparseVectorIndexLargeBenchmark compared the index against brute force but had no serial-vs-split arm, so it could not answer the question the issue asks it to. It now runs the sweep twice - traversal pinned to the caller thread, then the adaptive split - reports both plus the ratio, and asserts the two shapes return the same documents. That assertion is the only correctness check the split gets at real scale: multi-segment, a real index built through the SQL path, rather than a hand-built segment. Measured on an 18-worker box: 10M docs: 24.18 -> 6.90 ms/query (3.51x), same documents 1M docs: 5.13 -> 2.83 ms/query (1.82x), same documents The 1M gain is smaller because this benchmark's query is 10 terms over 30 nnz/doc: at 5 ms of work, per-range cursor setup and the merge take a visible share. The split pays in proportion to how much work the query does, which is why the 59-term SPLADE shape shows 3.4x at the same corpus size.
The card showed pool mechanics only, which left an idle sparse-vector row ambiguous in a way an operator could not resolve: nobody querying, everybody querying but the load gate has switched splitting off, queries too small to qualify, or something broken - all four render identically, because a query that stays serial never touches the pool. Three gauges on the sparse-vector row, carried by the existing arcadedb.executor.* naming so the metrics endpoint and any Grafana selector pick them up with no handler change: workers currently reserved, top-K queries in flight, and cumulative queries split. The middle one is what the gate decides against, so it explains the other two. Registered for this pool only - the others take work as handed to them and have no decision to explain - so Studio renders a dash rather than a zero that would read as "nothing is splitting" where the concept does not apply. PoolMetricsTest pins both halves: registered for sparse_vector, absent for query, in the gauge registry and in the JSON the dashboard actually reads.
|
Tick the box to add this pull request to the merge queue (same as
|
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 1 high |
| CodeStyle | 7 minor |
🟢 Metrics 12 complexity
Metric Results Complexity 12
🟢 Coverage 85.31% diff coverage · -6.61% coverage variation
Metric Results Coverage variation ✅ -6.61% coverage variation Diff coverage ✅ 85.31% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (c906c6b) 146708 108811 74.17% Head commit (013468d) 178902 (+32194) 120869 (+12058) 67.56% (-6.61%) Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch:
<coverage of head commit> - <coverage of common ancestor commit>Diff coverage details
Coverable lines Covered lines Diff coverage Pull request (#5518) 211 180 85.31% Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified:
<covered lines added or modified>/<coverable lines added or modified> * 100%
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
Review: parallel RID-range top-K (#4085)Went through this end to end. It is a strong, unusually well-documented change: the reservation-based load gate is a genuinely clever answer to the "a query runs on the caller thread, so the pool always looks idle" blind spot, the tie-break change makes serial/parallel equivalence provable rather than hand-waved, and the test suite covers the parts that matter (range equivalence at 2..16, tie determinism, empty/out-of-bounds ranges, no-nest, worker release, in-flight suppression, engine-level split, plus the large-benchmark equivalence assertion). The reasoning captured in the Javadoc is the kind that stops a future refactor from silently regressing this. A few things worth a second look before merge, none of them blockers: 1. Reservation is acquired and released in different methods
2.
|
…unt, partition clamp Four points from review, plus a regression the benchmark caught while fixing one of them. **Reservation released on any throw between claim and return.** planPartitionBoundaries claimed workers and topK released them, so a throw in between leaked the claim permanently, silently shrinking what every later query believed was free. The boundary walk cannot currently throw, but the method reads segment metadata and is declared to throw, so a future page read there would have been a silent, unbounded leak. **Pool-thread queries counted separately from caller-thread ones.** The per-bucket fan-out submits one topK per bucket, so a single query against an 8-bucket type registered as eight in flight and could trip the load gate for unrelated queries on a quiet box. They are real load, but of a different kind: they occupy workers, so they now subtract from grantable capacity, while the caller-thread count keeps driving the gate. Skipping them entirely, as suggested, would have left a split blind to per-bucket work and free to oversubscribe it. That fix first read ThreadPoolExecutor.getActiveCount(), which is a trap: it takes the pool's main lock and walks the worker set. At 16 concurrent clients, with splitting almost entirely gated off, it cost 29% of throughput on its own - a decision path more expensive than the decision. Replaced with an AtomicInteger; 16-client throughput went from 549 qps against 772 serial back to 718 against 709. The trap is recorded at the call site. **Explicit maxPartitions clamped** to 4x pool size. The knob means "do not throttle me", not "open a thousand cursor stacks" - past a small multiple of the pool there is no thread free to run them. **Tie-break documented as reaching serial-only users.** The ordering lives in the collector, so a database with splitting disabled gets it too: anyone whose scores tie can see a different retained set than a previous release. Also: the deadline path is now tested, deterministically - every worker is jammed with a latch so the range is submitted but never started, rather than racing a clock - and it asserts the timed-out query hands its claim back. Import nit fixed.
|
Thanks, this was a useful review. Pushed as 1. Reservation acquired and released in different methods. Agreed, and fixed. The claim is now released on any throw between acquiring it and returning the boundaries. You are right that the walk cannot currently throw, but the method reads segment metadata and is declared 2. Worth flagging what that cost me. My first implementation read 3. Explicit 4. Tie-break affects serial-only users. Correct, and the more important half of that point. Now documented where the behaviour lives: the ordering is in the collector, so a database with splitting disabled entirely gets it too, and anyone whose scores tie can see a different retained set than a previous release. Nits. Import fixed. The deadline path is now tested, and deterministically rather than by racing a clock: every worker is jammed with a latch so the range gets submitted but never started, and the test also asserts the timed-out query hands its claim back. Verification, including what I could not finish
The full What bounds the risk: every production file in this commit is inside |
Review: split a sparse-vector top-K into parallel RID ranges (#4085)Thorough, well-instrumented change. The load-gate reasoning, the reservation-vs-observation argument, and the tie-break determinism are all carefully documented, and the test coverage (range/serial equivalence, tie determinism, no-nested-split, worker-leak, in-flight suppression, deadline release) is excellent. One issue below is worth blocking on; the rest are minor. 1. (High) parallelTopK can roll back the caller's transaction under the pool's caller-runs policyIn The caller thread already holds an active transaction context, so Why this is reachable:
Contrast with the two patterns this copies:
Suggested fix - mirror final boolean created = DatabaseContext.INSTANCE.getContextIfExists(database.getDatabasePath()) == null;
if (created)
DatabaseContext.INSTANCE.init(database);
try {
return rangeTopK(...);
} finally {
if (created)
DatabaseContext.INSTANCE.removeContext(database.getDatabasePath());
}(or skip init/remove entirely when 2. (Minor) Shared deadline is consumed by the caller's own range-0 scoring
3. Notes (no action needed)
Overall a strong change; #1 is the one I would want addressed before merge. Reviewed by Claude Code. |
…its transaction The pool's queue is bounded and its rejection policy is caller-runs, so once the queue fills, a submitted range runs inline on the submitting thread - the user's, inside the user's transaction. The task called DatabaseContext.init unconditionally, which on a thread that already has a context takes the "ROLLBACK PREVIOUS TXS" branch and silently rolls the caller's transaction back; the matching removeContext in the finally then wiped the context the rest of the query still needed, so the query failed with "Transaction context not found on current thread" on top of a transaction that was already gone. Now create-only-if-absent and tear down only what the task created, the same shape LocalDatabase.checkDatabaseIsOpen uses. The identical init/remove pair in FetchFromTypeExecutionStep that this was modelled on is safe only because its pool has an unbounded queue and never runs a task on the caller - not a property this pool has. Reachable two ways: an explicit maxPartitions bypasses the capacity gate, and even in adaptive mode the reservation counts started tasks rather than queued ones, so a burst can fill the queue while capacity still looks free. New test drives it deterministically - saturates workers and every queue slot so the range is rejected onto the caller, inside an open transaction - and asserts both the results and that the transaction and its context survive. Verified to fail without the fix, with exactly the reported symptom. Also: a range that has already finished is now harvested regardless of the deadline. The caller scores its own range before draining the futures, so it could consume the whole budget on work that succeeded and then fail the query while every worker range sat complete and waiting to be read. The deadline governs waiting, not collecting.
|
Good catch on #1, and it is worse than a crash. Fixed in 1. Caller-runs rolling back the caller's transaction. Confirmed exactly as described. Fixed with the create-only-if-absent guard you suggested, tearing down only what the task created. Your diagnosis of why the pattern was unsafe here is the part I had wrong when I wrote it: I copied The new test drives it deterministically rather than hoping to hit the window: it saturates every worker and every queue slot so the range is rejected onto the caller, with the caller inside an open transaction, and asserts both the result and that the transaction and its context are still alive afterwards. Verified it fails without the fix with precisely the reported symptom, so it is a real regression test and not a decoration. You were also right that 2. Shared deadline consumed by the caller's own range 0. Agreed, fixed. A future that is already done is now harvested regardless of the deadline; the timeout governs waiting, not collecting. Failing a query while every worker range sits complete and waiting to be read was the wrong outcome, however unlikely balanced ranges make it. 3. Noted, thanks for checking the merge behaviour at the k-th boundary and the Studio colspan - both were on my list of things I believed but had not proven. Verification
Still outstanding from my last comment: the full |
|
Review: split a sparse-vector top-K into parallel RID ranges (issue 4085) I read through the engine changes (BmwScorer, PaginatedSparseVectorEngine, RidScoreMinHeap, SparseVectorScoringPool), the server/Studio metrics, and the new tests. This is a strong, unusually careful piece of work: the correctness argument (disjoint RID partition, deterministic tie-break, merge = serial result) holds up, and the concurrency hazards are handled rather than hoped away. A few things I checked closely and found already correct, plus some minor observations. Verified correct (worth calling out)
Minor observations
On the three items you flagged for reviewers
Nice work overall. The gating rationale in the commit message and Javadoc is genuinely useful for the next person who touches this path. |
… caveat, balance limits Three observations from review, none behavioural except the log line. An explicit sparseVectorScoringMaxPartitions still bypasses the load gate, deliberately, but now logs a throttled WARNING the first time it is granted while enough queries are in flight that the adaptive default would have refused. The setting is JVM-wide and long-lived, so whoever configured it is rarely the person watching latency later; without a signal the trade it makes is invisible at exactly the load where it hurts. Same 60-second throttle as the saturation warning. queries.split counts the decision, not the outcome: a range submitted to a full queue runs inline on the caller under the caller-runs policy, so a query counted as split can still have executed serially. Said so in the metric description and on the Studio card, next to the Caller-Run Fallbacks column where that shows up. Recorded what the balance heuristic does not promise: boundaries come from one dim of one segment, so on an index with several live segments, before compaction merges them, that layout need not represent the global RID distribution and ranges can come out uneven. Correctness is unaffected - the ranges still partition the RID space and every document is scored exactly once - only the speedup, visible as one range finishing after its siblings. Not taken: @tag("slow") on ParallelRangeTopKTest. Measured at 1.46s for 10 tests, which is not what CLAUDE.md means by noticeably long, and tagging would pull a transaction-safety regression test out of regular CI.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5518 +/- ##
============================================
+ Coverage 65.31% 65.41% +0.10%
+ Complexity 1114 971 -143
============================================
Files 1748 1748
Lines 146708 147233 +525
Branches 31358 31468 +110
============================================
+ Hits 95817 96316 +499
+ Misses 37940 37662 -278
- Partials 12951 13255 +304 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
CI's unit-tests job hung for a full hour on aRangeForcedOntoTheCallerThreadLeavesTheCallersTransactionAlone. The test saturated the pool by computing "workers + free queue slots" from a snapshot and then submitting exactly that many blocking tasks. The snapshot races, and overshooting it by one is not a slow test but a permanently stuck one: the surplus task is rejected, the caller-runs policy executes it on the submitting thread, and it waits on a latch released only in a finally that thread can no longer reach. Locally the arithmetic happened to come out right, so it passed every run here. Two changes, either of which would have prevented it: - Submit until the queue reports itself full rather than computing a count up front, with a runaway ceiling. - A saturation task blocks only when it finds itself on a worker. One running on the submitting thread was rejected, and blocking there is the deadlock. It also waits with a timeout now, so a mis-sized saturation fails the test instead of wedging the job - the difference between a diagnosis and an hour of nothing. Applied to the deadline test's hogs as well, which had the same shape and the same latent hazard. Verified the test still earns its place: with the production contextCreated guard removed it fails with "Transaction context not found on current thread" exactly as before, so the rewritten loop still saturates for real.
|
Thanks. Review points addressed in CI: my test hung the unit-tests job for an hour
It saturated the pool by computing "workers + free queue slots" from a snapshot and then Fixed twice over, either half being sufficient: submit until the queue reports itself full I re-verified the test still earns its place rather than being quietly defanged: with the The other two CI failures are not mine
Review points1. Test tagging. Not taken, with a measurement: the class runs 10 tests in 1.46 s, which is 2. 3. Balance heuristic with multiple live segments. Taken as documentation. Boundaries come On the explicit- |
Review: split a sparse-vector top-K into parallel RID ranges (#4085)Read the whole diff plus the surrounding transaction/pool code. This is high-quality work: the correctness argument (ranges partition the RID space, so every doc is scored once and merge-then-keep-k is exact) is sound, the tie-break change that makes split and serial results identical is the right fix, and the adaptive load gate is genuinely well-reasoned. The test coverage is excellent - range/serial equivalence at 2..16 ranges, tie determinism, edge ranges, no-nested-split, worker-leak, in-flight suppression, deterministic deadline and caller-runs-transaction tests. The iterative fixes in patches 4-7 (reservation leak on throw, caller-runs rollback, the CI deadlock from a racing saturation count) show careful attention. A few points, none blocking: 1.
|
|
#5518 reproduces on real SPLADE: 3.44x at 1M. Your 3.5x holds on Big-ANN's SPLADE-cocondenser corpus, not just a SPLADE-shaped one. Wheel built from
recall@10 identical across all six cells, 0.9935 at 1M and 0.9950 at 100k, so the split is exact on our data as well as your tests. Two things you may not have measured. Your adaptive default beats a forced split at 1M, 11.42 against 13.30 ms. Whatever the heuristic is doing with The 100k tier gains much less, 1.69x against 3.44x. That fits your "pays in proportion to how much work the query does" exactly: our 1M queries carry 1.3M to 4.3M summed postings against a 200k threshold, so partitioning engages on essentially all of them, while at 100k a good share of queries sit near or under the gate. What it does to the comparison. At 1M against Qdrant's 2.91 ms, we go from 12.7x to 3.9x. For context on the trajectory, the same tier measured 165 ms when we first filed the cliff, so this is 165 -> 36.8 (released dev21) -> 11.4 with your split. How I will report it. Prose only for now, marked development line, since the branch is unmerged and our tables only carry released wheels. If it merges and ships, it moves into the table on the next release. The caveat is yours and I will carry it. Our lane is single-stream, so we measure the favourable end of a latency-versus-throughput trade; your 1.89x total CPU at 8 ranges is real and our numbers do not show it. Anyone reading our sparse row should know the split buys latency with cores on an idle box. If you want, I can run a concurrent-client arm to put a number on what it costs under load, which is the half our harness currently cannot see. Running now at 8.84M, serial versus adaptive, since that is where the traversal does the most work and where the gap to specialists is widest. I will post it whichever way it goes. |
…queries are load too Four review points, two of them real holes. **Uncommitted-changes guard missed nested transactions.** It asked only the innermost transaction whether it had changes, and begin() on an already-active transaction pushes a nested one - so a caller sitting in a fresh inner transaction over a dirty outer one reported clean, split, and its workers would have read committed pages without seeing what the outer transaction had written. Exactly the class of bug the guard exists to remove. Now checks every transaction on the stack. New test fails against the innermost-only version. **Grouped queries were invisible to the load gate.** topKGrouped never splits, but it runs a full traversal on its caller's thread and competes for the same cores. Unregistered, a plain topK arriving alongside heavy grouped traffic saw an idle gate, split, and took throughput from it - the failure the gate exists to prevent, from a source it could not see. **The gate factor is named.** CALLER_LOAD_GATE_FACTOR, with what it means and the measurements behind it. Kept a literal rather than a setting: it shapes only the middle of the range, and an operator wanting splitting off or always-on already has sparseVectorScoringMaxPartitions for both, so a third knob would be easy to set wrongly and hard to reason about. **Splitting disabled no longer touches the pool.** With maxPartitions=1 the query returns before getInstance(), which would otherwise build a ThreadPoolExecutor this JVM has just been told it will never use.
|
All four addressed in 1. Nested transactions - you were right, and it was reachable. 2. 3. The 4. Serial path touching the pool. Taken - with Nits. The caller-as-extra-concurrency point is exactly as you read it. The Studio browser check is still not done and I am still not claiming otherwise. Separately, @tae898 has now run this branch on the real Big-ANN SPLADE corpus, and it reproduces: 1M 39.31 ms serial to 11.42 ms adaptive, 3.44x, recall@10 identical across every cell. Two things from his run worth folding into the record here:
He is carrying the CPU-cost caveat explicitly, since his lane is single-stream and therefore measures the favourable end of the trade. He has offered a concurrent-client arm to put an independent number on what it costs under load, which is the half our own measurements cover and his currently cannot. |
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 1 high |
| CodeStyle | 8 minor |
🟢 Metrics 12 complexity
Metric Results Complexity 12
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
Review: split a sparse-vector top-K into parallel RID ranges (#4085)Reviewed the full 8-commit diff. This is a strong piece of work: the correctness argument (ranges partition the RID space, merge keeps best-k, tie-break made deterministic so the split is indistinguishable from serial) is sound and backed by 1. The reservation-leak guard in
|
…tten for The guard released the worker claim on `RuntimeException | Error`, while its own comment said it existed for a future edit that adds a page read - which throws IOException, and would have sailed straight past it. topK's finally cannot cover that case either, since it only releases inside the branch where boundaries came back non-null, so the claim would have leaked exactly as the comment warned. Replaced with a handed-off flag and a finally. Enumerating exception types is how a guard like this fails quietly; a finally covers checked, unchecked, Error, and any future early return that forgets the claim exists.
|
Point 1 is a fair hit and fixed in Rather than adding On point 2, the tie-break in the changelog. Agreed it needs to be there and it is not something a reader should have to infer from a PR body. Flagging it for @lvca to place, since release notes are his call rather than something I should be writing into the tree. The shape of it: any query whose scores tie can return a different retained set than a prior release, including serial queries and Point 3, agreed, no change. Thanks for the read on Two things still open on my side, unchanged: |
Review: PR #5518 - split a sparse-vector top-K into parallel RID rangesThorough, well-engineered change. The commit history shows several real review holes already found and closed (reservation leak, caller-runs transaction rollback, nested-transaction guard, per-bucket load double-count, the CI deadlock in the saturation test), and the test suite is genuinely adversarial rather than happy-path. The gating rationale (reservation as a claim rather than an observation, counting queries-in-flight rather than pool activity, the A few points, from most to least substantive. 1. The "exactly the serial result, ties included" claim has a gap the tests don't reach (correctness / test coverage)The PR states the split returns the serial documents and ranking, with only "scores differ by one ulp." But an ulp-level score difference and the tie-break are not independent:
So the strong equivalence claim is really "identical for distinct scores and for exactly-representable ties." Suggest either softening the wording in the docs/commit ("same documents up to ulp-level ties at the k boundary") or adding an equivalence case built on a quantized corpus with genuine near-ties at the boundary. The 2.
|
|
On review item 1, the tie/ulp gap: we may be able to supply the missing evidence, because our sparse lane runs exactly the corpus class the item says is untested, real Big-ANN SPLADE-cocondenser at INT8. What we already have. Across serial ( What that is worth, honestly: less than it looks. Recall is computed against Big-ANN's exact ground truth, so it counts how many of the true top-10 were returned, not which documents came back. Two different retained sets can score the same recall, which is precisely the failure the review describes (a tied pair straddling k/(k+1) swaps one document for another of equal score). So our numbers are consistent with set equivalence and are not evidence of it. I would not want them cited as if they closed the question. So I have queued the direct check. One build of the 1M INT8 index, then three query passes over the same 1000 queries at
If the set count is zero across 1000 real quantized queries, that is a stronger statement than the current tie test can make, since our corpus has the genuine near-ties the It is behind an 8.84M job on our box, so a few hours out. I will post the counts whichever way they come, including if they contradict the equivalence claim. One thing I cannot test from here: the review is right that the divergence depends on the order MaxScore sums a document's terms relative to where a range picks up the watermark. Our probe only observes the outcome, so a zero result is empirical evidence over one corpus and query set, not a proof over the algorithm. |
…dary The claim "identical list, ties included" was too strong, and the tie test could not have caught it: 0.5f on four dims sums to exactly 2.0 in any order, so it removes the rounding and exercises the tie-break alone. Rounding and the tie-break are not independent. MaxScore sums a document's terms in an order that follows the essential/non-essential split, which a range reaches at a different watermark than the whole scan, so two documents that tie exactly in one shape can sit an ulp apart in the other - and at the k boundary an ulp decides which is kept. The RID tie-break cannot reach that case: it only orders scores that are still equal after rounding. New test builds the case the old one excluded - a plateau of documents with mathematically equal scores straddling the k-th place, from weights with no exact binary representation - and asserts what actually holds: same count, rank-for-rank indistinguishable scores, nothing ranking below the serial k-th. The split never returns a worse answer; it may return a different one among documents nothing can tell apart. Also corrects the queries.in_flight gauge description, which stopped matching its counter when the caller-thread and pool-thread counts were split apart: it reports caller-thread queries only, which is the number the gate uses and deliberately not the total.
|
Both probes I promised are done, on real Big-ANN SPLADE at 1M, INT8, released dev21 as the baseline and this branch's wheel for the split arms. One answers the tie/ulp question, one prices the split under load and speaks to the open review point about explicit 1. Equivalence on 1000 real quantized queries: no divergence at all1000 dev queries, k=10, three settings, comparing the returned id lists against
So on this corpus the split is not merely as good as serial, it is byte-identical: same documents, same order, every time. The interesting number is the one that is not zero. The maximum rank-for-rank score difference is 9.0e-06, on scores of order 20, so about 0.45 ppm. That is direct evidence the summation-order effect you described is real and measurable in the arithmetic. It just stays roughly four orders of magnitude below what would be needed to flip a decision at the k boundary on this weight distribution. What this does not do is prove the corner cannot happen. It shows it did not arise across 1000 real queries at three partition settings. Your weakened guarantee is still the right one to state, and the plateau test you added is still the thing that pins it, because it constructs the case deliberately rather than hoping to meet it. If anything this pair is the useful combination: your test proves the bound holds when the case occurs, and this says the case is rare enough on real SPLADE that nobody should expect to see it. 2. Under load: your gate works, and forcing a split is worse than not splittingClosed loop, N Python client threads against one index, 30 s per cell, same 1000 queries. CPU is charged from the container cgroup, so it counts worker threads, which a per-query wall clock cannot see.
Your 1.89x corroborates. Forced 8 at one client costs 1.98x serial's CPU per query, measured end to end through the engine on a different harness and corpus. Adaptive at one client costs 2.74x, buying 3.39x on p50 and 3.70x on throughput. The load gate demonstrably engages. Adaptive's CPU per query falls 145.8 to 112.8 as clients rise and lands on serial's 112.0 at 16 clients (and 84.2 against 85.7 at 8). It stops splitting under load, which is exactly what the gate is for, and it is visible in CPU rather than inferred from latency. Overriding the gate costs the overrider, not only everyone else. Forced 8 keeps splitting regardless of load, its CPU per query sits flat at ~148 from 2 clients on, and by 16 clients it delivers 0.76x serial's throughput with a p50 1.75x worse than serial. On the open question of whether explicit One genuine subtlety in forced 8's favour. It trades median for predictability. At 16 clients its p99/p50 is 1.53x against serial's 5.21x (297 vs 194, against 579 vs 111). Every query gets the same 8 ranges, so per-query cost is uniform and the tail compresses even as the median degrades. If someone is chasing tail latency under load rather than median, that is not a nonsensical thing to want. The censoring, stated plainlySerial throughput scales only 5.51x from 1 to 16 clients, so my harness has a ceiling: concurrency is driven from Python threads, and while JPype releases the GIL for the engine call, marshalling and row counting do not. Cells at 8 and 16 clients are limited by that boundary, not by ArcadeDB. Linearity holds to 2 clients (2.03x), is 89% of linear at 4, and 34% at 16. That matters for one conclusion in particular, so I want to be explicit rather than let it pass: adaptive converging on serial at 8 and 16 clients could in principle be "both pinned at my ceiling" rather than "the gate engaged." The reason I think it is the gate is the CPU column, which is CPU divided by completed queries and therefore not ceiling-bound. If adaptive were still splitting it would still burn about 2x serial's CPU per query no matter how few queries finished. It burns 1.0x. Forced 8, which bypasses the gate, stays at 1.3x to 2.7x throughout and plateaus at 81 qps, comfortably under serial's 107, so its loss is not a ceiling artefact either. What I cannot measure from here: The 8.84M serial-versus-adaptive arms are running now and I will post them when they land. |
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 1 high |
| CodeStyle | 9 minor |
🟢 Metrics 12 complexity
Metric Results Complexity 12
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
|
Opened #5561 for the CI redness, since it was agreed it deserved its own issue rather than a footnote here. One correction to what we concluded above, from pulling the per-run job data: it is two separate phenomena, not one. Nothing there touches this branch. Detail and run links are on #5561. |
…nto what users see @tae898 ran both probes on real Big-ANN SPLADE at 1M. Neither changes behaviour; both change what we can honestly tell an operator, so they belong in the message and the setting rather than only in a thread. **Equivalence.** 1000 real INT8 queries, adaptive and forced 8 against serial: identical sets, order and counts every time. The summation-order effect is real and measurable - largest rank-for-rank score delta 9.0e-06 on scores of order 20 - and about four orders of magnitude short of flipping a k-boundary decision on that weight distribution. The weakened guarantee stays the one we state and the plateau test stays what pins it, since it constructs the case rather than waiting to meet it; the field number only calibrates how rare it is. **Load.** Forced 8 costs 1.98x serial CPU per query against the 1.89x measured here, on a different harness and corpus. Adaptive's CPU per query falls from 145.8 to 112.8 as clients rise and lands on serial's 112.0 at 16, so the gate engaging is now visible in CPU rather than inferred from latency. The finding worth surfacing to operators is counterintuitive: forcing a split on a loaded box makes the forcing query slower too, not only its neighbours - 0.76x throughput and a median 1.75x worse than not splitting at 16 clients. It does compress the tail (p99/p50 1.5x against 5.2x), which is a legitimate reason to want it. Both halves are now in the throttled WARNING and in the setting's own documentation, so the trade is visible at the point of decision.
Review: parallel RID-range split for sparse-vector top-K (#4085)Reviewed the full diff (engine, server metrics, Studio). This is high-quality work: the concurrency model is careful, the reservation/load-gate design is sound, and the test suite is genuinely thorough (range/serial equivalence at 2..16 ranges, tie determinism, nested-split prevention, worker-leak, in-flight suppression, caller-runs transaction safety, live-memtable split, deadline release). I traced the correctness argument and could not find a bug. A few notes below, mostly minor. Correctness (verified, no issues)
Minor observations
Test coverageExcellent. On the reviewer questions
Nice work - the notes above are non-blocking. |
|
On the one open reviewer question, explicit "Explicit means do not throttle me" stops being a trade and becomes a pure loss at about 4 concurrent queries. 1M INT8, closed loop, 12 cores, 30 s per cell:
The argument for the current semantics is that the operator knowingly bought latency with CPU. Past roughly 4 clients they are not buying anything: forced 8 is worse than serial on latency and on throughput at the same time. Its CPU per query stays pinned at ~148 ms from 2 clients on, because it never yields, while serial sits at 52 to 112. So the operator who set the knob to protect one query's latency ends up with that query slower than if they had left it alone. That reframes the decision. It is not "let them trade throughput for latency", it is "let them make a setting that cannot help them above a handful of concurrent queries". Which argues for the gate applying to explicit values too, or for the third additive mode suggested above, rather than for keeping the current contract. Two honesty notes. First, the crossover point is hardware and workload specific; 4 clients on 12 cores with these queries is not a universal number, and I would not put it in a doc. Second, my harness has a ceiling: serial throughput scales only 5.51x over 16x clients, so the 8- and 16-client cells are limited by our Python/JPype boundary rather than the engine. That does not rescue forced 8 though, because it plateaus at ~81 qps while serial reaches ~107, comfortably under the ceiling, so its throughput loss is real and not an artefact. The CPU column is also ceiling-independent, being CPU divided by completed queries. What I still cannot measure is the actual multi-tenant case, one forced client alongside adaptive ones, because |
…ng said "already in flight" Two corrections to what an operator is told. The warning reported inFlightQueries as "queries were already in flight", but queryStarted() has counted the current query by then, so a lone forced query read as one already in flight. The gate counts self deliberately; only the wording was wrong. Now "including this one". More substantially, the trade was described as latency-for-throughput, which holds on a quiet box and inverts on a busy one. Measured independently at 16 concurrent clients a forced 8-way split returned 0.76x the throughput of no split at all AND a median 1.75x worse - so past a few concurrent queries the operator who set it to protect one query's latency has made that query slower than leaving it alone. What survives at that load is the tail: p99 297 ms against 579. So the honest description is that it inverts as concurrency rises - median win while quiet, median and throughput loss once busy, tail win throughout - which makes it right for tail-sensitive traffic and wrong for anything else. Stated that way in both the warning and the setting, in absolute p99 rather than a p99/p50 ratio, since the ratio reads as "worse tail" when the tail is in fact lower. No crossover figure is quoted: it is hardware and workload specific.
|
Correction on provenance, before anyone acts on the numbers I posted an hour ago. My wheel was built from this branch at 07:36Z today, so it predates eight commits, including Your fix targets the half my harness cannot see, and it is the half that matters. The real harm in explicit splitting was never the overrider's own throughput, it was that an over-claim of up to four times the pool recorded parallelism the machine was not providing and suppressed every concurrent query for that query's whole duration. I said explicitly that I could not measure the mixed-tenant case because What I think still stands, and why. My forced-8 arm had every client forced, so there were no adaptive queries present to be suppressed. Both before and after
Above roughly 4 concurrent queries a forced split makes the overrider's own latency worse than not splitting, not just its neighbours'. Your rationale covers the neighbours and the honour-the-knob principle; it does not cover the operator harming themselves. I am not proposing the gate again. But if the throttled WARNING is the mechanism that surfaces this, it may be worth firing on self-harm too, not only on contention, because at 16 clients the setting is doing the opposite of what whoever set it wanted. Flagging that as reasoning, not measurement. I have not re-measured on the current head, and the argument above is mine rather than a number. Say the word and I will rebuild and re-run the concurrency arm on One thing that is unaffected either way. The 8.84M serial-versus-adaptive arms running now are single-stream, one query at a time, so |
|
On "pure loss past about 4 clients": your p50 and qps columns support that, but your own p99 figures do not. From the numbers you posted earlier in this thread, at 16 clients:
Forced 8's tail is roughly half serial's at that load. So it is not dominated - it is a median-versus-tail trade that inverts as concurrency rises. Every query gets the same 8 ranges, so per-query cost stays uniform and the tail compresses even while the median degrades, which is exactly what someone chasing p99 under load is buying. You had already spotted this as the "one genuinely good point in forced 8's favour"; I think it survives the later framing. That is the reading I have gone with, so the explicit setting keeps its semantics rather than yielding. What changed is what an operator is told: the warning and the setting now describe it as a median win while the box is quiet, a median and throughput loss once it is busy, and a tail win throughout - so "yes for tail-sensitive traffic, no for anything else". Quoted as absolute p99 rather than p99/p50, because the ratio reads as a worse tail when the tail is in fact lower. No crossover figure, per your point that 4-on-12-cores is not universal. The CPU-per-query column is the most useful thing in that run, by the way. It is the first evidence that the gate actually engages rather than the latency merely looking as though it does. |
|
@lvca one ask, because Would you re-run the forced-8-under-load arm on your own harness before that wording ships? Not because I think the numbers are wrong, but because for this particular claim your harness is the better instrument and mine is measurably censored. Why mine is the weaker one here. I drive concurrency from Python threads. JPype releases the GIL for the engine call, but argument marshalling and row counting do not, so serial throughput in my harness scales only 5.51x across a 16x increase in clients. My 8- and 16-client cells are limited by that boundary, not by ArcadeDB. Yours is JVM-native with no such ceiling, and it already produced the 1.89x CPU figure and the 14% pool-activity regression, so it can see the region where mine goes blind. What rescued my conclusion was the CPU column, which is CPU over completed queries and therefore not ceiling-bound, plus the fact that forced 8 plateaus at ~81 qps while serial reaches ~107, comfortably under the ceiling. That is enough to say the inversion is real. It is not enough to trust the magnitudes now in The specific thing to check, if you do: whether the inversion survives If the magnitudes move, please take yours over mine and change the doc. I would rather the setting carry a number from the harness that can actually see 16 clients than one from mine with a footnote. What our side is genuinely better at, and is doing instead: the 8.84M serial-versus-adaptive arms are running now on real Big-ANN SPLADE with exact ground truth, single stream, which is where our corpus and scale are the asset and where the GIL ceiling cannot bite because there is only ever one query in flight. Those I will stand behind, with the wheel commit stated. Also worth folding into the same doc pass if you agree with it: the tail behaviour. p99 297 against 579 at 16 clients means forced splitting is a tail optimisation that costs median and throughput, which is a coherent thing for someone to want and is not obvious from the setting's name. |
Review:
|
…aim it disproves @tae898 asked us to re-measure before shipping his magnitudes, because his harness drives concurrency from Python and scales only 5.51x across 16x clients while ours is JVM-native. He was right to ask, and the numbers move in two ways. The inversion is real and sharper than his. 1M INT8, 18-thread pool, 30 s cells, at 16 clients: forced 8 returned 0.52x serial's throughput (his 0.76x), a median 1.85x worse (his 1.75x), and 1.9x the CPU per query - which corroborates the 1.89x measured here and his 1.98x. The tail benefit does not reproduce. His p99 297 against serial 579 was the one argument for keeping a forced split under load. Here forced 8's p99 is 156.4 against serial's 62.8, two and a half times WORSE. So above light concurrency there is no measure on which forcing wins - not median, not throughput, not tail - and the setting is only worth using when one query at a time must be as fast as possible with nothing else running. Both the WARNING and the setting now say that, with our numbers rather than his. The warning also fires earlier. Its only trigger was "the adaptive gate would have refused this", which needs half the pool's worth of queries in flight - 10 on an 18-thread pool - while the crossover where the split turns against the query that asked for it sat between 4 and 8. A second trigger covers that regime, keyed on the forced ranges oversubscribing the pool rather than on a client count, which lands on the crossover on both harnesses. Worth recording what the adaptive default did in the same run, since it is the shape the gate was built for: at 8 clients it beat serial on all three of throughput (275 vs 196), median (35.9 vs 37.5) and p99 (75.2 vs 95.9), and at 16 it matched serial while splitting 2 queries out of 11444.
|
Thanks for running it. My tail claim was wrong and yours should replace it entirely. The interesting part is not that my magnitudes were soft, it is which number my harness broke, because it is the one I should have distrusted first and did not. Your serial p99 at 16 clients is 62.8 ms. Mine read 579 ms for the same cell. That is a factor of nine, and it is all mine: 16 Python client threads on a 12-core cpuset, with marshalling serialised behind the GIL, so the serial arm spends most of its tail waiting on my harness rather than on the engine. Forced 8's tail was inflated too, just less catastrophically, and the ratio between two differently-inflated numbers came out backwards. That is how I got a tail win out of what your harness shows is a 2.5x tail loss. I argued the CPU column rescued my conclusions because CPU over completed queries is not ceiling-bound. That was true, and I then over-extended it: it licences the inversion, which is a statement about total work, and it says nothing about a percentile. The tail is the metric most sensitive to queueing in front of the engine, so it is exactly the one a saturating harness cannot report. I had the evidence that my harness was saturating (5.51x over 16x clients, in the same comment) and still quoted a p99 comparison from inside the saturated region. That is the lesson worth me carrying, and it generalises past this PR: our numbers are trustworthy at concurrency 1, where nothing queues, and our percentiles should not be quoted above it. So: no measure on which forcing wins above light concurrency, and the setting is for one-query-at-a-time work with nothing else running. Your wording, your numbers. The adaptive figures you recorded are the better headline anyway, and I would not have obtained them: at 8 clients beating serial on throughput (275 vs 196), median (35.9 vs 37.5) and p99 (75.2 vs 95.9), then at 16 clients matching serial while splitting 2 queries out of 11,444. That last figure is the gate working, stated directly. I inferred the same thing indirectly from CPU per query converging on serial's, which was the right inference from the wrong instrument. 2 out of 11,444 is what the claim should rest on. Also good catch on the warning threshold. Firing only when the adaptive gate would have refused means ~10 queries in flight on an 18-thread pool, while the harm starts between 4 and 8, so the operator most likely to be hurt was the one least likely to be told. Keying the second trigger on forced ranges oversubscribing the pool rather than on a client count is the right shape, since it is the thing that is actually true on both our boxes despite the different core counts. Nothing here changes the 8.84M serial-versus-adaptive arms still running on our side. Those are single stream, one query in flight, so the ceiling that broke my tail numbers cannot reach them, and real Big-ANN SPLADE at 8.8M documents with exact ground truth is the part our harness is actually the right instrument for. I will post them with the wheel commit named. |
Review: split a sparse-vector top-K into parallel RID ranges (#4085)Reviewed the full diff. This is a high-quality change: the correctness argument for the range split is sound (disjoint RID partitions -> each doc scored exactly once -> merge-and-keep-best-k reproduces the serial result), the load gate is a genuinely thoughtful answer to the "pool looks idle because queries run on caller threads" problem, and the test suite is unusually complete (range/serial equivalence at 2..16 ranges, tie determinism, empty/out-of-bounds ranges, no-nested-split, worker-leak, in-flight suppression, caller-runs transaction safety, nested-tx guard, live-memtable equivalence, deadline release). The reserve/release accounting is symmetric by construction ( A few observations, mostly questions rather than defects: 1. Default config now eagerly builds the scoring pool for every sparse-vector query
2. Benchmark numbers baked into user-facing setting descriptions
3. Partition balance keys off a single segment's block layout
4. Pure-memtable indexes never split
Minor
Things I checked and found correct
The behaviour changes are both correctly flagged in the PR: (a) tie-break on RID-ascending is now applied to every query, split or not, so a result set that ties on score can differ from a prior release, and (b) the one-ulp summation-order difference at the k boundary, which the plateau test bounds rather than eliminates. Both are documented on the tests that assert them, which is the right call. Overall: LGTM pending a maintainer's read on point 1 (eager pool construction under the default) and the heuristic-tuning caveats the author already highlighted. Automated review by Claude. Please verify the observations above against your own judgement. |
|
Retracting my p99 argument above: I re-ran it here as you asked, and your original "pure loss" conclusion was right. Mine was wrong. 1M INT8, 18-thread pool, JVM-native clients, 30 s cells, on
The tail benefit does not reproduce. At 16 clients forced 8's p99 is 156.4 ms against serial's 62.8, two and a half times worse. Your 297-against-579 was the one thing making forced splitting a coherent choice under load, and it does not survive a harness without the Python boundary. So there is no regime above light concurrency where the setting wins - not median, not throughput, not tail - and my "median-versus-tail trade" reading was wrong. You were also right that the magnitudes needed our instrument, and they moved against the setting, not for it: 0.52x throughput rather than 0.76x, and a median 1.85x worse rather than 1.75x. Your 1.98x CPU per query corroborates the 1.89x measured here a different way, so the two harnesses agree on the cost and disagree only where yours is censored. Both the WARNING and the setting's documentation now carry these numbers instead of yours, and say plainly that forcing is worth it only when one query at a time must be as fast as possible with nothing else running. The warning also fires earlier: its only trigger was "the adaptive gate would have refused this", which needs half the pool's worth of queries in flight, while the crossover you identified sits well below that. A second trigger keyed on the forced ranges oversubscribing the pool covers the region where the setting has already turned against whoever set it - which was your point, and it was a good one. On the semantics themselves we are leaving explicit One thing worth having from your run that ours does not show: the adaptive default at 8 clients beat serial on all three of throughput (275 vs 196), median (35.9 vs 37.5) and p99 (75.2 vs 95.9), and at 16 clients matched serial while splitting 2 queries out of 11,444. The gate is not merely getting out of the way under load, it is finding a middle band where splitting still pays. That band is what your single-stream 8.84M arms cannot see and ours cannot corroborate at scale, so the two runs are complementary rather than redundant. Thank you for flagging the wheel provenance and your own ceiling unprompted. Both times it was the thing that made the numbers usable. |
|
Our retractions crossed, so we agree on the tail: I withdrew that claim a few minutes before your comment landed, for the same reason you found it. Good outcome either way, and no need to relitigate it. But there is something in your table neither of us has remarked on, and it is about the default rather than the override. Adaptive's p99 against serial's, from your own numbers:
Adaptive's tail is better than serial everywhere except at 4 clients, where it is worse than serial and worse than forced 8 (45.9). That is non-monotonic, and it sits exactly in the middle band you just identified as where the gate is doing something clever rather than merely getting out of the way. At that point its median is excellent, 12.9 against serial's 37.4, so it is buying a 2.9x median win with a 1.38x tail loss. That is a real median-versus-tail trade. It just belongs to the default, not to the setting you were documenting. A mechanism that would explain it, offered as a hypothesis rather than a finding. Four clients is presumably where the gate starts refusing some queries and not others. A refused query then runs unsplit while pool threads are still busy with other queries' ranges, so it is slower than the same query would be on an all-serial box, which is why adaptive's p99 exceeds serial's rather than merely matching it. Split and unsplit queries coexisting gives a bimodal distribution, and p99 reads the upper mode. Forced 8 has no such mixture (everything splits) and serial has none either (nothing does), which fits both of them having tidier tails at that point. Cheap ways to tell whether that is what it is, if you think it is worth knowing: the split rate at 4 clients (you already instrument it, you quoted 2 of 11,444 at 16), and whether the 4-client latency histogram is bimodal rather than long-tailed. If the split rate at 4 is somewhere in the middle rather than near 0 or near 100%, that is the mixture. Why I think it matters more than the forced-split question you have been documenting. Explicit I cannot check any of this myself: it lives in the region where my harness is censored, which is the whole reason you re-ran it. On leaving explicit |
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 1 high |
| CodeStyle | 9 minor |
🟢 Metrics 12 complexity
Metric Results Complexity 12
🟢 Coverage 86.86% diff coverage · -6.39% coverage variation
Metric Results Coverage variation ✅ -6.39% coverage variation Diff coverage ✅ 86.86% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (c906c6b) 146708 108811 74.17% Head commit (dcf2aee) 179225 (+32517) 121482 (+12671) 67.78% (-6.39%) Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch:
<coverage of head commit> - <coverage of common ancestor commit>Diff coverage details
Coverable lines Covered lines Diff coverage Pull request (#5518) 236 205 86.86% Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified:
<covered lines added or modified>/<coverable lines added or modified> * 100%
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
…annot see its cause @tae898 spotted something in the concurrency numbers that neither of us had remarked on, and it is about the default every user gets rather than the opt-in override. Confirmed over three reps: at 4 concurrent clients on an 18-thread pool the adaptive default's p99 exceeds serial's in every rep (32.3 / 37.7 / 48.6 against 30.8 / 27.3 / 31.7), while its median is 3.4x better (7.6 against 26.2 ms). His proposed mechanism holds. The band tracks the spread in how queries are treated, not the split rate as such: at 2 clients every query splits and the tail is fine, at 4 some claim a wide split while others get none and the tail is worst, at 8 the grants are uniformly small and it is fine again. Maximum spread in the middle. My own attempt to refute this used a 2-client cell measured on a loaded box - on a quiet one adaptive's tail there is better than serial's, so that counter-evidence was noise and the hypothesis stands. Documented in the setting rather than fixed. Flattening the band means giving up most of a 3.4x median win to shave a tail that is still around 38 ms, which is the wrong trade; but a user should not have to discover it. Also recorded where it will be found: queries.split counts whether a query split, not into how many ranges, and width is the variable that explains this. A distribution of granted partition counts is the gauge that would show it. That is a gap in what this PR instruments, not only in what has been measured.
|
Ran it. Your anomaly is real and your mechanism is right. Documented in Three reps, 1M INT8, 18-thread pool, 30 s cells:
Adaptive's p99 exceeds serial's in all three reps, and forced 8 - uniform width - has the tidiest tail of the three. So it is not a single-cell artefact. The mixture is the cause, with one refinement. It tracks the spread in how queries are treated rather than the split rate as such: at 2 clients every query splits and the tail is fine, at 4 some claim a wide split while others get none and the tail is worst, at 8 the grants are uniformly small and it is fine again. Maximum spread in the middle, which is why the band closes on both sides. Your bimodal-distribution reading is exactly what the numbers show; the variable is partition width, not split-versus-serial. I should also withdraw a counter-argument I nearly posted. My first pass had adaptive's p99 at 2 clients worse than serial with a 100% split rate, which I read as refuting the mixture. That cell was measured on a loaded box. On a quiet one adaptive's tail at 2 clients is better than serial's, so the refutation was noise and your hypothesis never had a problem. Documented rather than fixed. The trade at 4 clients is a 3.4x median win for about 1.2x the p99. Flattening it means giving up most of that median win to shave a tail still around 38 ms, which is the wrong way round - but you are right that a user should not have to discover it, so the setting now says the default can cost tail latency in a narrow concurrency band and quotes these numbers. And your ask exposed a gap in what I instrumented. Worth noting what this run also showed about the middle band, since it is the case your single-stream arms cannot reach: at 8 clients the adaptive default beat serial on all three of throughput (275 vs 196), median (35.9 vs 37.5) and p99 (75.2 vs 95.9). The gate is not only getting out of the way under load, it finds a region where splitting still pays on every measure. |
|
Review: split a sparse-vector top-K into parallel RID ranges (issue 4085) I read the full diff and traced the correctness and concurrency claims against the source (PaginatedSegmentReader, GlobalConfiguration, SparseVectorScoringPool, the Studio table). This is a high-quality change: the fan-out is correctly gated, shared state is handled carefully, and the test suite is unusually complete. Comments below, roughly in order of importance. Correctness / concurrency (looks sound)
Worth a second look
Maintainability nits (non-blocking)
Test coverage Excellent. Range/serial equivalence at 2..16, tie determinism, near-tie plateau, empty/out-of-bounds ranges, engine-level split accounting, no-nested-split, worker-leak, in-flight suppression, fully-claimed-pool fallback, deadline release, caller-runs transaction safety, nested-transaction gating, and live-memtable equivalence. The new MultiBucketSealedSegmentFanoutTest closing a pre-existing untested contract (segment-backed multi-bucket fan-out relying on checkDatabaseIsOpen side-effect context) is a good catch on its own. Studio card rendering is the one unverified piece, as you noted; the column count (9 headers, colspan=9, gaugeOrDash for absent keys) is internally consistent with PoolMetricsTest. Overall: solid, well-reasoned, and thoroughly tested. My only ask before merge is a CHANGELOG/release note for the tie-break behaviour change (item 1); the rest are optional. |
|
8.84M, as promised. It holds, and it is larger than at 1M: 3.58x. Full Big-ANN SPLADE corpus (8,841,823 docs), INT8, 1000 dev queries, cpuset 0-11, 16g heap, settled, single stream, N=3, median.
Per-rep p50 was [301.52, 299.69, 304.23] serial and [84.30, 82.65, 84.14] adaptive, so the spread is under 2% on both arms. Two things worth drawing out. The gain grows with the work, within a tier as well as across tiers. 3.58x at the median, 4.18x at p95, 4.11x at p99. The expensive queries benefit most, which is the same "pays in proportion to how much work the query does" property you described, showing up inside a single query mix rather than between 100k and 1M. It is also the useful direction: the queries that were painful are the ones that improve most, so the distribution tightens rather than just shifting. It is bigger here than at 1M (3.58x against 3.44x), which closes the scaling question. At 8.84M essentially every query carries enough posting mass to clear the 200k gate and partition fully, whereas at 100k a good share sat under it and only got 1.69x. The three tiers now read 1.69x / 3.44x / 3.58x, monotonic in corpus size. What it does to the comparison at the tier where we were furthest behind. Against Qdrant's 16.4 ms at 8.84M we go from 18.4x to 5.1x. For the whole trajectory on this tier: 387 ms when we were on the pre-dev21 line, 293 ms on released dev21 after your three merged rounds, 84 ms with the split. Elasticsearch answers this tier in 10.8 ms but at 0.781 recall against our 0.9914, so that one is not a like-for-like comparison and I would not quote it as one. Provenance, since it caught me out once already today. This wheel is from Congratulations on the merge. Once this ships in a release we will re-measure on the released wheel and it moves from our prose into the paper's table, which is where I have wanted to put it since the 165 ms days. |
…ame document SET The PR review's one merge-blocking item is that the equivalence tests use a tie corpus whose scores are exactly representable (0.5*4 = 2.0), which removes the ulp, so the interaction that can actually diverge is never exercised: on a real quantized corpus exact ties are common, and a range reaches the MaxScore watermark at a different point than the serial scan, so two documents tied in serial can end up an ulp apart and straddle k/(k+1). Our lane already reports recall identical to 4dp across partition settings, but recall is measured against ground truth and two different sets can score the same recall, so that is consistent with set equality rather than proof of it. This diffs the returned ids per query, serial vs adaptive vs forced-8, on Big-ANN SPLADE at INT8, which is exactly the corpus class the review says is untested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019XauanUtfhc9fUXpmwWgHJ
…obe fixed, iface guard split_setdiff_probe tests the guarantee upstream now states rather than the one the PR originally claimed. Upstream confirmed the tie/ulp interaction is real (MaxScore sums terms in an order that follows the essential/non-essential split, so a pair that ties exactly in serial can sit an ulp apart in a range, and the RID tie-break cannot reach that case), and weakened the guarantee to "as good as serial". So comparing id sets alone would flag expected behaviour as failure. The probe now reports set/order/count differences separately from the real failure, which is a split-returned document scoring strictly below serial's k-th, and prints the raw score deltas so "indistinguishable" is a measured number rather than an assertion against a tolerance we picked. split_concurrency_probe prices the split under load, the half a single-stream harness cannot see. Charges CPU from the cgroup so worker threads are counted, measures its own saturation point, and states the censoring: concurrency is driven from Python threads, so high-client cells are limited by our GIL boundary, not the engine. hop2_degree_probe: fixed the reason the first run produced garbage. l2_graph reads BENCH_GRAPH_SOURCE at import time and rebinds gen_persons/gen_edges to the LDBC streams only inside its own main(), so importing the module and calling build() directly built a SYNTHETIC graph with dense ids while seeds came from ldbc_snb's sparse-long id space. Every seed addressed a person that did not exist, so every degree was 0 and the reported Spearman of 0.383 was a correlation over a column of zeros. The old guard only checked vertex count, which that build satisfied. The new guard asserts what was actually broken: median 1-hop degree over a sample of the seeds themselves. iface_check.py is the cheap contract check that would have caught the three wrong interface guesses this lane has already cost. It found a real one: the sparse backends define no close(), so both probes' be.close() would have raised after the build and all query passes, discarding the run at the last line.
This file had grown one bespoke reader per release, each encoding that release's file naming, which makes the next release's layout a guess and had already produced one wrong assumption. queue30 onward writes all three sparse tiers into a single directory, so the dev22 overlay reads the tier from n_docs in the JSON instead: that field is written by the lane itself and cannot drift from the data it describes. A row whose n_docs matches no known tier is skipped rather than mislabelled, since a misfiled row would land in the wrong table column silently. Wired into sparse_table with the existing 'newest released line wins per tier, fall back rather than mix' rule. dev22 is the first RELEASED artifact carrying ArcadeData#5518, so it supersedes dev21 wherever present. Verified it returns {} and leaves the dev21 fallback intact while no dev22 data exists locally, so this is inert until the cells land.
…g past their median dev22 is the first RELEASED artifact carrying ArcadeData#5518, so the sparse row moves from prose into the table: 100k 6.51 -> 3.98 ms, 1M 36.84 -> 11.36, 8.84M 293.21 -> 83.70, recall unchanged at every tier. Reproduces the branch wheel to 0.5% (11.42/84.14 measured there), which is what the prose-only hedge existed to guard against. Also fixes a table defect the new row exposed. fmtb renders bracket endpoints one step coarser than fmt to save column width, and that can round an endpoint PAST the median: reps [3.98, 3.95, 4.20, 4.04, 3.98] printed as '3.98 [4.0--4.2]', a median outside its own range. Every number individually correct, the pair reads as an error. Endpoints now fall back to the median's precision when the coarse rendering would not contain it. Same failure mmm_rec already documents for recall, one band down. Side effect worth noting: cells whose variance was being rounded away now show it, e.g. LadybugDB SF1 1.12 -> 1.12 [1.07--1.13] and Qdrant recall 0.980 -> 0.980 [0.979--0.983]. Those previously printed as bare values implying zero spread. No median changed anywhere; verified 107 bracket cells across all four tables, 0 medians outside their range, with the check failing closed if it matches no files.
) * feat(#4085): split a sparse-vector top-K into parallel RID ranges Wires the dispatch SparseVectorScoringPool was built for. A query over one index is scored as several RID ranges concurrently and merged, instead of one traversal on the caller's thread. Measured on an 18-worker box, SPLADE-shaped corpus, INT8, 59 terms, k=10: 1M docs, single query: 26.2 ms p50 serial -> 7.6 at 8 ranges (3.5x), 4.5 at 16 (5.9x) 500k docs, single query: 13.4 -> 4.0 (3.4x) -> 2.4 (5.6x) Splitting is not free and the gating is most of this commit. A range prunes against its own top-K watermark rather than the global one, so it does more work than its share - 1.16x total CPU at 2 ranges, 1.89x at 8. On an idle machine that buys latency for nothing; on a busy one it takes throughput from other queries. A query therefore claims the workers it wants up front, and the claim is refused once enough queries are in flight to keep the pool busy without help. Sizing it from pool activity instead was tried and measured worse: a query runs on its caller's thread, so at 16 concurrent clients the pool reads idle at every sampling instant, a third of queries split anyway, and throughput dropped 14%. With the in-flight gate, 500k/16 clients: 829 vs 869 qps serial (-4.6%), 2 of 8289 queries split; at 4 clients, 468 vs 290 qps (+61%) with p50 13.6 -> 4.1 ms. Also refuses to split when the caller holds uncommitted page changes (a worker's own transaction context cannot see them), and when the caller is already a pool worker - a nested fan-out on a bounded queue deadlocks rather than degrading, since the outer tasks hold every worker while waiting on inner tasks nothing is left to run. Results are the serial ones, ties included: RidScoreMinHeap now breaks ties on RID ascending so which of several equally-scored documents survives no longer depends on heap layout, and the merge ranks the same way. Scores can differ by an ulp between the two shapes because MaxScore sums a document's terms in an order that follows the pruning split - documented on the test that asserts it. The caller scores one range itself rather than blocking on all of them. topKGrouped stays serial: merging grouped results needs the per-group worsts, not a flat best-k, and that is its own piece of work. New knobs: arcadedb.sparseVectorScoringMaxPartitions (0 = adaptive, 1 = off), arcadedb.sparseVectorScoringMinPostingsForPartitioning. Tests: ParallelRangeTopKTest (range/serial equivalence at 2..16 ranges, tie determinism, empty and out-of-bounds ranges, engine-level equivalence, no nested split, no worker leak, in-flight suppression) and MultiBucketSealedSegmentFanoutTest, which covers multi-bucket over sealed segments - previously untested, and passing only because LocalDatabase creates a thread context as a side effect. * test(#4085): measure the range split in the committed 10M benchmark LSMSparseVectorIndexLargeBenchmark compared the index against brute force but had no serial-vs-split arm, so it could not answer the question the issue asks it to. It now runs the sweep twice - traversal pinned to the caller thread, then the adaptive split - reports both plus the ratio, and asserts the two shapes return the same documents. That assertion is the only correctness check the split gets at real scale: multi-segment, a real index built through the SQL path, rather than a hand-built segment. Measured on an 18-worker box: 10M docs: 24.18 -> 6.90 ms/query (3.51x), same documents 1M docs: 5.13 -> 2.83 ms/query (1.82x), same documents The 1M gain is smaller because this benchmark's query is 10 terms over 30 nnz/doc: at 5 ms of work, per-range cursor setup and the merge take a visible share. The split pays in proportion to how much work the query does, which is why the 59-term SPLADE shape shows 3.4x at the same corpus size. * feat(#4085): surface the split decision on Studio's Executor Pools card The card showed pool mechanics only, which left an idle sparse-vector row ambiguous in a way an operator could not resolve: nobody querying, everybody querying but the load gate has switched splitting off, queries too small to qualify, or something broken - all four render identically, because a query that stays serial never touches the pool. Three gauges on the sparse-vector row, carried by the existing arcadedb.executor.* naming so the metrics endpoint and any Grafana selector pick them up with no handler change: workers currently reserved, top-K queries in flight, and cumulative queries split. The middle one is what the gate decides against, so it explains the other two. Registered for this pool only - the others take work as handed to them and have no decision to explain - so Studio renders a dash rather than a zero that would read as "nothing is splitting" where the concept does not apply. PoolMetricsTest pins both halves: registered for sparse_vector, absent for query, in the gauge registry and in the JSON the dashboard actually reads. * fix(#4085): address PR review - reservation leak, in-flight double-count, partition clamp Four points from review, plus a regression the benchmark caught while fixing one of them. **Reservation released on any throw between claim and return.** planPartitionBoundaries claimed workers and topK released them, so a throw in between leaked the claim permanently, silently shrinking what every later query believed was free. The boundary walk cannot currently throw, but the method reads segment metadata and is declared to throw, so a future page read there would have been a silent, unbounded leak. **Pool-thread queries counted separately from caller-thread ones.** The per-bucket fan-out submits one topK per bucket, so a single query against an 8-bucket type registered as eight in flight and could trip the load gate for unrelated queries on a quiet box. They are real load, but of a different kind: they occupy workers, so they now subtract from grantable capacity, while the caller-thread count keeps driving the gate. Skipping them entirely, as suggested, would have left a split blind to per-bucket work and free to oversubscribe it. That fix first read ThreadPoolExecutor.getActiveCount(), which is a trap: it takes the pool's main lock and walks the worker set. At 16 concurrent clients, with splitting almost entirely gated off, it cost 29% of throughput on its own - a decision path more expensive than the decision. Replaced with an AtomicInteger; 16-client throughput went from 549 qps against 772 serial back to 718 against 709. The trap is recorded at the call site. **Explicit maxPartitions clamped** to 4x pool size. The knob means "do not throttle me", not "open a thousand cursor stacks" - past a small multiple of the pool there is no thread free to run them. **Tie-break documented as reaching serial-only users.** The ordering lives in the collector, so a database with splitting disabled gets it too: anyone whose scores tie can see a different retained set than a previous release. Also: the deadline path is now tested, deterministically - every worker is jammed with a latch so the range is submitted but never started, rather than racing a clock - and it asserts the timed-out query hands its claim back. Import nit fixed. * fix(#4085): a range forced onto the caller thread must not roll back its transaction The pool's queue is bounded and its rejection policy is caller-runs, so once the queue fills, a submitted range runs inline on the submitting thread - the user's, inside the user's transaction. The task called DatabaseContext.init unconditionally, which on a thread that already has a context takes the "ROLLBACK PREVIOUS TXS" branch and silently rolls the caller's transaction back; the matching removeContext in the finally then wiped the context the rest of the query still needed, so the query failed with "Transaction context not found on current thread" on top of a transaction that was already gone. Now create-only-if-absent and tear down only what the task created, the same shape LocalDatabase.checkDatabaseIsOpen uses. The identical init/remove pair in FetchFromTypeExecutionStep that this was modelled on is safe only because its pool has an unbounded queue and never runs a task on the caller - not a property this pool has. Reachable two ways: an explicit maxPartitions bypasses the capacity gate, and even in adaptive mode the reservation counts started tasks rather than queued ones, so a burst can fill the queue while capacity still looks free. New test drives it deterministically - saturates workers and every queue slot so the range is rejected onto the caller, inside an open transaction - and asserts both the results and that the transaction and its context survive. Verified to fail without the fix, with exactly the reported symptom. Also: a range that has already finished is now harvested regardless of the deadline. The caller scores its own range before draining the futures, so it could consume the whole budget on work that succeeded and then fail the query while every worker range sat complete and waiting to be read. The deadline governs waiting, not collecting. * docs(#4085): review follow-ups - explicit-split warning, split-metric caveat, balance limits Three observations from review, none behavioural except the log line. An explicit sparseVectorScoringMaxPartitions still bypasses the load gate, deliberately, but now logs a throttled WARNING the first time it is granted while enough queries are in flight that the adaptive default would have refused. The setting is JVM-wide and long-lived, so whoever configured it is rarely the person watching latency later; without a signal the trade it makes is invisible at exactly the load where it hurts. Same 60-second throttle as the saturation warning. queries.split counts the decision, not the outcome: a range submitted to a full queue runs inline on the caller under the caller-runs policy, so a query counted as split can still have executed serially. Said so in the metric description and on the Studio card, next to the Caller-Run Fallbacks column where that shows up. Recorded what the balance heuristic does not promise: boundaries come from one dim of one segment, so on an index with several live segments, before compaction merges them, that layout need not represent the global RID distribution and ranges can come out uneven. Correctness is unaffected - the ranges still partition the RID space and every document is scored exactly once - only the speedup, visible as one range finishing after its siblings. Not taken: @tag("slow") on ParallelRangeTopKTest. Measured at 1.46s for 10 tests, which is not what CLAUDE.md means by noticeably long, and tagging would pull a transaction-safety regression test out of regular CI. * fix(#4085): stop the saturation loop deadlocking its own test thread CI's unit-tests job hung for a full hour on aRangeForcedOntoTheCallerThreadLeavesTheCallersTransactionAlone. The test saturated the pool by computing "workers + free queue slots" from a snapshot and then submitting exactly that many blocking tasks. The snapshot races, and overshooting it by one is not a slow test but a permanently stuck one: the surplus task is rejected, the caller-runs policy executes it on the submitting thread, and it waits on a latch released only in a finally that thread can no longer reach. Locally the arithmetic happened to come out right, so it passed every run here. Two changes, either of which would have prevented it: - Submit until the queue reports itself full rather than computing a count up front, with a runaway ceiling. - A saturation task blocks only when it finds itself on a worker. One running on the submitting thread was rejected, and blocking there is the deadlock. It also waits with a timeout now, so a mis-sized saturation fails the test instead of wedging the job - the difference between a diagnosis and an hour of nothing. Applied to the deadline test's hogs as well, which had the same shape and the same latent hazard. Verified the test still earns its place: with the production contextCreated guard removed it fails with "Transaction context not found on current thread" exactly as before, so the rewritten loop still saturates for real. * fix(#4085): the split guard must see outer transactions, and grouped queries are load too Four review points, two of them real holes. **Uncommitted-changes guard missed nested transactions.** It asked only the innermost transaction whether it had changes, and begin() on an already-active transaction pushes a nested one - so a caller sitting in a fresh inner transaction over a dirty outer one reported clean, split, and its workers would have read committed pages without seeing what the outer transaction had written. Exactly the class of bug the guard exists to remove. Now checks every transaction on the stack. New test fails against the innermost-only version. **Grouped queries were invisible to the load gate.** topKGrouped never splits, but it runs a full traversal on its caller's thread and competes for the same cores. Unregistered, a plain topK arriving alongside heavy grouped traffic saw an idle gate, split, and took throughput from it - the failure the gate exists to prevent, from a source it could not see. **The gate factor is named.** CALLER_LOAD_GATE_FACTOR, with what it means and the measurements behind it. Kept a literal rather than a setting: it shapes only the middle of the range, and an operator wanting splitting off or always-on already has sparseVectorScoringMaxPartitions for both, so a third knob would be easy to set wrongly and hard to reason about. **Splitting disabled no longer touches the pool.** With maxPartitions=1 the query returns before getInstance(), which would otherwise build a ThreadPoolExecutor this JVM has just been told it will never use. * fix(#4085): make the reservation guard cover the exception it was written for The guard released the worker claim on `RuntimeException | Error`, while its own comment said it existed for a future edit that adds a page read - which throws IOException, and would have sailed straight past it. topK's finally cannot cover that case either, since it only releases inside the branch where boundaries came back non-null, so the claim would have leaked exactly as the comment warned. Replaced with a handed-off flag and a finally. Enumerating exception types is how a guard like this fails quietly; a finally covers checked, unchecked, Error, and any future early return that forgets the claim exists. * test(#4085): pin what the equivalence claim actually is at the k boundary The claim "identical list, ties included" was too strong, and the tie test could not have caught it: 0.5f on four dims sums to exactly 2.0 in any order, so it removes the rounding and exercises the tie-break alone. Rounding and the tie-break are not independent. MaxScore sums a document's terms in an order that follows the essential/non-essential split, which a range reaches at a different watermark than the whole scan, so two documents that tie exactly in one shape can sit an ulp apart in the other - and at the k boundary an ulp decides which is kept. The RID tie-break cannot reach that case: it only orders scores that are still equal after rounding. New test builds the case the old one excluded - a plateau of documents with mathematically equal scores straddling the k-th place, from weights with no exact binary representation - and asserts what actually holds: same count, rank-for-rank indistinguishable scores, nothing ranking below the serial k-th. The split never returns a worse answer; it may return a different one among documents nothing can tell apart. Also corrects the queries.in_flight gauge description, which stopped matching its counter when the caller-thread and pool-thread counts were split apart: it reports caller-thread queries only, which is the number the gate uses and deliberately not the total. * test(#4085): pin equivalence with a live memtable under the query Every other engine-level equivalence test flushes before querying, so the memtable leg of the merged cursor was empty in all of them and the split had only ever been asserted against sealed segments. It splits over a populated memtable too: the guard refuses only when the caller holds uncommitted changes, and postings committed by an earlier transaction are not that. There was reason to expect it to hold - ranges partition the RID space whatever the postings are stored in, and the memtable's looser per-term ceiling can only prune more conservatively - but the memtable is also the one source reporting no finite block boundary, so it is the leg where the block-skip machinery behaves differently from a segment, and that is worth asserting rather than reasoning about. The test interleaves committed memtable postings with a sealed segment across the RID space so ranges straddle both legs, and checks the memtable is genuinely non-empty and the query genuinely split before comparing - otherwise it could pass by quietly degrading into the case already covered. * fix(#4085): the load gate and its warning must ask the same question warnExplicitSplitUnderLoad hardcoded `inFlight * 2 <= ceiling` while the gate itself used CALLER_LOAD_GATE_FACTOR. They agreed only because the constant is 2, so tuning the gate would have left the warning claiming "the adaptive default would have refused this" about a load where it would have allowed it - the warning quietly becoming wrong, which is exactly what naming the constant was supposed to prevent. Both now call one predicate, callersAloneCanSaturate(), so they agree by construction rather than by two authors keeping two expressions in step. Referencing the constant from both places would have fixed today's drift and left the shape that caused it. Also labels the parallel_scan row on Studio's Executor Pools card. It rendered the raw pool key through the fallback, which is graceful but not friendly, and the comment above it already listed the pool as expected. * fix(#4085): an explicit split keeps its ranges but cannot claim capacity the pool lacks Two decisions, taken together because they pull the same way. An explicit sparseVectorScoringMaxPartitions does not yield under load - the setting means what it says, and a knob honoured only sometimes is worse than either extreme. But it no longer claims workers the pool does not have. It could previously record up to four times the pool's worth, and while it did, every concurrent query saw no free capacity and stopped splitting for that query's whole duration. Those extra ranges are queued, not running, so the claim described parallelism the machine was not providing and suppressed others far beyond the contention it actually caused. reserveWorkers now saturates at the ceiling and returns what it recorded, which may be less than asked or zero; the split proceeds regardless. The alternative - lowering the oversubscription clamp so the claim fits - was rejected because it caps the claim by capping the partition count, silently giving an operator who asks for 32 ranges on an 8-thread pool 9 of them. That makes the reserved figure no longer derivable from the boundary count, so it travels with it in a PartitionPlan and the release uses it. Reserve and release previously agreed only because two places computed `partitions - 1` alike - the same shape as the constant drift the warning path had, where a number is duplicated and the copies are expected to stay in step. Both tests fail against the previous behaviour: the claim test on the un-saturated add, and the no-yield test pins that a fully claimed pool does not stop an explicit split. * docs(#4085): fold the independent load and equivalence measurements into what users see @tae898 ran both probes on real Big-ANN SPLADE at 1M. Neither changes behaviour; both change what we can honestly tell an operator, so they belong in the message and the setting rather than only in a thread. **Equivalence.** 1000 real INT8 queries, adaptive and forced 8 against serial: identical sets, order and counts every time. The summation-order effect is real and measurable - largest rank-for-rank score delta 9.0e-06 on scores of order 20 - and about four orders of magnitude short of flipping a k-boundary decision on that weight distribution. The weakened guarantee stays the one we state and the plateau test stays what pins it, since it constructs the case rather than waiting to meet it; the field number only calibrates how rare it is. **Load.** Forced 8 costs 1.98x serial CPU per query against the 1.89x measured here, on a different harness and corpus. Adaptive's CPU per query falls from 145.8 to 112.8 as clients rise and lands on serial's 112.0 at 16, so the gate engaging is now visible in CPU rather than inferred from latency. The finding worth surfacing to operators is counterintuitive: forcing a split on a loaded box makes the forcing query slower too, not only its neighbours - 0.76x throughput and a median 1.75x worse than not splitting at 16 clients. It does compress the tail (p99/p50 1.5x against 5.2x), which is a legitimate reason to want it. Both halves are now in the throttled WARNING and in the setting's own documentation, so the trade is visible at the point of decision. * docs(#4085): the forced split inverts with concurrency, and the warning said "already in flight" Two corrections to what an operator is told. The warning reported inFlightQueries as "queries were already in flight", but queryStarted() has counted the current query by then, so a lone forced query read as one already in flight. The gate counts self deliberately; only the wording was wrong. Now "including this one". More substantially, the trade was described as latency-for-throughput, which holds on a quiet box and inverts on a busy one. Measured independently at 16 concurrent clients a forced 8-way split returned 0.76x the throughput of no split at all AND a median 1.75x worse - so past a few concurrent queries the operator who set it to protect one query's latency has made that query slower than leaving it alone. What survives at that load is the tail: p99 297 ms against 579. So the honest description is that it inverts as concurrency rises - median win while quiet, median and throughput loss once busy, tail win throughout - which makes it right for tail-sensitive traffic and wrong for anything else. Stated that way in both the warning and the setting, in absolute p99 rather than a p99/p50 ratio, since the ratio reads as "worse tail" when the tail is in fact lower. No crossover figure is quoted: it is hardware and workload specific. * fix(#4085): measure the forced-split trade here, and drop the tail claim it disproves @tae898 asked us to re-measure before shipping his magnitudes, because his harness drives concurrency from Python and scales only 5.51x across 16x clients while ours is JVM-native. He was right to ask, and the numbers move in two ways. The inversion is real and sharper than his. 1M INT8, 18-thread pool, 30 s cells, at 16 clients: forced 8 returned 0.52x serial's throughput (his 0.76x), a median 1.85x worse (his 1.75x), and 1.9x the CPU per query - which corroborates the 1.89x measured here and his 1.98x. The tail benefit does not reproduce. His p99 297 against serial 579 was the one argument for keeping a forced split under load. Here forced 8's p99 is 156.4 against serial's 62.8, two and a half times WORSE. So above light concurrency there is no measure on which forcing wins - not median, not throughput, not tail - and the setting is only worth using when one query at a time must be as fast as possible with nothing else running. Both the WARNING and the setting now say that, with our numbers rather than his. The warning also fires earlier. Its only trigger was "the adaptive gate would have refused this", which needs half the pool's worth of queries in flight - 10 on an 18-thread pool - while the crossover where the split turns against the query that asked for it sat between 4 and 8. A second trigger covers that regime, keyed on the forced ranges oversubscribing the pool rather than on a client count, which lands on the crossover on both harnesses. Worth recording what the adaptive default did in the same run, since it is the shape the gate was built for: at 8 clients it beat serial on all three of throughput (275 vs 196), median (35.9 vs 37.5) and p99 (75.2 vs 95.9), and at 16 it matched serial while splitting 2 queries out of 11444. * docs(#4085): record the default's tail band, and that queries.split cannot see its cause @tae898 spotted something in the concurrency numbers that neither of us had remarked on, and it is about the default every user gets rather than the opt-in override. Confirmed over three reps: at 4 concurrent clients on an 18-thread pool the adaptive default's p99 exceeds serial's in every rep (32.3 / 37.7 / 48.6 against 30.8 / 27.3 / 31.7), while its median is 3.4x better (7.6 against 26.2 ms). His proposed mechanism holds. The band tracks the spread in how queries are treated, not the split rate as such: at 2 clients every query splits and the tail is fine, at 4 some claim a wide split while others get none and the tail is worst, at 8 the grants are uniformly small and it is fine again. Maximum spread in the middle. My own attempt to refute this used a 2-client cell measured on a loaded box - on a quiet one adaptive's tail there is better than serial's, so that counter-evidence was noise and the hypothesis stands. Documented in the setting rather than fixed. Flattening the band means giving up most of a 3.4x median win to shave a tail that is still around 38 ms, which is the wrong trade; but a user should not have to discover it. Also recorded where it will be found: queries.split counts whether a query split, not into how many ranges, and width is the variable that explains this. A distribution of granted partition counts is the gauge that would show it. That is a gap in what this PR instruments, not only in what has been measured. (cherry picked from commit 084ca9f)
Closes #4085.
Wires the dispatch
SparseVectorScoringPoolwas built for: a top-K over a singleLSM_SPARSE_VECTORindex is scored as several RID ranges concurrently and merged, instead of one traversal on the caller's thread.Measured
SPLADE-shaped corpus, INT8, 18-worker box. Single query, ranges forced:
LSMSparseVectorIndexLargeBenchmark, which now runs both shapes and asserts they return the same documents:The 1M gain is smaller there because that benchmark's query is 10 terms over 30 nnz/doc: at 5 ms of work, per-range cursor setup and the merge take a visible share. The split pays in proportion to how much work the query does.
Why the gating is most of the diff
A range prunes against its own top-K watermark rather than the global one, so it does more work than its share: 1.16x total CPU at 2 ranges, 1.89x at 8. That is free latency on an idle machine and stolen throughput on a busy one, so the decision has to be adaptive.
Sizing it from pool activity was tried first and measured wrong. A query runs on its caller's thread, so at 16 concurrent clients the pool reads idle at every sampling instant: a third of queries split anyway and throughput fell 14%. The gate that works counts queries in flight, not pool activity. At 500k:
A query also refuses to split when it is already running on a pool worker (a nested fan-out on a bounded queue deadlocks rather than degrading, since the outer tasks hold every worker while waiting on inner tasks nothing is left to run), and when the caller holds uncommitted page changes, which a worker's own transaction context cannot see.
Results are the serial ones, ties included
RidScoreMinHeapnow breaks ties on RID ascending, so which of several equally-scored documents survives no longer depends on heap layout, and the merge ranks the same way. This is a behaviour change: a query with tied scores could previously return either document and now deterministically returns the lowest RID.Scores can differ by one ulp between the two shapes, because MaxScore sums a document's terms in an order that follows the pruning split. Document set and ranking are unaffected; documented on the test that asserts it.
Also here
topKGroupedstays serial: merging grouped results needs the per-group worsts, not a flat best-k.MultiBucketSealedSegmentFanoutTestcovers multi-bucket over sealed segments, which had no coverage and passes today only becauseLocalDatabase.checkDatabaseIsOpencreates a thread context as a side effect. Nothing states that contract, so a refactor could have broken segment-backed multi-bucket queries silently.New settings
arcadedb.sparseVectorScoringMaxPartitions(0 = adaptive, 1 = off, >1 = explicit and bypasses the load gate)arcadedb.sparseVectorScoringMinPostingsForPartitioning(default 200,000)Testing
Full
enginemodule 10,128 tests and fullservermodule 763 tests, zero failures. NewParallelRangeTopKTestcovers range/serial equivalence at 2..16 ranges, tie determinism, empty and out-of-bounds ranges, engine-level equivalence, no nested split, no worker leak, and in-flight suppression.Not verified: the Studio card rendering in a browser. The gauges and the JSON contract are pinned by
PoolMetricsTest, but nobody has looked at the page.Worth a reviewer's attention
maxPartitions > 1should really bypass the load gate, or whether an operator asking for N ranges should still yield under load.🤖 Generated with Claude Code
https://claude.ai/code/session_01XF4M78mPjg8VH7GR5pPZEp