https://github.com/ericsssan/ic-fuzzer
Diagnose & fix V8 megamorphic deoptimizations in Node/TypeScript: runnable example, benchmark, raw --trace-deopt/--log-ic tracing, strict TS, JIT-friendliness lint, and a CI deopt-gate. Companion to the article 'From Production Flamegraph to Fixed Megamorphic Call Site'.
https://github.com/ericsssan/ic-fuzzer
deoptimization jit nodejs performance typescript v8
Last synced: 8 days ago
JSON representation
Diagnose & fix V8 megamorphic deoptimizations in Node/TypeScript: runnable example, benchmark, raw --trace-deopt/--log-ic tracing, strict TS, JIT-friendliness lint, and a CI deopt-gate. Companion to the article 'From Production Flamegraph to Fixed Megamorphic Call Site'.
- Host: GitHub
- URL: https://github.com/ericsssan/ic-fuzzer
- Owner: ericsssan
- License: mit
- Created: 2026-06-01T04:29:18.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-06-14T14:37:45.000Z (about 1 month ago)
- Last Synced: 2026-07-16T02:33:59.764Z (8 days ago)
- Topics: deoptimization, jit, nodejs, performance, typescript, v8
- Language: JavaScript
- Size: 144 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# ic-fuzzer
Runnable companion to **[From Production Flamegraph to Fixed Megamorphic Call Site](./ARTICLE.md)** — a walkthrough of diagnosing and fixing V8 JIT deoptimization in Node.js.
*Profilers tell you where time goes. They don't tell you why the JIT gave up.*
---
There's a class of production performance problem that profilers can locate but not explain. The flamegraph is unambiguous — `handleEvent` is eating 40% of CPU. The function looks fine. Local benchmarks look fine. The problem isn't in the code; it's in what the runtime decided to do with it.
V8 optimizes by speculating on object shapes. When too many distinct shapes flow through a property access, V8 gives up on the fast path — megamorphism — and falls back to a hashtable lookup on every access. This doesn't show up in source. It's a runtime fact, invisible to the reader and undetectable by profiler sampling.
This repo shows the full loop: find it with `--log-ic`, fix it at the shape boundary, enforce it in CI.
```
events: 2,000,000, hot iterations: 20
broken 229.4ms (megamorphic loads, un-inlinable call)
fixed 54.5ms (monomorphic loads, inlined) ← ~4.2× faster
```
The fix is one line at the ingestion boundary. `broken/handler.js` and `fixed/handler.js` are byte-for-byte identical.
---
## Running this repo
```bash
npm install
npm run bench # broken vs fixed timing (~4-5×)
npm run typecheck # strict tsc on the TypeScript example
npm run lint # static JIT-friendliness rules (--max-warnings=0)
npm run trace:broken # raw --trace-deopt --log-ic for the broken hot path
npm run trace:fixed # raw --trace-deopt --log-ic for the fixed hot path
npm run gate # CI deopt-gate on the fixed path -> PASS
npm run gate:broken # CI deopt-gate on the broken path -> FAIL
npm run ts # the TypeScript version
```
---
## ic-fuzzer
This repo also ships `ic-fuzzer`, a tool that finds the minimal set of object shapes that push a function's inline caches to megamorphic. It uses IC severity — not crashes or coverage — as the feedback signal, which makes it novel: no existing fuzzer does this.
### How V8 executes your code
V8 runs JavaScript in three tiers:
```
Ignition (interpreter) → Maglev (mid-tier JIT) → Turbofan (optimizing compiler)
```
At each property access (`.type`, `.id`, etc.), **Ignition** maintains an **inline cache (IC)** — a record of which object shapes (hidden classes) that site has seen. `monomorphic` = 1 shape, `polymorphic` = 2–4, `megamorphic` = 5+.
When a function gets hot, **Turbofan** reads the IC feedback Ignition collected, then *replaces* the IC with specialized machine code. Monomorphic feedback → direct memory load (fast). Megamorphic feedback → generic hash-table lookup (slow, but still "optimized").
**ic-fuzzer's purpose:** find input shapes that push a call site to megamorphic *during Ignition*, before Turbofan reads the accumulated feedback. The ICs disappear after Turbofan compiles — but their legacy is baked into the generated machine code. Megamorphic IC feedback causes Turbofan to emit permanently slower code.
**What "no IC data" means:**
| Situation | Cause | What to do |
|---|---|---|
| Function never called | Wrong export name, or seed doesn't trigger the call path | Fix the seed |
| `X`/no_feedback state | Turbofan compiled before the probe ran; IC sites replaced by specialized code | Use `--no-turbofan` to observe Ignition-phase ICs |
| No property accesses | Function doesn't read properties on its argument | ic-fuzzer doesn't apply |
```bash
cd ic-fuzzer
node bin/ic-fuzzer.js ../broken/handler.js handleEvent \
--seed='{"type":"click","id":1,"value":2}'
```
```
[ic-fuzzer] minimal set: 5 shape(s) (0 shrink step(s))
1. literal:type-id-value { type, id, value } literal
2. incr:type-value-id e.type; e.value; e.id incremental
3. incr:id-type-value e.id; e.type; e.value incremental
4. incr:value-type-id e.value; e.type; e.id incremental
5. incr:value-id-type e.value; e.id; e.type incremental
[ic-fuzzer] IC sites at megamorphic:
[MEGAMORPHIC ] .id handleEvent (broken/handler.js:10:16)
[MEGAMORPHIC ] .type handleEvent (broken/handler.js:10:32)
[MEGAMORPHIC ] .value handleEvent (broken/handler.js:10:52)
```
Use `--watch=` when your entry point is a thin wrapper around a library — it redirects IC collection to the file you actually care about:
```bash
node bin/ic-fuzzer.js test/fixtures/picomatch-scan-entry.js scanDirect \
--seed='{"pattern":"**/*.{js,ts}","parts":false,...}' \
--watch=../node_modules/picomatch/lib/scan.js
```
Other flags: `--target=polymorphic`, `--corpus=`, `--collector=`, `--dry-run`, `--rng=` (reproduce), `--runs=`, `--count=`, `--iters=`, `--function=`, `--report-maps`, `--trace-reads`, `--trace-deopt`, `--no-turbofan`, `--max-maps=N`.
### The full optimization workflow
ic-fuzzer covers the complete find → quantify → fix → verify loop:
```bash
# 1. Find it
ic-fuzzer ./handler.js handleEvent --seed='{"type":"click","id":1,"value":2}'
# → minimal set: 5 shapes, IC sites: .id .type .value
# 2. Quantify — is this worth fixing? (--bench compares mono vs mixed-shape timing)
ic-fuzzer ./handler.js handleEvent --seed='...' --bench
# → [ic-fuzzer] bench: mono=54ms mixed=229ms Δ=4.2×
# 3. Fix it — write a boundary normalizer (e.g. toEvent()) that coerces inputs
# to a single canonical shape (class instance or literal) before the hot path
# 4. Verify — CI gate that fails if any IC rises above monomorphic
ic-fuzzer ./handler.js handleEvent --seed='...' --assert-max=monomorphic
# → [ic-fuzzer] PASS — handleEvent stays within monomorphic (exit 0)
```
**Why `fixed/handler.js` still reports megamorphic without a gate.**
ic-fuzzer feeds plain objects directly to the target function, bypassing any boundary normalizer. If your fix is a `toEvent()` that coerces raw inputs to a `new Event(...)`, running ic-fuzzer on `handleEvent` after the fix will still report megamorphic — because ic-fuzzer never calls `toEvent`. This is expected: the megamorphic reads were *moved* to the boundary, not eliminated.
The right targets after a boundary-normalization fix:
- `handleEvent` with `--assert-max=monomorphic` — verifies the hot path is now clean
- `toEvent` (without `--assert-max`) — verifies the boundary does what it should: finds megamorphism, confirming the coalescing reads are confined there
### Programmatic API
`ic-fuzzer` exposes its full internals as a Node module:
```js
const { fuzz, probe, bench, derive, fromCorpus, SEV_LABEL } = require('ic-fuzzer');
```
**`probe(fnFile, fnName, strategies, watchFile?, count?, iters?)` → `{ severity, ics, hasICs, crashed, error }`**
Run one shaped probe and get back raw IC severity. Use this for a CI assertion that a hot path stays monomorphic:
```js
// ci/monomorphic-gate.js
const path = require('path');
const { probe, derive } = require('ic-fuzzer');
const file = path.resolve('src/handler.js');
const seed = { type: 'click', id: 1, value: 2 };
(async () => {
// Five distinct shapes — enough to trigger megamorphism if the IC is polymorphic
const five = derive(seed).slice(0, 5);
const { severity } = await probe(file, 'handleEvent', five);
if (severity >= 3) {
console.error(`handleEvent is megamorphic (severity ${severity}) — normalize at the boundary`);
process.exit(1);
}
console.log('handleEvent is JIT-friendly');
})();
```
**`fuzz(opts)` → `{ found, minimalStrategies, ics, numShrinks, rngSeed, anyICs, anyMonomorphic, numCrashes }`**
Run the full fast-check search + shrink loop. Useful for fuzzing internal closures or class methods that aren't exported directly — wrap them in a thin harness file:
```js
// test/fuzz-internal.js
const path = require('path');
const { fuzz, derive } = require('ic-fuzzer');
// harness.js exports a wrapper around the private function under test
const file = path.resolve('test/fuzz-harness.js');
(async () => {
const result = await fuzz({
fnFile: file,
fnName: 'processNode',
strategies: derive({ type: 'Identifier', name: 'x', start: 0, end: 1 }),
targetSev: 3, // 2 = polymorphic, 3 = megamorphic
numRuns: 200,
onRun({ subset, severity, phase }) {
if (severity >= 3) console.log(' megamorphic:', subset.join(', '));
},
});
if (result.found) {
console.error(`megamorphic set: ${result.minimalStrategies.map(s => s.name).join(', ')}`);
process.exit(1);
}
console.log('no megamorphism found');
})();
```
---
## Layout
```
ts-jit-deopt/
ARTICLE.md ← the full article
event-source.js ← heterogeneous event source (pure shape variation)
bench.js ← broken vs fixed timing harness
broken/
handler.js ← the hot path (receives many shapes → megamorphic)
drive.js ← driver
fixed/
event.js ← canonical shape + boundary normalizer
handler.js ← identical hot path (receives one shape → monomorphic)
drive.js ← driver (normalized at the boundary)
ci/
deopt-gate.js ← fail CI on mono→megamorphic regression
gate-driver.js ← replays the corpus through the chosen handler
ic-fuzzer/
bin/ic-fuzzer.js ← CLI: ic-fuzzer --seed= [--watch=]
src/mutate.js ← derives shape strategies from a seed object
src/probe.js ← subprocess driver: runs under --log-ic, parses IC severity
src/fuzzer.js ← fast-check loop: search + shrink to minimal shape set
src/reporter.js ← progress, result formatting, corpus.json output
fuzzer/ ← legacy shape fuzzer (predates ic-fuzzer)
ts/
example.ts ← the TypeScript version (boundary encoded in types)
.github/workflows/
deopt-gate.yml ← drop-in GitHub Actions job
```
## License
MIT © 2026 Eric San. See [LICENSE](./LICENSE).