fix(schema): let unused types be garbage collected (#1584, part B) - #2
Open
isaacwasserman wants to merge 2 commits into
Open
isaacwasserman wants to merge 2 commits into
isaacwasserman wants to merge 2 commits into
Conversation
Types that are made at runtime and then dropped (e.g. types with inline narrow/pipe functions, keys made at runtime, or "this") stayed in memory, because global caches held them strongly. - compiled code gets its registered values from a parameter that hides the global registry, so registered values are no longer kept in $ark - nodesByRegisteredId, scope nodesByHash, the string parse cache and the default value caches hold their values weakly (WeakValueMap) - the intersection cache is keyed weakly on both operands - lazilyResolve remembers its first result, so a synthetic alias always resolves to the same node - names of global registry entries (e.g. intrinsic) are reserved, so a function with the same name can't replace them Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A WeakRef keeps its target alive until the current job ends. Each parse put
its new context into a WeakRef, so in a synchronous loop each context stayed
in memory until the loop ended (about 190 bytes per type("string") call).
This undid part of the fix in arktypeio#1673 for synchronous code.
- add WeakValueMap.setStrong for short-lived values
- createParseContext holds the context strongly. The parse replaces it with
its node or releases it, as before
- release the context if its parse throws, and release generic parameter
contexts after their parse (both stayed in the registry before)
- scope alias contexts stay weak in the registry, because the scope holds them
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.
Problem
Part B of #1584. This PR is stacked on #1 (part A), which is merged upstream as arktypeio#1673.
ArkType kept each type that you make at runtime, even after you drop it. Global caches held the types strongly. Some examples of types that stayed in memory:
.narrow(s => ...),.pipe(...)type({ [key]: "string" })type(`string > ${n}`)type({ box: "this | undefined" })Five places kept each type:
$arkregistry (functions,propsByKeyobjects, symbols)namesByResolutionnodesByRegisteredIdscope.nodesByHashintersectionCacheFix
Compiled code no longer reads registered values from the global
$ark.CompiledFunction.compile()passes the registry name ($ark) as the first parameter, and binds it to an object that holds only the values that the code uses. The parameter hides the global, so:register()now holds values weakly. Names that the object does not hold (e.g.$ark.intrinsic) come from the global registry through the object's prototype.New
WeakValueMap(@ark/util). This map holds its values weakly and removes the entries of freed values in amortized sweeps. It is used for:nodesByRegisteredIdscope.nodesByHashparseCacheintersectionCacheis a nestedWeakMapkeyed on the attachments of both operands. An entry is removed when either operand is freed.Parse contexts. A short-lived parse context is held strongly (
WeakValueMap.setStrong) until its parse replaces or releases it. AWeakRefwould keep it alive until the end of the current job, so a synchronous loop would keep each context (about 190 bytes per call). The context is also released if its parse throws, and a generic parameter context is released after its parse. Before this PR, both stayed in the registry forever. The context of a scope alias is held weakly, and the scope holds it until it is resolved.lazilyResolveremembers its first result. Compiled code refers to the id of the result, and weak caches can no longer make sure that the result stays the same.Names of global registry entries are reserved in
register()(e.g.intrinsic,version). Before, a user function with such a name replaced the global entry. Forintrinsic, this crashed ArkType.Hashes contain registered names such as
$ark.fn12. A name belongs to exactly one value, and names are never used again. So after a value is freed, no new node can have the same hash, and it is safe to use hashes as keys of weak-value maps.WeakRefis not in ES2020. IfWeakRefis not available, or for aSymbol.forsymbol, ArkType holds values strongly (the old behavior).Results
2000 types per case. Each type is validated one time, then dropped, then GC runs:
.narrow(fn).pipe(fn){ box: "this | undefined" }With 30,000
.narrowcalls, the heap grew to 567 MB before. After the fix, it goes up and down between 57 and 143 MB.Repeated parses in one synchronous loop (20,000 calls each, bytes per call, before → after):
type("string")0 → 2,T.pick("a")7 → 7, parse error 234 → 5, generic 196 → 30.Limit: a
WeakReftarget can be freed only after the current job ends. So new types that a fully synchronous loop makes are freed when the loop ends or yields (await), not during the loop.Speed
pnpm benchRuntime): no measurable change.T.and(U): 0.9 µs → 0.5 µs).WeakRefwork. The benchmark had large variation between runs.Breaking changes (internal API)
$ark.nodesByRegisteredIdandscope.nodesByHashare nowWeakValueMaps (get,set,delete,keys,size), not plain objects.$ark.fn12) are no longer properties of the global$ark.Tests
Added to
ark/type/__tests__/registry.test.ts:type("string")calls in one synchronous job keep no memory. It fails if parse contexts are held weakly.Symbol.forkey, and a function namedversion.intrinsic.$arktoo.pnpm test/pnpm testTypedpnpm buildandpnpm testRepo(V8, integration, attest)pnpm tsc, ESLint, PrettierNot run:
pnpm testTsVersions,pnpm bench(type benchmarks).🤖 Generated with Claude Code