Why does NODE_V8_COVERAGE write ~5x larger profiles and cost ~9x more time when a child is run through tsx instead of compiled JS? #211812
Replies: 1 comment
|
There's no supported knob for this. The gap comes from what tsx adds to the isolate; coverage isn't inherently worse on transformed code. Why no knob: with Where the extra bytes come from (reproduced on Node 24.21, tsx 4.23):
The time cost is the same story: V8 has to count and serialize all of it in every child. Options:
My numbers (macOS arm64, hyperfine, median, without -> with coverage):
Your absolute numbers on Windows will differ, but the ratios match yours. References: |
Uh oh!
There was an error while loading. Please reload this page.
🏷️ Discussion Type
Question
Body
Node.js Version
v24.19.0
NPM Version
11.17.0
Operating System
windows
Subsystem
child_process, Other
Description
I have a Node CLI test suite that spawns the CLI itself as a child process (~880 spawns per run — argv parsing and exit codes are part of what is under test). Coverage comes from
NODE_V8_COVERAGE, which propagates to every child. The whole test phase is 76 s without coverage and 232 s with it, and I have narrowed the cause to the cost per child: recording coverage is dramatically more expensive when the child executes loader-transformed TypeScript than when it executes the same program compiled bytsc.Measured cost of one
entry --versionchild process — median of 25 runs after 3 warmups, Windows 11 x64, 16 logical cores, 16 GB RAM, Node 24.19,tsx4.23:node -e ""(process startup only)node --import tsx -e ""(loader attached, no source)node dist/entry.jsnode --import tsx src/entry.tsnode dist/entry.js+NODE_V8_COVERAGEnode --import tsx src/entry.ts+NODE_V8_COVERAGEThe loader attachment itself is free (rows 1–2), and coverage over compiled output is nearly free (+10%). Over transformed output it costs +84% and writes 5.7× the bytes. A full run therefore writes ~1000 profiles totalling ~2.1 GB, and merging them afterwards takes ~25 s.
Example.
gen.jswrites a small module graph — 200 files, ~5 lines each — behind one entry point. The shape that matters is that the entry has to load many sibling modules, the way a real CLI does.What I am asking*
Is the size and the precision of V8 block coverage inherently a function of how much transformed source a process loads, or is there a supported way to reduce what
NODE_V8_COVERAGErecords per process — a flag, a granularity setting, a way to scope it? I can find nothing in the Node docs about profile size or cost, and c8's options (--temp-directory,--exclude-after-remap,--merge-async) all act after the bytes are on disk.Context on why "just precompile it" is not a free answer for me: if the parent process loads source through tsx while children run the
tscbuild, the merged report combines two transform lineages for the same file and function coverage collapses (94% → 87%, below my gate). Compiling both sides into one tree removes the cost and lifts function coverage to ~98%, so I may have found the answer already and be asking whether the runtime offers something direct. Say the word and I will add a minimal repo.Searched and ruled out (so the usual answers can be skipped):
NODE_COMPILE_CACHE/module.enableCompileCache()— measured slower on both paths (tsx child 527 → 624 ms, plain CJS child 190 → 301 ms), and the docs warn coverage is less precise for functions deserialized from the cache and recommend disabling it while testing.node --experimental-strip-types— my package is"type": "commonjs", so a.tsentry usingimportsyntax fails to load ("Failed to load the ES module… set"type": "module"").--test-isolation=none— my suites monkey-patchfsandconsoleglobally, so files interfere; also slower, because concurrency becomes 1.--experimental-test-coverage(native, no c8) — no--allequivalent before v26.7, so it printedall files | 100.00for a run where c8 with--allsaid 72.14%. Same profile volume, same speed. (Posting separately, since it is a different question.)--test-concurrency— pool efficiency falls from ~85% at 4 workers to ~52% at 8; the children are latency-bound, not CPU-bound.Guidelines
All reactions