Skip to content

Space bounds: measure the live-heap peak, and bound it statically - #151

Open
hhefesto wants to merge 3 commits into
Stand-In-Language:masterfrom
hhefesto:hhefesto/space-bounds
Open

Space bounds: measure the live-heap peak, and bound it statically#151
hhefesto wants to merge 3 commits into
Stand-In-Language:masterfrom
hhefesto:hhefesto/space-bounds

Conversation

@hhefesto

Copy link
Copy Markdown
Contributor

Telomare certifies time — the sizing pass infers a concrete iteration count
for every recursion site and --certificate prints the certified caps. It has
no memory figure at all. This branch adds one, on both sides: a measured
live-heap peak for a run, and a static bound over input sizes for the program,
with the second tested against the first on every corpus program.

Three commits, each one idea, fast-forwardable onto master.

Why it took until now

The codebase records two failed attempts, and they fail in opposite directions:

  • Tree-counting overcounts sharing. Eval/Meter.hs counts the nodes a run
    builds; counting what it holds as a tree counts shared structure once per
    reference. Application in telomare is environment binding, so the residual is
    a graph with heavy sharing — for tictactoe.tel a tree count reads about
    1.2TB for a run that fits in a few GB.
  • A per-node cost algebra cannot see retention. map suc over n elements
    is Θ(n) live, and an algebra over combinators computes a constant.

Both are answered by the same move: evaluate over an explicit store so
sharing is id-sharing, and take the live heap as reachability over distinct
nodes
from what the machine still holds. Retention needs no rule — a value a
frame still holds is reachable. Then run that same machine at compile time over
a symbolic input, and the bound falls out of the same reachability.

What lands

1. Measure the live-heap peak and report it from --meter
Telomare.Eval.Space: the reference interpreter defunctionalized into a CEK
machine over an IntMap store. Steps and builds tick exactly where the old
meter's counters do, and the conformance suite asserts that parity tick for
tick — the drift guard for the rewrite. Two sweep policies: exact
(per-allocation, for tests) and adaptive (amortized, brackets the peak).

                     before                    after
steps (measured):    2143391                   2143391
nodes built:         502707                    502707
live heap peak:      —                         15640..20473 cells

2. Bound the live-heap peak statically, over input sizes
Telomare.SpaceBound — maxima of affines Σ c_p·|p| + k over input-part
sizes, with dominance pruning, widening and substitution of refinement-pinned
sizes. Telomare.Space.Static — the same machine over the sized term and an
abstract input shaped by the sizing pass's own InputRestrictions; a gate on
an unknown takes both branches and joins them in a superposition. Convergence
on tictactoe.tel comes from four things: world-consistency tags (k tests of
one unknown cost 2 worlds, not 2^k), a pointwise pair merge (a superposition of
pairs is a pair of superpositions), depth-capped widening guarded by a
has-function flag, and deep-forced accumulators. sizeTermM hands back the
restrictions it already computes; the sizing report carries the bound lazily;
the artifact moves to version 3 so a .telc re-reports without re-walking.

3. Report the space bound in the certificate and after --compile

$ telomare --certificate simpleplus.tel
…
space (static bound, refinement-valid inputs): sizes of 116 input parts (116 weighted) + 4337 cells
  |input.path| stands for the size in cells of that input part;
  a run whose input fails a refinement is outside this bound.

$ telomare --compile tictactoe.tel
wrote tictactoe.telc (48621 nodes, space <= max(sizes of 153 input parts (8386 weighted) + 52327,
  sizes of 17 input parts (399 weighted) + 90428, … 11 more …), sources 1309be3f2e02)

On tictactoe.tel the bound is a maximum of thirteen affines whose largest
constant, 90,428 cells, is about four times the measured peak of a completed
game (15,640..20,473).

What it costs

A/B against master (db63967) in a worktree, both built the same way (cabal
-O1, GHC 9.10.3, same box), 3 runs each, median wall time:

tictactoe.tel master this branch delta
--certificate 14.63s 22.28s +7.65s (+52%)
--compile 14.66s 23.17s +8.51s (+58%)
plain run 14.13s 14.97s +0.85s (+6%)
--meter 14.48s 15.18s +0.70s (+4.8%)

simpleplus.tel: certificate 0.109s → 0.135s, meter 0.097s → 0.111s.

Where the time goes, isolated in one process: sizing 12.74s, abstract walk
7.97s
(2,583,177 transitions, 661,011 allocations; 7.84s at -O2, so this is
the walk's real cost, not a missing optimization). Three things worth knowing
about that table:

  • A plain run pays nothing for the bound. sizingReportSpace is a lazy
    field; only a report forces the walk. The +6% on the run is not the walk — it
    is the extra live data surviving the sizing call (compiler max residency
    1469MB → 1603MB in that run); sizing's hot path is untouched.
  • Compile once and the cost is gone. Re-reporting from the .telc takes
    0.043s; the artifact grew 57,651 → 64,745 bytes.
  • The compiler's memory profile is unchanged. RTS peak stays ~3.76GB on
    both sides. The walk adds ~22GB of allocation churn (9.1GB → 31GB total
    allocation for a certificate run) and no peak.

How it is checked

  • The headline invariant (test/SpaceTests.hs): on every corpus program
    and every refinement-valid iteration, the static bound with the actual input
    sizes substituted stands at or above the exactly measured peak. Iterations
    that fire an abort are detected via spAborts and skipped — an invalid input
    builds and retains the failure message, which is outside the restricted
    abstract input — and at least one comparison must remain, or the test fails
    as vacuous.
  • Tick parity (test/ConformanceTests.hs): the space machine must agree
    with the old meter on the value and on both counters, tick for tick.
  • Artifact round-trip per field, including the stored bound, plus the
    version-refusal message for older files.
  • Confirmed by the A/B above: identical program output, identical step and
    build counts, identical node count and source hash. The only new text in the
    certificate is the space section.

Known looseness (documented, not soundness holes)

  • Sharing that crosses a superposition boundary is counted on both sides.
  • A widened superposition keeps only its bound, which is why tictactoe's top
    affine sits ~4x above the measured peak. Tightening candidates: sup-aware
    sharing credit, and a canonical merge for small domains (a board cell is one
    of three values).
  • The bound is stated for refinement-valid inputs, and the certificate says
    so on its own line.

Also in here

The README's timings are restated from measured runs. Two had drifted before
this work: sizing tictactoe.tel is about 13 seconds, not the 70 the README
claimed (it predates the sizing speedup), and the sample certificate's nesting
columns and metered step counts no longer matched real output.

Reproducing the numbers

# both sides, from their own checkout root
telomare --certificate tictactoe.tel
telomare --compile tictactoe.tel -o /tmp/tt.telc
printf '1\n9\n2\n8\n3\n' | telomare --meter tictactoe.tel

# sizing vs walk, split (uses evalSpaceStatic', which returns the counts)
#   sizeTermM …  ->  evalSpaceStatic' defaultStaticSpaceFuel restrictions sized

The memory figure Meter.hs deliberately left undone. runMeter
defunctionalized into a CEK machine whose values live in an explicit
IntMap store: sharing is id-sharing, so the live heap is reachability
from the machine's roots (environment, continuation frames, returning
value) over distinct cells, and the peak is the maximum along the run.
Retention needs no rule: a value a frame still holds is reachable.

Steps and built tick exactly where runMeter's counters do, and the
conformance suite now asserts that parity tick for tick alongside value
agreement -- that is what keeps a third interpreter honest. Gate
branches stay syntax in their frame, so the unchosen branch is never
evaluated, same as the other evaluators.

Two sweep policies: every allocation (exact, for tests) and adaptive
(amortized, brackets the peak between a reached figure and a never-
exceeded one). Sweeps drop unreachable nodes, which is what makes the
bracket's upper end valid. The metered loop runs at the adaptive
cadence, so a metered session now prints the peak alongside the step
and build counts.

The hand-computed fixtures pin what "live" means: a literal pair is its
three cells; an argument referenced twice is counted once (a tree count
of the same result reads 7 for a peak of 5); and a transient the result
drops still shows up, growing with its size -- the retention case the
sibling project's cost algebra could not see.

The README's metered sample is refreshed from a measured run: its step
and build counts had drifted. Profiling output joins .gitignore.
The same machine at compile time, over an abstract input. Bounds are
maxima of affine expressions in input-part sizes -- sum of c_p * |p|
plus a constant -- with dominated alternatives pruned, widening to
pointwise-maximum coefficients, and substitution of the sizes a
refinement pins. That language is what a static answer has to be: a
program's peak depends on how big its input is, so a single number
would be either wrong or vacuous.

The walk runs on the sized term, so every recursion is a church tower
it unrolls exactly its inferred count of times, and retention is
measured on the abstract run by reachability rather than modelled by
per-combinator rules -- the dead end design/SPACE.md records. The
abstract input mirrors the sizing pass's initialInput: refinement-
guaranteed pairs expand, refinement-guaranteed zeroes are concrete,
and the rest are symbolic nodes whose cell bound is |p|. A gate on an
unknown takes both branches and joins the values in a superposition
whose frozen bound is the maximum of its sides' reachable subgraphs.

Four things keep that from exploding, and together they are why
tictactoe.tel converges -- 2.6M transitions under the default fuel,
about eight seconds after sizing:

- World-consistency tags, the sizing pass's filterLeft/filterRight
  discipline: a fork is about an input path, that commitment is in
  force while its branch runs, and a repeated test of the same
  unknown dispatches to the committed side instead of re-forking. k
  tests of one unknown cost two worlds, not 2^k.
- A pointwise pair merge: a superposition of two pairs joins as a
  pair of superpositions, sound because a maximum of sums never
  exceeds the sum of maxima. Closure superpositions collapse to one
  closure over superposed environments, and nesting depth resets at
  every pair.
- Widening of deep data superpositions, at supDepthCap = 4, guarded
  by a per-node has-function flag so a value that must still be
  applied is never reduced to a bound alone. Board cells touched by k
  moves stay bounded-depth instead of depth k, which is what keeps
  whoWon's nested gates from forking over an unbounded frontier.
- Deep-forced peak and debt accumulators: left lazy, each allocation
  parked a thunk retaining the machine state it was made from, and
  the walk's memory grew with its history instead of its live set.

A join memo, rare store pruning and a pin stack for ids the machine
holds mid-transition keep the walk near 3.7M transitions a second, and
the measuring sweep is amortized: fresh one-cell and symbolic-input
allocations enter a debt, join-produced nodes measure on the spot, and
last live plus debt bounds any intermediate live set because the store
only grows between sweeps.

sizeTermM hands back the InputRestrictions it already computes; the
sizing report carries the bound lazily, so a plain run never forces the
walk; the artifact encoding gains it and moves to version 3.

The bound covers refinement-valid runs: a run on invalid input
constructs and retains the failed check's aborted message, which is
outside the restricted abstract input. The space meter now counts
constructed aborted values so a harness can tell those runs apart, and
the headline test asserts, on every corpus program, that the bound with
actual input sizes substituted stands at or above the exactly measured
peak of every abort-free iteration, and that at least one such
iteration exists.

simpleplus comes out at 116 input parts + 4337 cells; tictactoe at a
maximum of thirteen affines whose largest constant, near ninety
thousand cells, is about four times the measured peak of a completed
game -- loose where a deep superposition is widened to its bound
alone, but finite and sound.
The certificate gains a space section between sizing and structure: the
bound as an expression over input-part sizes, stated for refinement-
valid inputs (a run whose input fails a check builds and retains the
aborted message, which is outside it), or an honest unknown with the
reason it could not be found. --compile's summary line carries the same
figure, so the line that says what was written also says what running it
will cost. Affines over many input parts are summarized in report lines
rather than spelled out path by path.

The README's timings are refreshed from measured runs, since two of them
had drifted: sizing tictactoe.tel takes about 13 seconds, not the 70 it
claimed before the sizing pass got faster, the abstract walk adds some
eight more, and the sample certificate's nesting columns are restated
from real output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant