Skip to content

map_free: reclaim a transient map on the main goroutine's arena (#667) - #669

Merged
javimosch merged 2 commits into
mainfrom
map-free
Sep 22, 2026
Merged

javimosch merged 2 commits into
mainfrom
map-free

Conversation

@javimosch

@javimosch javimosch commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #667.

A map made while a scoped or goroutine arena is current is allocated from that arena and dies with it. A map made on the main goroutine's arena is malloc-backed instead — a long-lived map has to survive arena_reset(), and nothing at make() distinguishes it from a transient one — so it lives until the process exits and arena_reset() does not touch it.

That is right for the long-lived caches the malloc path exists for. It bites exactly one shape: a single-actor server whose request loop runs on main because its engine is not goroutine-safe, so it can neither hand each connection to a goroutine nor wrap the loop body in arena { } (the engine writes to globals and ARENA001 correctly refuses the escape).

map_free(m) hands that memory back — entries, their keys and values, the bucket array, the struct. On a map the arena owns it is a deliberate no-op rather than an error: the caller usually cannot tell which path make() took, and a silently-correct no-op beats a footgun that only fires in the configuration nobody tested.

Measured

peak RSS
floor (empty program) 9,856 kB
200k maps, no map_free 104,320 kB — 94 MB of growth
200k maps, with map_free 11,392 kB — 1.5 MB of growth

Clean under AddressSanitizer on both the main-arena and arena-owned paths. Non-map arguments and wrong arity are refused at compile time.

Real consumer: microexits' per-request headers map comes from machweb's parse_headers on the main goroutine. Freeing it took that service from 1.5 kB/request to 0.07.

On the test

It compares peak RSS of the same workload built twice, with and without the call, minus a measured floor. Two earlier versions of this test were wrong in ways worth recording:

  • the first read /proc/self/status from MFL and compared 0 against 0, because read_file returns empty on procfs (now read_file returns empty for /proc and /sys files (st_size is 0 there) #668) — it passed with map_free entirely stubbed out;
  • the second compared totals rather than work, and ru_maxrss for a child includes what it inherited at fork, so it passed alone and failed inside the suite where the parent's RSS had grown.

The current one is verified to fail when the implementation is stubbed.

Full suite green: ok machin 458.005s.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XJdASnjD4R5h6xX2fzfMfk

Summary by CodeRabbit

  • New Features

    • Added a map_free builtin to release memory used by temporary maps that are no longer needed.
  • Documentation

    • Updated the guide to explain when map_free should be used and added a related memory note.
  • Tests

    • Added coverage for freeing temporary maps, ensuring arena-managed maps are left alone, and validating argument checks.

A map made while a scoped or goroutine arena is current is allocated from that
arena and dies with it. A map made on the MAIN goroutine's arena is
malloc-backed instead — a long-lived map has to survive arena_reset(), and
nothing at make() distinguishes it from a transient one — so it lives until
the process exits and arena_reset() does not touch it.

That is harmless for the long-lived caches the malloc path exists for. It
bites exactly one shape: a single-actor server whose request loop runs on main
because its engine is not goroutine-safe, so it can neither hand each
connection to a goroutine nor wrap the loop body in arena { } (the engine
writes to globals and ARENA001 correctly refuses the escape). Such a server
leaks one lookup table per request. Measured: 200k maps cost 104 MB.

map_free(m) hands that memory back — entries, their keys and values, the
bucket array and the struct. On a map the arena owns it is a deliberate no-op
rather than an error, because the caller usually cannot tell which path make()
took, and a silently-correct no-op beats a footgun that only fires in the
configuration nobody tested.

Measured on the same workload: 104,320 kB without, 10,624 kB with. Clean under
AddressSanitizer on both paths. The real consumer — microexits, whose
per-request headers map comes from machweb's parse_headers on the main
goroutine — went from 1.5 kB/request to 0.07.

The test compares peak RSS of the same workload built twice, with and without
the call, rather than against an absolute threshold. The first version read
/proc/self/status from MFL to measure RSS and silently compared 0 against 0,
because read_file returns 0 bytes on procfs (st_size is 0 there); it passed
with map_free entirely stubbed out. Verified the current one fails when the
implementation is disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJdASnjD4R5h6xX2fzfMfk
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: d8b78c24-0635-47f6-aec4-73343170b518

📥 Commits

Reviewing files that changed from the base of the PR and between c8b21ce and ca81caf.

📒 Files selected for processing (5)
  • codegen.go
  • guide.go
  • map_free_test.go
  • selfhost/cgprelude.src
  • types.go
 _______________________
< Hasta la vista, bugs. >
 -----------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The self-hosted compiler embeds its own copy of the C runtime, so adding
mfl_map_free to codegen.go without re-running selfhost/gen-prelude.py would
leave the self-hosted machin building programs against a runtime with no
map_free in it. CI caught this; go test . does not, because the check is a
separate step rather than a Go test.

Note for next time: gen-prelude.py shells out to ./bin/machin, a build
artifact. Running it without rebuilding that binary first regenerates the
prelude with the OLD compiler and silently produces no diff, which looks like
'already up to date'. CORE goes 215,533 -> 216,551 bytes here; the decoded
block contains mfl_map_free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJdASnjD4R5h6xX2fzfMfk
@javimosch
javimosch merged commit 9216a6c into main Sep 22, 2026
2 checks passed
@javimosch
javimosch deleted the map-free branch September 22, 2026 22:01
javimosch added a commit that referenced this pull request Sep 22, 2026
…outine's arena) (#670)

A map made while a scoped or goroutine arena is current is allocated from that
arena and dies with it. A map made on the MAIN goroutine's arena is
malloc-backed instead — a long-lived map has to survive arena_reset(), and
nothing at make() distinguishes it from a transient one — so it lived until the
process exited and arena_reset() did not touch it.

That is right for the long-lived caches the malloc path exists for, and wrong
for one shape: a single-actor server whose request loop runs on main because
its engine is not goroutine-safe, so it can neither hand each connection to a
goroutine nor wrap the loop body in arena { }. map_free(m) hands that memory
back; on a map the arena owns it is a deliberate no-op.

94 MB of growth without it on a 200k-map workload, 1.5 MB with (#667, #669).


Claude-Session: https://claude.ai/code/session_01XJdASnjD4R5h6xX2fzfMfk

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

transient maps on the MAIN goroutine's arena are never freed (single-actor servers)

1 participant