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:1650heap_block_alloc(rt, new_size) - free the old one:
source/pit_gc_copy.c:2015heap_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):
| actor | used MB | size MB (logical) | alloc MB (physical) | peak MB | gc | turns |
|---|---|---|---|---|---|---|
| pit-shop/shop_actor | 120.2 | 128.0 | 256.0 | 256.0 | 25 | 2479 |
| pit-shop/clerk | 5.6 | 8.0 | 16.0 | 128.0 | 108 | 18898 |
| pit-shop/logger | 1.7 | 4.0 | 8.0 | 16.0 | 57 | 6229 |
| pit-shop/daemon_listener | 6.0 | 8.0 | 8.0 | 8.0 | 17 | 2981 |
| 5 small actors | ~3.0 | ~8.1 | ~8.1 | ~9.0 | ||
| TOTAL (9) | 136.6 | 156.1 | 296.1 | 417.0 |
Two things to read off this table:
shop_actorholds 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 memcorroborates independently: pool index 1 istotal_size_bytes: 256MB, alloc_count: 1, used_percent: 100%— one 256 MB block, one owner.clerkREFUTES 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 nurseries —
heap_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:
| # | what | where | est. bytes |
|---|---|---|---|
| 1 | Canonical instruction rows — one heap array per instruction, [op, …operands, line, col]; ~6 elems -> cap 8 -> 80 B | pit-compiler/mcode.cm:397-401, held via ir.functions :4504, :4680 | ~8.4 MB |
| 2 | The 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 end | pit-compiler/stream_ir.cm:151-153, :300-302; pit-compiler/streamline.cm:5033-5040 (encode all), :5501-5507 (decode last) | ~7–12 MB |
| 3 | sites — one 80 B record + one freshly built text id per positioned instruction, retained in the returned unit | pit-compiler/compiler.cm:1345-1351, retained :1421, :1703 | ~11.7 MB |
| 4 | Two more copies at the portable boundary — intern_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 local | pit-compiler/compiler.cm:1272-1310, :688-717, :1430-1441, :1641, :1657-1665 | ~5 MB+ |
| 5 | Canonical 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 abandoned | shoplib/canonical.cm:123-127, pitlib/blob.c:21-31; result still held at pit-shop/builder_worker.ce:104 | ~21 MB transient |
| 6 | AST + the FULL token stream, alive for the whole compile — parse_result returns tokens: and every layer re-carries it; a token is a 7-key record = 128 B; ~40–50k tokens | pit-compiler/compiler.cm:55-69, :243, :262, :373; pit-compiler/tokenize.cm:190-195 | ~6 MB |
| 7 | Retained per-function fact tables — _write_types, _type_spans, _const_spans, _no_overflow are one entry per instruction; survive into portable_facts | pit-compiler/streamline.cm:4926, :4054, :4075; pit-compiler/int_ranges.cm:696-697, :946-948; pit-compiler/compiler.cm:1067-1077 | ~1–2 MB |
| 8 | Per-function transients (~12 arrays per int_ranges call) | pit-compiler/int_ranges.cm:639-640, 714-715, 747, 776-778, 832, 876-877, 893 | churn |
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):
| actor | gc# | old_used | new_used | new_cap | rec% |
|---|---|---|---|---|---|
| pit-shop/shop_actor | 25 | 134,217,728 | 29,243,312 | 134,217,728 | 78 |
| time | 21 | 33,554,424 | 30,610,824 | 67,108,864 | 9 |
| pit-shop/clerk | 27 | 33,554,312 | 26,117,872 | 67,108,864 | 22 |
| pit-shop/builder_worker | 19 | 33,554,416 | 14,219,152 | 67,108,864 | 58 |
| pit-shop/builder_worker | 19 | 33,554,424 | 17,507,240 | 67,108,864 | 48 |
| forge | 15 | 16,569,856 | 10,059,696 | 33,554,432 | 39 |
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_requestis the fleet transport (pit-shop/clerk.ce:1457-1458,:1508-1509,:1897). Every call doesstart_through_root({id: guid(), program: builder_program, …})(:1583-1593) — a new guid per request — then onesend(worker, value, …)(:1600). No worker table, no free list, no idle queue.builder_program = "pit-shop/builder_worker"(:42).pit-shop/builder_worker.ceself-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$startof 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-245records what D2 deleted: the olddo_compile_batchwhere 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).
| scenario | alive builders | x per-actor block | predicted | measured |
|---|---|---|---|---|
| post-pin-lock, unbounded | 77 | 128 MB | 9.9 GB | 9.16 GB (seed-speed.md:553) |
| pre-pin-lock, unbounded | 77 nominal, compile_average_width 34 | mostly still on the 8–32 MB rungs | 1–4 GB | 3.27 GB (seed-speed.md:108) |
clamped to ncpus() = 15 | 15 | 128 MB | ~1.9 GB | not 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_mapped752 MB vstotal_mapped432 MB proves it returns memory. - Off-heap (
actor_offheap_bytes768 KB,shared_offheap_bytes1 MB) is noise. - The one systematic overcount is §1.5’s shrink lag:
heap_alloc_sizecan be 2xheap_size, so$inspect’sheap_sizeandpit ps mem’sactor_heap_capacity_bytesUNDER-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”:
- 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.
- A missing OOM guard.
ACTOR_MEMORY_LIMITis advisory (source/pit_gc_copy.c:1633-1636) andbuddy.capis 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. - 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:
- 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 retainingcompiledacrossportable_body. Target ~15 MB live -> 64 MB block. - 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_sizefromnext_block_sizerather thancurrent_block_size. This alone recovers 128 MB fromshop_actortoday. - 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_mappedis 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 profile — shop_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 andalloc_sample_bytes. - The runtime emits three record types on the
profilechannel:profile_sample(source/scheduler.c:550-605) — 21 fields, top frame fn/file/line/col/pc plus a full pit stack tostack_depth.profile_turn(source/scheduler.c:700-743) — per-turnduration_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
compilepreset: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, fromctx->old_alloc_peak_bytes(source/pit_internal.h:2232, setsource/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_sis collected and not exposed.source/pit_internal.h:2451holds the cumulative in-turn seconds per actor; the snapshot only carriesavg_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) andmajor_max_s(:2235) are maintained;std_endowments/vm.cpassespause_hist_minorto both percentile calls. Already flagged atplans/measurement-suite.md:657— ~6 lines. ct_main_used_bytes/ct_overflow_bytes/ct_index_bytesexist inPitRuntimeMemorySnap(source/pit_internal.h:2899-2902), are filled, and are dropped bypit_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
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.$inspect’sheap_sizeandpit ps mem’sactor_heap_capacity_bytesunder-report resident memory by up to 2x because of §1.5. Any future measurement row must useheap_alloc_size, notheap_size. Themeasurement-suite.mddesign at:462and:496currently specifiesheap_size.- The gc log is on by default and persists. 759
gcrecords in a 1.9 MBobserve.jsonlfrom one light session. A full seed press writes far more. Worth knowing before someone treatsobserve.jsonlsize as a signal — and worth keeping, because it is the only record that survives a dead actor.
Source: plans/proposal-notes/builder-memory.md