GitNexus/gitnexus/bench/cpp-qualified-ns/measure.mjs
Gergő Magyar 74409a37f6
perf(cpp): index qualified namespace members once per pipeline run (#2788) (#2794)
* 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>
2026-08-02 15:52:33 +01:00

496 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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');