|
| 1 | +<!-- SPDX-License-Identifier: MPL-2.0 --> |
| 2 | + |
| 3 | +# 2026-05-30 — L2.3 cancel-token preemption for speculative search |
| 4 | + |
| 5 | +ADR-style addendum to [2026-05-30-chapel-rehabilitation.md][rehab]. |
| 6 | +Documents the L2.3 cancel-token plumbing that closes the |
| 7 | +"speculative-search winner doesn't kill losers" gap explicitly carried |
| 8 | +forward from Wave 1. |
| 9 | + |
| 10 | +[rehab]: ./2026-05-30-chapel-rehabilitation.md |
| 11 | + |
| 12 | +## Status |
| 13 | + |
| 14 | +Accepted. Implemented in the PR that introduces this ADR. |
| 15 | + |
| 16 | +## Context |
| 17 | + |
| 18 | +PR #146's L2.2 `parallelProofSearchSpeculative` shipped with a clear |
| 19 | +Wave-1 caveat (recorded in the function's docstring and in the rehab |
| 20 | +ADR): |
| 21 | + |
| 22 | +> Wave 1 scope deliberately stops short of the "kill in-flight losers" |
| 23 | +> step. Once a prover has been spawned, `tryProver` runs it to its own |
| 24 | +> per-prover timeout — coforall doesn't know how to cancel a child task |
| 25 | +> that's blocked on subprocess I/O. L2.3 introduces a cancel token |
| 26 | +> threaded through `tryProver` so the children can SIGKILL their own |
| 27 | +> subprocesses when they observe a winner; that's a separate PR. |
| 28 | +
|
| 29 | +That separate PR is this one. The visible symptom Wave 1 left behind: |
| 30 | +once a successful prover wins the CAS, the bench's wall-clock for |
| 31 | +`parallel_speculative` was still bounded by the slowest in-flight |
| 32 | +loser (because each one ran to its own `tryProver` timeout). The |
| 33 | +`baseline.md` 5-run medians show this — speculative and best-of were |
| 34 | +within noise on the success cases, instead of speculative dominating. |
| 35 | + |
| 36 | +## Decision |
| 37 | + |
| 38 | +Add a shared `CancelToken` instance to `parallelProofSearchSpeculative`, |
| 39 | +thread an optional `borrowed CancelToken?` reference through |
| 40 | +`tryProver`'s signature, and have the winner pair its CAS with a |
| 41 | +write to the token. Each loser polls the token at every 100 ms tick |
| 42 | +inside the existing bounded-wait loop; on observing `cancelled = true` |
| 43 | +it SIGKILLs its own subprocess and returns a preempted result. |
| 44 | + |
| 45 | +Why a `class` (not `record`) `CancelToken`: |
| 46 | + |
| 47 | +- The token must be **shared by reference** across all `coforall` |
| 48 | + tasks so the winner's write is visible to every loser. Chapel |
| 49 | + records have value semantics; classes are passed by reference. |
| 50 | +- Atomic-bool fields on a class have well-defined initial state |
| 51 | + (`false`) after `init this` commits field initialisation, so the |
| 52 | + init proc body is empty — no race-on-construction concern. |
| 53 | +- The class is owned by the search function (`new owned`) and |
| 54 | + borrowed by each `tryProver` call, so its lifetime is exactly the |
| 55 | + search's lifetime and no extra liveness reasoning is needed. |
| 56 | + |
| 57 | +Why exitCode = -5 for preempted losers: |
| 58 | + |
| 59 | +- The existing failure codes are `-1` (not available / general failure), |
| 60 | + `-2` (temp file failure), `-3` (timeout), `-4` (subprocess error). |
| 61 | + Preemption is a distinct fourth-kind of failure — the prover did |
| 62 | + not get to run to completion, but neither did it time out nor crash; |
| 63 | + it was actively SIGKILLed by the portfolio. Reusing `-3` (timeout) |
| 64 | + would mis-categorise preemption as a slow-prover signal, polluting |
| 65 | + any later bench that tracks timeout rates. |
| 66 | + |
| 67 | +## Soundness |
| 68 | + |
| 69 | +The aggregation soundness proof in `proofs/agda/ParallelSoundness.agda` |
| 70 | +already covers this case. Theorem `cancellation-safety` states: |
| 71 | + |
| 72 | +```agda |
| 73 | +cancellation-safety : {n : ℕ} (bs : Vec Bool n) |
| 74 | + → IsTrue (speculative (true ∷ bs)) |
| 75 | +cancellation-safety _ = tt |
| 76 | +``` |
| 77 | + |
| 78 | +i.e. **once a head witness is true, the verdict is true regardless of |
| 79 | +the tail bs's contents**. In the L2.3 implementation the "head" is the |
| 80 | +CAS-winner's `success = true` and the tail `bs` is every loser's |
| 81 | +post-preemption result (which is now `success = false` with |
| 82 | +exitCode = -5 instead of running to a possibly-true result). The |
| 83 | +theorem says: the verdict over the whole result vector is unaffected |
| 84 | +by this difference. The Agda module's header docstring is updated in |
| 85 | +this PR to spell out the L2.3 cross-reference. |
| 86 | + |
| 87 | +The CAS itself is the linearisation point: any prover whose poll |
| 88 | +observes `cancelled = true` ran AFTER the winning CAS. The |
| 89 | +happens-before edge from `winnerIdx.compareAndSwap(-1, i)` → |
| 90 | +`cancelToken.cancelled.write(true)` → next-loser's |
| 91 | +`cancelToken!.cancelled.read()` is exactly the atomic-bool semantics |
| 92 | +Chapel inherits from the C11 memory model. |
| 93 | + |
| 94 | +## Expected speedup |
| 95 | + |
| 96 | +Wave-1 baseline (`docs/bench/2026-05-30-chapel-mrr-baseline.md`) |
| 97 | +showed speculative ≈ best-of on the success cases because every |
| 98 | +loser ran to its own per-prover timeout. After L2.3 the loser wall |
| 99 | +is bounded by `pollInterval + SIGKILL latency ≈ 100-150 ms`. For the |
| 100 | +trivial-success regime this is invisible because the winner itself |
| 101 | +returns in 200-700 ms. For the realistic-corpus regime (10-30 s |
| 102 | +real prover invocations) this is the difference between waiting |
| 103 | +`max(losers)` and `min(winner) + ~150 ms`. The dramatic speedup the |
| 104 | +bench writeup predicted is now structurally present. |
| 105 | + |
| 106 | +A second baseline run (`docs/bench/`) is **not** included in this PR |
| 107 | +— the trivial fixtures don't exercise the new code path enough to |
| 108 | +show a meaningful number. A real-corpus bench run is the L2.4+ |
| 109 | +follow-up. |
| 110 | + |
| 111 | +## Reproduce locally |
| 112 | + |
| 113 | +```bash |
| 114 | +just bench-chapel-mrr |
| 115 | +# Watch the parallel_speculative wallclock vs parallel_bestof: |
| 116 | +# trivial fixtures show no change; a follow-up corpus bench would |
| 117 | +# show speculative << bestof. |
| 118 | +``` |
| 119 | + |
| 120 | +## Risks / counter-arguments considered |
| 121 | + |
| 122 | +1. **Premature cancellation race.** If two provers succeed at nearly |
| 123 | + the same instant, the late one might write `cancelled = true` |
| 124 | + between the early CAS and the early winner's exit. **Resolved:** the |
| 125 | + CAS is what selects the winner; whichever prover's `compareAndSwap` |
| 126 | + returns true wins regardless of token-write order. The late |
| 127 | + prover's `compareAndSwap` returns false (expected != -1 now), so |
| 128 | + it does NOT write to the token. Only ONE prover writes the token, |
| 129 | + and it's the same prover that wins the CAS. |
| 130 | + |
| 131 | +2. **Self-cancellation.** Could a winning prover's poll race with its |
| 132 | + own success-detection and trigger preemption on itself? **No:** |
| 133 | + the cancellation poll happens INSIDE the `subproc.running` loop, |
| 134 | + which exits once the subprocess has terminated. A successful |
| 135 | + prover exits the loop via the `!subproc.running` edge before |
| 136 | + `cancelToken.cancelled.write(true)` is even called. |
| 137 | + |
| 138 | +3. **Loser starvation.** What about provers whose first `tryProver` |
| 139 | + poll is delayed? **Resolved by 100 ms ceiling:** even the |
| 140 | + most-delayed prover observes the cancel within 100 ms of its |
| 141 | + first poll. Total preemption-observation latency is bounded by |
| 142 | + one pollInterval. |
| 143 | + |
| 144 | +## Wave-3+ follow-ups |
| 145 | + |
| 146 | +- A real-corpus bench (10-30 s prover invocations) to demonstrate |
| 147 | + the speculative >> bestof speedup quantitatively. |
| 148 | +- A `--bench-chapel-mrr` `--strategy=speculative` flag that |
| 149 | + reports the per-prover preemption-vs-timeout-vs-success breakdown, |
| 150 | + so we can measure the wallclock improvement attributable to |
| 151 | + preemption specifically. |
| 152 | +- Extend `CancelToken` to carry a reason string (winner-name, |
| 153 | + timeout-budget-exhausted, external-cancel, …) for future |
| 154 | + cooperative-cancellation flows beyond the speculative-search |
| 155 | + case. |
0 commit comments