fix(extract): AST traversal stacks no longer outlive the file they walk - #2013
fix(extract): AST traversal stacks no longer outlive the file they walk#2013shafty023 wants to merge 1 commit into
Conversation
Fixes DeusData#2010. Refs DeusData#1997. Indexing a 14k-file TypeScript repository holds 13.3 GB resident at post_extract on Linux aarch64, and about 3.1 GB of it is AST traversal scratch that no CBMFileResult points at. ts_nstack_init cut its stacks from ctx->arena, which is result->arena. In the parallel path that arena is stored into ec->result_cache[file_idx] (pass_parallel.c:963) and freed only after parallel_resolve and the infra passes (pipeline.c:1324), so a structure written for a one-file lifetime is in fact held for the whole index, once per file, concurrently. The header claimed otherwise and was wrong: "freed when the arena is destroyed at end of file extraction". The largest instance is channels. CHAN_STACK_CAP is 4096 and sizeof(TSNode) is 32, so scan_string_consts_js (extract_channels.c:104) and extract_channels_js (:376) take 128 KB each, per JavaScript or TypeScript file. Across 12,673 such files that is 3,168 MB, and it produced 23 Channel nodes out of 164,684. CBMExtractCtx gains a scratch arena, created and destroyed by the cbm_extract_file_ex call that builds the context. Nothing else changes about where anything lives: ctx->arena still owns every string a CBMFileResult points at, and ctx->scratch owns only traversal stacks, which nothing points at. Nothing a CBMFileResult points at moved. A context built without a scratch, and a file whose scratch arena fails to allocate, both fall back to ctx->arena, which is exactly the behaviour that shipped before this change, so no path can be handed a NULL arena and silently lose nodes. The signature change is what makes the mistake unrepresentable. ts_nstack_init takes the context rather than an arena, so handing it ctx->arena is an incompatible pointer type; push and push_children read the arena the stack recorded, so a stale argument is an arity error. Both fail under -Werror rather than silently retaining memory. The 55 init sites are NOT one uniform sed. 29 pass ctx->arena and 26 pass a local alias, and that alias is shared with result allocations: in parse_zig_imports (extract_imports.c:1093) the same `a` feeds cbm_node_text, strip_quotes, path_last and cbm_imports_push (extract_imports.c:1101, 1106, 1108, 1109). Rebinding it would have moved import names and module paths into memory that dies with the call while the result kept pointing at them. Each site was edited on its own and no alias was rebound. Re-derive the set with: grep -rn 'ts_nstack_init' internal/cbm/extract_*.c grep -rn 'ts_nstack_push' internal/cbm/extract_*.c parse_lisp_imports used its alias only for the stack, so the alias goes too. push_nested_class_nodes and push_class_body_children took an arena purely to reach the stack, so they take the context now; the one call site passes ctx. Two measurements, from two different builds, and they are not the same number. A macOS census build, which instruments every cbm_arena_alloc and sums the blocks behind every live CBMFileResult arena, reports the retained per-file arena set dropping from 11,226 MB mapped to 5,680 MB, and blocks from 48,045 to 34,186. Those are mapped bytes, not resident: that build logs mem.allocator.bound_populations_only, so untouched block tails never commit. The resident figures come from Linux aarch64, gcc 13.3, static build, where the build logs mem.allocator.owned classes=all and mapped equals resident. Five runs per side on the full 13,999-file tree: mem.collect post_extract rss_mb 13293 -> 6752 (-49.2%), mem.phase peak_mb 14840 -> 8565 (-42.3%). nodes 164684, parse_partial 280 and registry entries/defines/imports 787954/804999/100027 identical on all ten runs; edges 835869-835874 against 835867-835881, overlapping ranges, so run to run noise. Wall clock means 35651 ms against 30502 ms, but the ranges overlap and the host had other load, so the only supportable claim is no regression. ObjectScript Studio Export on a small fixture: nodes 18, edges 25, both sides. Scratch block size is 512 KB, chosen from measurement. arena_grow fires on one file in 12,000 at both 512 KB and 1 MB, and constantly at 256 KB, where the two channel stacks alone are exactly 262144 bytes. 512 KB therefore costs half the resident block per worker for the same growth behaviour, and stays under MI_LARGE_MAX_OBJ_SIZE in the vendored mimalloc, which is MI_LARGE_PAGE_SIZE/8, exactly 524288 bytes (types.h:426, with MI_ENABLE_LARGE_PAGES defaulting to 1 at :115 and not overridden by this build), so a 512 KB block is still bin-allocated from a 4 MiB large page. Growth is not free at that size for the same reason: arena_grow doubles to 1 MiB, above that bound and therefore a singleton OS allocation. One file in twelve thousand pays it, which is why it is accepted. The largest single file's scratch high water mark on this corpus is 33.5 MB, now transient rather than retained. Tests. Nothing in the suite asserted on result->arena size, block count or total_alloc after cbm_extract_file, which is why this went unnoticed for the life of extract_node_stack.h. traversal_stack_not_in_result_arena_issue2010 pins the byte budget: extracting "export const x = 1;" as TypeScript charged 365984 bytes to the result arena before and charges 87456 after, a difference of 278528, exactly the two 4096-entry channel walks plus the 512-entry ES import walk at 32 bytes per TSNode. Of the 87456 that remain, 7680 is the defs item array at GROW_ARRAY's starting capacity of 32 times sizeof(CBMDefinition) 240 and the other 79776 is everything else the extraction interns, none of it traversal scratch; the 128 KB bound is therefore above the new figure with room and a factor of four below the old one. extract_traversal_stacks_come_from_ctx_scratch_issue2010 in test_mem.c pins the lifetime, since a byte budget alone would also be satisfied by shrinking CHAN_STACK_CAP: it builds the extraction context directly over two arenas it owns, runs cbm_extract_channels, and asserts the scratch took at least the 262144 bytes of the two walks while the result arena did not. extract_c_macro_hidden_call_survives_preprocessed_pass_issue2010 is not a scratch test. pp_ctx carries ctx->scratch so every context in the file is uniform, but nothing reads it there: pp_ctx reaches only cbm_extract_unified and cbm_run_c_lsp, and neither extract_unified.c nor anything under internal/cbm/lsp/ includes extract_node_stack.h, so no traversal stack is built on that path today. The test guards the macro-expansion path itself, which had no assertion on a call that exists only after expansion. ObjectScript composite extraction is already covered by pipeline_objectscript_export_preserves_calls_sequential_parallel and iris_export_xml_multi_class, so no new test is added there; a per-call arena is correct on that path by construction, since each call owns and destroys its own. Local: scripts/test.sh, 7558 passed, 0 failed, 8 skipped, 139 suites. Alternatives considered Thread-local scratch arena, reset per file, with a depth guard for nesting. Rejected on three counts. This codebase never uses thread-local storage for owned bulk memory; every existing _Thread_local here is a small cache, a guard or a log buffer, and every per-worker resource is a field in a context struct. It would have needed cbm_arena_reset, which has no production caller and a documented subtlety with grown block sizes (arena.c:213-217). And extraction threads are created per cbm_parallel_for call and joined (worker_pool.c:71,88) with no thread-exit hook in this project's own code, so the arena would have leaked once per thread per dispatch and turned the leak lane red. Save and restore mark and release on the arena. Rejected because a per-call arena already makes nesting correct by construction, and a mark type plus two functions would have to be added to both arena headers. Per-walk arenas, one per traversal rather than one per file. Rejected on cost: 55 sites would each need an explicit span, for the same bound. Right-sizing the initial capacities alone. CHAN_STACK_CAP at 256 rather than 4096 would cut those two sites from 3,168 MB to about 198 MB, which is real, but it tunes the constant while the lifetime defect remains at all 55 sites, and growth is not free: 256 doubling to 4096 abandons 122,880 bytes, and one file in this corpus took 33.5 MB of stacks. The cap and the scratch block size are coupled, which is a further reason to change one at a time. Worth doing separately. Routing to a scratch inside extract_node_stack.h while keeping the arena parameters. Rejected because a parameter that is ignored lies about the API, and the alias hazard above would stay a review obligation instead of a compile error. Freeing the per-file result arena earlier. Rejected: the results are read by parallel_resolve and the infra passes. That is the retention half DeusData#1925 addresses, and this change is independent of it. Signed-off-by: Daniel Ochoa <daniel.ochoa@closedloop.ai>
|
Approved on merit. I traced the mechanism end to end rather than taking the numbers, and every step holds. Verified
So the header comment at 3.1 GB of scratch to produce 23 One thing you did not claim, which strengthens it
On the shape of the fixA separate And making the signatures enforce it matters more than the change itself. Threading Scoping it to the traversal half and leaving the retention half to #1925 is right too, and I appreciate you saying plainly that #1997's underlying complaint — the memory budget does not bound the index — is not answered here. Splitting #2010 out so the closing keyword points at the defect actually fixed is the correct bookkeeping.
Before mergeCI is still running and our Actions pool has been backlogged all day (25 runs queued as I write), so expect a wait unrelated to this PR. Once green I will merge. Thank you — the diagnosis, the arithmetic, and the "23 nodes for 3.1 GB" framing made this reviewable in one pass. |
|
Thanks for opening this — it has been seen, and it is queued. This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence. Current review status: working through a backlog. What that means for this PR, concretely:
Things that will genuinely speed it up whenever review does happen:
If this fixes a bug, a reproduction we can run is worth more than a description of the symptom. Thanks for contributing, and sorry in advance for the wait. |
|
@DeusData Thanks for the fast and thorough read. Added a Public surface section to the description covering the cbm.h field and the ts_nstack signature changes, as asked. Your point about growth compounding on deep files is right, and it is the same reason the scratch block was sized from measured growth counts rather than the old caps. Happy to wait out the CI queue. |
Fixes #2010. Part of #1997; the retention half is #1925.
#2010 is the focused issue I split out of #1997 for exactly this defect, so the
closing keyword points there. #1997 itself stays open: its retention half is in
flight as #1925 and its underlying complaint, that the memory budget does not
bound the index, is not answered here.
Indexing a 14k-file TypeScript monorepo holds 13.3 GB resident at
post_extracton Linuxaarch64, and about 3.1 GB of that is AST traversal scratch that nothing in the graph points at.
The diagnosis is in #1997 (comment) and this is the fix it asked about.
Why this change, and why now
Of the memory a 14k-file index retains, roughly half is traversal scratch that nothing in
the result ever points at, and the other half is the extraction output that later passes
read. This PR takes the first half with a contained change: one owner for the scratch, a
lifetime that is correct by construction, and signatures that make the old mistake a compile
error rather than a review item. It needs nothing from, and changes nothing in, the bounded
two-pass design in #1925 that takes the second half, so the two land independently and in
either order. The alternatives below were each measured or read out before this shape was
chosen; the numbers that decided it are in the section after.
The mechanism
ts_nstack_initcut its stacks fromctx->arena, which isresult->arena. In the parallel paththat result is stored into
ec->result_cache[file_idx](pass_parallel.c:963) and freed onlyafter
parallel_resolveand the infra passes (pipeline.c:1324), so a structure written for aone-file lifetime is in fact held for the whole index, once per file, concurrently.
extract_node_stack.hsaid otherwise and was wrong: "freed when the arena is destroyed at end offile extraction".
The largest instance is channels.
CHAN_STACK_CAPis 4096 andsizeof(TSNode)is 32, soscan_string_consts_js(extract_channels.c:104) andextract_channels_js(:376) take 128 KBeach, per JavaScript or TypeScript file. Across 12,673 such files that is 3,168 MB, and it
produced 23
Channelnodes out of 164,684.CBMExtractCtxnow carries a scratch arena, created and destroyed by thecbm_extract_file_excall that builds the context. Nothing else moves:
ctx->arenastill owns every string aCBMFileResultpoints at,ctx->scratchowns only traversal stacks, which nothing points at.Public surface
cbm.hgains one field,CBMExtractCtx.scratch, andextract_node_stack.hchanges the threets_nstack_*signatures. Both are additive for anything that builds a context throughcbm_extract_file_ex; a context built by hand without a scratch keeps the old behaviour throughthe fallback to
ctx->arena. Nothing else on the public extraction surface moves.Measured, Linux aarch64, gcc 13.3, static build, five runs per side
Full 13,999-file tree,
CBM_PROFILE=1, a privateCBM_CACHE_DIRper run, 18 workers, 24 GBcontainer.
mem.collect post_extract rss_mbmem.phase peak_mbpipeline.done nodespipeline.done edgesparse_partial_countentries/defines/importspipeline.done elapsed_msThe wall clock is not a claim: the two ranges overlap, so all that is supportable is that there is
no regression. The memory ranges do not overlap, by a factor of two.
ObjectScript Studio Export, the one path that calls
cbm_extract_file_exmore than once perphysical file, on a small fixture: nodes 18, edges 25, both sides, identical.
Scratch block size
512 KB, chosen from measurement rather than taste, and named
CBM_EXTRACT_SCRATCH_BLOCKincbm.c.arena_growfiredpost_extract rss_mbelapsed_ms512 KB and 1 MB grow equally rarely, so 512 KB wins on resident cost per worker. It is also
exactly
MI_LARGE_MAX_OBJ_SIZEin the vendored mimalloc,MI_LARGE_PAGE_SIZE/8= 524,288(
vendored/mimalloc/include/mimalloc/types.h:426, withMI_ENABLE_LARGE_PAGESdefaulting to 1 at:115and not overridden by this build), so the block is still bin-allocated from a 4 MiB largepage. Growth is not free at that size for the same reason:
arena_growdoubles to 1 MiB, which isabove that bound and therefore a singleton OS allocation. One file in twelve thousand pays it,
which is why the cost is accepted. The largest single file's scratch high water mark on this corpus
is 33.5 MB, now transient rather than retained.
The block is created before the quarantine short-circuit, not after.
cbm_arena_init(&result->arena)already sits ahead of that check, so this is consistent with what the path pays today, and the
wrapper is what keeps the change to one create and one destroy across the body's seven early
returns instead of eight destroy sites.
The mechanical part
165 call sites:
ts_nstack_init55,ts_nstack_push70,ts_nstack_push_children40, acrossextract_imports.c108,extract_channels.c30,extract_defs.c9,extract_type_refs.c6,extract_semantic.c6,extract_type_assigns.c3,extract_env_accesses.c3.It is not one uniform sed. 29 init sites passed
ctx->arenaand 26 passed a local alias, andthat alias is shared with result allocations: in
parse_zig_imports(
extract_imports.c:1093) the sameafeedscbm_node_text,strip_quotes,path_lastandcbm_imports_pushat:1101,:1106,:1108and:1109. Rebinding it would have moved import names and module paths into memory that dieswith the call while the result kept pointing at them. Each site was edited on its own and no alias
was rebound.
parse_lisp_importsused its alias only for the stack, so the alias goes too. Verifythe set with:
The signature change is what makes the mistake unrepresentable rather than merely avoided.
ts_nstack_inittakes the context, so handing itctx->arenais an incompatible pointer type;pushandpush_childrenread the arena the stack recorded at init, so a stale argument is anarity error. Both fail under
-Werror.Alternatives considered
Thread-local scratch arena, reset per file, with a depth guard. Rejected on three counts. This
codebase never uses thread-local storage for owned bulk memory; every existing
_Thread_localisa small cache, a guard or a log buffer, and every per-worker resource is a field in a context
struct. It would have needed
cbm_arena_reset, which has no production caller and a documentedsubtlety with grown block sizes (
arena.c:213-217). And extraction threads are created percbm_parallel_forcall and joined (worker_pool.c:71, 88) with no thread-exit hook in thisproject's own code, so the arena would have leaked once per thread per dispatch and turned the
leak lane red.
Save and restore mark and release on the arena. Rejected because a per-call arena already
makes nesting correct by construction, and this would add a mark type and two functions to both
arena headers.
Per-walk arenas, one per traversal rather than one per file. Rejected on cost: 55 sites would
each need an explicit span, for the same bound.
Right-sizing the initial capacities alone.
CHAN_STACK_CAPat 256 rather than 4096 would cutthose two sites from 3,168 MB to about 198 MB, which is real, but it tunes the constant while the
lifetime defect remains at all 55 sites, and growth is not free: 256 doubling to 4096 abandons
122,880 bytes, and one file here needs 33.5 MB of stacks. Worth doing separately, and the cap and
the scratch block size are coupled, which is another reason to change one at a time.
Routing to a scratch inside
extract_node_stack.hwhile keeping the arena parameters.Rejected because a parameter that is ignored lies about the API, and the alias hazard above would
stay a review obligation instead of a compile error.
Freeing the per-file result arena earlier. Rejected: the results are read by
parallel_resolveand the infra passes. That is the retention half #1925 addresses, and thischange is independent of it and does not touch that machinery.
Tests
Nothing in the suite asserted on
result->arenasize, block count ortotal_allocaftercbm_extract_file, which is why this went unnoticed for the life ofextract_node_stack.h.traversal_stack_not_in_result_arena_issue2010(tests/test_extraction.c) is the byte budget. Extracting"export const x = 1;"as TypeScript charged 365,984 bytes to the result arena before andcharges 87,456 after, a difference of 278,528, exactly the two 4096-entry channel walks plus the
512-entry ES import walk at 32 bytes per
TSNode. Of the 87,456 that remain, 7,680 is the defsitem array at
GROW_ARRAY's starting capacity of 32 timessizeof(CBMDefinition)240, and theother 79,776 is everything else this file's extraction interns, none of it traversal scratch. The
128 KB bound is therefore derived: above the new figure with room, a factor of four below the old
one.
extract_traversal_stacks_come_from_ctx_scratch_issue2010(tests/test_mem.c) is the lifetime half. Abyte budget alone would also be satisfied by shrinking
CHAN_STACK_CAP, so this builds theextraction context directly over two arenas it owns, runs
cbm_extract_channels, and assertsthe scratch took at least the 262,144 bytes of the two walks while the result arena did not.
extract_c_macro_hidden_call_survives_preprocessed_pass_issue2010is not a scratch test.pp_ctxcarriesctx->scratchso every context in the file is uniform, but nothing reads itthere:
pp_ctxreaches onlycbm_extract_unifiedandcbm_run_c_lsp, and neitherextract_unified.cnor anything underinternal/cbm/lsp/includesextract_node_stack.h, so notraversal stack is built on that path today. It guards the macro-expansion path itself, which
extract_c_ifdef_split_brace_fn_recovered_issue961andextract_cpp_preproc_signature_gap_issue946reach but neither asserts a call that exists onlyafter expansion.
pipeline_objectscript_export_preserves_calls_sequential_parallel(tests/test_pipeline.c) andiris_export_xml_multi_class, so no new test is added. A per-call arena is correct there byconstruction, since each call owns and destroys its own.
scripts/test.sh: 7558 passed, 0 failed, 8 skipped, 139 suites.Failure modes closed rather than added
A context built without a scratch, and a file whose scratch arena fails to allocate, both fall
back to
ctx->arena, which is exactly the behaviour that shipped before this change. No path canbe handed a NULL arena and silently lose nodes, which matters because
ts_nstack_pushalready swallows an allocation failure (if (!new_items) return;), and silentlytruncating a traversal is the failure class this file was created to fix in #199.
Two things a reviewer will ask
Size. 544 changed lines against the "under 500" guideline in CONTRIBUTING. 165 of them are a
one-line mechanical edit at every
ts_nstack_*call site, which the two greps above re-derive; thechange itself is about 200 lines in
cbm.cand three headers. I kept it as one PR because asignature change and its call sites cannot land separately without breaking the build in between.
Happy to split differently if you would rather review it another way.
Why
ts_nstack_inittakes the context rather than an arena parameter. An arena parameter wouldhave kept
extract_node_stack.ha leaf header, but it would also have kept the hazard: 26 of the55 init sites pass a local
athat is an alias ofctx->arenaand is also the arena for theresult's own strings, so getting one site wrong is a use-after-free that survives into resolve
rather than a compile error. Taking the context makes that unrepresentable, at the cost of the
header including
cbm.h. That include is acyclic and a no-op in all seven consumers, each of whichalready has
cbm.has its first line, so nothing is reordered. If you prefer the leaf header, theplain-parameter variant is a small change from here:
ts_nstack_init(TSNodeStack *, CBMArena *, int)with the same field and the same
pushsignatures, and I will take the review burden on the 26sites instead. Say which you want.
Separate observations, not fixed here
internal/cbm/arena.handsrc/foundation/arena.hshare the guardCBM_ARENA_Hand declarethe same struct, but the internal copy omits
init_sized,calloc,resetandtotal, anddisagrees on
CBM_ARENA_DEFAULT_BLOCK_SIZE.-Isrcprecedes-I internal/cbm(
Makefile.cbm:53-55) and there is nosrc/arena.h, so asrc/pipelinefile writing#include "arena.h"also lands on the reduced copy, andinternal/cbm/arena.cis in no*_SRCSlist and is dead. This change works within that rather than untangling it:cbm.cincludes
foundation/arena.hahead ofcbm.h, the spellingsrc/pipeline/lsp_surface.h:24andtests/test_mem.c:9already use, and no header is edited. Happy to open an issue for theduplication itself.
arena_growreturns 0 atCBM_ARENA_MAX_BLOCKS256,cbm_arena_allocthen returns NULL, andGROW_ARRAYdoes a barereturnatcbm.c:111, silently dropping items with no log line andno
has_error.collect_children(lsp/ts_lsp.c:201) andcbm_lsp_collect_children(
lsp/c_lsp.c,cs_lsp.c,go_lsp.c, eight call sites) allocateTSNodearrays from theresult arena that are pure iteration scratch. They are not routed here because they sit behind
LSP entry points taking a bare arena, which the cross-file resolve pass also calls with a
different lifetime. Worth a follow-up.
is 475 lines against
readability-function-size.LineThreshold400. It was 471 at 46ae198, sothe wrapper adds four lines to an already-over-threshold function rather than crossing it.