Skip to content

Bounds-check elision for synth: export proven-safe access sites + close the br_if precision gap #114

Description

@avrabe

Context and credit

WasmBounds (Emma Sudo & Keith Winstein, Stanford — paper) applies abstract interpretation to elide Wasm memory bounds checks, and reports a measured 1.21× speedup on a dot-product microbenchmark. It is an early-stage project (their words), but it does two things we have not: it states the motivation crisply and it measures the payoff end to end.

Their motivation section is the clearest statement I've seen of why this matters for our embedded target: hardware guard pages need virtual memory, so on no-MMU embedded — and, increasingly, under the Memory64 and Custom Page Sizes proposals — runtimes fall back to a software bounds check on every memory access. synth's own SoftwareBoundsChecker documents that cost as "~25-40% overhead".

scry already computes the verdict (TrapCheck{kind: OutOfBounds, verdict: ProvenSafe} via region_in_bounds). What's missing is an export a compiler can consume, and one guard idiom that matters for real loop code.

1. Export: safe-accesses.json (schema v1)

Keyed on the wasmparser operator index space — the same space synth's frontend consumes, so no source re-mapping step.

{
  "schema": "scry/safe-accesses/v1",
  "scry_version": "3.2.4",
  "module_sha256": "<hex>",
  "memory_min_bytes": 65536,
  "premises": { "bounded_memory": true },
  "proven_safe": [ { "func": 4, "pc": 41, "op": "i32.load", "width": 4 } ],
  "counts": { "access_sites": 0, "proven_safe": 0 }
}

Soundness contract (belongs in the schema doc):

  • module_sha256 binds the verdicts to one exact module; the consumer fails closed on mismatch. Eliding on a stale analysis is a memory-safety hole, not a stale optimisation.
  • Verdicts are proven against memory_min_bytes, the guaranteed floor. Wasm memory only grows, so a site proven in-bounds against the floor stays in-bounds under memory.grow.
  • Absence from proven_safe means "not proven", never "unsafe".

This mirrors WasmBounds' output contract — "a non-exhaustive list of provably safe memory instruction offsets" that any runtime can consume — which is a good, simple interface worth copying.

2. Precision: br_if on a bare value

Our guard refinement recognises local.get X; i32.const c; <cmp>; br_if, local.get X; i32.eqz; br_if, and the two-local compare (try_guard_brif / try_guard_brif_rel). It does not yet handle local.tee $x; br_if — a branch on the raw value's truthiness (implicit ≠ 0), which is what LLVM emits for count-to-zero loops.

WasmBounds' BrIf rule covers this case: the fall-through edge gets the local = {0}, the taken edge gets ≠ 0. It's the dual of the i32.eqz shape we already support, and it's worth adding.

Their mechanism for it generalises better than ours, too: they pair each operand-stack entry with the expression that produced it, so br_if knows the popped value is local $x. Ours is a syntactic peephole over ops[pc..pc+4]. A light provenance tag (Local(i) | Const | Mem | Other) on the abstract stack would make refinement general rather than idiom-matched.

3. Domain choice: keep intervals + congruence

WasmBounds tracks sets of intervals, which keeps branch-disjointness precise and supports their fixpoint without a widening heuristic (they note widening as future work).

For scry we should keep our current stack, for a specific reason: for the in-bounds decision a disjunctive set and its hull are equivalent — proving every element in bounds is the same as proving the hull in bounds, since the hull's endpoints are attained by elements. So our single interval already answers this question as precisely, and our threshold widening + narrowing (FEAT-016/042) reaches the fixpoint without unrolling. Where a set would otherwise help — stride/alignment — we have the known-bits congruence domain (FEAT-037).

Worth revisiting if we find a case where branch-disjointness matters for a decision other than in-bounds.

4. Measure it

We have never published "% of i32.load/i32.store sites proven safe". That's the number that makes the capability legible, and the input to the synth-side benchmark.

Related: pulseengine/synth#901 (consumer side), FEAT-046 (OOB verdicts), FEAT-061 (loop-range memory content), REQ-004 (invariants for consumers).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions