Skip to content

ericsssan/ic-fuzzer

Repository files navigation

ic-fuzzer

Runnable companion to From Production Flamegraph to Fixed Megamorphic Call Site — 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

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
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=<file> when your entry point is a thin wrapper around a library — it redirects IC collection to the file you actually care about:

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=<file>, --collector=<file>, --dry-run, --rng=<n> (reproduce), --runs=<n>, --count=<n>, --iters=<n>, --function=<name>, --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:

# 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:

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:

// 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:

// 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 <file> <export> --seed=<json> [--watch=<file>]
    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.

About

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'.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors