Working state — a note taken while the work happens, not a specification. The system as it is meant to be is in Architecture.

Why a builder actor reaches 128 MB, and why 15 of them cost gigabytes

Investigation notes, 2026-08-05, worktree agitated-dubinsky-a3501e @ c13443301. All paths are relative to /Users/johnalanbrook/Documents/work/cell/.claude/worktrees/agitated-dubinsky-a3501e.

Method: code reading + one read-only live probe against the daemon holding this worktree’s socket. Probe caveat: that daemon is pid 12600, binary .claude/worktrees/lane-cp/pit, and lane-cp is at the identical commit c13443301 (verified by git -C .../lane-cp rev-parse HEAD), so the numbers below describe the same code. It is a foreign worktree’s daemon, so the fleet state it reports is that session’s history, not a controlled workload. No make, no make seed, no forge, no test gate was run.


0. The one-sentence answer

Nothing “leaks”. The copying collector deliberately reserves 2.5x–5x the live bytes, the physical block it hands back to the OS lags one GC behind the logical shrink, and a builder actor’s live set at peak is 6–9x its own 7 MB mcode output because four whole representations of the same unit coexist. Multiply by an unbounded fleet that starts one fresh actor per unit and you get gigabytes. Measured on the live daemon right now: 137 MB of live actor data is sitting inside 296 MB of buddy blocks inside 432 MB of mapped pools — 3.2x, at idle, with nine actors.


1. The per-actor memory model

1.1 ACTOR_MEMORY_LIMIT is 1 GiB and it is ADVISORY — it has no teeth

source/pit_internal.h:2152

#define ACTOR_MEMORY_LIMIT      (1024ULL * 1024 * 1024)

Installed on every actor shell at source/scheduler.c:3674 (Pit_SetHeapMemoryLimit(actor, ACTOR_MEMORY_LIMIT) inside actor_alloc_shell). Pit_SetHeapMemoryLimit is a one-line store, source/runtime.c:1851-1853. Overridable per actor at source/scheduler.c:3846-3856 (actor_shell_set_heap_limit) and source/pit_actor.c:640.

The limit does not clamp growth. source/pit_gc_copy.c:1633-1636, verbatim:

/* The stated heap limit is ADVISORY and does not clamp growth: clamping
   starves a genuinely needy actor into a full block and an OOM death,
   which is the kill the limit no longer performs. The limit's teeth are
   the warning at the end of GC. */

Confirmed live: every gc log record in .pit/log/observe.jsonl carries "heap_limit_bytes": 1073741824.

There is also no runtime-wide cap: buddy.cap is 0 (pit ps mem reports capacity_bytes: 0), and buddy_max_block returns SIZE_MAX when cap is 0 (source/runtime.c:1500-1502). So there is nothing anywhere that stops the fleet from mapping until the OS kills the daemon.

1.2 Not a semispace — but a GC transiently holds old + new

gc=copy is a Cheney copy into a freshly allocated block; the old block is freed as soon as the copy finishes:

  • allocate new block: source/pit_gc_copy.c:1650 heap_block_alloc(rt, new_size)
  • free the old one: source/pit_gc_copy.c:2015 heap_block_free(rt, from_base, old_alloc_size)

So steady-state is 1x (not 2x like a classic semispace), but during a GC the actor holds both blocks. For a doubling GC that is old + 2*old = 3x the old block at one instant. A builder going 128 MB -> 256 MB transiently holds 384 MB.

1.3 Growth: two paths

(a) Need-driven (source/pit_gc_copy.c:1630-1648). new_size starts at current_block_size and doubles until it holds live + alloc_size.

(b) Poor-recovery streak (source/pit_gc_copy.c:2055-2072). If recovered < old_used * 2/5 (i.e. under 40% reclaimed) three GCs in a row, next_block_size is doubled. This is what emits the heap growing NKB -> 2N KB (poor recovery, streak 3) message on the memory channel (source/pit_gc_copy.c:2157-2162). Live examples from .pit/log/observe.jsonl — one actor’s whole ladder:

heap growing 8KB -> 16KB      (poor recovery, streak 3)
heap growing 64KB -> 128KB    (poor recovery, streak 3)
heap growing 512KB -> 1024KB  (poor recovery, streak 3)
time (...): heap growing 1024KB -> 2048KB
time (...): heap growing 8192KB -> 16384KB
time (...): heap growing 65536KB -> 131072KB   <- 64 MB -> 128 MB

1.4 Shrink: it EXISTS, but the policy reserves 2.5x–5x live

gc_capacity_target, source/pit_gc_copy.c:447-454:

static size_t gc_capacity_target(PitContext *ctx, size_t live, size_t alloc_size) {
  size_t need = 1ULL << BUDDY_MIN_ORDER;
  size_t forty_pct = gc_ceil_mul_div(live, 5, 2);      /* 2.5 x live */
  size_t live_plus_alloc = gc_sat_add(live, alloc_size);
  if (need < forty_pct) need = forty_pct;
  if (need < live_plus_alloc) need = live_plus_alloc;
  return gc_round_heap_block(ctx, need);               /* round UP to a power of two */
}

gc_round_heap_block (source/pit_gc_copy.c:438-445) rounds up to the next power of two, floor 1 << BUDDY_MIN_ORDER = 1 KB on 64-bit (source/pit_internal.h:1217). Applied only on the good-recovery branch, source/pit_gc_copy.c:2072-2078.

Therefore capacity/live is always in [2.5, 5). That is the direct answer to “why is this actor at 128 MB”: because it has 26–51 MB live.

Worked live example (pit ps mem + .pit/log/observe.jsonl): shop_actor gc #25, new_used_bytes 29,243,312, new_cap_bytes 134,217,728. round_up_pow2(2.5 x 29.2 MB = 73.1 MB) = 128 MB. 4.6x.

When does a heap actually shrink one step? A GC fires when the block is full, so old_used ~= C. Shrinking to C/2 needs 2.5*live <= C/2, i.e. live <= C/5, i.e. recovery >= 80%. shop_actor’s gc #25 recovered 78% — one point short — and kept 128 MB. So heaps shrink only on very good recoveries, one power of two at a time.

1.5 The real ratchet: the PHYSICAL block lags one GC behind the LOGICAL shrink

source/pit_gc_copy.c:1614-1617, verbatim:

/* Size the new buddy block from the current usable capacity. Shrink is
   applied after live bytes are known by lowering current_block_size as a
   soft cap; the next GC then allocates the smaller buddy block. */
size_t new_size = ctx->current_block_size;

and at the end, source/pit_gc_copy.c:2128-2129:

ctx->current_block_size = new_capacity;   /* logical, may have shrunk */
ctx->heap_alloc_size    = new_size;       /* physical, did NOT shrink */

So after a shrinking GC the actor still holds the pre-shrink physical block. It is only released at the actor’s next GC. An idle actor never GCs again, so it holds its pre-shrink block forever.

Measured live, this daemon, right now ($inspect.snapshot() probe; heap_alloc_size and heap_peak_alloc_size are real exposed fields — std_endowments/inspect.c:60-61, fed from source/scheduler.c:3039 and ctx->old_alloc_peak_bytes, source/pit_internal.h:2232):

actorused MBsize MB (logical)alloc MB (physical)peak MBgcturns
pit-shop/shop_actor120.2128.0256.0256.0252479
pit-shop/clerk5.68.016.0128.010818898
pit-shop/logger1.74.08.016.0576229
pit-shop/daemon_listener6.08.08.08.0172981
5 small actors~3.0~8.1~8.1~9.0
TOTAL (9)136.6156.1296.1417.0

Two things to read off this table:

  1. shop_actor holds a 256 MB buddy block for 120 MB of live data. 128 MB of resident memory is stranded by the shrink lag alone. pit ps mem corroborates independently: pool index 1 is total_size_bytes: 256MB, alloc_count: 1, used_percent: 100% — one 256 MB block, one owner.
  2. clerk REFUTES the pure-ratchet story for a busy actor: peak 128 MB, now 16 MB physical. Given enough GCs (108) the ladder does come back down.

So the honest verdict on “ratchet or shrink” is: it shrinks if the actor keeps working; it ratchets if the actor goes quiet. Given builders are one-shot (§3), they always go quiet — but they also always die, which frees the block outright. The stranding matters for long-lived actors like shop_actor, and for the last GC of every builder.

1.6 Does the buddy give memory back to the OS? Mostly yes

buddy_free unmaps a pool when its alloc_count reaches 0 (source/runtime.c:1393-1406), keeping at least one pool. New pools double up to BUDDY_MAX_POOL = 64 MB (source/pit_internal.h:1223, source/runtime.c:1294-1296), but a request larger than that gets a dedicated pool sized to the request (source/runtime.c:1264-1267), so a 128/256 MB heap block owns its pool alone and its pool is unmapped when the block is freed.

Live: total_mapped 432MB, allocated_bytes 310MB, free_bytes 122MB, peak_mapped 752MB, buddy_unattributed_bytes 0. So 320 MB was returned; 122 MB (28%) is free-but-mapped fragmentation in partially-used pools. The buddy is a secondary consumer, not the story.

The buddy is used for nothing but actor heap blocks and nurseriesheap_block_alloc is the only caller of buddy_alloc (source/runtime.c:1508-1510, 1211); pit_malloc_rt is plain malloc (source/runtime.c pit_malloc_rt). Off-heap is negligible here: actor_offheap_bytes 768KB, shared_offheap_bytes 1MB.


2. What a builder actor holds while compiling one unit

Calibration: pit-compiler/streamline.cm = 5,461 lines / ~193 KB source / 104,896 IR instructions / 7,021,742 B of canonical mcode — i.e. 67 encoded bytes per instruction (plans/measurement-suite.md:571-576, schema at :485-489).

Sizing constants: PitArray = 8 hdr + 8 len + 8/elem (source/pit_internal.h:1552-1556); PitRecord = 16 + 16/entry (source/pit_internal.h:1639-1651); arrays grow by doubling from 2 (source/runtime.c:2409-2418), so budget up to 2x slack on every one of these.

Ranked, all coexisting in the same actor heap at the portable_body -> canonical_encode window:

#whatwhereest. bytes
1Canonical instruction rows — one heap array per instruction, [op, …operands, line, col]; ~6 elems -> cap 8 -> 80 Bpit-compiler/mcode.cm:397-401, held via ir.functions :4504, :4680~8.4 MB
2The interned CP1 stream, for EVERY function at once — 5 parallel per-instruction arrays + a dense operand array (ops, widths, starts, dirty, cache, operands); encode runs over all functions up front, decode only at the endpit-compiler/stream_ir.cm:151-153, :300-302; pit-compiler/streamline.cm:5033-5040 (encode all), :5501-5507 (decode last)~7–12 MB
3sites — one 80 B record + one freshly built text id per positioned instruction, retained in the returned unitpit-compiler/compiler.cm:1345-1351, retained :1421, :1703~11.7 MB
4Two more copies at the portable boundaryintern_function_literals builds a new spine + array per access row; strip_function_locations builds a brand-new array per instruction; both alive while the un-stripped compiled is still a live localpit-compiler/compiler.cm:1272-1310, :688-717, :1430-1441, :1641, :1657-1665~5 MB+
5Canonical encode buffer — one growing blob, blob.make(1024) then stone(out); blob_grow doubles and abandons the old buffer behind a forward pointer, so the final 7 MB output costs ~14 MB capacity + ~7 MB abandonedshoplib/canonical.cm:123-127, pitlib/blob.c:21-31; result still held at pit-shop/builder_worker.ce:104~21 MB transient
6AST + the FULL token stream, alive for the whole compileparse_result returns tokens: and every layer re-carries it; a token is a 7-key record = 128 B; ~40–50k tokenspit-compiler/compiler.cm:55-69, :243, :262, :373; pit-compiler/tokenize.cm:190-195~6 MB
7Retained per-function fact tables_write_types, _type_spans, _const_spans, _no_overflow are one entry per instruction; survive into portable_factspit-compiler/streamline.cm:4926, :4054, :4075; pit-compiler/int_ranges.cm:696-697, :946-948; pit-compiler/compiler.cm:1067-1077~1–2 MB
8Per-function transients (~12 arrays per int_ranges call)pit-compiler/int_ranges.cm:639-640, 714-715, 747, 776-778, 832, 876-877, 893churn

Answer to “what multiplies 7 MB into >100 MB”: items 1–4 are each ~0.7–1.7x the 7 MB artifact and are simultaneously live. Peak live for one 200 KB unit is ~41 MB, plus a ~21 MB encode buffer and uncollected garbage — call it 6–9x the artifact. Feed 41–60 MB of live into §1.4’s 2.5x-rounded-to-pow2 policy and you get a 128 MB block. That is the whole chain, with no leak anywhere in it.

pit-shop/builder_worker.ce:98-103 already names this hazard in a comment (“retaining two multi-megabyte representations”) — but it names it for the migration lane only; the steady-state path retains four.

Corroboration from the live gc log (.pit/log/observe.jsonl, 443 parseable gc records, 55 distinct actors, from a light make/forge session — NOT a seed press):

actorgc#old_usednew_usednew_caprec%
pit-shop/shop_actor25134,217,72829,243,312134,217,72878
time2133,554,42430,610,82467,108,8649
pit-shop/clerk2733,554,31226,117,87267,108,86422
pit-shop/builder_worker1933,554,41614,219,15267,108,86458
pit-shop/builder_worker1933,554,42417,507,24067,108,86448
forge1516,569,85610,059,69633,554,43239

Builders in a light session already reach 64 MB caps with 14–17 MB live. A seed press compiles far larger units.


3. Actor reuse: FRESH ACTOR PER UNIT. No pool, no reuse.

pit-shop/clerk.ce:1568-1571 says it literally: “One derivation, one actor.”

  • builder_fleet_request is the fleet transport (pit-shop/clerk.ce:1457-1458, :1508-1509, :1897). Every call does start_through_root({id: guid(), program: builder_program, …}) (:1583-1593) — a new guid per request — then one send(worker, value, …) (:1600). No worker table, no free list, no idle queue. builder_program = "pit-shop/builder_worker" (:42).
  • pit-shop/builder_worker.ce self-terminates after one message: $stop() is the last statement of every op (:125, :146, :150, :185, :217, :224, :231, :238, :259, :262, :274). Header: “the compiler behind one message” (:1).
  • pit-shop/build_fleet.cm:3-5 — “a requestor whose first operation is $start of a one-shot builder actor”.
  • pit-shop/shop_build.cm:1409-1412 — “a fifty-unit closure with two misses starts two workers, not fifty and never one batch worker”. Cache hits start zero actors; each miss starts exactly one.
  • History: this is the ruled shape. plans/archive/board.md:41 — D2 landed, builder_worker “survives as the pure per-message compiler the fleet fans out — intended shape”. plans/archive/derivations.md:236-245 records what D2 deleted: the old do_compile_batch where the fleet held exactly ONE worker (set_builder_worker, a single ref). E10 (plans/archive/derivations.md:496-509) pushed encode/hash/object-write into the per-unit actor.

Consequence for the shrink question: builder heaps are freed wholesale at actor death, so §1.5’s stranding does not accumulate across units. The RSS is not a ratchet across a build; it is N simultaneously-alive actors x their individual peaks, all at once.

3.1 The arithmetic — does it close?

parallel(jobs, null) means concurrent = len (lang/requestors.cm:112, :203, :205) — all jobs dispatched at once, so all N builder actors are alive simultaneously. fleet_peak = 77 = compile_count in the pit-shop/clerk plan (plans/archive/night-2026-08-04/seed-speed.md:342-348).

scenarioalive buildersx per-actor blockpredictedmeasured
post-pin-lock, unbounded77128 MB9.9 GB9.16 GB (seed-speed.md:553)
pre-pin-lock, unbounded77 nominal, compile_average_width 34mostly still on the 8–32 MB rungs1–4 GB3.27 GB (seed-speed.md:108)
clamped to ncpus() = 1515128 MB~1.9 GBnot measured in the doc

It closes. 77 x 128 MB = 9.9 GB against a measured 9.16 GB is as good as this arithmetic gets. The pre-fix 3.27 GB is lower precisely because the image-pin lock starved the fleet: blocked actors never climbed the growth ladder, which is exactly what seed-speed.md:556-558 says (“RSS goes up on purpose: the fleet was previously blocked, so it never got wide”).

Is there another consumer? No. Checked:

  • buddy_unattributed_bytes = 0 live — every buddy byte is attributed to an actor.
  • The buddy releases empty pools (source/runtime.c:1393-1406); peak_mapped 752 MB vs total_mapped 432 MB proves it returns memory.
  • Off-heap (actor_offheap_bytes 768 KB, shared_offheap_bytes 1 MB) is noise.
  • The one systematic overcount is §1.5’s shrink lag: heap_alloc_size can be 2x heap_size, so $inspect’s heap_size and pit ps mem’s actor_heap_capacity_bytes UNDER-report resident memory by up to 2x. Live proof: capacity 156 MB vs actual buddy-owned 296 MB.

4. The clamp verdict

4.1 What is there today

pit-shop/shop_build.cm:189-197 (rationale comment :177-188):

189  var fleet_width_default = null
190  function default_fleet_width() {
191    var n = null
192    if (fleet_width_default != null) return fleet_width_default
193    function ask() { n = sysinfo.ncpus() } disruption { n = null }
194    ask()
195    fleet_width_default = is_number(n) && n > 0 ? floor(n) : null
196    return fleet_width_default
197  }

Consumed at :1800-1801, applied at :1458 (parallel(jobs, state.width)) and :3389-3390 (pool lane). sysinfo imported at :127; internal/sysinfo.c:99-100, :120 -> sys_cpu_count (platform/posix-runtime/source/sys_thread_pthread.c:116). Landed in 8f363b6f7. shop_build.cm:2703 parallel(jobs) is deliberately unclamped (boot floor, 2 fixed jobs).

Measured paired presses: fleet_peak 77 -> 15, summed compile 272.0 s -> 271.1 s (plans/archive/night-2026-08-04/seed-speed.md:768-786). Throughput cost: zero.

4.2 Why the throughput cost is structurally zero, not luck

The scheduler already caps running actors at the core count: source/scheduler.c:2503 int n = sys_cpu_count();, :2514 engine.num_workers = n, one actor_runner thread each (:2516-2517). PIT_WORKERS can only lower it (:2508-2512). Actors are dispatched off shared priority queues with an ACTOR_READY/ACTOR_RUNNING state machine (source/scheduler.c:3290-3307).

So fleet width bounds ALIVE actors (each with a heap), never RUNNING ones. The 62 extra actors in an unbounded 77-wide fleet cannot execute; they can only hold heaps, sit in queues, and be walked by actor_gc_scan and every snapshot. That is why the clamp bought memory and context switches and not a single millisecond of throughput.

There is no max-actors cap anywhere: scheduler_actor_count() (source/scheduler.c:2859) is used only for shutdown and crash dumps. Execution lanes (:61-66, :2361-2447) are named single-threaded host-callback serializers, not a global cap.

4.3 Can the clamp deadlock a DAG? No.

The clamped parallel at shop_build.cm:1458 is over a flat, independent job list built by arrfor(walk.files, …) (pit-shop/shop_build.cm:1437-1456) after the closure walk has already resolved the graph. No job waits on another job in the same parallel. lang/requestors.cm:190/:197 run a sliding window (start_one() on each completion). So a clamp cannot starve a dependency.

4.4 So what does the clamp still buy, and should it be deleted?

It buys three things, and only one of them is “just memory”:

  1. RSS: ~9.9 GB -> ~1.9 GB. John’s “it’s only memory” is true only if the memory exists. On this 15-core box the seed already peaks at 9.16 GB.
  2. A missing OOM guard. ACTOR_MEMORY_LIMIT is advisory (source/pit_gc_copy.c:1633-1636) and buddy.cap is 0, so nothing in the system refuses to grow. Unbounded fleet + no cap = the failure mode is the OS killing the daemon mid-press, not a named refusal. That is the opposite of this project’s stated posture.
  3. GC transient amplification. §1.2: an actor doubling its block holds 3x for the duration of the copy. 77 actors doubling near the same time is a spike far above the steady 9.9 GB. The clamp caps the spike too.

Two smaller ones: actor_gc_scan and every $inspect.snapshot() walk are O(alive actors); and plans/archive/night-2026-08-04/seed-speed.md:786 measured involuntary context switches 7.82 M -> 0.67 M on the width pair (caveated — that pair also differed in how much it compiled).

What per-actor peak would make unbounded safe? Take a 4 GB budget and the seed’s 149-unit closure — but the real bound is the widest single plan, which is pit-shop/clerk at 77 units (seed-speed.md:95-97). 4 GB / 77 = 53 MB physical per builder, which given the 2.5x–5x capacity policy means a live peak of 11–21 MB. Today’s live peak is ~41 MB and its block is 128 MB. So unbounded needs roughly a 2.5x cut in builder live-set and a tighter capacity policy — and that still only holds for a 77-unit plan on a 4 GB budget. A 500-unit closure blows any fixed per-actor number.

Honest fix order — yes, fix the bloat first:

  1. Cut the builder live set (§2 items 1–4): drop the token stream after mcode, decode/encode the CP1 stream per-function instead of all-functions- up-front, and stop retaining compiled across portable_body. Target ~15 MB live -> 64 MB block.
  2. Close the shrink lag (§1.5): on a shrinking GC, allocate the target block instead of the old one — or, cheaper, do the shrink one GC earlier by sizing new_size from next_block_size rather than current_block_size. This alone recovers 128 MB from shop_actor today.
  3. Then the clamp becomes a policy choice rather than a guardrail — and even then, replace it with a memory admission gate (dispatch the next job when $vm.memory().total_mapped is under a budget) rather than deleting the bound outright. Count is the wrong unit; bytes are the right one.

Deleting the clamp today buys nothing measurable (throughput is a wash because worker threads are already ncpus) and costs 8 GB plus the absence of any OOM backstop. Recommendation: keep it, and make the bound a byte budget after the bloat is cut.


5. Observability: far more exists than plans/measurement-suite.md credits

5.1 What EXISTS today

pit profileshop_tools/profile.ce, 3,036 lines. This is very nearly the tool John is describing, already built:

  • Installs the runtime’s per-actor sampling profiler via $vm.profile_set (profile.ce:279-287) -> pit_vm_profile_set (std_endowments/vm.c:1171, registered :1210) -> actor_profile_set (source/scheduler.c:747-786), with knobs for interval, stride, stack depth, slow-turn threshold and alloc_sample_bytes.
  • The runtime emits three record types on the profile channel:
    • profile_sample (source/scheduler.c:550-605) — 21 fields, top frame fn/file/line/col/pc plus a full pit stack to stack_depth.
    • profile_turn (source/scheduler.c:700-743) — per-turn duration_ms, gc_count_delta, gc_bytes_copied_delta, gc_ms_delta, alloc_count, alloc_bytes, slow.
    • profile_missed (source/scheduler.c:678-696).
  • Sub-commands: summary, actors, hot-functions, hot-stacks, allocations, turns, explain-turn, events, plus a live SSE UI (profile.ce:74-121, :1574-1624) with a “Memory & GC” section and an “Allocation Sites” table.
  • There is already a compile preset: profile.ce:1713{scope:'all', duration:0, sample_mode:'fixed', interval_ms:1, stride:256, stack_depth:12, slow_ms:25, snapshot_ms:250, alloc_sample_bytes:262144}.

Allocation-site attribution already exists and is ALWAYS ON. Every GC on every actor emits a gc record with persist: true (source/pit_gc_copy.c:1502-1562, source/scheduler.c:343-361), gated only on system_log_active() (source/scheduler.c:130). It carries 27 fields: old_used_bytes, old_cap_bytes, new_used_bytes, new_cap_bytes, recovered_bytes, recovered_pct, poor_recovery, poor_streak_before, heap_limit_bytes, gc_ms, trigger_alloc_bytes, a pit stack, alloc_since_gc / alloc_since_turn broken down by kind, live_after_gc broken down by object type, and allocation_sites — sampled {kind, bytes, count, fn, file, line, col, pc} rows (wota_write_alloc_sites source/pit_gc_copy.c:1472-1500; sampled by pit_alloc_stats_sample_site (:156) from the gate at :190-199; struct PitAllocSite source/pit_internal.h:671-680, PitAllocStats :682-687, 16 site slots, PIT_ALLOC_SITE_COUNT :81). Sampling interval defaults to 256 KB via PIT_ALLOC_SAMPLE_BYTES (source/runtime.c:1096-1105, installed at :1930).

This means the memory profile of a compile is being written to .pit/log/observe.jsonl right now, for every actor, whether or not anyone asked for it — including the transient builders. I read 443 of them out of this worktree’s current log to produce §2’s table.

Per-actor fields — $inspect.snapshot(), std_endowments/inspect.c:24-178, ~100 fields. plans/measurement-suite.md:367-383 under-reports this. The ones that matter here and that the doc does not list:

  • heap_alloc_size (inspect.c:60) — the physical block, §1.5’s number.
  • heap_peak_alloc_size (inspect.c:61) — the per-actor peak-heap high-water already exists, from ctx->old_alloc_peak_bytes (source/pit_internal.h:2232, set source/pit_gc_copy.c:2130-2131, never reset).
  • heap_limit, total_alloc_bytes, total_alloc_count, frame_stack_high_water_bytes, plus full per-type byte/count breakdowns for both the object region and the frame stack.

Runtime-wide — $vm.memory() (std_endowments/vm.c:618-677): total_mapped, peak_mapped, cap, allocated_bytes, peak_allocated_bytes, largest_request_bytes, free_bytes, actor_heap_capacity_bytes, actor_buddy_owned_bytes, actor_heap_used_bytes, actor_offheap_bytes, buddy_unattributed_bytes, shared_offheap_bytes, and a per-pool table with an ASCII occupancy bitmap. Surfaced as pit ps mem (shop_tools/ps.ce:98, :199-220).

Per-actor heap graph — pit heap <actor> summary|largest|object|refs| retainers|path (shop_tools/heap.ce), backed by $vm.dumpmem (std_endowments/vm.c:337-348). This is the “what is IN the 128 MB” tool and it already answers retainer questions.

Current-actor counters — $vm.vm_stats() (std_endowments/vm.c, fields: instructions, alloc_bytes, alloc_count, gc_count, gc_ms, nursery_bytes, nursery_cap_bytes, old_block_bytes, old_alloc_bytes, old_alloc_peak_bytes, nursery_card_bytes, minor_count, major_count, minor_p50_us, minor_p95_us, minor_max_us, minor_total_ms, nursery_survivor_bytes, nursery_survivor_peak_bytes, minor_survivor_copied_bytes, minor_promoted_bytes). Self-only, no actor-id argument.

pit compile profile (shop_tools/compile.ce:697-726, pit.compiler.profile.report@1): per-phase and per-pass CPU timings, IR census, guard counts, portable stats, and a self-documenting measurement_protocol block. Zero memory fields.

pit.compile.plan.telemetry@1 (pit-shop/build_telemetry.cm:16-70+): ~60 fields of time/count/identity — hits, misses, distinct keys, elapsed per stage, fleet_peak, fleet_average_width. Zero memory fields.

5.2 The gaps, ranked

CORRECTION to a first draft of this section: I initially wrote that the CPU sampler is never installed on actors born after pit profile starts. That is wrong. maybe_enable_lifecycle_child (shop_tools/profile.ce:405-415) reacts to actor_start on the subscribed lifecycle channel and calls enable_actor for any newcomer, in both all and tree scope modes. So pit profile --all does follow a build’s one-shot builders. The gaps below are what is left after that correction.

Gap 1. A dying actor’s peak is never recorded. heap_peak_alloc_size is readable only while the actor is alive, and a builder lives for one message. The periodic profile_snapshot (shop_tools/profile.ce:348-364) samples every snapshot_ms (default 250 ms), so a builder can be born, peak and die between two samples, and the last sample before death can be 250 ms stale. Nothing emits a final memory record at teardown — the lifecycle actor_stop record carries no bytes. Fix: emit heap_peak_alloc_size, heap_alloc_size, heap_used, total_alloc_bytes, total_turn_s, gc_count, gc_total_s on the existing lifecycle channel at actor teardown. ~30 lines of C in source/scheduler.c beside the existing lifecycle emit; low risk (one more WotaBuffer record on a channel that already exists and that profile.ce already subscribes to). This turns “why did the fleet cost 9 GB” into one jq over observe.jsonl, and it is the single highest-value change in this document.

Gap 2. No memory in either build/compile report. Neither pit.compiler.profile.report@1 nor pit.compile.plan.telemetry@1 carries a single byte figure — so “compile something -> what memory did it use” requires correlating a third stream by hand. Fix: (a) add heap_peak_alloc_size, heap_used, total_alloc_bytes and GC deltas to the compile profile report — the fields already exist on $inspect.snapshot(); (b) add fleet_peak_heap_bytes and fleet_peak_mapped_bytes to pit.compile.plan.telemetry@1, sampled from $vm.memory() at each fleet_peak update in pit-shop/build_telemetry.cm. ~40 lines of pit total, zero C, low risk.

Gap 3. There is no “profile this command” wrapper. pit profile requires a live target or --all (shop_tools/profile.ce:2605), so profiling a compile means starting collection in one place, running the compile in another, and stopping. --duration makes this workable but manual. A pit profile run -- <command> that starts collection, starts the target, and stops on its reply is ~40 lines of pit and is what turns this from a tool into a habit.

Three smaller ones, all cheap:

  • total_turn_s is collected and not exposed. source/pit_internal.h:2451 holds the cumulative in-turn seconds per actor; the snapshot only carries avg_turn_s = total_turn_s / total_turns (source/scheduler.c:3019, :3194). Recoverable by multiplication, but expose it directly — 2 lines.
  • Major-GC pause data is collected and thrown away. pause_hist_major (source/pit_internal.h:2239) and major_max_s (:2235) are maintained; std_endowments/vm.c passes pause_hist_minor to both percentile calls. Already flagged at plans/measurement-suite.md:657~6 lines.
  • ct_main_used_bytes / ct_overflow_bytes / ct_index_bytes exist in PitRuntimeMemorySnap (source/pit_internal.h:2899-2902), are filled, and are dropped by pit_vm_memory~6 lines (plans/measurement-suite.md:378-381).

What genuinely does not exist and is real work: per-actor attribution of pinned mach-image / shared stone bytes (a view pinned by three actors is not three copies — that is a policy question, plans/measurement-suite.md:386-394). Not on the critical path for this question: off-heap is 1.8 MB against 296 MB of actor heap.

5.3 The minimal path to “why is this actor at 128 MB”

Today, with zero code changes:

pit ps mem                                  # per-actor used/size, buddy pools
pit heap <actor> largest                    # what objects are IN the 128 MB
pit heap <actor> retainers <object>         # who holds them
jq 'select(.channel=="gc")' .pit/log/observe.jsonl   # growth ladder + alloc sites
pit profile --all --duration N              # CPU + alloc sites; scope follows
                                            # newly born builders (profile.ce:405)

With Gaps 1-3 closed (~80 lines pit, ~30 lines C, all additive):

pit profile run --all -- pit compile mcode <file> --out p.json
pit profile summary   --path p.json
pit profile actors    --path p.json --sort peak_heap
pit profile allocations --path p.json --actor <builder>

…and one pit compile <file> reports its own CPU and memory in the same record.


6. Things worth John’s attention that were not asked

  1. pit-shop/shop_actor — the ROOT actor of a dev daemon — is sitting at 120 MB live in a 256 MB block, after 2,479 turns. Breakdown: blob 46.6 MB, text 31.7 MB, record 16.8 MB, array 11.2 MB. That is a cache with no eviction policy, and unlike a builder it never dies. It is 40% of this daemon’s mapped memory at idle.
  2. $inspect’s heap_size and pit ps mem’s actor_heap_capacity_bytes under-report resident memory by up to 2x because of §1.5. Any future measurement row must use heap_alloc_size, not heap_size. The measurement-suite.md design at :462 and :496 currently specifies heap_size.
  3. The gc log is on by default and persists. 759 gc records in a 1.9 MB observe.jsonl from one light session. A full seed press writes far more. Worth knowing before someone treats observe.jsonl size as a signal — and worth keeping, because it is the only record that survives a dead actor.

Source: plans/proposal-notes/builder-memory.md