From d510de476638eb321b7dfc126848321d455bc887 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 17 Jul 2026 12:20:17 -0500 Subject: [PATCH] =?UTF-8?q?perf(synthesis):=20cFnPtrEdges=202.07x=20at=20k?= =?UTF-8?q?ernel=20scale=20=E2=80=94=20probe-profiled,=20edge=20set=20hash?= =?UTF-8?q?-identical=20(#1341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cFnPtrEdges was 86% of kernel-scale synthesis (306s, §7a.3). Standalone probe iterations against the live kernel DB attributed and fixed: - sliceLines split the ENTIRE file per node (~1.6M full-file splits across the B/D/E sweeps) — split once per file, slice from the line array (D 46.2->20.5s, E 94.5->69.1s). - The 128-entry strip cache re-stripped every file 4.4x across the four file sweeps (71.8s). Sizing is now memory-budget-aware and ALL-OR-NOTHING with 5% slack over the file count: a partial LRU on cyclic sweeps thrashes to ~0% hit (measured twice: cap ~61k and cap == files both thrashed against 63.8k + includes). Full-cache strips: exactly 1.0/file, 15.5s. - Pass D pre-gates field names on fieldToStructs before the two regex type resolutions — a->f = b->g matches every struct-field assignment in the tree, ~99% of them data fields. - recvTypeIn/varTypeIn compiled a new RegExp per call - cached per name. - Line numbers counted incrementally per match (ascending indexes) instead of splitting the body prefix per match; cursor rewinds between the two dispatch scans. - CODEGRAPH_SYNTH_TIMINGS now prints per-sweep walls + read/strip/nodes accounting for this pass. 278.8s -> 134.8s standalone at the 2c envelope. Output identity proven at full scale: probe edge set (merge-dedup applied, canonical sort) SHA256 21c2a971... == the original code's 274,762 edges extracted from the live kernel DB. Pass suite 15/15. Co-authored-by: Claude Fable 5 --- src/resolution/c-fnptr-synthesizer.ts | 108 ++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/src/resolution/c-fnptr-synthesizer.ts b/src/resolution/c-fnptr-synthesizer.ts index 62c0e95..1c7d6de 100644 --- a/src/resolution/c-fnptr-synthesizer.ts +++ b/src/resolution/c-fnptr-synthesizer.ts @@ -53,6 +53,7 @@ import type { Edge, Node } from '../types'; import type { QueryBuilder } from '../db/queries'; import type { ResolutionContext } from './types'; import type { MaybeYield } from './cooperative-yield'; +import { memoryBudgetBytes } from './memory-budget'; import { LRUCache } from './lru-cache'; import { stripCommentsForRegex } from './strip-comments'; @@ -70,9 +71,13 @@ interface FieldInfo { type: string; } -function sliceLines(content: string, startLine?: number, endLine?: number): string { +/** Slice a node's body from a pre-split line array — the per-file sweeps (B/D/E) + * call this once per NODE, and splitting the whole file per node was an + * O(nodes × file-size) term (~1.6M full-file splits on the Linux tree, + * §7a.3 cFnPtr round). Split once per file, slice many times. */ +function sliceLinesPre(lines: string[], startLine?: number, endLine?: number): string { if (!startLine) return ''; - return content.split('\n').slice(startLine - 1, endLine ?? startLine).join('\n'); + return lines.slice(startLine - 1, endLine ?? startLine).join('\n'); } /** Index of the `}` matching the `{` at `open` (which must point at a `{`). -1 if unbalanced. */ @@ -318,6 +323,13 @@ export async function cFnPointerDispatchEdges( const files = ctx.getAllFiles().filter((f) => C_CPP_EXT.test(f)); if (files.length === 0) return []; + // CODEGRAPH_SYNTH_TIMINGS sub-attribution: this pass is 86% of kernel-scale + // synthesis (306s, §7a.2/§7a.3) — per-sweep walls + read/strip accounting + // name which sweep and which cost class owns it. + const prof = process.env.CODEGRAPH_SYNTH_TIMINGS + ? { A: 0, B: 0, C: 0, D: 0, E: 0, readMs: 0, readN: 0, stripMs: 0, stripN: 0, nodesMs: 0, nodesN: 0 } + : null; + // Within-pass progress: this is the pass that parks the "Linking dynamic // dispatch" bar on C-heavy repos, so it reports a real fraction of its // dominant work. `files` is swept once per file loop below (passes A, C, D, @@ -337,14 +349,30 @@ export async function cFnPointerDispatchEdges( // Every sweep below iterates in `files` order, and node-kind scans return // rows in file-commit order, so access is near-sequential and a small LRU // hits; a miss just re-reads + re-strips. - const rawCache = new LRUCache(128); + // Cache sizing is memory-budget-aware AND all-or-nothing (§7a.3 cFnPtr + // round): the flat 128 caused 4.4 strips/file across the pass's four file + // sweeps — 71.8s of the kernel-scale wall — and a partial LRU is WORSE than + // useless for cyclic sweeps (a first attempt sized ~61k against 63.8k files + // and thrashed to a ~0% cross-sweep hit rate). Hold every stripped file + // (~24KB each measured on the Linux tree) only when 40% of the live memory + // budget covers it; otherwise keep the old within-sweep-locality 128. The + // cache is pass-scoped — freed on return. + // Slack over files.length: non-indexed includes (.def/.inc, generated + // headers) join the working set mid-pass, and even a handful of keys past + // the cap re-triggers cyclic eviction (measured: cap==files.length still + // stripped 2.25×/file). Pass-scoped transient, freed on return. + const fullCacheCap = Math.ceil(files.length * 1.05) + 512; + const cacheCap = memoryBudgetBytes() * 0.5 >= fullCacheCap * 24_576 ? fullCacheCap : 128; + const rawCache = new LRUCache(Math.min(cacheCap, 4096)); const raw = (file: string): string | null => { if (rawCache.has(file)) return rawCache.get(file)!; + const t0 = prof ? Date.now() : 0; const r = ctx.readFile(file); + if (prof) { prof.readMs += Date.now() - t0; prof.readN++; } rawCache.set(file, r); return r; }; - const srcCache = new LRUCache(128); + const srcCache = new LRUCache(cacheCap); const src = (file: string): string | null => { // A cached '' (empty or unreadable file) returns '' where the miss path // returns null for unreadable — every caller falsy-checks, so the two are @@ -352,7 +380,9 @@ export async function cFnPointerDispatchEdges( const hit = srcCache.get(file); if (hit !== undefined) return hit; const r = raw(file); + const t0 = prof ? Date.now() : 0; const s = r == null ? '' : stripCommentsForRegex(r, 'c'); + if (prof) { prof.stripMs += Date.now() - t0; prof.stripN++; } srcCache.set(file, s); return r == null ? null : s; }; @@ -375,6 +405,7 @@ export async function cFnPointerDispatchEdges( // declared as `redisCommandProc *proc;`. Without this, `proc` reads as data. const fnPtrTypedefs = new Set(); const fnTypeTypedefs = new Set(); + let tPass = Date.now(); for (const file of files) { await tick(); const s = src(file); @@ -390,6 +421,7 @@ export async function cFnPointerDispatchEdges( if (fm && !C_TYPE_KEYWORDS.has(fm[1]!)) fnTypeTypedefs.add(fm[1]!); } } + if (prof) { prof.A = Date.now() - tPass; tPass = Date.now(); } // ---- Pass B: struct field layouts ---- // structLayout: struct name → ordered fields, for structs with ≥1 fn-pointer @@ -456,17 +488,21 @@ export async function cFnPointerDispatchEdges( if (fields.some((f) => f.isFnPtr)) structLayout.set(name, fields); }; + let linesFile = ''; + let linesArr: string[] = []; for (const st of (ctx.iterateNodesByKind?.('struct') ?? ctx.getNodesByKind('struct'))) { if ((++scannedFiles & 255) === 0) await onYield(); if (!C_CPP_EXT.test(st.filePath)) continue; const s = src(st.filePath); if (!s) continue; - const body = sliceLines(s, st.startLine, st.endLine); + if (linesFile !== st.filePath) { linesFile = st.filePath; linesArr = s.split('\n'); } + const body = sliceLinesPre(linesArr, st.startLine, st.endLine); const open = body.indexOf('{'); const close = open >= 0 ? matchBrace(body, open) : -1; if (open < 0 || close < 0) continue; registerStructLayout(st.name, parseStructFields(body.slice(open + 1, close))); } + if (prof) { prof.B = Date.now() - tPass; tPass = Date.now(); } // NB: no early return on an empty structLayout here — an inline `struct TAG // { … } var[]` table whose struct never became a node (vim's `cmdname`, broken // up by `#ifdef`) is discovered later during the unit scan. The `reg.size === 0` @@ -813,12 +849,19 @@ export async function cFnPointerDispatchEdges( processUnit({ text, file: target, env: incEnv, objEnv: incObjEnv }); } } + if (prof) { prof.C = Date.now() - tPass; tPass = Date.now(); } // ---- receiver-type resolution within a function's source ---- // `(?:struct )?TYPE [*]recv` declared in the params or body → TYPE (if a known // fn-pointer-bearing struct). + const recvReCache = new Map(); const recvTypeIn = (fnSrc: string, recv: string): string | null => { - const re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${recv}\\b\\s*(?:[,)=;]|\\[)`, 'g'); + let re = recvReCache.get(recv); + if (!re) { + re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${recv}\\b\\s*(?:[,)=;]|\\[)`, 'g'); + recvReCache.set(recv, re); + } + re.lastIndex = 0; let m: RegExpExecArray | null; while ((m = re.exec(fnSrc))) { if (structLayout.has(m[1]!)) return m[1]!; @@ -830,8 +873,14 @@ export async function cFnPointerDispatchEdges( // structs (the base of a chained receiver needn't carry a fn pointer itself). // Falls back to a file-scope table variable (`cmdnames` in `cmdnames[i].fn()`). const escapeRe = (x: string): string => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const varReCache = new Map(); const varTypeIn = (fnSrc: string, v: string): string | null => { - const re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${escapeRe(v)}\\b\\s*(?:[,)=;]|\\[)`, 'g'); + let re = varReCache.get(v); + if (!re) { + re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${escapeRe(v)}\\b\\s*(?:[,)=;]|\\[)`, 'g'); + varReCache.set(v, re); + } + re.lastIndex = 0; let m: RegExpExecArray | null; while ((m = re.exec(fnSrc))) { if (!C_TYPE_KEYWORDS.has(m[1]!)) return m[1]!; @@ -867,14 +916,23 @@ export async function cFnPointerDispatchEdges( await tick(); const s = src(file); if (!s || !s.includes('=')) continue; - for (const fn of ctx.getNodesInFile(file)) { + const tN = prof ? Date.now() : 0; + const fnsD = ctx.getNodesInFile(file); + if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; } + const dLines = s.split('\n'); + for (const fn of fnsD) { if (!FN_KINDS.has(fn.kind)) continue; - const body = sliceLines(s, fn.startLine, fn.endLine); + const body = sliceLinesPre(dLines, fn.startLine, fn.endLine); if (!body.includes('=')) continue; FIELD_ASSIGN_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = FIELD_ASSIGN_RE.exec(body))) { const [, lrecv, lfield, rrecv, rfield] = m; + // Pre-gate on field NAMES: `a->f = b->g` matches every struct-field + // assignment in the tree (millions on the kernel), but only fields + // that are fn-pointer fields of SOME struct can pass fnPtrFieldOf — + // skip the two regex type resolutions for the ~99% that can't. + if (!fieldToStructs.has(lfield!) || !fieldToStructs.has(rfield!)) continue; const lt = recvTypeIn(body, lrecv!); const rt = recvTypeIn(body, rrecv!); if (lt && rt && fnPtrFieldOf(lt, lfield!) && fnPtrFieldOf(rt, rfield!)) { @@ -899,6 +957,7 @@ export async function cFnPointerDispatchEdges( } if (!changed) break; } + if (prof) { prof.D = Date.now() - tPass; tPass = Date.now(); } if (reg.size === 0 && arrayReg.size === 0) return []; // ---- Pass E: dispatch sites → edges ---- @@ -918,12 +977,26 @@ export async function cFnPointerDispatchEdges( await tick(); const s = src(file); if (!s) continue; - for (const fn of ctx.getNodesInFile(file)) { + const tN = prof ? Date.now() : 0; + const fnsE = ctx.getNodesInFile(file); + if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; } + const eLines = s.split('\n'); + for (const fn of fnsE) { if (!FN_KINDS.has(fn.kind)) continue; - const body = sliceLines(s, fn.startLine, fn.endLine); + const body = sliceLinesPre(eLines, fn.startLine, fn.endLine); DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; let added = 0; + // Incremental line counting: matches arrive in ascending index order, so + // count newlines since the previous match instead of re-splitting the + // whole body prefix per match (O(body) each — real time on god-files). + let lcIdx = 0; + let lcLine = fn.startLine; + const lineAt = (idx: number): number => { + for (let i = lcIdx; i < idx; i++) if (body.charCodeAt(i) === 10) lcLine++; + lcIdx = idx; + return lcLine; + }; while ((m = DISPATCH_RE.exec(body)) && added < FANOUT_CAP) { const baseChain = m[1]!.replace(/\s*(?:->|\.)\s*$/, '').trim(); // receiver, minus the trailing arrow const field = m[2]!; @@ -942,7 +1015,7 @@ export async function cFnPointerDispatchEdges( if (!struct) continue; const targets = reg.get(`${struct}.${field}`); if (!targets) continue; - const line = fn.startLine + body.slice(0, m.index).split('\n').length - 1; + const line = lineAt(m.index); for (const tid of targets) { if (tid === fn.id) continue; const key = `${fn.id}>${tid}`; @@ -966,6 +1039,9 @@ export async function cFnPointerDispatchEdges( // ---- bare array-of-fn-pointers dispatch (`tbl[i](…)`) ---- if (arrayReg.size && added < FANOUT_CAP) { + // Fresh scan from the body's start — rewind the line-count cursor too. + lcIdx = 0; + lcLine = fn.startLine; ARRAY_DISPATCH_RE.lastIndex = 0; while ((m = ARRAY_DISPATCH_RE.exec(body)) && added < FANOUT_CAP) { const entries = arrayReg.get(m[1]!); @@ -976,7 +1052,7 @@ export async function cFnPointerDispatchEdges( ? entries[0]!.ids : (entries.find((e) => e.file === fn.filePath)?.ids ?? null); if (!ids) continue; - const line = fn.startLine + body.slice(0, m.index).split('\n').length - 1; + const line = lineAt(m.index); for (const tid of ids) { if (tid === fn.id) continue; const key = `${fn.id}>${tid}`; @@ -1000,5 +1076,11 @@ export async function cFnPointerDispatchEdges( } } } + if (prof) { + prof.E = Date.now() - tPass; + console.error( + `[synth-timing] cFnPtr sub: A=${prof.A}ms B=${prof.B}ms C=${prof.C}ms D=${prof.D}ms E=${prof.E}ms | read n=${prof.readN} ${prof.readMs}ms strip n=${prof.stripN} ${prof.stripMs}ms nodesInFile n=${prof.nodesN} ${prof.nodesMs}ms` + ); + } return edges; }