Skip to content

fix(#6296, #6289): the biggest allocation of an algo call is the one nobody was looking at - #6298

Merged
lvca merged 2 commits into
mainfrom
issue-6296-6289
Aug 17, 2026
Merged

fix(#6296, #6289): the biggest allocation of an algo call is the one nobody was looking at#6298
lvca merged 2 commits into
mainfrom
issue-6296-6289

Conversation

@lvca

@lvca lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member

Closes #6296, closes #6289.

Both were surfaced while reviewing PR #6285 (issue #6263) and deliberately left out of it: #6296 is the result set of the same call rather than its working set, and #6289 is churn rather than a peak. Neither is something that budget could have caught.

#6296 - algo.apsp materialised n² rows before returning any of them

The distance matrix is reserved against arcadedb.cypher.algoMaxWorkingMemory; the rows beside it were not. At the 64 MB floor of that setting the matrix check admits n ≈ 2890, and a connected graph of 2890 nodes then built ~8.3M ResultInternal objects - each with a three-entry property map, all alive at once, well over a gigabyte - against the 64 MB the budget had just finished enforcing next to it. The budget did its job and the call still died, of an allocation its message never named.

Floyd-Warshall completes the matrix before the first row is emitted, so the rows are a pure projection of it and nothing required them to exist together. They now stream. The row-side footprint is O(1), and CallStep keeps the iterator rather than collecting it, so a LIMIT upstream costs what it says it costs.

Measured on the 200-node cycle in the regression test (ThreadMXBean.getThreadAllocatedBytes, per-thread so no GC can move it):

building the stream draining the ~39 800 rows
before 13 621 944 bytes 256 bytes
after ~83 000 bytes the rows

The issue also raises whether the row count should be bounded. Streaming answers it in the right place: the consumer decides how many it holds, and a LIMIT is now what bounds the call. A top-k form of the procedure would be a different interface, not a fix, so it is left alone.

The secondary finding in the issue - toEmbeddingList boxing every element - is deliberately not taken: a primitive-backed List<Double> view boxes on get() instead of up front, so on a consumer that reads each row once it moves the allocation rather than removing it. The issue itself says it is only worth doing if a profile says the boxing shows up, and this change is not that profile.

#6289 - allocation churn on the dense paths

1. The per-spur-node n x n mask in algo.kShortestPaths. ~200 MB through the young generation for one k=10 call at 1000 nodes. The issue proposed hoisting the mask and clearing it, and asked for a measurement before choosing between that and reallocation.

Neither was needed - the mask never had to be square, which is the better alternative the measurement would not have found. Yen's keeps the edge (p[i], p[i+1]) only for a path sharing the root p[0..i] with prevPath, and prevPath[i] is the spur node, so every entry it ever set had the same source. The other n-1 rows were allocated and zeroed only to prove they were empty. A single boolean[nodeCount] indexed by target says exactly as much, in O(nodeCount) rather than O(nodeCount²).

That also removes the Arrays.fill-vs-reallocate question the issue framed: the mask is allocated once for the whole call and cleared by naming the few entries it set - at most one per previously-found path - so clearing is O(paths), not O(nodeCount). The budget reservation follows the allocation and now prices two node-sized masks instead of a square one.

Measured on a 501-node graph driving ~150 spur nodes: 43 259 968 -> 4 411 528 bytes for the call, against a weight matrix of 2 008 008 bytes allocated once.

2. The boxed Integer[] Kruskal sort in algo.steinerTree and algo.mst. Replaced by one shared primitive index sort, AbstractAlgoProcedure.sortedIndexesByWeight.

A bottom-up merge sort rather than the index quicksort the issue suggested, for two reasons that both matter here: the keys are user data, so a quicksort degrades to O(n²) on an input a caller chooses; and merging is stable, which is what keeps ties in exactly the order the TimSort behind Arrays.sort(Integer[], Comparator) produced. Double.compare is the comparison, so NaN and -0.0 order identically too - the test asserts the new order against the old comparator sort on both awkward and randomised inputs.

Measured: 8.4 bytes per index against 24. BOXED_INTEGER_BYTES, which existed only to price that boxing honestly, is gone with it, and algo.steinerTree's reservation now prices five primitive arrays per terminal pair.

3. MemoryBudget.reserve() mutating before it throws. Committed only once granted. The invariant is now observable: a refused reservation no longer appears in the "on top of the N bytes this call already reserved" of the next message.

Also, while in AlgoKShortestPaths

An unused PriorityQueue, and a dead incremental cost expression that computed the path weight four ways - two Arrays.copyOf and four pathWeight walks per spur node - and then threw the answer away in favour of the single pathWeight(totalPath, ...) on the line below it. Same file, same theme.

Verification

Every allocation assertion was checked against the old code before being trusted: reverting each fix in turn makes the corresponding test red (13.6 MB build for #6296, 43.3 MB for the mask, a lying "already reserved" figure for the budget), so none of them is vacuous.

  • Issue6296AlgoAPSPLazyRowsTest (4 tests) - the full n² - n row set is unchanged and each pair appears once; distances around the cycle are unchanged; a partially read stream still yields its tail; and the allocation split between building and draining.
  • Issue6289AlgoAllocationChurnTest (7 tests) - k distinct paths in ascending weight (the correctness counterweight to collapsing the mask); the reservation message; the call's allocation against the weight matrix; the index sort against the boxed comparator sort it replaced, including NaN, ±0.0, the infinities and tie stability; MST and Steiner trees unchanged, pinned from both sides (the MST takes the two cheap bypass edges of every hop, the Steiner path takes the single direct hop instead); and the budget invariant.
  • Issue6263AlgoWorkingMemoryBudgetTest updated for the smaller kShortestPaths reservation (400 -> 328 bytes on its 4-node graph).
  • Full com.arcadedb.query.opencypher sweep: 4520 tests, 0 failures.

The two ThreadMXBean measurements are tagged @Tag("performance"), matching RidScoreMinHeapTest, and skip themselves on a JVM without per-thread allocation counters.

Files changed

  • engine/.../algo/AbstractAlgoProcedure.java - sortedIndexesByWeight, budget invariant, BOXED_INTEGER_BYTES removed
  • engine/.../algo/AlgoAPSP.java - lazy rows
  • engine/.../algo/AlgoKShortestPaths.java - node-sized spur masks, reservation, dead code
  • engine/.../algo/AlgoMST.java, engine/.../algo/AlgoSteinerTree.java - primitive index sort
  • engine/src/test/.../algo/Issue6296AlgoAPSPLazyRowsTest.java (new), Issue6289AlgoAllocationChurnTest.java (new), Issue6263AlgoWorkingMemoryBudgetTest.java

…nobody was looking at

#6263 bounded the working set of an `algo.*` call. Both of these were left out of it
deliberately: #6296 is the *result* set of the same call, and #6289 is churn rather
than a peak, so neither is something that budget could have caught.

## #6296 - algo.apsp materialised n² rows before returning any of them

The distance matrix is reserved against `arcadedb.cypher.algoMaxWorkingMemory`; the
rows beside it were not. At the 64 MB floor of that setting the matrix check admits
n ≈ 2890, and a connected graph of 2890 nodes then built ~8.3M `ResultInternal`
objects - each with a three-entry property map, all alive at once, well over a
gigabyte - against the 64 MB the budget had just finished enforcing next to it. The
budget did its job and the call still died, of an allocation its message never named.

Floyd-Warshall completes the matrix before the first row is emitted, so the rows are
a pure projection of it and nothing required them to exist together. They now stream:
the row-side footprint is O(1), and `CallStep` keeps the iterator rather than
collecting it, so a `LIMIT` upstream costs what it says it costs. Measured on the
200-node cycle in the regression test: building the stream went from 13.6 MB to
83 KB, and the rows are now paid for as they are read.

## #6289 - allocation churn on the dense paths

1. `algo.kShortestPaths` allocated a `nodeCount x nodeCount` removed-edge mask per
   spur node - ~200 MB through the young generation for one k=10 call at 1000 nodes.
   The issue proposed hoisting it and clearing it, and asked for a measurement first.
   Neither was needed: the mask never had to be square. Yen's keeps the edge
   (p[i], p[i+1]) only for a path sharing the root p[0..i] with prevPath, and
   prevPath[i] IS the spur node - so every entry it ever set had the same source, and
   the other n-1 rows were allocated and zeroed only to prove they were empty. One
   `boolean[nodeCount]` indexed by target says exactly as much. It is allocated once
   for the call and cleared by naming the few entries it set, so there is no
   `Arrays.fill` over the node count either. 43.3 MB -> 4.4 MB on the test graph, and
   the budget reservation now prices two node-sized masks rather than a square one.

2. `algo.steinerTree` and `algo.mst` sorted their Kruskal indices through an
   `Integer[]` - 24 bytes per entry against the 4 the `int` occupies, over a count
   that is quadratic in a caller-supplied terminal list. Replaced by a shared
   primitive index sort. A bottom-up merge sort rather than the index quicksort the
   issue suggested: the keys are user data, so a quicksort degrades to O(n²) on an
   input a caller chooses, and merging is stable, which is what keeps the tie order
   identical to the TimSort behind `Arrays.sort(Integer[], Comparator)`. Measured at
   8.4 bytes per index against 24. `BOXED_INTEGER_BYTES`, which existed only to price
   the boxing honestly, is gone with it.

3. `MemoryBudget.reserve()` added to the running total before checking it, so a
   refused reservation was recorded as granted and the next message quoted it back as
   "the N bytes this call already reserved". Committed only once granted now.

While in `AlgoKShortestPaths`: an unused `PriorityQueue` and a dead incremental cost
expression that computed the path weight four ways - two `Arrays.copyOf` and four
`pathWeight` walks per spur node - and then threw the answer away in favour of the
single `pathWeight(totalPath, ...)` beside it.
@lvca lvca self-assigned this Aug 17, 2026
@mergify

mergify Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Aug 17, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 12 complexity

Metric Results
Complexity 12

View in Codacy

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.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review

Went through the diff carefully (algorithm changes, memory-budget fix, and both new test classes). Overall this is a well-scoped, well-tested fix — the two allocation issues are real, the fixes are minimal and match the root cause, and the regression tests actually pin the old-vs-new behavior rather than just re-asserting the new one. A few notes below; nothing blocking.

Correctness

  • AlgoKShortestPaths mask collapse (n×n → n) is sound. Verified by hand: sameRoot requires p[r] == prevPath[r] for all r <= spurIndex, which includes r == spurIndex, so every entry the old removedEdges[p[i]][p[i+1]] ever set had p[i] == spurNode. The new boolean[n] indexed by target plus the u == spurNode check in the Dijkstra filter is exactly equivalent — the other n-1 rows of the old matrix were always false by default, so nothing behavioral changes. The set/clear via markRemovedSpurTargets(..., marked) walks the same kPaths traversal both times, so it's guaranteed to unset exactly what it set (kPaths doesn't mutate mid-spur-loop). Good.

  • sortedIndexesByWeight merge sort — traced the bottom-up merge with width as long to avoid the int overflow near Integer.MAX_VALUE that the comment calls out; stability is preserved correctly (Double.compare(weights[from[b]], weights[from[a]]) < 0 ? from[b++] : from[a++] takes the left run on ties). The from/to swap-per-iteration logic correctly returns the last-written buffer regardless of how many width-doublings ran (no odd/even parity bug). Edge cases (count 0, 1) are short-circuited before the scratch array is used. The test's coverage of NaN/±0.0/infinities/duplicate weights against the actual Arrays.sort(Integer[], Comparator) it replaces is exactly the right way to pin this.

  • MemoryBudget.reserve() fix is correct — reserved is now only mutated on the accepted path (total <= limit), so a refused reservation is no longer echoed back in a later "already reserved" message. Good catch, matches the described bug.

  • AlgoAPSP lazy row streaming — the IntStream.range(...).mapToObj(...).flatMap(Function.identity()) construction is correct and preserves the original filter semantics (j != i && !(distances[j] >= INF), including the NaN-safety the comment calls out). One thing worth double-checking (not blocking, since streams are consumed within the query's execution before the transaction is torn down per CallStep#executeProcedure's non-autocommit path): graph.getRID(i)/getRID(j) are now invoked lazily at drain time rather than eagerly inside execute(). For the OLTP-backed GraphData this is just vertices.get(i).getIdentity() — a cheap in-memory field read, no DB access — so it's safe regardless of when it runs. For the CSR-backed path it goes through GraphAnalyticalView.getRIDsnap.nodeMapping.getRID(database, nodeId), which does take a database reference. Given the rest of the engine already relies on CallStep keeping non-write procedure streams lazy (per its own doc comment), this is presumably fine and consistent with existing behavior for other algo.* procedures returning lazy streams — but it might be worth a sentence in the PR/commit confirming that path was exercised under the CSR-backed GraphTraversalProvider, since none of the new tests seem to build a CSR-backed graph (both new test fixtures use plain OLTP vertices/edges).

Memory-budget bookkeeping

  • The updated reservation formulas were checked against the actual allocations they price:
    • algo.kShortestPaths: matrixBytes(n,n,DOUBLE) + matrixBytes(2,n,BOOLEAN) for the weight matrix + two node-sized masks — matches removedSpurTargets + removedNodes, and the test's expected 328 bytes at n=4 checks out arithmetically (4×64 + 2×36 = 328).
    • algo.steinerTree: pairCount * (4*INT + DOUBLE) matches pU+pV+pW (2 int + 1 double) plus the two int arrays sortedIndexesByWeight allocates internally (order + merge scratch) — 4 ints + 1 double per pair, and it's reserved before any of pU/pV/pW/sortIdx is allocated, consistent with the "before anything is allocated" invariant the class documents.
    • algo.mst doesn't reserve a budget for its edge arrays at all (pre-existing, out of scope here) — just flagging for awareness, not something this PR needs to fix.

Style / minor nits

  • Test method name arefusedReservationIsNotAddedToTheRunningTotal in Issue6289AlgoAllocationChurnTest is missing a space (arefuseda refused). Cosmetic only.
  • The two @Tag("performance") tests use a tag that isn't part of the benchmark/slow/vector partition CLAUDE.md describes; they follow the existing RidScoreMinHeapTest precedent (and CI's default lane only excludes slow,benchmark,vector, so performance-tagged tests run in the normal lane). Given these are deterministic ThreadMXBean allocation-counter assertions rather than wall-clock throughput measurements, running them in the default lane seems intentional and reasonable — just noting the tag doesn't match the three named in the CLAUDE.md convention verbatim, in case that's meant to be benchmark per "JMH-style or comparison runs."
  • No leftover System.out/debug statements, no unused imports left behind (double-checked PriorityQueue/Comparator imports in AlgoKShortestPaths — still legitimately used by the inner dijkstra helper, unrelated to the removed dead candidates queue).
  • No new dependencies, so no ATTRIBUTIONS.md/NOTICE changes needed.

Test coverage

Good — the new tests pin correctness (k distinct ascending-weight paths, MST/Steiner tree unchanged from both directions) alongside the allocation-shape assertions, and the PR description states each allocation assertion was checked against the pre-fix code (reverting the fix turns the corresponding test red). That's exactly the discipline you want for this kind of change, where "faster" and "just wrong" can look identical without a correctness counterweight.

Nice writeup in the PR description too — the "why not X" call-outs (not touching toEmbeddingList boxing, not doing quicksort for the index sort) are exactly the kind of context that saves a future reader from re-litigating those choices.

Review of #6298 raised one substantive gap and two nits.

The gap: making the rows lazy moves exactly one thing - `graph.getRID()` now runs
as a row is read rather than inside `execute()`. On the OLTP-backed `GraphData`
that is an in-memory field read that cannot care when it happens; on the
CSR-backed one it goes through the analytical view's node mapping, which takes a
database reference. Both new fixtures were plain OLTP, so that path was unpinned.
`theRowsAreTheSameWhenTheGraphIsCSRBacked` builds a `GraphAnalyticalView` over the
cycle, asserts `CSR_ACCELERATED_VAR` so the test cannot silently pin the OLTP path
a second time, and checks the same n² - n pairs at the same hop distances.

Codacy's one minor finding, on the same line: `!(distances[j] >= INF)` was written
as a literal negation of the eager loop's guard so the row set would provably match
for every double. It is equivalent to `< INF` here anyway - no NaN can reach the
matrix, since every write to it is guarded by `<`, which a NaN fails - so the
straight comparison goes back in and the argument moves to the comment.

Also: `arefused` -> `aRefused` in a test name, and a paragraph in both test classes
on why the allocation assertions are tagged `performance` and not `benchmark` -
`benchmark` is one of the three lanes CI excludes, and a regression guard that never
runs guards nothing. These read a per-thread allocation counter rather than a clock,
so the default lane is where they belong.

The review's third note - that `algo.mst` reserves no budget for its edge arrays -
is real and pre-existing, and stays out of scope here.
@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review

Went through the full diff (AbstractAlgoProcedure, AlgoAPSP, AlgoKShortestPaths, AlgoMST, AlgoSteinerTree, and the two new regression test classes) plus CallStep to check the laziness assumption. I traced through the algorithmic transformations by hand rather than just reading the PR description's claims. Summary: this is a well-scoped, well-verified change and I didn't find any correctness bugs.

Verified correct

  • AlgoAPSP lazy row streaming: confirmed the matrix (dist) is fully computed by Floyd-Warshall before the stream is built, so deferring row construction to IntStream...mapToObj(...).flatMap(...) is safe - nothing mutates dist afterward. Checked CallStep.executeProcedure to confirm the read-procedure path keeps the iterator lazy (resultStream.iterator(), no .toList() outside the write auto-commit branch), so a LIMIT genuinely short-circuits and the transaction stays open long enough to drain the stream - matches the new theRowsAreTheSameWhenTheGraphIsCSRBacked test's premise.
  • NaN claim: verified by hand that every write into dist[][] (initialization aside) is guarded by <, which a NaN operand always fails, so a NaN edge weight can never land in the matrix. The new filter distances[j] < INF is therefore equivalent to the old !(dist[i][j] >= INF) for every value that can actually occur.
  • AlgoKShortestPaths mask collapse: proved the invariant the PR relies on - in the original boolean[n][n] removedEdges, an entry removedEdges[p[i]][p[i+1]] is only ever set when sameRoot holds, which forces p[i] == prevPath[i] == spurNode. So removedEdges[u][v] was always false for u != spurNode, making the old 2D lookup exactly equivalent to (u == spurNode && removedSpurTargets[v]). The set/clear symmetry in markRemovedSpurTargets (same traversal, marked flag flipped) also checks out - nothing else touches kPaths between the set and clear calls, so clearing always undoes exactly what was set, with no reallocation needed between spur nodes.
  • sortedIndexesByWeight merge sort: bottom-up iterative merge sort, walked through the width/lo bookkeeping (long to avoid the int overflow the comment calls out) and the stability rule (< 0 ? right : left, i.e., ties prefer the left/earlier run) - matches TimSort-via-Comparator stability. The int-vs-long casts are all safely bounded by count <= Integer.MAX_VALUE.
  • Budget invariant fix: reserve() now computes total into a local before comparing against limit, only writing back to reserved on the accepted path, and the error message reads the pre-update reserved. Correct - a refused reservation is no longer counted.
  • Reservation arithmetic: manually recomputed matrixBytes(4,4,DOUBLE_BYTES) + matrixBytes(2,4,BOOLEAN_BYTES) = 256 + 72 = 328, matching the updated test literal, and the old 400 figure for the previous square-mask pricing. AlgoSteinerTree's new 4*INT_BYTES + DOUBLE_BYTES per pair also lines up with the two arrays sortedIndexesByWeight actually allocates (order + scratch) replacing the one boxed Integer[].
  • Confirmed BOXED_INTEGER_BYTES has no other references left in the tree, and that dropped imports (ArrayList in AlgoAPSP, Arrays in AlgoMST, the standalone PriorityQueue candidates in AlgoKShortestPaths) are indeed fully unused elsewhere in their files.

Minor observations (not blocking)

  • AlgoMST's missing budget reservation for its edge arrays (eu/ev/ew) is called out in the PR description as pre-existing and out of scope - agreed that's fine to leave for a follow-up, but worth an issue if one doesn't exist yet, since it's the same class of bug Embedding matrices in the algo.* procedures are sized nodeCount x dimension and sit outside every budget (follow-up to #6216) #6263 was meant to close off.
  • The fully-qualified com.sun.management.ThreadMXBean in the new test classes looks like it goes against the repo's "always import, don't use FQNs" convention, but it actually matches the existing precedent in RidScoreMinHeapTest, so it's consistent rather than a new deviation.
  • Both new test classes carry substantial javadoc essays justifying the @Tag("performance") choice. It's useful context, but duplicated near-verbatim across both files - could be worth centralizing in a shared base/util comment if a third such test shows up.

Test coverage

The new regression tests are well-targeted: Issue6296AlgoAPSPLazyRowsTest pins row completeness/uniqueness, distance correctness, partial-drain behavior, and (via the second commit) the CSR-backed path specifically, since that's the one place where deferring graph.getRID() could plausibly matter. Issue6289AlgoAllocationChurnTest has a real correctness counterweight for the mask collapse (kShortestPathsStillReturnsKDistinctPathsInAscendingWeight) and pins the sort against a reference boxed-comparator sort including the NaN/-0.0/duplicate edge cases, plus MST/Steiner outputs from both directions. Good adherence to the "regression test" and "verify before trusting" guidance in CLAUDE.md - the PR description mentions each fix was confirmed to turn its test red when reverted.

No functional or security issues found. Nice work isolating churn-vs-peak as a separate concern from the #6263 budget, and for actually proving the O(n²) mask reduction rather than just asserting it.

@lvca

lvca commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Follow-up for the review's one actionable note: the missing algo.mst working-memory reservation is now filed as #6300, with the Borůvka-over-CSR alternative that would remove the allocation rather than bound it. Kept out of this PR as agreed - it is pre-existing, and this PR is about the result set and about churn.

The other two notes I'm leaving as they are: the com.sun.management.ThreadMXBean FQN matches the RidScoreMinHeapTest precedent (as the review itself concludes), and centralising the @Tag("performance") rationale is explicitly gated on a third such test showing up - two occurrences is not yet an abstraction worth inventing.

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