Skip to content

Commit 870a2ec

Browse files
feat(chapel): L2.3 cancel-token preemption (#154)
## Summary Wave-2 follow-on to PR #146. Closes the explicitly-deferred \"kill in-flight losers\" gap from L2.2: once the CAS-winner is selected, every still-polling loser observes the shared \`cancelToken.cancelled\` flag on its next poll tick and SIGKILLs its own subprocess instead of running to its per-prover timeout. After this PR, loser wall is bounded by \`pollInterval ≈ 100 ms\` rather than \`max(losers)\`. ## What changed - **\`parallel_proof_search.chpl\`** - New \`class CancelToken { var cancelled: atomic bool }\` — class (not record) so all \`coforall\` tasks share by reference. - \`tryProver\` signature gains \`cancelToken: borrowed CancelToken? = nil\`. Sequential / best-of callers pass nil and behave identically. - tryProver's poll loop checks \`cancelToken!.cancelled.read()\` each tick; on observing true, SIGKILL → preempted result with \`exitCode = -5\` (distinct from -3 timeout / -4 subprocess error). - \`parallelProofSearchSpeculative\` creates the token and pairs \`winnerIdx.compareAndSwap\` with \`cancelToken.cancelled.write(true)\` so only the prover that wins the CAS triggers the signal. - **\`proofs/agda/ParallelSoundness.agda\`** - Header docstring extended to spell out that the existing \`cancellation-safety\` theorem IS the L2.3 preemption claim. No new lemma needed — \`speculative (true ∷ bs) = true\` for arbitrary \`bs\` is exactly what preemption preserves. - **ADR** at \`docs/decisions/2026-05-30-chapel-l23-cancel-token.md\` covering rationale, soundness, three race-condition counter-arguments, expected speedup, reproduction recipe, Wave-3 follow-ups. - Cross-link from the rehab ADR's Wave-2 plan to the new ADR. ## Test plan - [x] \`chpl --library --static parallel_proof_search.chpl chapel_ffi_exports.chpl -I ../zig_ffi\` compiles cleanly - [x] \`agda --safe ParallelSoundness.agda\` green - [x] \`just bench-chapel-mrr\` still returns correct speculative winners - [ ] Real-corpus bench showing speculative << bestof (Wave-3; trivial fixtures don't exercise enough) - [ ] CI: chapel-build + chapel-smoke remain green 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cfa1e7f commit 870a2ec

4 files changed

Lines changed: 256 additions & 20 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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.

docs/decisions/2026-05-30-chapel-rehabilitation.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ Wave 2 (separate PR, after Wave 1 green-on-main for ≥7 days):
113113
CI flip deferred — the ~30 min source build dominates the chapel-ci
114114
budget; a registry-pushed container image is the L2.5 prerequisite.
115115
- Add the cancel-token thread through `tryProver` and switch the
116-
Chapel-side default to the speculative search.
116+
Chapel-side default to the speculative search. Implementation +
117+
soundness argument:
118+
[2026-05-30-chapel-l23-cancel-token.md](./2026-05-30-chapel-l23-cancel-token.md).
117119
- Wire the `proven` and `docudactyl` parallel-dispatch tracks
118120
off the same scaffold; breadcrumb issues filed in those repos
119121
on the PR landing.

proofs/agda/ParallelSoundness.agda

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,17 @@
1515
-- 3. Cancellation-safety — once a prover position witnesses
1616
-- success, the verdict is true regardless
1717
-- of what other provers do (the winner is
18-
-- insensitive to the losers' state)
18+
-- insensitive to the losers' state).
19+
-- THIS IS THE L2.3 PREEMPTION CLAIM.
20+
-- Once the speculative-search winner has
21+
-- flipped the shared `CancelToken`, every
22+
-- in-flight loser self-SIGKILLs on its
23+
-- next poll and returns a preempted
24+
-- result (exitCode = -5). Those preempted
25+
-- results are NEVER returned to the
26+
-- caller — `winner` was set before any
27+
-- cancellation could race the CAS — so
28+
-- the aggregation verdict is unchanged.
1929
--
2030
-- The Chapel implementation uses an atomic compare-and-swap on a
2131
-- shared `winnerIdx` to record the first-completing successful prover.
@@ -25,6 +35,13 @@
2535
-- the (possibly several) provers that succeed, but the aggregation
2636
-- verdict (success or not) is unaffected by which index wins.
2737
--
38+
-- L2.3 cancel-token implementation lives in `src/chapel/parallel_proof_search.chpl`:
39+
-- the `CancelToken` class (class for shared-by-reference semantics across
40+
-- coforall tasks), the `cancelToken` parameter on `tryProver`, and the
41+
-- `cancelToken.cancelled.write(true)` paired with the CAS in
42+
-- `parallelProofSearchSpeculative`. `cancellation-safety` below is the
43+
-- formal statement of what that implementation preserves.
44+
--
2845
-- Wave-1 scope: this module proves the AGGREGATION layer. The
2946
-- complementary layer — that `tryProver` itself is sound (i.e. its
3047
-- `success = true` return implies the proof actually checks under the

src/chapel/parallel_proof_search.chpl

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,38 @@ proc isProverAvailable(info: ProverInfo): bool {
128128
}
129129
}
130130

131+
// ---------------------------------------------------------------------------
132+
// Cancellation token (L2.3)
133+
// ---------------------------------------------------------------------------
134+
135+
// A shared cancellation flag used by `parallelProofSearchSpeculative`
136+
// to signal in-flight losers as soon as the first winner has been
137+
// established via the CAS. A `class` (not `record`) so that all
138+
// `coforall` tasks share the same instance by reference.
139+
//
140+
// `cancelled.write(true)` is the only mutation; all readers are
141+
// `read()` polls inside `tryProver`'s wait loop. The atomic semantics
142+
// give us a happens-before edge from the winner's CAS to every
143+
// loser's next poll — no extra synchronisation is required.
144+
class CancelToken {
145+
var cancelled: atomic bool;
146+
proc init() {
147+
// `atomic bool` defaults to `false` after `init this` commits
148+
// field initialisation; no further write needed.
149+
init this;
150+
}
151+
}
152+
131153
// ---------------------------------------------------------------------------
132154
// Real prover invocation via subprocess
133155
// ---------------------------------------------------------------------------
134156

135-
// Write goal content to a temporary file and invoke the prover
136-
proc tryProver(info: ProverInfo, goal: string, timeout: int = defaultTimeout): ProofResult {
157+
// Write goal content to a temporary file and invoke the prover. If
158+
// `cancelToken` is non-nil and `cancelled.read()` returns true mid-poll,
159+
// SIGKILL the child and return a cancelled-result (exitCode = -5).
160+
// Callers that do not race (sequential / best-of) pass nil.
161+
proc tryProver(info: ProverInfo, goal: string, timeout: int = defaultTimeout,
162+
cancelToken: borrowed CancelToken? = nil): ProofResult {
137163
var timer = new stopwatch();
138164
timer.start();
139165

@@ -199,12 +225,39 @@ proc tryProver(info: ProverInfo, goal: string, timeout: int = defaultTimeout): P
199225
// The previous implementation used `!proc.running` which is true
200226
// before the child has begun and after it has exited, so it was
201227
// both racy at startup and stopped polling once running latched.
228+
// L2.3: also break out if the shared `cancelToken` flips to true,
229+
// which the speculative-search winner sets after its CAS succeeds.
202230
var elapsed: real = 0.0;
203231
const pollInterval: real = 0.1;
232+
var preempted = false;
204233
while subproc.running && elapsed < timeout:real {
205234
sleep(pollInterval);
206235
elapsed += pollInterval;
207236
subproc.poll();
237+
if cancelToken != nil && cancelToken!.cancelled.read() {
238+
preempted = true;
239+
break;
240+
}
241+
}
242+
243+
if preempted {
244+
// L2.3 preemption: the speculative-search winner has been
245+
// declared elsewhere. SIGKILL ourselves and return a
246+
// distinct exitCode = -5 so the caller's result table
247+
// distinguishes preempted-loser from timed-out from failed.
248+
subproc.sendPosixSignal(9);
249+
subproc.wait();
250+
timer.stop();
251+
try { remove(tmpFile); } catch { }
252+
return new ProofResult(
253+
success = false,
254+
prover = info.name,
255+
proverId = info.id,
256+
time = timer.elapsed(),
257+
exitCode = -5,
258+
output = "Preempted by speculative-search winner",
259+
category = info.category
260+
);
208261
}
209262

210263
if subproc.running {
@@ -370,21 +423,25 @@ proc parallelProofSearch(goal: string, provers: [] ProverInfo,
370423
// - parallelProofSearch waits for ALL tasks then picks the fastest
371424
// success. Wall time is bounded by the slowest prover.
372425
// - parallelProofSearchSpeculative records the first-completing
373-
// success via an atomic CAS and returns it. Wall time is bounded
374-
// by the fastest successful prover plus whatever in-flight tasks
375-
// have already begun (no mid-flight preemption in Wave 1).
426+
// success via an atomic CAS and SIGKILLs any still-running losers
427+
// via the shared `CancelToken`. Wall time is bounded by the
428+
// fastest successful prover plus one poll-interval (~100 ms) of
429+
// observation lag on the losers.
376430
//
377-
// Wave 1 scope deliberately stops short of the "kill in-flight losers"
378-
// step. Once a prover has been spawned, `tryProver` runs it to its own
379-
// per-prover timeout — coforall doesn't know how to cancel a child task
380-
// that's blocked on subprocess I/O. L2.3 introduces a cancel token
381-
// threaded through `tryProver` so the children can SIGKILL their own
382-
// subprocesses when they observe a winner; that's a separate PR.
431+
// L2.3 cancellation: the winning task's CAS is paired with a write to
432+
// the shared `CancelToken`. Loser tasks read the token at every poll
433+
// step in `tryProver` and self-SIGKILL their subprocess as soon as
434+
// the flag is observed. The atomic-bool semantics give us a
435+
// happens-before edge from CAS-success to next-loser-poll, so no
436+
// further locking is needed.
383437
//
384-
// Cancellation-safety today: every losing task is run to its natural
385-
// end. The result table records all outcomes; only the winning index
386-
// (the first successful CAS) is returned. No prover's exit can poison
387-
// the join because the atomic CAS is monotone — the first winner wins.
438+
// Soundness: the monotone first-wins CAS still controls which index
439+
// is returned. The cancellation flag only affects how the LOSERS
440+
// terminate — their results land in the table with exitCode = -5
441+
// instead of running to completion, but they are never returned to
442+
// the caller because `winner` is set before any cancellation could
443+
// race the CAS. See proofs/agda/ParallelSoundness.agda:
444+
// `cancellation-safety` for the formal statement.
388445
proc parallelProofSearchSpeculative(goal: string, provers: [] ProverInfo,
389446
timeout: int = defaultTimeout): ProofResult {
390447
if verbose then
@@ -397,15 +454,20 @@ proc parallelProofSearchSpeculative(goal: string, provers: [] ProverInfo,
397454
var results: [provers.domain] ProofResult;
398455
var winnerIdx: atomic int;
399456
winnerIdx.write(-1);
457+
var cancelToken = new owned CancelToken();
400458

401459
coforall (prover, i) in zip(provers, provers.domain) {
402-
results[i] = tryProver(prover, goal, timeout);
460+
results[i] = tryProver(prover, goal, timeout, cancelToken.borrow());
403461

404462
if results[i].success {
405463
// Monotone first-wins CAS: only the first successful
406-
// worker flips the atomic from -1 to its own index.
464+
// worker flips the atomic from -1 to its own index. The
465+
// paired write to cancelToken signals every still-polling
466+
// loser to SIGKILL its subprocess on the next poll.
407467
var expected = -1;
408-
winnerIdx.compareAndSwap(expected, i);
468+
if winnerIdx.compareAndSwap(expected, i) {
469+
cancelToken.cancelled.write(true);
470+
}
409471
}
410472
}
411473

0 commit comments

Comments
 (0)