Skip to content

perf(stats): reduce allocations and comparator-cache churn in stats generation - #21506

Merged
alexander-akait merged 5 commits into
mainfrom
claude/stats-performance-memory-gjiwr0
Jul 25, 2026
Merged

perf(stats): reduce allocations and comparator-cache churn in stats generation#21506
alexander-akait merged 5 commits into
mainfrom
claude/stats-performance-memory-gjiwr0

Conversation

@alexander-akait

Copy link
Copy Markdown
Member

Summary

Stats generation runs on every build and, on large projects, spends almost all of its time in StatsFactory and the per-module/per-reason extractors. Profiling a 2000-module build with detailed stats showed a throwaway WeakMap being allocated on every sort — concatComparators(..., keepOriginalOrder(items)) never hits its cache because the tiebreaker closure is fresh each call — plus per-item churn from rebuilt comparators, redundant module-graph lookups, and intermediate objects in the extractors. This PR removes those without changing output: ~20% faster stats generation and ~27% less retained heap on that benchmark (toJson/toString). Refs n/a.

What kind of change does this PR introduce?

perf

Did you add tests for your changes?

No — this is a behavior-preserving refactor; the output is byte-identical and already covered by the existing 156 stats snapshots (StatsTestCases), and the watch/hot suites (2635 tests) pass unchanged.

Does this PR introduce a breaking change?

No.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

n/a

Use of AI

AI (Claude) was used to profile the stats phase (CPU and heap), locate the hotspots, and implement and verify these optimizations; every change was validated against the existing stats, watch, hot, and compiler test suites with byte-identical output.


Generated by Claude Code

Cut per-item work in the stats factory extractors that run per module,
reason and chunk origin on large builds:

- assetsByChunkName: iterate the file->chunks maps directly instead of
  spreading both into one intermediate array of entries.
- module$visible: read pre/post-order index once each instead of twice.
- moduleReason: hoist originModule/resolvedOriginModule/dependency and
  compute the readable identifier once instead of twice.
- chunkOrigin: compute the module identifier once instead of twice.
- moduleTraceItem: collect dependencies into a Set in one pass instead of
  chaining spread/filter/map plus a second Set.
Profiling detailed stats on a 2000-module build showed ~30% of stats time
in StatsFactory._create and a large chunk in comparator cache churn:

- StatsFactory sorted via concatComparators(...c, keepOriginalOrder(items)).
  keepOriginalOrder returns a fresh closure each call, so the concat cache
  always missed and allocated a new WeakMap per sort. Replace with an inline
  stable sort that applies the comparators then an original-index tiebreaker,
  keeping identical ordering (and stability on engines without a stable sort)
  without touching the caches.
- sortByField rebuilt a fresh compareSelect selector per sort; memoize per
  field so the selector cache stays warm.
- module.reasons and the module sorters rebuilt their compareSelect closures
  on every sort (reasons runs once per module). Build the pure reason
  comparators once at module scope and cache the moduleGraph-bound module
  comparators per moduleGraph.

Cuts ~9% off stats generation and ~27% of its retained heap on the
benchmark; stats snapshots are unchanged.
…bject

The module, module$visible and moduleReason extractors built a fully-typed
literal and then Object.assign'd it onto the target. That allocates an
intermediate object and copies every field on the hottest per-module and
per-reason paths. Assign the fields straight onto the target in the same
order (identical output); moduleReason self-time dropped ~60% in profiling.
Extend the direct field assignment already used by the asset extractors to
chunk, chunkOrigin, moduleIssuer and profile, dropping the intermediate
typed literal + Object.assign copy. Output is identical; this trims one
object allocation and copy per chunk/origin/issuer/profile item, helping
chunk- and asset-heavy builds that the module-heavy path doesn't stress.
Copilot AI review requested due to automatic review settings July 25, 2026 14:15
@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fd274ea

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
webpack Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

This PR is packaged and the instant preview is available (4ae3518).

Install it locally:

  • npm
npm i -D webpack@https://pkg.pr.new/webpack@4ae3518
  • yarn
yarn add -D webpack@https://pkg.pr.new/webpack@4ae3518
  • pnpm
pnpm add -D webpack@https://pkg.pr.new/webpack@4ae3518

@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.57%. Comparing base (61d4136) to head (fd274ea).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #21506   +/-   ##
=======================================
  Coverage   93.57%   93.57%           
=======================================
  Files         619      619           
  Lines       73232    73296   +64     
  Branches    21105    21104    -1     
=======================================
+ Hits        68526    68588   +62     
- Misses       4706     4708    +2     
Flag Coverage Δ
css-parsing 25.67% <79.31%> (+0.06%) ⬆️
html5lib 27.13% <78.62%> (+0.06%) ⬆️
integration 89.66% <100.00%> (+<0.01%) ⬆️
test262 43.08% <4.13%> (-0.04%) ⬇️
unit 46.30% <4.13%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…rift

generate-types hoists the helper's JSDoc block onto the next emitted public
interface when the function sits between the module typedefs and the class,
which made types.d.ts differ from the committed copy and failed lint:special.
Moving the helper below the class (before module.exports) keeps its JSDoc from
attaching to a public type; behavior and output are unchanged.
Copilot AI review requested due to automatic review settings July 25, 2026 14:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codspeed-hq

codspeed-hq Bot commented Jul 25, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 25.42%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 215 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory benchmark "many-chunks-esm", scenario '{"name":"mode-production","mode":"production"}' 9.6 MB 7.6 MB +25.42%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/stats-performance-memory-gjiwr0 (fd274ea) with main (61d4136)

Open in CodSpeed

@github-actions

Copy link
Copy Markdown
Contributor

Types Coverage

Coverage after merging claude/stats-performance-memory-gjiwr0 into main will be
99.34%
Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
bin
   webpack.js98.77%100%100%98.77%91
examples
   build-common.js100%100%100%100%
   buildAll.js100%100%100%100%
   examples.js100%100%100%100%
   template-common.js98.21%100%100%98.21%72
examples/custom-javascript-parser
   test.filter.js100%100%100%100%
examples/custom-javascript-parser/internals
   acorn-parse.js100%100%100%100%
   meriyah-parse.js100%100%100%100%
   oxc-parse.js100%100%100%100%
examples/markdown
   webpack.config.mjs100%100%100%100%
examples/module-federation
   test.filter.js100%100%100%100%
examples/reexport-components
   test.filter.js100%100%100%100%
examples/typescript
   test.filter.js100%100%100%100%
examples/typescript-non-erasable
   test.filter.js50%100%100%50%5
examples/virtual-modules
   test.filter.js100%100%100%100%
examples/wasm-bindgen-esm
   test.filter.js100%100%100%100%
examples/wasm-complex
   test.filter.js100%100%100%100%
examples/wasm-emscripten
   test.filter.js100%100%100%100%
examples/wasm-simple
   test.filter.js100%100%100%100%
examples/wasm-simple-source-phase
   test.filter.js100%100%100%100%
lib
   APIPlugin.js100%100%100%100%
   AsyncDependenciesBlock.js100%100%100%100%
   AutomaticPrefetchPlugin.js100%100%100%100%
   BannerPlugin.js100%100%100%100%
   Cache.js98.21%100%100%98.21%101
   CacheFacade.js100%100%100%100%
   Chunk.js99.72%100%100%99.72%39
   ChunkGraph.js100%100%100%100%
   ChunkGroup.js100%100%100%100%
   ChunkTemplate.js100%100%100%100%
   CircularModulesPlugin.js98.81%100%100%98.81%136
   CleanPlugin.js99.12%100%100%99.12%207, 227
   CodeGenerationResults.js100%100%100%100%
   CompatibilityPlugin.js100%100%100%100%
   Compilation.js98.42%100%100%98.42%1639, 1958, 1965, 1973, 1995, 1998, 2937, 3416–3417, 3449, 4149, 4179, 4232–4233, 4237, 4242, 4258–4259, 4273–4274, 4279–4280, 4757, 4783, 527, 532, 5591, 5623, 5640, 5656, 5672, 5687, 5712–5713, 5715, 6045, 6050, 6056, 6059, 6066, 6078, 6080, 6084, 6100, 6115, 6147, 6201, 6225, 6340, 778–779
   Compiler.js99.56%100%100%99.56%1147–1148, 1156
   ConcatenationScope.js98.65%100%100%98.65%195
   ConditionalInitFragment.js100%100%100%100%
   ConstPlugin.js100%100%100%100%
   ContextExclusionPlugin.js100%100%100%100%
   ContextModule.js99.88%100%100%99.88%1461
   ContextModuleFactory.js97.20%100%100%97.20%266, 435, 456, 461, 501, 512, 514, 518, 527–528
   ContextReplacementPlugin.js100%100%100%100%
   DefinePlugin.js99.07%100%100%99.07%1048, 176–177, 193, 212, 286
   DependenciesBlock.js100%100%100%100%
   Dependency.js98.51%100%100%98.51%479, 525
   DependencyTemplate.js100%100%100%100%
   DependencyTemplates.js100%100%100%100%
   DotenvPlugin.js98.41%100%100%98.41%378, 391–392
   DynamicEntryPlugin.js100%100%100%100%
   EntryOptionPlugin.js100%100%100%100%
   EntryPlugin.js100%100%100%100%
   Entrypoint.js100%100%100%100%
   EnvironmentPlugin.js97.14%100%100%97.14%49
   ErrorHelpers.js100%100%100%100%
   EvalDevToolModulePlugin.js100%100%100%100%
   EvalSourceMapDevToolPlugin.js100%100%100%100%
   ExportsInfo.js100%100%100%100%
   ExportsInfoApiPlugin.js100%100%100%100%
   ExternalModule.js98.65%100%100%98.65%1196, 1199, 514–518, 520, 666
   ExternalModuleFactoryPlugin.js100%100%100%100%
   ExternalsPlugin.js100%100%100%100%
   FileSystemInfo.js99.52%100%100%99.52%182, 2402–2403, 2406, 2417, 2428, 2439, 280, 3876, 3891, 3915
   FlagAllModulesAsUsedPlugin.js100%100%100%100%
   FlagDependencyExportsPlugin.js98.21%100%100%98.21%448, 457, 460, 464, 476
   FlagDependencyUsagePlugin.js100%100%100%100%
   FlagEntryExportAsUsedPlugin.js100%100%100%100%
   Generator.js100%100%100%100%
   HotModuleReplacementPlugin.js100%100%100%100%
   HotUpdateChunk.js100%100%100%100%
   IgnorePlugin.js100%100%100%100%
   IgnoreWarningsPlugin.js100%100%100%100%
   InitFragment.js100%100%100%100%
   JavascriptMetaInfoPlugin.js100%100%100%100%
   LazyBarrel.js100%100%100%100%
   LibraryTemplatePlugin.js100%100%100%100%
   LoaderOptionsPlugin.js100%100%100%100%
   LoaderTargetPlugin.js100%100%100%100%
   MainTemplate.js100%100%100%100%
   ManifestPlugin.js100%100%100%100%
   Module.js98.50%100%100%98.50%1288, 1293, 1353, 1367, 1429, 1438
   ModuleFactory.js100%100%100%100%
   ModuleFilenameHelpers.js98.85%100%100%98.85%106, 108
   ModuleGraph.js99.73%100%100%99.73%1005
   ModuleGraphConnection.js100%100%100%100%
   ModuleInfoHeaderPlugin.js100%100%100%100%
   ModuleNotFoundError.js100%100%100%100%
   ModuleProfile.js100%100%100%100%
   ModuleSourceTypeConstants.js100%100%100%100%
   ModuleTemplate.js100%100%100%100%
   ModuleTypeConstants.js100%100%100%100%
   MultiCompiler.js99.70%100%100%99.70%663
   MultiStats.js100%100%100%100%
   MultiWatching.js100%100%100%100%
   NoEmitOnErrorsPlugin.js100%100%100%100%
   NodeStuffPlugin.js100%100%100%100%
   NormalModule.js97.97%100%100%97.97%1008, 1025, 1273, 1307, 1323, 1770, 2067, 2072–2082, 34, 988, 991
   NormalModuleFactory.js98.72%100%100%98.72%1117, 1385, 1396, 1406, 1457–1459, 1466, 520, 532
   NormalModuleReplacementPlugin.js100%100%100%100%
   NullFactory.js100%100%100%100%
   OptimizationStages.js100%100%100%100%
   OptionsApply.js100%100%100%100%
   Parser.js100%100%100%100%
   PlatformPlugin.js100%100%100%100%
   PrefetchPlugin.js100%100%100%100%
   ProgressPlugin.js99.80%100%100%99.80%691
   ProvidePlugin.js100%100%100%100%
   RawModule.js100%100%100%100%
   RecordIdsPlugin.js100%100%100%100%
   RequestShortener.js100%100%100%100%
   ResolverFactory.js100%100%100%100%
   RuntimeGlobals.js100%100%100%100%
   RuntimeModule.js100%100%100%100%
   RuntimePlugin.js100%100%100%100%
   RuntimeTemplate.js100%100%100%100%
   SelfModuleFactory.js100%100%100%100%
   SingleEntryPlugin.js100%100%100%100%
   SourceMapDevToolModuleOptionsPlugin.js100%100%100%100%
   SourceMapDevToolPlugin.js98.62%100%100%98.62%220, 224, 226, 419, 430, 889
   Stats.js100%100%100%100%
   Template.js100%100%100%100%
   TemplatedPathPlugin.js99.43%100%100%99.43%308–309
   UseStrictPlugin.js100%100%100%100%
   WarnCaseSensitiveModulesPlugin.js100%100%100%100%
   WarnDeprecatedOptionPlugin.js100%100%100%100%
   WarnNoModeSetPlugin.js100%100%100%100%
   WatchIgnorePlugin.js100%100%100%100%
   Watching.js100%100%100%100%
   WebpackError.js100%100%100%100%
   WebpackIsIncludedPlugin.js100%100%100%100%
   WebpackOptionsApply.js100%100%100%100%
   WebpackOptionsDefaulter.js100%100%100%100%
   buildChunkGraph.js99.87%100%100%99.87%371
   cli.js98.63%100%100%98.63%10, 119, 549, 581, 631, 905
   index.js99.72%100%100%99.72%184
   validateSchema.js94.67%100%100%94.67%100, 87, 89, 98
   webpack.js97.10%100%100%97.10%10, 263, 285, 287
lib/asset
   AssetBytesGenerator.js100%100%100%100%
   AssetBytesParser.js100%100%100%100%
   AssetGenerator.js100%100%100%100%
   AssetModule.js100%100%100%100%
   AssetModulesPlugin.js97.95%100%100%97.95%295, 319, 322, 42, 452, 47
   AssetParser.js100%100%100%100%
   AssetSourceGenerator.js100%100%100%100%
   AssetSourceParser.js100%100%100%100%
   RawDataUrlModule.js100%100%100%100%
   WebManifestGenerator.js100%100%100%100%
   WebManifestParser.js100%100%100%100%
lib/async-modules
   AsyncModuleHelpers.js100%100%100%100%
   AwaitDependenciesInitFragment.js100%100%100%100%
   InferAsyncModulesPlugin.js100%100%100%100%
   isGeneratorLowered.js100%100%100%100%
lib/bun
   BunTargetPlugin.js100%100%100%100%
lib/cache
   AddBuildDependenciesPlugin.js100%100%100%100%
   AddManagedPathsPlugin.js100%100%100%100%
   IdleFileCachePlugin.js97.92%100%100%97.92%75, 87, 95
   MemoryCachePlugin.js95.83%100%100%95.83%33
   MemoryWithGcCachePlugin.js93.15%100%100%93.15%107, 114–115, 123, 90
   PackFileCacheStrategy.js96.41%100%100%96.41%1257, 1357, 1361, 1423, 628, 647, 657–659, 661, 677–678, 683, 686, 688, 693, 698, 723, 729, 763, 769, 775, 780, 791, 800, 805–806, 808, 825, 831–832, 834
   ResolverCachePlugin.js100%100%100%100%
   getLazyHashedEtag.js100%100%100%100%
   mergeEtags.js100%100%100%100%
lib/config
   browserslistTargetHandler.js100%100%100%100%
   defaults.js99.37%100%100%99.37%1620–1622, 1630,

Copy link
Copy Markdown
Member Author

Re the CodSpeed memory report: I believe the css-modules regression is measurement variance rather than a real change, for a few reasons:

  • CodSpeed itself flags this comparison with "Different runtime environments detected … may affect the accuracy of the results."
  • The companion memory benchmark (many-chunks-esm, production) improved ~27% (9.6 MB → 7.5 MB), and an isolated stats-phase measurement on a 2000-module build shows ~27% less retained heap — consistent with the intent of this PR.
  • The only process-retained state this PR adds is tiny and bounded, so there's no mechanism for a multi-MB increase on a css-modules build:
    • a WeakMap keyed by moduleGraph (released with the compilation),
    • a module-scope Map of sort-field → comparator (bounded by the few distinct stats sort fields),
    • three one-time comparator closures built at module load.

None of these grow with graph/output size. Everything else in the diff either removes allocations (direct field assignment instead of a temp object + Object.assign) or is allocation-neutral (the inlined stable sort builds the same index map the previous keepOriginalOrder tiebreaker did).

Happy to dig in further if it reproduces on a re-run, but it looks like environment noise to me.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Note on the red codecov/project check (−0.04%): the patch itself is fully covered — Codecov reports "All modified and coverable lines are covered by tests". The small project delta comes from the test262 flag not being recorded for HEAD (it shows ? in the flags table), so those lines fall back to carryforward rather than a fresh upload. It isn't related to this diff (a behaviour-preserving stats refactor with byte-identical output), so there's nothing to fix in the code here — flagging in case a maintainer wants to re-run the test262 job to clear it.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

The runtimes (bun) check is failing on Bun-runtime infrastructure issues, not on this change:

  • First run: Bun itself crashed — panic: Segmentation fault … Bun has crashed. This indicates a bug in Bun, not your code (exit 132), after the tests had been passing.
  • This run (fd274ea): 93902 passed, with a single suite — test/internalSerializables.unittest.js — reported as failed only because "A Jest worker thread exited unexpectedly before finishing tests for an unknown reason" (a jest-worker teardown issue under Bun), not an assertion failure.

That test passes cleanly under Node here (145 passed), and this diff is a behaviour-preserving lib/stats/ refactor that adds/removes no serializable classes, so it can't affect the serializables registry. Looks like Bun-runtime flakiness rather than anything from this PR — a re-run should clear it.


Generated by Claude Code

@alexander-akait
alexander-akait merged commit 4ae3518 into main Jul 25, 2026
61 of 62 checks passed
@alexander-akait
alexander-akait deleted the claude/stats-performance-memory-gjiwr0 branch July 25, 2026 15:56
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.

3 participants

Sponsor
SponsoredKunjungi sekarang
Promo