|
| 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