perf(cs): eliminate the C# corpus-proportional scans — 1211s -> 450s, +37% edges

Four measured changes on the dotnet/runtime corpus (58,656 files; every
step's numbers below are from full-corpus runs on the same host, baselines
captured this session; v0.9.0 = 444.3 s):

1. Two-phase cs registry build (types -> finalize -> funcs), the same
   pre-finalize linear-lookup disease fixed for Java: prepare 280 -> 140 s.

2. C# import-context carve-out: cs_import_types lists namespace_declaration
   (for namespace-name mapping) and using_statement (C#'s RAII block — a
   grammar-name collision), so EVERY namespaced C# file's whole body ran
   with inside_import=true. That both suppressed ordinary usage extraction
   under namespaces and sent every identifier through the ancestor-walking
   import-binding check (tree-sitter's ts_node_parent re-descends from the
   root, so wide files went quadratic: one 147 KB JIT torture file cost
   490 s; 6.8 s after). Only using_directive / namespace_use_declaration
   open an import scope now. Restores the suppressed usages:
   edges 4,291,387 -> 5,869,093 (+37%), nodes identical.

3. The usages walker maintains call/import ancestry as enter/exit counters
   on its explicit stack instead of per-node ancestor re-walks
   (extract_usages.c had grown from 6 to 100 ts_node_parent calls since
   v0.9.0; the two per-node gates are now O(1) with semantics preserved —
   strict ancestors only, emit before self-count).

4. Registry short-name indexes replace the two remaining full scans:
   cs_lookup_extension walked all 963k funcs per unresolved invocation
   (now the existing free-func short-name iterator, first-match order
   preserved via min-index selection), and cs_resolve_type_name's step-9
   fallback scanned every type per unresolved name IN BOTH the builder and
   per-file resolution (new type_short index in finalize, same
   reverse-insertion ascending-order pattern, best-score ties keep the
   first-in-registration-order winner). Builder 140,095 -> 500 ms; resolve
   cross-LSP CPU 5.5M -> 317k ms (us_per_file_per_kdef 191 -> 11).

End state: 449.9 s wall (1.01x of v0.9.0) with +48.6% edges vs v0.9.0 —
per-edge cost 32% BETTER than v0.9.0. Extract's remaining 359 s is the
24 MB hugeexpr1.cs parse floor both versions pay.

Guarded by the complexity suite; cs_lsp/extraction/edge/lang-contract
suites green including the 53-language calls-breadth contract.

Refs #1669.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-16 21:50:34 +02:00
parent e700b6215f
commit a4c0ffbce8
6 changed files with 1094 additions and 555 deletions
+12
View File
@@ -1646,6 +1646,18 @@ static bool is_actual_import_boundary(CBMExtractCtx *ctx, TSNode node, const CBM
char *name = ts_node_is_null(head) ? NULL : cbm_node_text(ctx->arena, head, ctx->source);
switch (ctx->language) {
case CBM_LANG_CSHARP:
/* cs_import_types also lists namespace_declaration (so the import pass
* can map namespace names) and using_statement (C#'s RAII block, a
* grammar-name collision with using_directive). Neither is an import
* CONTEXT: treating them as one put every namespaced C# file's whole
* body behind inside_import, which both suppressed ordinary usage
* extraction there and sent every identifier through the ancestor-
* walking import-binding check — 92% of extract time on wide files
* (dotnet/runtime JIT torture tests, 490 s for one 147 KB file). Only
* the using DIRECTIVE opens an import scope. */
return strcmp(kind, "using_directive") == 0 ||
strcmp(kind, "namespace_use_declaration") == 0;
case CBM_LANG_ELIXIR:
return strcmp(kind, "call") != 0 ||
(name && (strcmp(name, "import") == 0 || strcmp(name, "alias") == 0 ||
+74 -42
View File
@@ -49,36 +49,6 @@ static void walk_usages(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec
static bool is_direct_argument_value(TSNode node);
static TSNode python_direct_callable_attribute_site(TSNode node);
// Check if a node is inside a call expression (to avoid double-counting as usage)
static bool is_inside_call(TSNode node, const CBMLangSpec *spec) {
TSNode cur = ts_node_parent(node);
while (!ts_node_is_null(cur)) {
if (cbm_kind_in_set(cur, spec->call_node_types)) {
return true;
}
cur = ts_node_parent(cur);
}
return false;
}
// Check if a node is inside an import statement
static bool is_inside_import(TSNode node, const CBMLangSpec *spec) {
bool has_imports = spec->import_node_types && spec->import_node_types[0];
bool has_from_imports = spec->import_from_types && spec->import_from_types[0];
if (!has_imports && !has_from_imports) {
return false;
}
TSNode cur = ts_node_parent(node);
while (!ts_node_is_null(cur)) {
if ((has_imports && cbm_kind_in_set(cur, spec->import_node_types)) ||
(has_from_imports && cbm_kind_in_set(cur, spec->import_from_types))) {
return true;
}
cur = ts_node_parent(cur);
}
return false;
}
// Is this an identifier-like node that represents a reference?
static bool is_reference_node(TSNode node, CBMLanguage lang) {
const char *kind = ts_node_type(node);
@@ -2426,7 +2396,8 @@ static bool emit_direct_perl_coderef_usage(CBMExtractCtx *ctx, TSNode node,
}
// Try to emit a usage for a reference node. Returns early if the node should be skipped.
static void try_emit_usage(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) {
static void try_emit_usage(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec,
bool inside_call, bool inside_import) {
if (emit_direct_perl_coderef_usage(ctx, node, cbm_enclosing_func_qn_cached(ctx, node), 0)) {
return;
}
@@ -2439,7 +2410,7 @@ static void try_emit_usage(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *s
if (is_call_argument_label(node)) {
return;
}
if (is_inside_call(node, spec) || is_inside_import(node, spec)) {
if (inside_call || inside_import) {
return;
}
if (is_binding_occurrence(ctx, node, spec, NULL) ||
@@ -2456,18 +2427,79 @@ static void try_emit_usage(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *s
}
}
// Iterative usage walker — explicit stack
// Iterative usage walker — explicit stack.
//
// The call/import ancestry that gates usage emission is maintained as ENTER/
// EXIT counters on the walk instead of per-node ancestor re-walks: the old
// is_inside_call/is_inside_import helpers climbed every ancestor via
// ts_node_parent, and tree-sitter's ts_node_parent RE-DESCENDS from the root
// scanning siblings — O(depth x sibling-position) per node, which went
// quadratic on wide nodes (a 1,536-argument call in dotnet/runtime's JIT
// torture tests put 92% of extract time into these walks; 490 s for one
// 147 KB file). Counter semantics match the helpers exactly: strict ancestors
// only — a node is emitted BEFORE its own kind increments the counters, so a
// call node itself does not count as "inside a call".
static void walk_usages(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) {
TSNodeStack stack;
ts_nstack_init(&stack, ctx->arena, 4096);
ts_nstack_push(&stack, ctx->arena, root);
typedef struct {
TSNode node;
uint32_t next_child;
bool counts_call;
bool counts_import;
} UsageFrame;
int cap = 256;
UsageFrame *frames = (UsageFrame *)cbm_arena_alloc(ctx->arena, (size_t)cap * sizeof(*frames));
if (!frames) {
return;
}
bool has_imports = spec->import_node_types && spec->import_node_types[0];
bool has_from_imports = spec->import_from_types && spec->import_from_types[0];
int call_depth = 0;
int import_depth = 0;
int top = 0;
frames[top++] = (UsageFrame){root, 0, false, false};
bool entering = true;
while (stack.count > 0) {
TSNode node = ts_nstack_pop(&stack);
try_emit_usage(ctx, node, spec);
uint32_t count = ts_node_child_count(node);
for (int i = (int)count - LAST_IDX; i >= 0; i--) {
ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i));
while (top > 0) {
UsageFrame *f = &frames[top - 1];
if (entering) {
try_emit_usage(ctx, f->node, spec, call_depth > 0, import_depth > 0);
f->counts_call = cbm_kind_in_set(f->node, spec->call_node_types);
f->counts_import =
(has_imports && cbm_kind_in_set(f->node, spec->import_node_types)) ||
(has_from_imports && cbm_kind_in_set(f->node, spec->import_from_types));
if (f->counts_call) {
call_depth++;
}
if (f->counts_import) {
import_depth++;
}
}
uint32_t count = ts_node_child_count(f->node);
if (f->next_child < count) {
TSNode child = ts_node_child(f->node, f->next_child);
f->next_child++;
if (top == cap) {
int new_cap = cap * 2;
UsageFrame *grown =
(UsageFrame *)cbm_arena_alloc(ctx->arena, (size_t)new_cap * sizeof(*grown));
if (!grown) {
return;
}
memcpy(grown, frames, (size_t)cap * sizeof(*frames));
frames = grown;
cap = new_cap;
}
frames[top++] = (UsageFrame){child, 0, false, false};
entering = true;
} else {
if (f->counts_call) {
call_depth--;
}
if (f->counts_import) {
import_depth--;
}
top--;
entering = false;
}
}
}
+886 -513
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -169,6 +169,46 @@ static void build_type_embed_index(CBMTypeRegistry *reg, CBMArena *idx_arena) {
/* Index: short_name -> chain of FREE-function (receiver_type==NULL) indices.
* Descending-iterate + prepend for ascending chain order (as above). */
static void build_type_short_index(CBMTypeRegistry *reg, CBMArena *idx_arena) {
int tcount = 0;
for (int i = 0; i < reg->type_count; i++) {
if (reg->types[i].short_name)
tcount++;
}
if (tcount == 0)
return;
int bucket_count = next_pow2(tcount * 2);
if (bucket_count < 16)
bucket_count = 16;
int *buckets = (int *)cbm_arena_alloc(idx_arena, (size_t)bucket_count * sizeof(int));
CBMRegistryHashEntry *entries = (CBMRegistryHashEntry *)cbm_arena_alloc(
idx_arena, (size_t)tcount * sizeof(CBMRegistryHashEntry));
if (!buckets || !entries)
return;
for (int i = 0; i < bucket_count; i++)
buckets[i] = -1;
int idx = 0;
/* Reverse insertion so each chain yields ASCENDING types[] order — callers
* that used first-match-in-registration-order keep their tie-breaks. */
for (int i = reg->type_count - 1; i >= 0; i--) {
const CBMRegisteredType *t = &reg->types[i];
if (!t->short_name)
continue;
uint64_t h = fnv1a(t->short_name);
int slot = (int)(h & (uint64_t)(bucket_count - 1));
entries[idx].hash = h;
entries[idx].payload_index = i;
entries[idx].next_index = buckets[slot];
entries[idx].slot = slot;
buckets[slot] = idx;
idx++;
}
reg->type_short_buckets = buckets;
reg->type_short_entries = entries;
reg->type_short_bucket_count = bucket_count;
reg->type_short_entry_count = idx;
}
static void build_ffunc_short_index(CBMTypeRegistry *reg, CBMArena *idx_arena) {
int fcount = 0;
for (int i = 0; i < reg->func_count; i++) {
@@ -286,6 +326,42 @@ int cbm_free_func_iter_next(CBMFreeFuncIter *it) {
return -1;
}
void cbm_registry_types_by_short_name(const CBMTypeRegistry *reg, const char *short_name,
CBMTypeShortIter *out) {
out->reg = reg;
out->hash = fnv1a(short_name);
if (reg->type_qn_buckets && reg->type_qn_bucket_count > 0) {
if (reg->type_short_buckets && reg->type_short_bucket_count > 0) {
int slot = (int)(out->hash & (uint64_t)(reg->type_short_bucket_count - 1));
out->chain_idx = reg->type_short_buckets[slot];
} else {
out->chain_idx = -1;
}
out->tail_i = reg->type_qn_entry_count;
out->tail_end = reg->type_count;
} else {
out->chain_idx = -1;
out->tail_i = 0;
out->tail_end = reg->type_count;
}
}
int cbm_type_short_iter_next(CBMTypeShortIter *it) {
const CBMTypeRegistry *reg = it->reg;
while (it->chain_idx >= 0) {
const CBMRegistryHashEntry *e = &reg->type_short_entries[it->chain_idx];
int p = e->payload_index;
uint64_t h = e->hash;
it->chain_idx = e->next_index;
if (h != it->hash)
continue;
return p;
}
if (it->tail_i < it->tail_end)
return it->tail_i++;
return -1;
}
void cbm_registry_methods(const CBMTypeRegistry *reg, const char *receiver_qn,
const char *method_name, CBMMethodIter *out) {
memset(out, 0, sizeof(*out));
@@ -346,6 +422,7 @@ void cbm_registry_finalize_into(CBMTypeRegistry *reg, CBMArena *idx_arena) {
build_method_index(reg, idx_arena);
build_type_embed_index(reg, idx_arena);
build_ffunc_short_index(reg, idx_arena);
build_type_short_index(reg, idx_arena);
}
void cbm_registry_finalize(CBMTypeRegistry *reg) {
+20
View File
@@ -123,6 +123,10 @@ typedef struct CBMTypeRegistry {
int type_embed_entry_count;
// Free-function short-name index: fnv1a(short_name) -> chain of FREE-function
// (receiver_type==NULL) indices. payload_index = func index.
int *type_short_buckets;
CBMRegistryHashEntry *type_short_entries;
int type_short_bucket_count;
int type_short_entry_count;
int *ffunc_short_buckets;
CBMRegistryHashEntry *ffunc_short_entries;
int ffunc_short_bucket_count;
@@ -248,6 +252,22 @@ typedef struct {
int tail_i;
int tail_end;
} CBMFreeFuncIter;
/* Iterate TYPE indices sharing a short name — the type-side twin of the free-
* function iterator. Built by finalize into type_short_buckets; degrades to a
* full types[] scan on an unfinalized registry (same correctness, old cost).
* Added for cs_resolve_type_name's step-9 fallback, which scanned type_count
* per unresolved name — quadratic against the shared Tier-2 registry. */
typedef struct {
const CBMTypeRegistry *reg;
uint64_t hash;
int chain_idx;
int tail_i;
int tail_end;
} CBMTypeShortIter;
void cbm_registry_types_by_short_name(const CBMTypeRegistry *reg, const char *short_name,
CBMTypeShortIter *out);
int cbm_type_short_iter_next(CBMTypeShortIter *it);
void cbm_registry_free_funcs_by_short_name(const CBMTypeRegistry *reg, const char *short_name,
CBMFreeFuncIter *out);
int cbm_free_func_iter_next(CBMFreeFuncIter *it);
+25
View File
@@ -1259,14 +1259,39 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx,
cbm_arena_init(&cross_lsp_arena);
CBMCrossLspRegistries cross_registries = {0};
if (all_defs) {
/* Per-builder split of lsp_cross_prepare — attributes a slow prepare to
* ONE language instead of re-diagnosing the whole pass (the cs builder
* hid ~140 s behind the pass total, #1669 follow-up). */
struct timespec t_b;
long b_ms[6];
cbm_clock_gettime(CLOCK_MONOTONIC, &t_b);
cross_registries.go = cbm_go_build_cross_registry(&cross_lsp_arena, all_defs, def_count);
b_ms[0] = (long)elapsed_ms(t_b);
cbm_clock_gettime(CLOCK_MONOTONIC, &t_b);
cross_registries.python =
cbm_py_build_cross_registry(&cross_lsp_arena, all_defs, def_count);
b_ms[1] = (long)elapsed_ms(t_b);
cbm_clock_gettime(CLOCK_MONOTONIC, &t_b);
cross_registries.c = cbm_c_build_cross_registry(&cross_lsp_arena, all_defs, def_count);
b_ms[2] = (long)elapsed_ms(t_b);
cbm_clock_gettime(CLOCK_MONOTONIC, &t_b);
cross_registries.cs = cbm_cs_build_cross_registry(&cross_lsp_arena, all_defs, def_count);
b_ms[3] = (long)elapsed_ms(t_b);
cbm_clock_gettime(CLOCK_MONOTONIC, &t_b);
cross_registries.ts = cbm_ts_build_cross_registry(&cross_lsp_arena, all_defs, def_count);
b_ms[4] = (long)elapsed_ms(t_b);
cbm_clock_gettime(CLOCK_MONOTONIC, &t_b);
cross_registries.java =
cbm_java_build_cross_registry(&cross_lsp_arena, all_defs, def_count);
b_ms[5] = (long)elapsed_ms(t_b);
char b_buf[6][CBM_SZ_16];
const char *b_name[6] = {"go", "python", "c", "cs", "ts", "java"};
for (int bi = 0; bi < 6; bi++) {
snprintf(b_buf[bi], sizeof(b_buf[bi]), "%ld", b_ms[bi]);
}
cbm_log_info("lsp_cross_prepare.builders", b_name[0], b_buf[0], b_name[1], b_buf[1],
b_name[2], b_buf[2], b_name[3], b_buf[3], b_name[4], b_buf[4], b_name[5],
b_buf[5]);
/* Rust: NOT built here. The shared all_defs registry is built LAZILY on the
* first NULL-filter rust file (the amplifier files) inside cbm_parallel_resolve
* — repos whose rust files all filter to subsets never pay the build/RSS. */