mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* perf(cpp): index qualified namespace members once per pipeline run (#2788)
`resolveCppQualifiedNamespaceMember` walked every parsed file — rebuilding a
per-file `scopesById` map each time — once per qualified `ns::member()` call
site, so the scope-resolution emit phase cost O(callsites x scopes). On a
1,473-file C++ repo that was 25.3 min of a 33-min analyze, with 75% of total
self-time in this one function. Its inner `findMemberInNamespaceTransitive`
compounded it: each recursion step filtered `scopesById.values()` by parent,
O(scopes^2) per file on its own.
This is the same bug #1990 fixed in the sibling ADL path (`pickCppAdlCandidates`
-> `AdlCandidateIndex`), so it gets the same fix: a `QualifiedNsMemberIndex`
(receiver simple name -> member simple name -> callable defs) built lazily once
per `parsedFiles` identity and reset by `clearCppInlineNamespaces`, which runs
from `cppScopeResolver.loadResolutionConfig` at the start of every pass. Per
call site the work drops to two Map lookups.
Ordering is preserved exactly — file-major, `parsed.scopes` declaration order,
a namespace's own `ownedDefs` before its inline-namespace children, depth-first
— because the caller takes `allHits[0]` for the single-hit case and
`narrowOverloadCandidates` is first-wins. Non-inline nested namespaces are
still not descended into, and same-name hits across inline children still
report `'ambiguous'` (#1564).
Measured with `PROF_SCOPE_RESOLUTION=1 analyze --force --index-only` on a
synthetic corpus (`namespace ns_i { inline namespace v1 { ... } }` plus 20
`ns_j::fn()` call sites per file):
| files | emit before | emit after |
|-------|-------------|------------|
| 100 | 153ms | 16ms |
| 200 | 704ms | 24ms |
| 400 | 3,293ms | 42ms |
| 800 | 16,898ms | 78ms |
Before, doubling the file count quadrupled emit; now it doubles. At 800 files
total scope resolution goes 17.2s -> 394ms.
Output is unchanged, verified rather than assumed: a full graph dump (sorted
nodes + relationships) from a baseline build at the parent commit and from this
one are byte-identical on all 134 `cpp-*` fixtures merged into a single repo
(1573 nodes / 1997 relationships) and on the 400-file synthetic corpus.
`test/integration/resolvers/cpp.test.ts` passes 334/334.
#1990 shipped its ADL fix without a scaling gate, which is how the bug class
came straight back here, so this adds one: `bench/cpp-qualified-ns` measures
`(t_large/t_small)/(1600/400)` — 0.93-1.21 indexed versus 3.45 for the old
per-call-site scan — alongside a fingerprint over every
`receiver::member -> outcome` the corpus resolves, and CI runs it with
`--check`. `test/unit/cpp-qualified-ns-index.test.ts` covers the cache
invalidation the index introduces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cpp): address tri-review findings on the qualified-namespace index (#2788)
Multi-engine review of this PR (Claude swarm + ce-code-review, Codex
gpt-5.6-sol swarm + ce + adversarial) returned two P1s and five smaller
findings. All are fixed here.
P1 — the index defeated the pipeline's post-language memory release.
`scope-resolution/pipeline/phase.ts` evicts each language's files and then
calls `forceGc()`, on a stated premise that "This language's ParsedFiles are
now unreachable", sizing C/C++ at ~17-20GB on the Linux kernel. The
module-level `let qualifiedNsIndexSource` falsified that: it pinned the whole
`parsedFiles` array, and the index held defs reaching into those files'
scopes, until the *next* C++ pass cleared it — which in a single analyze never
comes. C++ is 7th of 16 in SCOPE_RESOLVERS, so the set survived nine later
language passes plus emit. Replaced with a
`WeakMap<readonly ParsedFile[], QualifiedNsMemberIndex>`, the pattern already
used by `moduleScopeIndexByPass` in `cpp/file-local-linkage.ts`.
`clearCppInlineNamespaces` still swaps in a fresh WeakMap, because the index
has a second input (`inlineNamespaceScopeIds`) the key cannot observe.
Measured with `--expose-gc`: 61.2MB retained after the caller drops the array
before, 0.1MB after.
The ADL twin (`adl.ts`) has the same pattern, so the hazard predates this PR —
but `pickCppAdlCandidates` returns early before `ensureAdlIndex` on
`noAdlSites`/empty `argInfoBySite`, so it rarely arms, whereas a qualified
`ns::member()` index arms on almost every C++ workspace. Moving the ADL twin
to a WeakMap is left as a follow-up.
P1 — the new bench could not see the regression class it exists to gate.
`callSites()` drew every receiver from `ns_${...}`, so the receiver lookup
never missed; production is the opposite, since Case 1.5 in
`receiver-bound-calls.ts` is reached by every plain-identifier receiver call
and misses on most. A rescan reintroduced only on the receiver-bucket-absent
path scored 1.279 and PASSED the old bench. The corpus now mirrors production
(~1 in 5 receivers name a declared namespace) and adds a namespace reopened
across files, a same-name inline nest, a member declared at both namespace and
inline-child level, and call sites carrying a real `Callsite` so
`narrowOverloadCandidates`/`cppConversionRank`/
`isOverloadAmbiguousAfterNormalization` are inside the fingerprinted surface at
all. That same rescan now measures 4.538 and FAILS; defeating the dedup now
fails the fingerprint arm where it previously passed byte-identical. The
fingerprint moved once, deliberately, for the corpus expansion — recorded in
`_rebaseline_2788_review`, explicitly not precedent.
Also fixed:
- Unbounded recursion aborted analyze. `collectNamespaceMembers` recursed per
inline child with no bound and threw an uncontained `RangeError` at inline
depth 8000 (`phase.ts`'s try has a `finally`, no `catch`), and a receiver
*miss* paid full recursion where the deleted walker skipped on a name
mismatch. An explicit work-stack alone would only have converted that into
an OOM at depth 6000, because the eager table was quadratic in memory too:
for a depth-D chain it legitimately holds D(D+1)/2 entries, since `v2::foo()`
is a valid receiver at every level. Replaced with a lazily-queried node graph
(per-scope own-member buckets plus direct child links, resolved on demand and
memoized per receiver+member). Build is now linear; depth 100000 costs 133ms
where 8000 previously threw.
- "#1990 shipped without a scaling gate" was false. #1990 did ship
`test/integration/cpp-adl-benchmark.test.ts` (f1b843838). Corrected in the
bench header and the CI step comment. The accurate point is narrower and
stronger: that bench asserts `callsResolved === 0`, so it never drives the
qualified-receiver path, and it is `skipIf(!GITNEXUS_BENCH)` while the only
step setting that variable lists neither C++ bench — so it has never run in
CI. Wiring it in is a follow-up.
- "Ordering is load-bearing" was not a live property: `allHits[0]` is only
reached at length 1, and both tail branches return `'ambiguous'`. Order is
still preserved for byte-identity with the pre-#2788 walker; the comment now
says that instead, and the test named for ordering is renamed to the
parent/inline-child visibility it actually asserts.
- "Same contract as `ensureAdlIndex`" overstated parity — the sibling ships a
`validateAdlSeqCoverage` guard because it reads
`seqByNodeId.get(...) ?? 0`, which can silently collapse candidates. This
index has no analogous defaulting read, so no guard is added; the comment now
says why.
- Two coverage gaps closed, both mutation-verified: cross-file merge of one
namespace reopened in two files (no existing test covered it — confirmed by
making each file clobber the previous and watching only the new test fail),
and the same-name inline nest whose dedup, when defeated, flips a resolved
def to `'ambiguous'`.
- `resolveCppQualifiedNamespaceMember`'s JSDoc now names both production call
sites, including the callsite-less `resolveAdlCandidates` path.
- Memoized candidate buckets are frozen, so a future in-place sort in
`overload-narrowing.ts` throws instead of silently corrupting later
resolutions now that the array is shared across call sites.
- `_scaling_note`'s "measured 0.93-1.21" band did not reproduce; it is now the
honestly observed 1.28-1.45, with the small arm widened to ~14ms (halves the
spread) and a triage line saying a scaling failure is a timing signal to
re-run, unlike the deterministic fingerprint arm.
Verification: 840,000 differential probes against the pre-#2788 walker
extracted from base, 0 mismatches, plus 48,000 candidate-order comparisons,
0 mismatches. cpp resolver integration suite 334/334. Unit suite 10/10.
tsc, eslint, prettier clean. Bench --check PASS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(bench): escape the NUL separator so measure.mjs stays a text file
The fingerprint key separator was written as a literal NUL byte instead of the
`\u0000` escape. Git classifies any file containing a NUL as binary, so the
whole bench showed as `Bin` with no diff on GitHub and could not be reviewed —
the same defect this branch already fixed once before the tri-review.
Escaping it is byte-for-byte equivalent at runtime (both produce U+0000), so
the committed fingerprint is unchanged and `--check` still passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(cpp): quality cleanups from a four-angle review of the #2788 series
Reuse, simplification, efficiency and altitude passes over the diff. No
resolution behaviour changes: the bench fingerprint is unchanged and the C++
resolver integration suite still passes in full.
Efficiency
- The `Object.freeze` added in the review-fix commit to close an aliasing
residual costs 4.6x on the narrowing path — V8 moves frozen arrays to
PACKED_FROZEN_ELEMENTS, off the fast path for the `.filter`/`.map`/`.some`
runs `narrowOverloadCandidates` does at every multi-candidate call site.
The hazard it guards is already a compile error (both the memo and the
parameter are `readonly SymbolDefinition[]`), so it is now a dev-only
tripwire. Gated on `isSemanticModelValidatorEnabled()` — the repo's opt-IN
form used by `phase.ts` and `validate-bindings-immutability.ts` — not
`adl.ts`'s opt-out `NODE_ENV !== 'production'`, which would keep paying the
cost in CLI runs where `NODE_ENV` is unset. Large bench arm 100.3 -> 70.6ms.
- The index build scanned every scope in every file twice; pass 1 now collects
`[scope, node]` pairs for pass 2 to iterate. 800k scopes 14.40 -> 8.21ms.
- `simpleNameOf` uses `lastIndexOf('.')` + `slice` instead of
`split('.').pop()` (83 -> 24 ns/call), semantics verified byte-equivalent
over 12 edge cases including `undefined`, `''`, `'a.'`, `'.b'` and `'a..b'`.
- The per-call `hookCtx` literal is hoisted to a module const.
- The bench's fingerprint pass resolved 960k call sites to produce 5,800
distinct outcomes; it now dedups on the key it already builds. Bench wall
time 2.4 -> 1.9s, fingerprint byte-identical.
Reuse
- `bucketOwnMembers` used an inlined `Function | Method | Constructor` compare;
it now calls the canonical `isOverloadableCallable`. `graph-bridge/ids.ts`
already carries a note that inlining this list recreated twin-list drift once.
Altitude
- `adl.ts` had the same retention defect this series just fixed next door: a
module-level `let adlIndex` + `let adlIndexSource` strongly pinning the whole
`parsedFiles` array until the next C++ pass, which in a single analyze never
comes. Converted to the same `WeakMap` shape. Measured with `--expose-gc`:
89.11MB retained after the caller drops the array before, 0.17MB after. Six
file-local helpers now take the index as a parameter; no exported signature
changed.
- The index's freshness depended on `clearCppInlineNamespaces()` being called
from another file, guarded only by a warning paragraph. An epoch bumped in
both `populateCppInlineNamespaceScopes` and the clear is now stored with the
memo, so a missed clear degrades to a rebuild instead of a stale answer —
confirmed by driving inline state mid-pass without the clear. Roughly line
neutral, since it replaces most of the paragraph.
- `test/integration/cpp-adl-benchmark.test.ts` is wired into the
`GITNEXUS_BENCH` step. It is `skipIf`-gated and was absent from that step's
explicit file list, so #1990's ADL emit-scaling guard had never executed in
CI. It passes; ~50s added to a 25-minute job. `cpp-pipeline-benchmark.test.ts`
is deliberately NOT wired: it costs 115s for guards covering generic
per-language pipeline scaling that nothing here touches.
Simplification
- Deleted a comment referencing a `inlineChildrenByParent` map that only ever
existed inside this branch's own first commit, so "the legacy map" pointed a
reader at code that never shipped.
- Dropped three unreachable `undefined` guards (`strict: false`, no
`noUncheckedIndexedAccess`), keeping the load-bearing `visited` check.
- Compressed the `rootsByReceiver` doc from 17 lines to 7 — it was the longest
comment in the file and guarded the least consequential property — the
`validateAdlSeqCoverage` paragraph from 7 lines to 3, and turned three
restatements of the uncaught-throw and dedup arguments into pointers.
- Test fixtures: dropped the dead `'Module'` union arm, added a one-line `ns()`
builder for the nine hand-written scope literals, and moved the file to
`test/unit/scope-resolution/cpp/` where every other C++ scope-resolution unit
test lives. 403 -> 337 lines, same 10 tests, and the cross-file mutation check
still fails exactly one test.
Not done, and why: merging this index into `AdlCandidateIndex` (they key on
different names with different inline-transparency depth — a refactor with
correctness risk, not a cleanup); `ScopeTree.getChildren` (trades in-memory
`parsed.scopes` for store hits on a hot path); a shared `cpp/` util for the five
pre-existing `simpleName` copies; sharing fixtures across the bench/test
boundary (no precedent in this repo); and an exact-count arm for the bench,
which is a gate redesign worth its own change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
496 lines
21 KiB
JavaScript
496 lines
21 KiB
JavaScript
/**
|
||
* Build-free scaling + identity bench for `resolveCppQualifiedNamespaceMember`,
|
||
* the C++ qualified `ns::member()` receiver resolver (issue #2788).
|
||
*
|
||
* Before #2788 this function re-scanned EVERY parsed file — rebuilding a
|
||
* per-file `scopesById` map each time — once per qualified call site, so the
|
||
* scope-resolution emit phase cost O(callsites × scopes). On a 1,473-file C++
|
||
* repo that was 25.3 min of a 33-min analyze, with 75% of total self-time in
|
||
* this one function. It is the same bug #1990 had already fixed in the sibling
|
||
* ADL path (`pickCppAdlCandidates` → `AdlCandidateIndex`). #1990 DID ship a
|
||
* scaling gate for that path — `test/integration/cpp-adl-benchmark.test.ts`,
|
||
* which asserts `emitRatio < fileRatio^1.5` — but it could never have caught
|
||
* this one, for two independent reasons: its corpus asserts
|
||
* `callsResolved === 0`, i.e. it generates only UNRESOLVED ADL sites, so it
|
||
* never drives the qualified-receiver path at all; and it is
|
||
* `describe.skipIf(!BENCH_ENABLED)` while the single CI step that sets
|
||
* `GITNEXUS_BENCH=1` names its test files explicitly and, until this PR wired
|
||
* it in, listed neither C++ bench — so it had never executed in CI. Even now
|
||
* that it runs, the `callsResolved === 0` half stands: it still cannot reach
|
||
* this path. Hence this bench, in an always-on step: a per-call-site workspace
|
||
* scan must not be reintroduced silently.
|
||
*
|
||
* For a synthetic corpus at two scales it reports:
|
||
* - `elapsed_ms` per scale (fastest of REPS, see `fastest`) for resolving
|
||
* every call site once, INCLUDING the one-time index build — that build is
|
||
* the work the per-site scan was traded for, so hiding it would let an
|
||
* index that is itself quadratic pass;
|
||
* - a scaling ratio `(t_large/t_small)/(LARGE/SMALL)`: ~1.0 linear,
|
||
* ~4.x quadratic at this scale gap;
|
||
* - a sha256 fingerprint over every `receiver::member(callsite) → outcome`
|
||
* the corpus resolves, as the correctness gate. A fingerprint change means
|
||
* qualified lookup started resolving different symbols — a behaviour
|
||
* change, never a performance one.
|
||
*
|
||
* A gate only covers the code path its corpus drives. Two properties below are
|
||
* therefore load-bearing and must not be "simplified" away:
|
||
*
|
||
* 1. **The receiver mix is production-shaped: ~1 in 5 receivers names a
|
||
* namespace, the other ~4 name nothing.** Case 1.5 in
|
||
* `scope-resolution/passes/receiver-bound-calls.ts` is reached by EVERY
|
||
* plain-identifier receiver call — `obj.size()`, `Widget::make()`,
|
||
* `buf.data()` — so in real source the overwhelming majority of calls into
|
||
* this resolver are receiver MISSES, not member misses inside a resolved
|
||
* receiver. An earlier revision of this bench drew every receiver from
|
||
* `ns_${…}`, i.e. always a namespace the corpus declared, so the receiver
|
||
* lookup never missed. Measured consequence: a "defensive full rescan when
|
||
* the receiver bucket is absent" regression — the single most plausible
|
||
* way this bug returns — scored 1.332 against the 1.8 budget and printed
|
||
* PASS, while costing 507× on a production-shaped corpus.
|
||
* 2. **The corpus contains every structural shape whose loss the fingerprint
|
||
* is supposed to catch** (see `buildCorpus`), including a batch of sites
|
||
* that pass a real `Callsite`. Without those, `narrowOverloadCandidates` /
|
||
* `cppConversionRank` / `isOverloadAmbiguousAfterNormalization` are not in
|
||
* the fingerprinted surface at all, and behaviour-only regressions there
|
||
* re-fingerprint byte-identically.
|
||
*
|
||
* Build-free: imports the `.ts` hotpath through tsx
|
||
* (`node --import tsx bench/cpp-qualified-ns/measure.mjs`).
|
||
*
|
||
* Without args: prints the JSON report.
|
||
* With `--check`: asserts the fingerprint == the committed baseline AND the
|
||
* scaling ratio is within budget; exits non-zero on drift/regression.
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import crypto from 'node:crypto';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import {
|
||
clearCppInlineNamespaces,
|
||
markCppInlineNamespaceRange,
|
||
populateCppInlineNamespaceScopes,
|
||
resolveCppQualifiedNamespaceMember,
|
||
} from '../../src/core/ingestion/languages/cpp/inline-namespaces.ts';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||
|
||
const SMALL = 400;
|
||
const LARGE = 1600;
|
||
/** Sized so the SMALL arm measures in the tens of ms rather than ~1.7 ms.
|
||
* Sub-2 ms samples are dominated by timer granularity and scheduler noise on
|
||
* a shared runner, which is what made the ratio drift out of its documented
|
||
* band under load; see `_scaling_note` in baselines.json. */
|
||
const CALLS_PER_FILE = 480;
|
||
const REPS = 7;
|
||
const WARMUP = 3;
|
||
|
||
/** 1 in N receivers names a declared namespace; the rest name nothing. Header
|
||
* property 1 is why this ratio, and not an always-hits corpus. */
|
||
const NS_RECEIVER_IN = 5;
|
||
|
||
const NO_SCOPES = {};
|
||
|
||
/**
|
||
* Deterministic 32-bit avalanche (murmur3 finalizer). Stands in for
|
||
* `Math.random()` — the corpus, the receiver mix and therefore the fingerprint
|
||
* must be byte-reproducible across machines and Node versions.
|
||
*/
|
||
function mix(n) {
|
||
let x = n >>> 0;
|
||
x = Math.imul(x ^ (x >>> 16), 0x85ebca6b) >>> 0;
|
||
x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35) >>> 0;
|
||
return (x ^ (x >>> 16)) >>> 0;
|
||
}
|
||
|
||
/**
|
||
* Deterministic synthetic corpus — no randomness, so the fingerprint is stable.
|
||
*
|
||
* Per file `f`, three top-level namespaces. Every shape here exists because
|
||
* some behaviour of `resolveCppQualifiedNamespaceMember` is unobservable
|
||
* without it; dropping one silently un-gates that behaviour.
|
||
*
|
||
* namespace ns_f { // ABI-versioning idiom (std::__1)
|
||
* void own0(); void own1(); // direct members → hit
|
||
* void both(int); // ALSO declared in v1 below
|
||
* void over(int); // overload set spanning levels
|
||
* inline namespace v1 { // transitively visible
|
||
* void inl0(); // → hit
|
||
* void dup();
|
||
* void both(double); // the inline-child twin of `both`
|
||
* void over(int, int); void over(double);
|
||
* void same(int);
|
||
* }
|
||
* inline namespace v2 {
|
||
* void dup(); // two inline children → ambiguous
|
||
* void same(int); // identical signature → ambiguous
|
||
* }
|
||
* namespace detail { void hidden0(); } // NOT inline → invisible → miss
|
||
* }
|
||
*
|
||
* namespace twin_f { inline namespace twin_f { void twinned(); } }
|
||
* namespace shared_{f>>1} { void part{f&1}(); }
|
||
*
|
||
* What each shape gates:
|
||
* - `own0` / `inl0` / `hidden0` / `nosuch`: the three outcome classes (hit
|
||
* from the namespace's own defs, hit through an inline child, miss), each
|
||
* a different exit from the resolver.
|
||
* - `dup` across v1 and v2: `'ambiguous'` (#1564).
|
||
* - `twin_f`: the same-name inline nest — the only shape that observes
|
||
* `gatherQualifiedNsMember`'s `visited` dedup, without which the one
|
||
* `twinned` is collected twice and a resolved def flips to `'ambiguous'`
|
||
* (that function's comment explains why both scopes land on one receiver).
|
||
* - `shared_g` declared by files 2g and 2g+1: C++ namespaces are open, so one
|
||
* receiver's members are spread over however many files reopen it. The
|
||
* legacy per-call-site scan got this for free; the index has to merge
|
||
* across the whole `parsedFiles` array. `part0` and `part1` are declared in
|
||
* DIFFERENT files and both must resolve.
|
||
* - `both` at the namespace level and in the inline child: pins BOTH
|
||
* collection sources by nodeId, via the two `both` call sites that select
|
||
* between them on argument type. Drop own-def collection and the `int`
|
||
* probe moves; drop inline-child descent and the `double` probe moves. A
|
||
* pure REORDER of the two stays invisible, and correctly so: the return
|
||
* contract is order-blind — see `QualifiedNsMemberIndex.rootsByReceiver`.
|
||
* - `over` / `same` with a real `Callsite`: see `NS_PROBES`.
|
||
*/
|
||
function buildCorpus(fileCount) {
|
||
const parsedFiles = [];
|
||
for (let f = 0; f < fileCount; f++) {
|
||
const filePath = `src/file${f}.cpp`;
|
||
const scopes = [];
|
||
const inlineRanges = [];
|
||
let line = 1;
|
||
/** Push one Namespace scope with a range unique within this file, so
|
||
* `populateCppInlineNamespaceScopes` marks exactly the intended scopes. */
|
||
const scope = (id, parent, ownedDefs, isInline = false) => {
|
||
const range = { startLine: line, startCol: 0, endLine: line + 1, endCol: 0 };
|
||
line += 2;
|
||
scopes.push({ id, kind: 'Namespace', parent, ownedDefs, range });
|
||
if (isInline) inlineRanges.push(range);
|
||
return id;
|
||
};
|
||
const ns = (qualifiedName) => ({
|
||
nodeId: `def:${filePath}#${qualifiedName}`,
|
||
type: 'Namespace',
|
||
qualifiedName,
|
||
});
|
||
/** A callable def. `parameterTypes` are what makes overloads distinguishable
|
||
* both to `narrowOverloadCandidates` and — via the nodeId, exactly as the
|
||
* real C++ node keys do it — to the fingerprint. */
|
||
const fn = (qualifiedName, parameterTypes) =>
|
||
parameterTypes === undefined
|
||
? { nodeId: `def:${filePath}#${qualifiedName}`, type: 'Function', qualifiedName }
|
||
: {
|
||
nodeId: `def:${filePath}#${qualifiedName}(${parameterTypes.join(',')})`,
|
||
type: 'Function',
|
||
qualifiedName,
|
||
parameterTypes,
|
||
parameterCount: parameterTypes.length,
|
||
requiredParameterCount: parameterTypes.length,
|
||
};
|
||
|
||
const nsId = scope(`sc:${f}:ns`, null, [
|
||
ns(`ns_${f}`),
|
||
fn(`ns_${f}.own0`),
|
||
fn(`ns_${f}.own1`),
|
||
fn(`ns_${f}.both`, ['int']),
|
||
fn(`ns_${f}.over`, ['int']),
|
||
]);
|
||
scope(
|
||
`sc:${f}:v1`,
|
||
nsId,
|
||
[
|
||
ns(`ns_${f}.v1`),
|
||
fn(`ns_${f}.v1.inl0`),
|
||
fn(`ns_${f}.v1.dup`),
|
||
fn(`ns_${f}.v1.both`, ['double']),
|
||
fn(`ns_${f}.v1.over`, ['int', 'int']),
|
||
fn(`ns_${f}.v1.over`, ['double']),
|
||
fn(`ns_${f}.v1.same`, ['int']),
|
||
],
|
||
true,
|
||
);
|
||
scope(
|
||
`sc:${f}:v2`,
|
||
nsId,
|
||
[ns(`ns_${f}.v2`), fn(`ns_${f}.v2.dup`), fn(`ns_${f}.v2.same`, ['int'])],
|
||
true,
|
||
);
|
||
scope(`sc:${f}:detail`, nsId, [ns(`ns_${f}.detail`), fn(`ns_${f}.detail.hidden0`)]);
|
||
|
||
const twinId = scope(`sc:${f}:twin`, null, [ns(`twin_${f}`)]);
|
||
scope(
|
||
`sc:${f}:twin@inner`,
|
||
twinId,
|
||
[ns(`twin_${f}.twin_${f}`), fn(`twin_${f}.twin_${f}.twinned`)],
|
||
true,
|
||
);
|
||
|
||
const group = f >> 1;
|
||
scope(`sc:${f}:shared`, null, [ns(`shared_${group}`), fn(`shared_${group}.part${f & 1}`)]);
|
||
|
||
parsedFiles.push({ filePath, scopes, inlineRanges });
|
||
}
|
||
return parsedFiles;
|
||
}
|
||
|
||
/** Capture-time inline marking + `populateOwners`-time scope-id resolution, in
|
||
* the same order the pipeline runs them. Must re-run after every
|
||
* `clearCppInlineNamespaces`, which drops both the marks and the index. */
|
||
function populateInlineState(parsedFiles) {
|
||
clearCppInlineNamespaces();
|
||
for (const parsed of parsedFiles) {
|
||
for (const range of parsed.inlineRanges) markCppInlineNamespaceRange(parsed.filePath, range);
|
||
populateCppInlineNamespaceScopes(parsed);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Namespace-receiver probes: `[family, member, callsite]`. Drawn for the ~1 in
|
||
* `NS_RECEIVER_IN` call sites whose receiver actually names a namespace.
|
||
*
|
||
* The tail entries pass a real `Callsite`, which is the only way any of
|
||
* `narrowOverloadCandidates`, `cppConversionRank` or
|
||
* `isOverloadAmbiguousAfterNormalization` is reached — the resolver threads
|
||
* `callsite?.arity` / `callsite?.argumentTypes` into narrowing, and with no
|
||
* callsite those filters are pass-throughs. Each one is chosen to land on a
|
||
* DIFFERENT exit, so the fingerprint pins the whole narrowing ladder:
|
||
* - `over(int)` → exact-type filter, unique survivor (ns level)
|
||
* - `over(int,int)` → arity filter, unique survivor (inline child)
|
||
* - `over(double)` → exact-type filter, unique survivor (inline child)
|
||
* - `over(char)` → no exact match, `cppConversionRank` dominance
|
||
* picks `over(int)` (promotion 1) over
|
||
* `over(double)` (standard conversion 2)
|
||
* - `over(braced-init)` → conversion ranking rejects every candidate and
|
||
* `CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES` turns
|
||
* that into an empty set → `undefined`
|
||
* - `over` with arity 9 → arity filter empties an all-known-bounds set,
|
||
* the authoritative-empty branch → `undefined`
|
||
* - `same(int)` → two identical signatures survive narrowing →
|
||
* `isOverloadAmbiguousAfterNormalization` → `'ambiguous'`
|
||
* - `both(int)`/`both(double)` → select the namespace-level def and the
|
||
* inline-child def respectively, pinning both
|
||
* collection sources by nodeId
|
||
*/
|
||
const NS_PROBES = [
|
||
['ns', 'own0', undefined],
|
||
['ns', 'own1', undefined],
|
||
['ns', 'inl0', undefined],
|
||
['ns', 'dup', undefined],
|
||
['ns', 'hidden0', undefined],
|
||
['ns', 'nosuch', undefined],
|
||
['ns', 'both', undefined],
|
||
['twin', 'twinned', undefined],
|
||
['twin', 'nosuch', undefined],
|
||
['shared', 'part0', undefined],
|
||
['shared', 'part1', undefined],
|
||
['ns', 'over', { arity: 1, argumentTypes: ['int'] }],
|
||
['ns', 'over', { arity: 2, argumentTypes: ['int', 'int'] }],
|
||
['ns', 'over', { arity: 1, argumentTypes: ['double'] }],
|
||
['ns', 'over', { arity: 1, argumentTypes: ['char'] }],
|
||
['ns', 'over', { arity: 1, argumentTypes: ['braced-init:int:3'] }],
|
||
['ns', 'over', { arity: 9, argumentTypes: [] }],
|
||
['ns', 'same', { arity: 1, argumentTypes: ['int'] }],
|
||
['ns', 'both', { arity: 1, argumentTypes: ['int'] }],
|
||
['ns', 'both', { arity: 1, argumentTypes: ['double'] }],
|
||
];
|
||
|
||
/** Members asked of the non-namespace receivers. Real-source member names, so
|
||
* the miss is a receiver miss and not a member miss. */
|
||
const MISS_MEMBERS = ['size', 'begin', 'data', 'reset', 'own0', 'dup'];
|
||
|
||
/** A plain identifier naming NO namespace in the corpus — a local, a type, a
|
||
* buffer. The ~4-in-5 majority of header property 1. */
|
||
function missReceiver(key) {
|
||
const shape = key % 3;
|
||
if (shape === 0) return `obj${key % 97}`;
|
||
if (shape === 1) return `Widget${key % 31}`;
|
||
return `buf${key % 197}`;
|
||
}
|
||
|
||
/**
|
||
* The call sites: `[receiver, member, callsite]`, deterministic, with the
|
||
* production receiver mix (~1 in `NS_RECEIVER_IN` names a namespace).
|
||
*
|
||
* Two independently mixed keys per site so the receiver class (`a`) and the
|
||
* probe choice (`b`) do not correlate — deriving both from one linear key made
|
||
* `key % NS_RECEIVER_IN === 0` imply `key % NS_PROBES.length ∈ {0, 5}`, which
|
||
* silently reduced the probe set to two entries.
|
||
*/
|
||
function callSites(fileCount) {
|
||
const sharedGroups = Math.ceil(fileCount / 2);
|
||
const sites = [];
|
||
let nsReceiverSites = 0;
|
||
/** The declared-namespace receiver a probe family asks for. */
|
||
const nsReceiver = (family, key) => {
|
||
if (family === 'twin') return `twin_${key % fileCount}`;
|
||
if (family === 'shared') return `shared_${key % sharedGroups}`;
|
||
return `ns_${key % fileCount}`;
|
||
};
|
||
const pushNs = (probe, key) => {
|
||
sites.push([nsReceiver(probe[0], key), probe[1], probe[2]]);
|
||
nsReceiverSites++;
|
||
};
|
||
// Coverage prelude: every probe at least once at BOTH scales, so the
|
||
// fingerprinted outcome set never depends on how the mixer happens to spread.
|
||
for (let p = 0; p < NS_PROBES.length; p++) pushNs(NS_PROBES[p], p);
|
||
for (let f = 0; f < fileCount; f++) {
|
||
for (let c = 0; c < CALLS_PER_FILE; c++) {
|
||
const a = mix(f * 65599 + c);
|
||
const b = mix(a ^ 0x9e3779b9);
|
||
if (a % NS_RECEIVER_IN === 0) pushNs(NS_PROBES[b % NS_PROBES.length], b);
|
||
else sites.push([missReceiver(b), MISS_MEMBERS[a % MISS_MEMBERS.length], undefined]);
|
||
}
|
||
}
|
||
return { sites, nsReceiverSites };
|
||
}
|
||
|
||
/** The timed loop: resolution only. The outcome strings the fingerprint needs
|
||
* are built in a separate untimed pass (`outcomesOf`), so their allocation
|
||
* cost — which grows with the corpus and would inflate the scaling ratio on
|
||
* its own — never lands in the measurement. `sink` keeps the calls live. */
|
||
function resolveAll(parsedFiles, sites) {
|
||
let sink = 0;
|
||
for (const [receiver, member, callsite] of sites) {
|
||
const hit = resolveCppQualifiedNamespaceMember(
|
||
receiver,
|
||
member,
|
||
parsedFiles,
|
||
NO_SCOPES,
|
||
callsite,
|
||
);
|
||
if (hit !== undefined) sink++;
|
||
}
|
||
return sink;
|
||
}
|
||
|
||
/** Fingerprint key for one site. The callsite is part of the key: `ns::over`
|
||
* resolves to a different def per arity/argument-type, and collapsing those
|
||
* onto one key would drop the whole narrowing ladder from the gate. */
|
||
function siteKey(receiver, member, callsite) {
|
||
const args =
|
||
callsite === undefined ? '' : `${callsite.arity}|${callsite.argumentTypes.join(',')}`;
|
||
return `${receiver}::${member}(${args})`;
|
||
}
|
||
|
||
/** Untimed identity pass, one resolve per DISTINCT `siteKey`. On a fixed corpus
|
||
* the resolver is a pure function of `(receiver, member, callsite)`, so a
|
||
* repeated site can only re-derive what the first occurrence already put in
|
||
* the Set — the same argument that makes collecting into a Set correct makes
|
||
* skipping the repeat correct. That is nearly the whole pass: the 192k/768k
|
||
* sites carry only 2,330/3,470 distinct outcomes. */
|
||
function outcomesOf(parsedFiles, sites) {
|
||
const outcomes = new Set();
|
||
const seen = new Set();
|
||
for (const [receiver, member, callsite] of sites) {
|
||
const key = siteKey(receiver, member, callsite);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const hit = resolveCppQualifiedNamespaceMember(
|
||
receiver,
|
||
member,
|
||
parsedFiles,
|
||
NO_SCOPES,
|
||
callsite,
|
||
);
|
||
outcomes.add(
|
||
`${key}\u0000${hit === undefined ? '<none>' : hit === 'ambiguous' ? '<ambiguous>' : hit.nodeId}`,
|
||
);
|
||
}
|
||
return outcomes;
|
||
}
|
||
|
||
/**
|
||
* MIN, not median — same rationale as bench/callable-value-flow: both scales
|
||
* are timed in one process and every error source (scheduler preemption, GC, a
|
||
* noisy neighbour on a shared CI runner) is additive, so the fastest observed
|
||
* run is the closest estimate of the uncontended cost and keeps the derived
|
||
* ratio comparable across machines.
|
||
*/
|
||
function fastest(values) {
|
||
return Math.min(...values);
|
||
}
|
||
|
||
/** Time one full pass: index build (lazy, on the first call) + every call
|
||
* site. The corpus state is reset OUTSIDE the timer so the reset's own
|
||
* O(files) cost never lands in the measurement. */
|
||
function timeResolution(parsedFiles, sites) {
|
||
for (let w = 0; w < WARMUP; w++) {
|
||
populateInlineState(parsedFiles);
|
||
resolveAll(parsedFiles, sites);
|
||
}
|
||
const samples = [];
|
||
for (let r = 0; r < REPS; r++) {
|
||
populateInlineState(parsedFiles);
|
||
const t0 = performance.now();
|
||
resolveAll(parsedFiles, sites);
|
||
samples.push(performance.now() - t0);
|
||
}
|
||
return { ms: fastest(samples), outcomes: outcomesOf(parsedFiles, sites) };
|
||
}
|
||
|
||
function fingerprint(outcomes) {
|
||
return crypto
|
||
.createHash('sha256')
|
||
.update([...outcomes].sort().join('\n'))
|
||
.digest('hex');
|
||
}
|
||
|
||
const scales = {};
|
||
for (const [name, fileCount] of [
|
||
['small', SMALL],
|
||
['large', LARGE],
|
||
]) {
|
||
const parsedFiles = buildCorpus(fileCount);
|
||
const { sites, nsReceiverSites } = callSites(fileCount);
|
||
const { ms, outcomes } = timeResolution(parsedFiles, sites);
|
||
scales[name] = {
|
||
files: fileCount,
|
||
call_sites: sites.length,
|
||
// Reported, not asserted: `ns_receiver_sites` evidences header property 1's
|
||
// mix and `distinct_outcomes` the fingerprinted surface's size — a corpus
|
||
// edit collapsing either still yields a "valid" fingerprint over far less.
|
||
ns_receiver_sites: nsReceiverSites,
|
||
distinct_outcomes: outcomes.size,
|
||
ms: Number(ms.toFixed(3)),
|
||
fingerprint: fingerprint(outcomes),
|
||
};
|
||
}
|
||
|
||
const scalingRatio = scales.large.ms / scales.small.ms / (LARGE / SMALL);
|
||
|
||
const report = {
|
||
small: scales.small,
|
||
large: scales.large,
|
||
scaling_ratio: Number(scalingRatio.toFixed(3)),
|
||
fingerprint: scales.large.fingerprint,
|
||
};
|
||
|
||
if (!process.argv.includes('--check')) {
|
||
console.log(JSON.stringify(report, null, 2));
|
||
process.exit(0);
|
||
}
|
||
|
||
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
|
||
const failures = [];
|
||
if (report.fingerprint !== baseline.fingerprint) {
|
||
failures.push(
|
||
`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint} — qualified ` +
|
||
`namespace lookup resolved a DIFFERENT symbol set. This is a behaviour change, not a perf one.`,
|
||
);
|
||
}
|
||
if (report.scaling_ratio > baseline.scaling_budget) {
|
||
failures.push(
|
||
`scaling ${report.scaling_ratio} > budget ${baseline.scaling_budget} — per-call-site cost ` +
|
||
`now grows with corpus size again (#2788). Timing arm: re-run on an idle machine before ` +
|
||
`investigating (see _scaling_note in baselines.json); the fingerprint arm never warrants a re-run.`,
|
||
);
|
||
}
|
||
|
||
console.log(JSON.stringify(report, null, 2));
|
||
if (failures.length > 0) {
|
||
console.error(`[cpp-qualified-ns --check] FAIL\n - ${failures.join('\n - ')}`);
|
||
process.exit(1);
|
||
}
|
||
console.log('[cpp-qualified-ns --check] PASS');
|