Skip to content

string arc: emit ownership in the compiler, o(1) cstring length - #231

Merged
kacy merged 2 commits into
mainfrom
arc-strings-emitter
Jul 5, 2026
Merged

string arc: emit ownership in the compiler, o(1) cstring length#231
kacy merged 2 commits into
mainfrom
arc-strings-emitter

Conversation

@kacy

@kacy kacy commented Jul 5, 2026

Copy link
Copy Markdown
Owner

summary

strings now reclaim in the native path. this is the emitter half of the work started in #230 (refcounted cstring headers):

  • ownership emission: retain on binding a borrowed value (variables, collection elements, struct fields, extractions); release on rebinding, reassignment, and at every return; returns transfer their count to the caller; retains at each escape point (struct fields, containers, tuples, closure captures, channel sends). classification is borrowed-by-default — only expressions that provably mint a fresh buffer count as owned, so a wrong guess can only leak, never dangle.
  • params are borrows: an unmodified string parameter costs no rc traffic at all. a param the body reassigns is promoted to an owned local in the prologue.
  • temp reclamation: concat and interpolation chains free their intermediates as they fold, and discarded owned results release at statement end.
  • o(1) length: the refcount header from give heap cstrings a refcount header #230 also stores the length, so len() on a heap cstring is one read instead of a strlen.

the numbers

before after
string microbench peak rss 85.5 mb 15.2 mb
string microbench runtime 131ms 81ms
make self-host ~45s 1.7s
std_pipeline 50k 888ms 804ms

the compile-time one deserves a line: profiling showed the compiler spent ~80% of its own runtime in strlen — every while i < s.len() loop over a large string was quadratic. the header length turns all of those linear at once.

design notes

two hard-won facts shaped the implementation. plain name = value statements lower through the compound-assignment path, not the assign path — the first version put the ownership logic on a near-dead code path, and the lexer's token globals ended up holding freed buffers. and sibling scopes can bind the same name with different types over one flat storage slot, so the pre-pass poisons any name that is ever bound non-string; otherwise a retain fires on an integer handle and walks off the map. the runtime gained a PITH_CSTRING_NOFREE debug mode (over-release reporting plus content scrubbing) that made both bugs findable.

what still leaks: container elements in untagged collections, bytes objects, struct fields, and the ast/token structures that dominate the compiler's own peak rss. collections are the next reclamation target.

what was tested

  • full battery: examples 83/83, regressions 115/115, invalid parse/checker, cli, ir contract, safety-check, runtime cargo tests 41/41
  • bootstrap fixed point: stage-2 and stage-3 compiler binaries are byte-identical
  • bootstrap seed refreshed and verified (fresh-clone path rebuilt from it during debugging many times)
  • valgrind clean on the failing case that drove the poison fix

kacy added 2 commits July 4, 2026 20:44
correctness proven: the stage-2 compiler runs its own 21k lines under
the discipline and compiles itself. the missing piece was the real
assignment path (ir_emit_compound_assignment op '='), which stored
strings with no ownership handling — the bind/assign work originally
landed on a path the parser rarely produces.

not ready to merge: self-compile takes ~94s (rc traffic on every
string param, bind, and return) and peak rss is unchanged because the
compiler's memory is ast/token structures, not cstring garbage.
next: param-borrow convention instead of retain/release per call,
statement-temp releases (the real garbage), then the full suite.
the compiler now emits the string ownership discipline: retain on
binding a borrowed value, release on rebinding, reassignment, and at
every return, transfer on returns, and retains at each escape point
(struct fields, containers, tuples, closure captures, channel sends).
params are borrows — an unmodified string parameter costs no rc
traffic. concat and interpolation chains free their intermediates as
they fold.

two lessons are baked into the shape of this: plain assignments go
through the compound-assignment path, not ir_emit_assign_stmt, and
sibling scopes can bind the same name with different types, so the
pre-pass poisons any name that is ever non-string rather than let a
retain fire on an integer handle.

the refcount header also stores the length, so cstring len() is one
read instead of a strlen. profiling showed the compiler spent 80% of
its runtime in strlen — every while-i-less-than-len loop over a big
string was quadratic. make self-host drops from ~45s to 1.7s.

string microbench: 85.5mb peak rss to 15.2mb, 131ms to 81ms.
std_pipeline: 888ms to 804ms. stage2 and stage3 compiler binaries are
byte-identical (fixed point), bootstrap seed refreshed.
@kacy
kacy merged commit 2343305 into main Jul 5, 2026
0 of 2 checks passed
@kacy
kacy deleted the arc-strings-emitter branch July 5, 2026 03:41
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