Skip to content

collections reclaim: emitter ownership for lists, maps, and sets - #239

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

collections reclaim: emitter ownership for lists, maps, and sets#239
kacy merged 2 commits into
mainfrom
arc-collections-emitter

Conversation

@kacy

@kacy kacy commented Jul 5, 2026

Copy link
Copy Markdown
Owner

summary

arc phase b complete — collections now free. the string ownership discipline generalizes to collection handles:

  • kind-tagged tracking: the pre-pass, poison rules, and borrowed-by-default classification from the string work, with retain/release dispatched per kind. collection literals and collection-typed call results classify as owned (a fresh container or a transferred count); variable, element, and field reads borrow.
  • tagged creation: containers with a known element type are created tagged (List[String], List[List], List[Map], string-valued maps — including empty annotated literals and globals), so runtime inserts retain and the last release cascades into elements.
  • free-only cascade: overwritten and removed elements are deliberately NOT released — an untracked borrow may still be live. releasing on overwrite corrupted the compiler's own method registry through exactly that path (ir_reset's = {} in assign position also had to learn the tagged constructors). they leak until escape analysis can prove otherwise; only the container's death releases.
  • compensating retains stay where tagging can't be guaranteed: an empty-annotated List[List] is indistinguishable from List[Int] in the type strings and is created untagged, so collection-into-container stores keep an emitter-side count. double-counting a tagged container is a bounded leak; a missing count is a dangling element.

the numbers

churn-shaped work — a 50-element list and a map built per iteration, 200k iterations (the long-running-server shape):

before after
peak rss 218 mb 2.6 mb
runtime 343ms 179ms

constant memory where growth was unbounded. std_pipeline pays ~10% in rc traffic (826→904ms) and drops 1.65→1.45 gb peak — its memory is bytes objects and buffers, the next reclamation target. compiler self-compile stays ~2s.

what was tested

  • full battery green twice (after each late fix): examples 84/84, regressions 120/120, invalid parse/checker, cli, ir contract, safety, runtime cargo tests
  • compiler fixed point byte-identical through a clean seed → stage1 → stage2 chain; bootstrap seed refreshed from the fixed emitter
  • churn benchmark verified constant-memory via /proc peak sampling

kacy added 2 commits July 5, 2026 14:44
the string ownership machinery becomes kind-aware: tracked locals
carry a kind (string, list, map, set), retains dispatch to the
matching runtime operation, and the pre-pass poisons names bound with
conflicting kinds in sibling scopes. releases stay string-gated until
containers are tagged, so collections currently gain balanced retains
and nothing else — behavior parity by construction.
the string ownership discipline generalizes to collection handles:
kind-tagged tracking with the same pre-pass, poison rules, and
borrowed-by-default classification — except collection literals and
collection-typed call results, which arrive owned (a fresh container
or a transferred count). containers created with a known element type
are tagged so runtime inserts retain, and the last release cascades
into elements.

two hard-won rules are baked in. tagged containers never release an
element before the container itself dies: overwritten and removed
elements leak, because an untracked borrow of one may still be live —
releasing on overwrite corrupted the compiler's own method registry
through exactly that path. and stores of collections into other
containers keep an emitter-side compensating retain, because an
empty-annotated List[List] is indistinguishable from List[Int] in the
type strings and is created untagged.

churn-shaped work (a list and map built per loop iteration, 200k
iterations) goes from 218 mb peak and unbounded growth to 2.6 mb
constant, twice as fast. std_pipeline pays ~10% in rc traffic.
compiler fixed point holds; seed refreshed.
@kacy
kacy merged commit 1ecbec0 into main Jul 5, 2026
2 checks passed
@kacy
kacy deleted the arc-collections-emitter branch July 5, 2026 15:16
kacy added a commit that referenced this pull request Jul 28, 2026
* release a container's count when an element leaves it

a tagged list or map took a count on every heap element it stored and
then held it until the container itself died. removing a key,
overwriting one, clearing, and the list equivalents dropped the slot
and orphaned the value, so anything that evicts in a loop grew without
limit: 800k insert-then-remove rounds on a Map[Int, String] peaked at
38 mb against 8 mb for the Map[Int, Int] control. lists behaved the
same way on remove, clear, and assignment over an index.

the omission was deliberate when it went in (#239), and for a real
reason: releasing on overwrite corrupted the emitter's own method
registry through an untracked borrow of the element. what has changed
since is the emitter, which now retains a borrowed element into every
place that outlives the read, so the container can drop the one count
it owns and nothing else. overwrites retain the incoming element before
releasing what it displaces, so `m[k] = m[k]` cannot free the value
mid-assignment. primitive containers are skipped rather than released
with a no-op tag, since their storage can be narrower than a handle.

map.values() had to be fixed first: it built an untagged list of the
map's stored pointers, taking no count at all, so the first eviction
after that call freed values the list still pointed at. valgrind
reported the invalid read. it now builds a string-tagged list for a
heap-valued map, matching what the sort and slice copies already do.
the same shape already dangled when the map itself died, so this closes
a latent hole rather than one the eviction release opened.

sets need no change: they copy element bytes into their own storage
instead of holding the caller's handle.

* add a regression case for container eviction

churns every eviction shape and asserts the high-water mark does not
move across a hundred thousand rounds; on the unfixed runtime that half
reports "grew 4688kb". the more important half reads a value through a
live binding after each kind of eviction, and reads back a values()
list after the map is cleared, so a release that fires too early shows
up as a use-after-free rather than as a smaller number. registered in
the curated memcheck set for that reason.
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