Repository navigation
Carry lifetime regions across await and reuse freed region chunks - #1194
Merged
Merged
Conversation
An async function called inside LifetimeRegion.run now keeps its region after every await: its fiber records the region that was current at spawn, and the event loop makes that region current while it resumes the fiber. A region is not freed (or attempted) while a fiber that carries it exists, so a region released while its async work is pending keeps taking that work's objects and is freed once the work ends. Before, objects created after an await went to the default region, which is never freed, and kept the request's region allocated for good, so per-request regions in async servers grew without bound. Freed region chunks now go to a per-thread cache (at most 8 MiB resident, the first 256 KiB of each kept cleared, fresh pages past it, no huge-page chunks) instead of being unmapped, so a region per request or per batch no longer maps and faults in new memory every time. Adds the requests.ts region benchmark (concurrent async handlers), corpus 5023 and its region-stats checks, and a chunk-reuse runtime unit check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
scr_async.c reads scr_region_fiber_hooks, but several runtime test links (cipher, bytes, sha-kernels, runtime) take scr_async.c without scr_region.c and failed with an undefined reference. scr_object.c is in every runtime link, so the pointer now lives there; scr_region.c still installs the hooks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve the scr_resume_fiber conflict with stackless async coroutines (#1204): the region the fiber carries is made current before either resume path and restored after both. scr_async_spawn_stackless now captures the spawning region like the fiber spawns, so stackless bodies (every async body in 5013 and 5023 after the merge) keep allocating in their region after an await and keep it alive while they exist. 5024-lifetime-region-await-forms pins regions carried by stackless and fiber (for await) bodies that spawn and await each other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
LifetimeRegionregions now work for async request handling, and regions that are freed over and over cost less.Regions cross
await. Before this change, an async function called insideregion.run(fn)allocated in the region only until its firstawait. Everything it created after that went to the default region, which is never freed. Those objects usually pointed back at the objects created before the await, so the request's region could never be freed either. With one region per request, an async server's memory grew without bound, and everyrelease()also triggered a full cycle-collector pass over the heap. Now:ScrFiber.region). The event loop makes that region current while it resumes the fiber and restores the previous region afterwards. The runtime calls nothing for this until the program creates its first region: a process-wide hook table (scr_region_fiber_hooks) installed byscr_region_create.ScrRegion.fibers), and no release check runs on it in that time. So a region released while its async work is pending keeps receiving that work's objects. It is freed once the work ends and nothing outside the region refers into it. Escaping references still keep a region, as before. Exceptional exits are unchanged:runleaves the region in afinally, and a rejected async body drops its hold when its fiber is destroyed.queueMicrotaskcallbacks, event listeners and async-generator bodies run in the loop's (default) region. The docs say so.Freed chunks are reused. A freed region's 2 MiB chunks used to be unmapped immediately. A program that frees a region per request or per batch therefore mapped, faulted in and unmapped new memory every time. In the request benchmark that came to about 30 page faults per request and about 19% of samples in the kernel. Each thread now keeps freed chunks in a cache:
mmap(MAP_FIXED), so a small region that reuses a large region's chunk does not inherit its memory.A cache bug caught and fixed during development. The first version kept cached chunks fully resident. It doubled steady-state RSS in
generations-edit(76 → 159 MB), because small per-file regions kept reusing chunks that the checker region had fully touched. The 256 KiB warm limit fixes this.Measurements
Linux Vercel Sandbox, 8 vCPU. Each benchmark is compiled with base (
origin/main8620fa5) and with this branch, and the two run interleaved; values are medians of 9 runs (ab-regions.mjs). RSS figures come from/proc/self/status.Context for requests-async on this branch (medians of 5 interleaved runs, same VM):
on: the same classes asProgramLifetimewithout regions, so nothing is ever freed. 551 ms, 979 MB.off: plain reference-counted classes. 1288 ms, 26.6 MB.region: this PR. 680 ms, 8.9 MB.Per-request regions now run close to leak-everything speed. Against plain reference counting they are 1.9× faster and use a third of the memory.
How the requests-async gain splits between the two changes (separate, non-interleaved runs on a second sandbox): crossing
awaitwithout chunk reuse took it from about 2.37 s to about 1.0 s. Chunk reuse then took it to about 0.71 s, and minor page faults fell from 121k to 1.7k.scripts/bench-runtime.mjs --layouts=4 --runs=16(base vs candidate) is neutral. The geomean ratio is 0.995.region-buildschanges by −0.0% andasync-pipelineby −0.8%. Every workload is within ±3% excepthttp-api, at −4.4%. Async binaries grow by 48 bytes.Tests
5023-lifetime-region-await(byte-identical to Node.js). It covers:tests/harness/lifetime-regions.test.tschecks, fromSCRIPTC_REGION_STATS, that all 15 regions of 5023 are freed. Regions whose work was pending are freed without a kept attempt; the nested region and the escaping one are kept first and freed later. 5013 now frees 7 of 7 regions (was 2 of 7).scr_region.test.cadds a check that a freed region's chunk serves the next region, cleared./docs/experimentalLifetimeRegion section gains an "Async work" subsection with an example whose output was run, the API and cost text is updated, and the@scriptc/experimentalREADME andindex.d.tssay the same.Validation, all in a Linux Vercel Sandbox:
pnpm lintandpnpm format:checkare clean.pnpm test:ts7 --baselines-onlypasses. Re-recording added exactly the 5023 baseline and changed no existing entry.lifetime-regionprograms pass 15/15 in the plain lane, andSCRIPTC_SAN=1passes 62/62 across the differential and region-harness tests.async|await|promise|generatordifferential subset passes 194/195. The one failure,5570-fs-cp-async, also fails on the untouched base.scr_region.test.ts3/3 (size-class, sanitized, TSan workers),lifetime-regions.test.ts9/9,scr_async.test.ts, andregions.test.ts.--emit=irbuild reports no diagnostics, andself-hosting-native-driver.test.tspasses 1/1.NEXT_DIST_DIR=.next-check pnpm check) passes 25/25.🤖 Generated with Claude Code