Skip to content

perf(core,classes): optimize synchronous map() hot path and createMap startup#632

Merged
nartc merged 4 commits into
nartc:chore/9.0-migratefrom
kylecannon:chore/9.0-perf-optimizations
Jun 23, 2026
Merged

perf(core,classes): optimize synchronous map() hot path and createMap startup#632
nartc merged 4 commits into
nartc:chore/9.0-migratefrom
kylecannon:chore/9.0-perf-optimizations

Conversation

@kylecannon

Copy link
Copy Markdown

Summary

Optimizes the synchronous map() hot path and createMap startup on top of chore/9.0-migrate's compiled-mapping engine. Every change is behavior-preserving (characterization tests written first; full suite green across all packages) and the PR adds a benchmark + verifier harness so these paths stay measurable.

Against the chore/9.0-migrate base: per-call map() is ~1.7–2.2× faster with ~3–5× less allocation, and createMap startup is ~13% faster with ~0.5 MB less retained memory.

Benchmarks

M3 Max, Node v22.22.3, same harness run sequentially. Numbers are machine-specific — treat as ratios. Reproduce with the commands below.

map() — per call (ns): base (chore/9.0-migrate) → this PR

scenario base this PR speedup
pojos / flat (8 primitives) 500 247 2.0×
classes / flat (8 primitives) 523 237 2.2×
pojos / nested (object + array) 592 328 1.8×
classes / nested 570 331 1.7×
classes / forMember + mapFrom 530 300 1.8×
classes / naming snake→camel 404 205 2.0×

mapArray(1000) scales the same (e.g. classes/flat 528 µs → 215 µs).

Allocation — per element via mapArray under --expose-gc: base → this PR

scenario base this PR reduction
classes / flat 1.88 KB 405 B 4.6×
classes / forMember + mapFrom 2.49 KB 622 B 4.0×
classes / naming snake→camel 1.88 KB 703 B 2.7×

Lower allocation → smaller GC windows.

createMap startup (many plans, tail-skewed class sizes)

base this PR
wall (ms) 21.4 18.6
retained heap (MB) 12.3 11.8

For reference, vs the last published release (8.8.1) the 9.0 branch + this PR is ~12–18× faster per map(); the bulk is the 9.0 compiled-mapping rewrite already on this branch, and this PR contributes the ~2× / ~3–5× allocation improvements above.

What changed

Runtime (map()):

  • Precompute the unmapped-key residual at createMap, so assertUnmappedProperties does no per-call Set allocation or writable-key scan (it runs on every map / every array element).
  • Replace the per-member setMemberFn closure factory with a direct (path, dest, value) writer; a closure is built only on the async branch.
  • Specialize the mapFrom step in compileStep: call the selector directly, dropping the per-member thunk and the mapMember type switch.
  • MapInitialize scalar fast-path (primitives short-circuit before Date/File checks) + Symbol.toStringTag File detection (replacing Object.prototype.toString.call(x).slice(...)).
  • get() single-segment fast path.
  • Hoist the per-element options object out of the mapArray / mutateArray loops.

createMap / memory:

  • storeMetadata O(P²) → O(P) (build the list in place).
  • Drop the unused props / configuredKeys arrays from the retained compiled plan.
  • getPathRecursive: module-level Set for excluded keys; drop a redundant own-name Set dedup.
  • Size-gated metadata index + extend Set for very wide classes (small classes keep the cheaper .find).
  • @AutoMap decorator O(P²) → O(1) (push onto the class's own metadata list; inheritance-safe sliced seed).
  • Naming-convention / applyMetadata / getMetadataList compile-time cleanups.

Correctness

Behavior-preserving throughout — sync/async, mutate-vs-return, MapMemberError wrapping, and pre/condition handling unchanged. Each change has a characterization test written before it: File/Date/array MapInitialize; mapFrom return/mutate/throw/skip-undefined; get() single-segment; getPathRecursive diamond; wide (>30-prop) size-gated paths; @AutoMap inheritance (parent metadata not mutated).

Full test suite green across all packages (core, classes, integration-test, pojos, nestjs, mikro, sequelize, zod).

Method: mitata under --expose-gc (heap + gc), corroborated by an independent hrtime loop, plus a verifier that asserts each scenario maps correctly so a timed run can't be a silently no-op'd map.

New tooling (packages/benchmark-core)

  • bench.ts — identity / forMember(mapFrom) / naming groups under --expose-gc.
  • startup-bench.ts — many-createMap wall + retained heap.
  • profile-formember.ts + analyze-cpuprofile.ts — CPU self-time attribution.
  • verify.ts — correctness gate + independent timing.

Reproduce

cd packages/benchmark-core
pnpm bench           # runtime: cpu + heap + gc
pnpm bench:startup   # createMap wall + retained heap
node --expose-gc --import tsx src/verify.ts   # correctness + hrtime

🤖 Generated with Claude Code

kylecannon and others added 2 commits June 22, 2026 14:45
Benchmarks for the synchronous map() hot path and createMap startup:
- bench.ts: identity, forMember(mapFrom), and snake->camel naming groups
  (rotating 64-object pool to defeat constant-folding); runs under
  --expose-gc so mitata reports heap + gc.
- startup-bench.ts: many-createMap, tail-skewed class-size distribution;
  reports createMap wall time + retained heap.
- profile-formember.ts + analyze-cpuprofile.ts: clean CPU self-time
  attribution (no mitata overhead).
- verify.ts: cross-version correctness gate + independent hrtime timing,
  so a benchmark run can't be a silently no-op'd or wrong map.

Scripts: bench, bench:startup, bench:profile (+ :analyze).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… startup

Behavior-preserving optimizations (characterization tests added first;
full suite green across all packages). ~2x faster per map and ~3-5x less
allocation vs the previous branch baseline.

map() runtime:
- precompute the unmapped-key residual at createMap (no per-call Set
  allocation or writable-key scan in assertUnmappedProperties)
- replace the per-member setMemberFn closure factory with a direct
  (path, dest, value) writer; build a closure only on the async arm
- specialize the mapFrom step in compileStep: call the selector directly,
  dropping the per-member thunk and the mapMember type switch
- MapInitialize scalar fast-path + Symbol.toStringTag File check
- get(): single-segment fast path
- hoist the per-element options object out of mapArray/mutateArray loops

createMap / memory:
- storeMetadata O(P^2) -> O(P) (build the list in place)
- drop the unused props/configuredKeys from the retained compiled plan
- getPathRecursive: module-level Set, drop redundant own-name dedup
- size-gated metadata index + extend Set for very wide classes
- AutoMap decorator O(P^2) -> O(1) own-list push (inheritance-safe seed)
- naming / applyMetadata / getMetadataList compile-time cleanups

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kylecannon kylecannon marked this pull request as ready for review June 22, 2026 21:57
kylecannon and others added 2 commits June 22, 2026 15:18
Quality cleanups from review (no behavior change; full suite green):
- extract a shared pathKey(path) helper for the null-byte path-key idiom
  that was open-coded across create-initial-mapping and extend
- collapse extend()'s size-gated dual loop into a single Set-based pass
  (compile-time only, so the Set is cheap at any size)
- extract isFileTagged() for the duplicated File-tag check in compileStep
- reuse one nested-options object across nested map() recursion instead of
  allocating { extraArgs } per nested object / array element
- drop an unnecessary export on getWritableKeys

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- extract the shared classes-strategy model/factory/mapping setup into
  fixtures.ts (imported by bench, verify, profile-formember) instead of
  copy-pasting it in each; drop a dead keep-alive fixture
- analyze-cpuprofile: read the newest profile from the fixed ./profiles dir
  instead of an optional CLI argument — removes the path-injection surface
  entirely (no external input reaches the filesystem path)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kylecannon kylecannon force-pushed the chore/9.0-perf-optimizations branch from b23fb02 to d9af7e5 Compare June 22, 2026 22:23
@nartc nartc merged commit 8146f66 into nartc:chore/9.0-migrate Jun 23, 2026
5 checks passed
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants