Skip to content

Commit a14024b

Browse files
authored
Formal: literal two-counter sampler reduction + per-line attribution under sampling (#1076)
* Formal: close two modeling gaps — literal two-counter sampler + per-line attribution under sampling Follow-up closing the two gaps flagged in the memory-sampler work. (1) §1b MemorySampler.lean — the one-counter model faithfully reduces the LITERAL two-counter C++. Previously the 'faithful to thresholdsampler.hpp' claim rested on collapsing _increments/_decrements into bal = incr - decr in prose. Now proved: St2 models the literal two-counter machine (separate ℕ counters, trigger incr ≥ decr+I / decr ≥ incr+I, reset both to 0); step_bisim / run_bisim prove it bisimilar to the one-counter model under abs(incr,decr)=incr-decr; threshold2_conserves transfers exact conservation to the two-counter machine as written. 'Faithful to the C++' is now a theorem. (2) §10 PerLineAttribution.lean — ties the byte sampler back to §6's per-line fraction story. A profiler reports recorded[ℓ]/Σrecorded, a ratio of unbiased estimators (not exactly unbiased since E[X/Y]≠E[X]/E[Y]). Proved what actually holds: fraction_of_expectations (the window cancels ⇒ no systematic scale bias in the breakdown), recorded_fraction_exact (proportional/large-sample limit ⇒ reported fraction = true fraction exactly, rate-independent), and that the per-line memory breakdown is a probability distribution (sums to 1), like §6. Full formal/lean build clean (8568 jobs); no sorry; standard axioms only. README updated (§1b note, new §10, boundary bullet). * Formal: audit models vs code — leak-tracker safety proof + bugs-found writeup Adds LeakTrackerAudit.lean: the leak formula 1-(frees+1)/(allocs-frees+2) (scalene_leak_analysis.py:31) divides with NO guard, relying entirely on the implicit invariant frees<=allocs — and allocs/frees are incremented at separate code sites (scalene_memory_profiler.py:236,401), so it's not obvious. We model the raw two-counter increment discipline (single-shot armed trigger) and PROVE frees<=allocs (run_frees_le_allocs), hence allocs-frees+2>=2>0 (denom_pos_reachable). The MetricCorrectness leak model had *assumed* this (unfreed,frees:ℕ with allocs=unfreed+frees), so it could never have caught a violation; this discharges the assumption against the actual dynamics. README gains a 'Bugs the formalization found' section documenting the two real defects the audit surfaced (both fixed in the companion code PR): the leak-velocity ZeroDivisionError and the atol sampling-window=0 case. Full formal/lean build clean (8569 jobs); no sorry; standard axioms only. * Formal: add HANDOFF.md — follow-up notes for the formalization effort Captures the method (audit hypotheses, don't assume them), what's proven, what's merged vs open (#1076/#1077), the two bugs found, CI/flake notes, known modeling gaps, and ranked next steps — so the effort can resume after a context reset. * Formal: close the leak-tracker concurrency/fork gap LeakTrackerAudit proved frees <= allocs only for a sequential event stream. In the code the two increment sites run on a background sig-queue thread while the shutdown drain runs on the main thread, and the tracker state is duplicated across fork -- the biggest remaining 'assumed not proven' gap (HANDOFF sec 6/7). Add LeakTrackerConcurrency.lean, which models the real discipline and, per the audit method, proves it is *necessary* rather than just present: - interleave_preserves_inv / sigqueue_then_drain_safe: with each process_malloc_free_samples call atomic (the sigqueue RLock), every interleaving of the sig-queue thread's steps with the main-thread drain preserves frees <= allocs -- scheduler order is irrelevant. - torn_free_breaks_inv: the lost-disarm race that dropping the lock would permit double-credits one armed trigger (frees = allocs + 1), so the RLock is load-bearing. - fork_reset_inv / partial_fork_reset_breaks_inv: the full fork reset (clear() zeros leak_score AND last_malloc_triggered together) starts the child safe; a partial reset that left the trigger armed breaks the invariant -- so resetting both fields together is required. No sorry; standard axioms only. Full lake build green. Updates README (new sec 11) and HANDOFF (gap closed).
1 parent 6b39f76 commit a14024b

7 files changed

Lines changed: 919 additions & 5 deletions

File tree

formal/HANDOFF.md

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
# Formalizing Scalene — handoff / follow-up notes
2+
3+
Working doc so this can be picked up after a context reset. Captures what the
4+
formal-verification effort has produced, the *method* (including a hard-won
5+
lesson), what's merged vs. open, and concrete next steps.
6+
7+
Last updated: 2026-06-30.
8+
9+
---
10+
11+
## 0. The point of this effort (don't lose this framing)
12+
13+
We formalize Scalene's guarantees in Lean 4 (+ TLA+ for interleavings) for two
14+
reasons, in priority order:
15+
16+
1. **Find and fix real bugs.** Formalizing forces every implicit assumption to
17+
be named. Where the code doesn't enforce what a proof needs, that's a
18+
finding — a real defect or an undocumented invariant. This has already paid
19+
off (two production bugs, see §4).
20+
2. **Establish correctness** of the properties a profiler's *user* relies on.
21+
22+
**THE METHODOLOGICAL LESSON (most important thing in this doc):** a proof whose
23+
hypotheses mirror the code's *implicit* assumptions validates the **model**, not
24+
the **code**. Early models did exactly this and were therefore blind to real
25+
bugs. Example: the leak model used `unfreed, frees : ℕ` with
26+
`allocs = unfreed + frees`, which *hardcodes* `frees ≤ allocs` — so it could
27+
never have caught the unguarded divide-by-zero that assumption protects against.
28+
29+
**Rule going forward:** treat every modeling hypothesis (`0 < total`,
30+
`frees ≤ allocs`, `interval > 0`, `pythonTime + cTime = total`, every
31+
denominator) as a *claim to verify against the implementation*. If the code
32+
doesn't enforce it → that's a finding, not something to assume away. Audit
33+
models adversarially, don't just prove them.
34+
35+
---
36+
37+
## 1. Where the artifacts live
38+
39+
```
40+
formal/
41+
README.md # full writeup: §1–§10, source mappings, boundaries, repro
42+
HANDOFF.md # this file
43+
tla/ # TLA+ specs (model-checked with TLC)
44+
SignalSafety.tla + _Fix.cfg / _Bug.cfg
45+
Deadlock.tla + .cfg
46+
lean/ # Lean 4 project (lake); Mathlib dep; toolchain v4.31.0
47+
Scalene.lean # top-level: imports all modules below
48+
Scalene/*.lean # see §2
49+
extract/
50+
ScaleneExtract.lean # extraction-friendly defs (built under LeanToPython, Lean 4.12)
51+
scalene_verified_core.py # GENERATED Python oracle (committed)
52+
tests/
53+
test_verified_space_saving.py # differential test: prod vs verified oracle
54+
test_leak_velocity_zero_elapsed.py # regression for bug #1 (§4)
55+
```
56+
57+
**Build:** `cd formal/lean && lake exe cache get && lake build` (needs
58+
`~/.elan/bin` on PATH; Mathlib cache ~7GB, gitignored).
59+
**TLC:** Java + `tla2tools.jar`. Ran on the `cloudnew` box (has Java 21);
60+
`java -cp tla2tools.jar tlc2.TLC -config X.cfg X.tla`.
61+
**All Lean proofs: no `sorry`; depend only on `propext, Classical.choice,
62+
Quot.sound`** (verify with `#print axioms <thm>`).
63+
64+
`cloudnew` = a Linux dev box (192 cores) reachable via `ssh cloudnew`. Used for:
65+
TLC model-checking (Java there), building the C++ native ext, and reproducing
66+
free-threaded (3.13t/3.14t) behavior. Has `~/scalene-debug` checkout + uv venvs
67+
`.venv-313t` / `.venv-314t`, and `~/tla/tla2tools.jar`. LeanToPython checkout is
68+
at `/tmp/LeanToPython` locally (Lean 4.12).
69+
70+
---
71+
72+
## 2. What's proven (Lean modules) — each maps to real code in README
73+
74+
| Module | Property | Key theorems |
75+
|---|---|---|
76+
| `Attribution.lean` | CPU/mem bookkeeping conserved; fractions in [0,1] | `totalTime_eq_split`, `cpu_distribution_conserved`, `pythonFraction_le_one`, `footprint_conserved` |
77+
| `SignalSafety.lean` | `list(...)` snapshot decouples output-iteration from concurrent inserts | `snapshot_stable`, `snapshot_sound` (no axioms) |
78+
| `SpaceSaving.lean` | `combined_stacks` table never exceeds capacity; evicts min | `step_withinCap`, `fold_withinCap`, `minCount_le` |
79+
| `ProfilerCorrectness.lean` | **headline**: reported per-line profile is unbiased + consistent | `estimator_unbiased`, `jointVariance_eq` (=p(1−p)/N), `jointExpect_pair` |
80+
| `ExponentialSampler.lean` | sampler is Poisson ⇒ discharges the i.i.d. hypothesis | `sample_le_iff` (inverse-CDF), `survival_memoryless` |
81+
| `MetricCorrectness.lean` | GPU/copy/python-split (weighted-avg) + leak detection (Bayesian test) | `gpuFraction_bounds`, `python_c_fraction_sums_one`, `leakScore_*`, `reportsLeak_iff`, `no_leak_without_evidence` |
82+
| `MemorySampler.lean` | threshold sampler conserves net exactly; Poisson unbiased; **two-counter bisimulation** | `threshold_conserves`, `threshold_residual_bounded`, `threshold2_conserves`, `step_bisim`, `poisson_unbiased` |
83+
| `PerLineAttribution.lean` | per-line byte fraction faithful under sampling | `fraction_of_expectations`, `recorded_fraction_exact` |
84+
| `LeakTrackerAudit.lean` | proves the leak formula's *unguarded* denominator is safe (`frees ≤ allocs`) | `run_frees_le_allocs`, `denom_pos_reachable` |
85+
| `LeakTrackerConcurrency.lean` | `frees ≤ allocs` survives sig-queue/main-thread interleaving + fork; RLock atomicity & joint fork-reset shown *necessary* | `interleave_preserves_inv`, `torn_free_breaks_inv`, `fork_reset_inv`, `partial_fork_reset_breaks_inv` |
86+
| TLA+ `SignalSafety` | the combined_stacks race is reachable (bug cfg) / impossible (fix cfg) | 4-state counterexample; 99 states clean |
87+
| TLA+ `Deadlock` | no deadlock; handler never blocks on a lock; output liveness | 72 states clean |
88+
89+
The "profiler correctness" chain: **faithful per-sample attribution ⇒ unbiased,
90+
consistent reported profile**. Faithfulness is delivered by (a) the exponential
91+
sampler (§`ExponentialSampler`, Poisson + PASTA), (b) synchronous C++ stamping
92+
(`whereInPython`, `pywhere.cpp` — engineering, not yet Lean-proven), and (c) the
93+
conservation invariants (`Attribution`).
94+
95+
---
96+
97+
## 3. Merged to master (all green)
98+
99+
Formal + the CI/bug work that unblocked it:
100+
- **#1068** formal models (TLA+ + Lean core)
101+
- **#1070** SpaceSaving capacity proof + proof→production extraction pipeline
102+
- **#1072** profiler-correctness desideratum (unbiased + consistent)
103+
- **#1073** regenerate oracle w/ upstream-fixed LeanToPython (dropped `min2`)
104+
- **#1075** Poisson sampler + GPU/copy/python-split/leak + memory-sampler
105+
- Enabling fixes: **#1066** (test `sys.executable` leak — the original
106+
root-cause), **#1067** (combined_stacks race), **#1069/#1071/#1074** (CI
107+
timing flakes), **#1065** (`_scalene_unwind` GIL declaration).
108+
- **emeryberger/LeanToPython#1** (MERGED): fixed two transpiler bugs
109+
(Bool-param branch inversion; binary `min`/`max` operand drop) found while
110+
extracting Scalene's defs. Local checkout: `/tmp/LeanToPython`.
111+
112+
## 3b. OPEN PRs (need driving to merge)
113+
114+
- **#1077** `fix-leak-velocity-and-sampling-window` — the two production bug
115+
fixes from §4. Independent, mergeable now. (1 commit.)
116+
- **#1076** `formal-sampler-refinements` — two-counter bisimulation +
117+
per-line attribution + `LeakTrackerAudit.lean` + README "bugs found". (2
118+
commits.) Formal-only; CI failures on it are transient/flake (see §5).
119+
120+
Merge order suggestion: #1077 first (it's the real fix), then #1076.
121+
122+
---
123+
124+
## 4. Bugs the formalization found (BOTH FIXED in #1077)
125+
126+
1. **ZeroDivisionError, leak velocity.** `scalene_json.py` ~line 1255:
127+
`"velocity_mb_s": leak_velocity / stats.elapsed_time` was unguarded.
128+
`compute_leaks` (`scalene_leak_analysis.py`) gates on allocation *growth
129+
rate*, NOT wall-clock time, so a leak can be reported when `elapsed_time`
130+
is still `0.0` (sub-ms run) → crash. Sibling `elapsed_time` divides
131+
(~637, ~1186) were already guarded. Fixed + regression test.
132+
2. **Sampling window = 0 from bad env var.** `sampleheap.hpp`:
133+
`atol(getenv("SCALENE_ALLOCATION_SAMPLING_WINDOW"))` returns 0 for `"0"` /
134+
unparseable → ThresholdSampler triggers on *every* alloc (`incr >= decr+0`),
135+
catastrophic overhead. Violates `interval > 0` (proved necessary in
136+
`MemorySampler.lean`). Fixed by clamping ≤0 to the default. Verified on
137+
cloudnew: `WINDOW=0` run now completes.
138+
139+
Third finding (no bug, but was implicit): the leak formula
140+
`1 − (frees+1)/(allocs−frees+2)` has NO denominator guard; safety rests on
141+
`frees ≤ allocs`, which is non-obvious (two separate increment sites,
142+
`scalene_memory_profiler.py:236` and `:401`). `LeakTrackerAudit.lean` now
143+
*proves* it from the single-shot armed-trigger discipline.
144+
145+
---
146+
147+
## 5. CI notes (so flakes don't waste a session)
148+
149+
Matrix uses `fail-fast: true`, so one red job cancels the rest. Four wall-clock/
150+
signal timing flakes were hardened at the source (#1069/#1071/#1074) — parity
151+
contention-ratio (now non-gating), HyperLogLog hash-seed (now deterministic ints),
152+
`test_mac_sampler` (poll-not-sleep), `test_off_then_on_via_signal` (placeholder
153+
SIGILL handler + retry). Remaining transient: `vendor/libunwind` download can
154+
500. **When a formal-only PR shows red: check whether the failing step is
155+
`Build scalene` (transient download) or a known flake test — if so, just re-run
156+
`gh run rerun <id> --failed`.** `ubuntu-latest, 3.13t` runs ~40 min (compiles
157+
deps from source; no cp313t wheels) — near the 45-min cap but passes.
158+
159+
Oracle test (`test_verified_space_saving.py`) skips on Python < 3.10 (the
160+
generated `X | Y` unions need 3.10+).
161+
162+
---
163+
164+
## 6. Known modeling gaps / caveats (be honest about these)
165+
166+
- **Faithful sampling is assumed, not proven.** §ProfilerCorrectness proves
167+
"faithful sampling ⇒ correct profile". It does NOT prove the C++ stamping
168+
*establishes* faithful sampling (needs modeling signal delivery + CPython
169+
loop). Discharged by engineering + §Attribution, not Lean.
170+
- **PASTA is cited, not formalized** (needs continuous-time stochastic-process
171+
dev). It's the step connecting Poisson sampling to the `trueFraction`
172+
distribution.
173+
- **`LeakTrackerAudit` models the increment *discipline*, not the literal
174+
code.** It assumes single-shot arm/disarm. The sig-queue/main-thread
175+
interleaving and `fork` gap that was flagged here is now **closed** by
176+
`LeakTrackerConcurrency.lean` (§2, §11 in README): interleaving safety under
177+
the RLock, plus proofs that RLock atomicity and joint fork-reset are
178+
*necessary* (`torn_free_breaks_inv`, `partial_fork_reset_breaks_inv`). What
179+
remains assumed: that each `process_malloc_free_samples` call really is atomic
180+
w.r.t. the others (the RLock + join discipline the code implements) — the
181+
model takes step-atomicity as given rather than deriving it from the queue's
182+
operational semantics.
183+
- **Python/native split**: only the *conservation* (fractions sum to 1) is
184+
proven, NOT the per-sample *accuracy* of the CALL-opcode/signal-deferral
185+
classifier heuristic.
186+
- **The extraction oracle is a differential guard, not a drop-in.** Production
187+
still has its own `_space_saving_increment`; the test checks they agree.
188+
- **C++→Python wiring** (native counters → `ScaleneStatistics`) is not modeled.
189+
- TLC results are bounded (`Keys={k1,k2,k3}`, `N=3`, `MaxHandler=2`) — exhaustive
190+
within bounds, not a general proof.
191+
192+
---
193+
194+
## 7. Concrete next steps (roughly ranked)
195+
196+
1. **Merge #1077 then #1076** (drive CI; re-run flakes per §5).
197+
2. ~~Audit `LeakTrackerAudit`'s faithfulness under concurrency/fork~~ **DONE**
198+
`LeakTrackerConcurrency.lean` models the sig-queue/main-thread interleaving
199+
and fork reset explicitly, proves the invariant survives every interleaving,
200+
and proves the RLock atomicity + joint fork-reset are *necessary*. Residual:
201+
step-atomicity is taken as a modeling axiom (justified by the RLock + thread
202+
join in the code) rather than derived from the queue's operational semantics
203+
— a TLA+ spec of `ScaleneSigQueue.run` could discharge that too.
204+
3. **Keep auditing hypotheses adversarially** (the §0 method): every `0 <`,
205+
every denominator, every counter that could underflow. The audit that found
206+
§4's bugs covered the main division sites; re-run it whenever a model gains
207+
a new hypothesis.
208+
4. **Formalize PASTA** (or at least a discrete-time analogue) to fully discharge
209+
the i.i.d.→trueFraction step instead of citing it.
210+
5. **Prove per-sample classifier accuracy** for the python/native split, or
211+
document precisely why it's a heuristic with bounded error.
212+
6. **Wire the verified oracle into production directly** (have
213+
`_space_saving_increment` call the extracted core) rather than only
214+
differential-testing it — closes the proof→production loop tighter.
215+
7. **Model one more column end-to-end** (e.g. GPU or copy-volume) under the
216+
unbiased-estimator frame, including the C++→Python wiring.
217+
218+
---
219+
220+
## 8. Repro cheatsheet
221+
222+
```bash
223+
# Lean (local)
224+
cd formal/lean && lake exe cache get && lake build # 0 sorry
225+
echo 'import Scalene
226+
#print axioms Scalene.ProfilerCorrectness.Truth.estimator_unbiased' > /tmp/a.lean
227+
lake env lean /tmp/a.lean # standard axioms only
228+
229+
# TLA+ (on cloudnew, has Java)
230+
scp formal/tla/* cloudnew:~/tla/scalene/
231+
ssh cloudnew 'cd ~/tla/scalene && java -cp ~/tla/tla2tools.jar tlc2.TLC \
232+
-config SignalSafety_Fix.cfg SignalSafety.tla' # no error, 99 states
233+
234+
# Regenerate the extracted oracle (needs /tmp/LeanToPython, Lean 4.12)
235+
cp formal/extract/ScaleneExtract.lean /tmp/LeanToPython/
236+
cd /tmp/LeanToPython && lake env lean ScaleneExtract.lean > scalene_verified_core.py
237+
238+
# The bug-finding regression test
239+
python3 -m pytest tests/test_leak_velocity_zero_elapsed.py -q
240+
```

0 commit comments

Comments
 (0)