perf(api,bench): derive concurrency from the machine; drop the adjacent-RSS growth gate - #1466
Conversation
…fixed pair `ExecutionResourcePolicy::default()` requested `Explicit` mode with `tokio_worker_threads`, `target_partitions`, `io_concurrency` and `compute_threads` all hard-pinned to `2`, commented as preserving the pre-#337 two-worker facade. Every default-constructed instance therefore took two workers whatever the host had. Measured consequence on the G500 ladder (`f80f69fe`, 8 cores / 16 threads): | rung | edges | wall | CPU-s | effective cores | |------|-------|------|-------|-----------------| | S18 | 4.19M | 69 | 58.5 | 0.85 | | S20 | 16.8M | 270 | 231.5 | 0.86 | | S22 | 67.1M | 1088 | 960.8 | 0.88 | | S24 | 268M | 4725 |4201.5 | 0.89 | Flat across a 64x edge range: wall time is CPU time and fifteen threads idle. Default to `Automatic`, which derives a bounded count from observed parallelism (`observed.div_ceil(2).clamp(MIN_THREADS, 8)`) — 8 here rather than 2, still bounded, still fail-closed against the combined-budget and reserve caps. Callers passing explicit knobs are unaffected. The `<= 2` CPU machines stay serial by the existing Automatic rule, so small hosts keep the low-overhead path. Refs #1387 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iB limit `rss_bounded_or_plateaued` refused a rung whenever peak RSS grew more than 10% between the two projection sources. It refused S25 on the `f80f69fe` ladder at a growth fraction of 1.5613 — while absolute RSS was 470 MiB at S24 and 757 MiB projected at S25, against a 4 GiB limit that passed with over four times' margin on a 125 GB host. The absolute budget is the gate that governs memory now. `RSS_LIMIT_BYTES` stays at 4 GiB and `rss_headroom` is unchanged. `rss_growth_fraction` is still computed and still recorded in the evidence document, so the architectural signal the gate was watching for survives its removal as an observation rather than a refusal. Removed from: the harness check set, both schemas, the certify gate assertion and its `ProgressiveHeadroom` field (`deny_unknown_fields`, so the struct and the profile JSONs must agree), and the `max_adjacent_rss_growth_fraction` parameter in all seven G500 profiles. The evidence schema still *accepts* `rss_bounded_or_plateaued` as an optional boolean: `checks` is `additionalProperties: false`, and recorded runs carry the key. Without this, `read_native_rung` fails to validate every historical projection it reconciles against. Contract tests updated rather than deleted: the refusal test now asserts the fraction is recorded and does not refuse, and the S24 historical admission in `lifecycle-runtime-1279-baseline.json` flips to `admitted` because the growth gate was its only failing check. Regenerating that baseline moved exactly two lines. Refs #1387 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository: CurateLabs/graphforge/.coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Warning Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Do not merge yet: this change makes the S18 rung time outA/B on one host, quiet, same harness, same certify and generator binaries. The only difference is whether
Reproduced twice. The first observation was a What it is notIt is not a slowdown of the work itself. Driven directly, outside benchexec, the whole import sequence is unchanged: versus 35.90 s for Likely mechanism, stated as a hypothesis rather than a resultThe ladder runs inside a cgroup with I have not confirmed that memory is the binding resource — the killed runs left no evidence behind. It could also be thread oversubscription interacting with the cgroup's CPU controller. The reproduction is solid; the explanation is not. Why CI is greenNothing in the crate suites, the harness suites, clippy or This is the third fail-closed surface this work has found that only a real rung exercises, after the certify validator (#1462) and the evidence schema. A green PR touching concurrency defaults should be rung-tested before it merges, and that is now cheap: Suggested next stepBefore merging, re-run The change is still right in principle: a two-worker facade cannot show multi-core behaviour whatever the engine does (#1462 depends on this landing). It needs a bound, not reverting. |
Diagnosed: this exposes a livelock, not a resource limitCorrecting my previous comment. I hypothesised the 4 GiB cgroup cap was the binding resource. It is not. Caught the hung S18 rung in the act: Sampled One thread spins indefinitely while all sixteen others block on futexes. That is a livelock, not a deadlock — a deadlock parks every thread at 0% CPU. And it is not memory: RSS is 183 MB against a 4096 MB ceiling. It is non-deterministicDriven directly outside benchexec with the same derived worker count, the identical import completes in 35.12 s. Under benchexec it hangs. Same binary, same data, same host. So this is timing-dependent — a race that the direct run wins and the containerised run loses, not a property of the worker count alone. That also means the direct measurement I used to exonerate this change was luck, and a green CI run proves nothing here. A concurrency default cannot be validated by a single passing execution. What this means for the changeThe change is not wrong. It raises Two consequences worth separating:
Reproducing itHung in 2 of 2 attempts (17 minutes before I stopped the first; 420 s timeout on the second; a third reproduced within 100 s and was inspected above). The pinned build passes the same rung in ~55 s, and S18/S19/S20 all pass on it. The spinning thread's stack would name the loop. |
Correcting myself again: it is not a livelock. It is pathological parquet re-decompression.I called this a livelock because one thread sat at 100% CPU while sixteen blocked on futexes. A profile of the running thread shows it is doing real work, not spinning on a lock. Zstd decompression of parquet, plus SipHash hashing, on a tokio worker — continuously, for minutes, where the pinned build completes the whole S18 rung in ~55 s. The other threads are blocked because everything is serialised behind this one. Hypothesis, and why I think it, stated as a hypothesis
Raising I cannot prove causality from this profile. The release build has no frame pointers, so the call graph is too shallow to name the caller. What is established: the thread is doing parquet decode work, not waiting, and the volume is pathological. Corrections to my earlier comments on this PRThree, and they are worth stating plainly because each was confidently wrong:
What has held throughout: the S18 rung hangs with this change and passes without it, reproduced 4 of 4 times, and S18/S19/S20 all pass on the pinned build. Suggested next stepRebuild with The change should still land eventually — a two-worker facade cannot show multi-core behaviour, and #1462 depends on it. It should land after whatever this is, because the defect it exposes is in code that runs today. |
Located: it is the one-hop query, not ingestA frame-pointer build resolves the call graph completely. This is the whole of the hot path, 2410 samples over 12 s on the one running thread:
This explains the observation I could not reconcileI reported that driving the import directly showed no difference — 35.12 s with this change against 35.90 s without — and used that to argue the change was not a regression. That measurement ran What the defect is
That is the signature of readers being constructed repeatedly and each seeking forward through compressed data. Raising Why this matters beyond this PRThe defect is in code that runs today. The two-worker default keeps the multiplier small enough to hide it. Two open issues are already circling it without naming it:
This profile is the same query path, and it says the cost is repeated seeking and reader construction over zstd-compressed parquet. A filtered read that decompresses in order to skip is doing the decompression twice: once to find the rows, once to read them. Status of this PRStill should not merge as it stands, but the reason has changed: it is not an ingest regression, it is a query-path defect that this change amplifies. Fixing the query path is the prerequisite, and it is worth doing regardless of this PR, since it is on the critical path for #1388 as well. Reproduction: |
The profile says PR #1453 is the fix for this, and that it also unblocks PR #1466Following the call path posted above to its cause, and ruling out the obvious suspect first. It is not a missing page index. It is the access pattern against the page granularity. Pages are That is why 43.6% of the thread is in The consequenceThis is a layout-versus-access-pattern mismatch, not a coding defect. Filtered parquet scanning is the wrong mechanism for a scattered adjacency lookup, at any page size — shrinking pages trades decompression for index size and per-page overhead. Which is exactly what this issue proposes to remove. PR #1453 publishes the adjacency CSR with the generation instead of rebuilding it per process, so a hop query opens a CSR rather than scanning filtered parquet. If the hop path stops going through The dependency worth recordingPR #1453 plausibly unblocks PR #1466. #1466 (unpin the default resource policy) currently hangs the S18 rung — 5 of 5 attempts — and the profile puts the hang in this query path, amplified by That is a hypothesis, not a result: I have not built #1453 and re-run the rung. It is a cheap test — Supporting measurement#1449 records that a one-hop at S20 costs 14 s user CPU and 6.4 GB of disk reads while Reproduction for anyone picking this up: |
Tested: #1453 removes the hang that blocks #1466I posted this as a hypothesis. It now has a measurement.
37.6 s is ordinary S18 territory; the hang was unbounded. Taking the hop query off filtered parquet removes the cost that #1466's Suggested merge order: #1453 before #1466. Two honest caveatsThe combined run does not pass. It completes the work and then fails And it may be my mess rather than #1453's. This was a local merge across three worktrees carrying my own in-flight fixes, and I tripped over that twice while testing: first a The timing result is robust to that — 37.6 s versus >400 s is not a subtle difference and does not depend on which certify binary validated it. The ReproductionBoth on a quiet host, benchexec, same generator and data. |
|
Merge-order constraint, measured: #1453 must land before this PR. On this branch alone, S18 hangs 5/5 attempts. With #1453 applied, the same work completes in 37.6 s. The cause is on the query path rather than in this change: So this is a sequencing constraint, not a conflict — the two merge cleanly in either order, but landing this one first leaves 🤖 Generated with Claude Code |
Summary
Two independent commits that unblock the measurement work in plan rev 8. Both were sitting on a local-only branch; neither had been through CI.
1287a2f8— derive instance concurrency from the machine (#1463)ExecutionResourcePolicy::default()pinned a fixed two-worker facade —tokio_worker_threads: Some(2),target_partitions: Some(2),io_concurrency: Some(2),compute_threads: Some(2)— preserving pre-#337 behaviour. On an 8-core / 16-thread host that is simply wrong, and every default-constructed instance took two workers whatever the machine had.The default is now
mode: Automaticwith those four knobsNone, deferring to the mode. Explicit policies are untouched.Why it matters beyond the obvious: the G500 ladder runs at 0.85–0.89 effective cores across a 64x edge range on a 16-thread host. Until this lands, a serial-fraction instrument (#1462) measures the facade rather than the engine, so it is a precondition for the whole phase-0 measurement step, not merely independent cleanup.
Both pinning tests were rewritten to assert the invariant rather than the observation —
defaults_preserve_fixed_two_worker_baselinebecomesdefaults_derive_concurrency_from_machine_parallelism, checking the derived value instead of the constant2.5bbb8a71— drop the adjacent-RSS growth gate, keep the 4 GiB limitRemoves
rss_bounded_or_plateauedfrom admission andmax_adjacent_rss_growth_fractionfrom the harness, all seven G500 profiles and the profile schema.rss_growth_fractionis still measured and reported in the evidence, and the qualification test is rewritten to assert exactly that, so the architectural signal survives the gate's removal. RSS is governed by the absolute 4 GiB rung limit alone.Rationale, recorded on #1387:
What is given up: this was the only check refusing "RSS scales with edge count rather than with the streaming window" before it becomes an absolute problem, and #1387 records that shape as binding at S28/S30. It will now surface as a breached limit at a higher rung instead of a refused admission at a lower one. The fraction stays in the evidence, so it should be watched rather than assumed. #1439 keeps its place on the other merit — it remains the precondition for any
SortExecadoption.Consequence for the ladder
S25 admission had two failing checks. With this one gone,
io_reader_publication_headroomis the only remaining refusal, so the ladder is now gated on byte amplification alone.Testing
cargo test -p graphforge-api --lib— 744 passed, 0 failed, 2 ignored.PYTHONPATH=harness uv run --locked python -m unittest) acrosstest_progressive_qualification,test_progressive_run,test_progressive_provider_plan,test_progressive_provider_run,test_native_ladder_controller,test_lifecycle_runtime— 97 passed.cargo test -p graphforge-api --test bdd) — API BDD 118 passed, 0 failed; openCypher TCK 3897/3897, 0 regressed, 0 xpass.On the TCK perf warning
The run emits
TCK PERF WARNING: openCypher TCK total: 141,248 ms (baseline 66,505 ms, threshold 83,131 ms). Since unpinning worker threads could plausibly oversubscribe a parallel test harness, it was measured both ways on the same quiet host:1287a2f8(machine-derived)The overrun is pre-existing and not caused by this change. The recorded 66,505 ms baseline was captured on a GitHub-hosted runner (
runner = ubuntu-latestintests/tck/performance_baseline.json) and the comparison never checks that, so it warns on every local run. Filed as #1467, with the two per-scenario outliers (29.7x and 20.6x against a 2.1x aggregate) flagged there as probably real and not to be absorbed into a refreshed baseline.Note for anyone reproducing locally: the
graphforge-apisuite fails 243 tests under the defaultTMPDIRon this host because/tmpis tmpfs and filesystem admission fail-closes withUnsupportedFilesystem: phase=CLASSIFY cause=filesystem_class_unproven. PointTMPDIRat an ext4 path and it is green. That is environmental, unrelated to these commits.Closes #1463
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.