Skip to content

perf: keep memory use down at low thread counts - #1179

Open
ovflowd wants to merge 9 commits into
mainfrom
perf/memory
Open

ovflowd wants to merge 9 commits into
mainfrom
perf/memory

Conversation

@ovflowd

@ovflowd ovflowd commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Description

This PR keeps doc-kit's memory use down, most of all at low thread counts, where -p 1 couldn't build the Node.js API docs under --max-old-space-size=512:

  • Worker young generation: capped at 96MB (WORKER_GEN_SIZE, passed as resourceLimits.maxYoungGenerationSizeMb). With a 512MB old generation, V8 gives a worker 192MB of young generation on Node.js 24 and 96MB on Node.js 26, so the cap only changes Node.js 24. Node.js's --max-semi-space-size still replaces it.
  • One thread, one chunk at a time: with -p 1, every chunk used to start at once, so all their work was held together. Chunks now run one after another.
  • Generators that change their input copy it on the main thread: building a page in jsx-ast and legacy-html rewrites its entries' content, and on the main thread those are the metadata other generators read. A worker already gets its own copy, so they only copy there. With one thread, building legacy-html with other targets crashed on main (Cannot read properties of undefined (reading 'tagName')), and legacy-json only worked because it happened to finish before jsx-ast. Copying every generator's input instead cost ~2.5s at -p 1.
  • Letting go of the pages' code: once html compiled the page programs, both it and the scheduler still held every page's code until the last page was rendered. buildAllPage now runs inside buildPrograms, generate() clears its input, and generators.mjs returns generate() instead of awaiting it.
  • Vite's native bundler: rolldown and lightningcss started a thread per CPU, each holding memory of its own, and their allocator (mimalloc) kept what it freed. They now get doc-kit's thread count through ROLLDOWN_WORKER_THREADS and RAYON_NUM_THREADS (documented by rolldown; TOKIO_WORKER_THREADS and ROLLDOWN_MAX_BLOCKING_THREADS made no difference), and mimalloc returns memory as it's freed (MIMALLOC_PURGE_DELAY=0, MIMALLOC_ALLOW_THP=0). mimalloc only purges on its next call past the delay, which never comes once bundling ends, so a shorter delay like 250ms doesn't help. These are read when the bundler loads, so they're set just before it is, unless the environment already sets them.
  • DEFAULT_MAX_THREADS moves to the threading constants, next to WORKER_HEAP_SIZE.

V8 flags (--optimize-for-size, --memory-saver-mode, --heap-growing-percent, --max-semi-space-size and Node.js 26's memory pool flags) were measured too and left out: they were slower or saved nothing on top of the above.

Validation

node --run test (754/754), node --run lint and node --run format:check are clean. New tests cover each change and fail against main: one chunk at a time with a single thread, both worker generation limits, jsx-ast and legacy-html leaving their input unchanged on the main thread, and resolveBundler setting the bundler's variables (keeping any already set).

The output is the same as main. On a full web build with section pages, with asset hashes normalized, only 3 of 918 files differ (two chunks and sitemap.xml), the same 3 that differ between two main runs. all.json and orama-db.json only differ in order, which already varies between main runs (orama's index changes size with insertion order, hence the -84B in CI). A -p 1 build of legacy-html, legacy-json and web together matches the -p 4 one file for file.

CI's command and default threads, Node.js 24, pinned to 4 CPUs like CI's runners, medians of 4 runs:

Target main This PR
legacy-html 6.27s, 1870MB +0.8%, −14%
json 3.47s, 1310MB +4.9%, −23%
orama-db 3.50s, 1368MB −0.6%, −13%
legacy-json 3.41s, 1299MB −1.6%, −22%
web 9.21s, 2207MB +1.4%, −22%

A regular web build (all CPUs, default threads), 4 runs:

main This PR
Node.js 24 7.41–7.61s, ~2400MB 7.45–7.79s, 1581–1700MB (−32%)
Node.js 26 7.15–7.48s, ~2130MB 7.10–7.36s, 1538–1645MB (−25%)

-p 1 with --max-old-space-size=512 finishes 6/6 on this PR (1071–1181MB peak RSS on Node.js 24 and 26), while main finished 0/3 on Node.js 26 and 1/3 on Node.js 24.

CI's comparison comment pits a single run against an older main run, so its timings swing a lot; pinned to 4 CPUs locally, the difference stays within ~0–5%.

Related Issues

Follows #1156.

Check List

  • I have read the Contributing Guidelines and made commit messages that follow the guideline.
  • I have run node --run test and all tests passed.
  • I have check code formatting with node --run format:check & node --run lint.
  • I've covered new added functionality with unit tests if necessary.

- Cap each worker's young generation at 32MB. V8 sizes it as if each
  worker had the whole machine, so it was over half of a worker's heap.
- With one thread, process chunks one at a time, on a copy of their
  input as a worker gets. All chunks used to start at once, holding all
  their work together, and `jsx-ast` changed the metadata `legacy-json`
  reads, which only worked because `legacy-json` happened to finish first.
- Let go of the pages' code once `html` compiled their programs: the
  generator and its caller both held it until every page was rendered.
- Move `DEFAULT_MAX_THREADS` to the threading constants.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
Vite's native bundler started a thread per CPU in rolldown's runtime
and in the Rayon pool it shares with lightningcss, each holding memory
of its own, and its allocator (mimalloc) kept what it freed: it only
purges on its next call past a delay, which never comes once bundling
ends, and with transparent huge pages it only returns whole free 2MB
pages. On the Node.js docs that left ~800MB resident through rendering.

The pools now get the threads doc-kit was given (`ROLLDOWN_WORKER_THREADS`,
`RAYON_NUM_THREADS`; `TOKIO_WORKER_THREADS` and
`ROLLDOWN_MAX_BLOCKING_THREADS` made no difference), and mimalloc returns
memory as it's freed (`MIMALLOC_PURGE_DELAY=0`, `MIMALLOC_ALLOW_THP=0`).
They're read when the bundler loads, so they're set just before it is,
unless the environment already sets them.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
api-docs-tooling Ready Ready Preview Oct 26, 2026 12:29pm UTC

Request Review

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 18caa4c 2026-10-26T12:29:40.115Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 955c8ac 2026-10-26T03:11:40.150Z View logs ↗

@codecov

codecov Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.11321% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.66%. Comparing base (ec7751f) to head (18caa4c).

Files with missing lines Patch % Lines
...s/react/src/html/bundlers/__tests__/index.test.mjs 95.89% 3 Missing ⚠️
packages/node-legacy/src/legacy-html/generate.mjs 83.33% 1 Missing ⚠️
packages/react/src/jsx-ast/generate.mjs 85.71% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1179      +/-   ##
==========================================
+ Coverage   93.44%   93.66%   +0.21%     
==========================================
  Files         278      280       +2     
  Lines       27820    28054     +234     
  Branches     2790     2820      +30     
==========================================
+ Hits        25997    26276     +279     
+ Misses       1801     1756      -45     
  Partials       22       22              

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

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

api-links Generator

Performance estimate (single CI run)

  • Generation time: 0.9% slower (1.07 s → 1.08 s)
  • Peak memory: 1.0% higher (289.13 MB → 292.07 MB)

json Generator

Performance estimate (single CI run)

  • Generation time: 31.0% slower (7.46 s → 9.77 s)
  • Peak memory: 21.0% lower (1.18 GB → 953.80 MB)

legacy-html Generator

Performance estimate (single CI run)

  • Generation time: 2.7% slower (10.58 s → 10.87 s)
  • Peak memory: 10.9% lower (1.79 GB → 1.59 GB)

legacy-json Generator

Performance estimate (single CI run)

  • Generation time: 3.2% slower (9.04 s → 9.33 s)
  • Peak memory: 14.6% lower (1.15 GB → 1006.09 MB)

llms-txt Generator

Performance estimate (single CI run)

  • Generation time: 0.2% faster (8.10 s → 8.08 s)
  • Peak memory: 22.4% lower (1.19 GB → 944.59 MB)

orama-db Generator

Output size: 1 file changed · net -3.00 B

File size details
File Main PR Change
orama-db.json 29.24 MB 29.24 MB -3.00 B (-0.0%)

Performance estimate (single CI run)

  • Generation time: 8.8% faster (7.35 s → 6.70 s)
  • Peak memory: 20.9% lower (1.28 GB → 1.01 GB)

web Generator

Performance estimate (single CI run)

  • Generation time: 14.7% faster (22.42 s → 19.13 s)
  • Peak memory: 18.2% lower (1.90 GB → 1.56 GB)

Copying every chunk's input with one thread cost ~2.5s of a ~24s build,
while only `jsx-ast` needs it: building a page changes its entries, and
on the main thread those are the metadata other generators read. It now
copies its own input there, and workers keep using theirs as is.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
At 32MB, workers collected their young generation often enough to cost
6-8% at 4 threads. 64MB keeps most of the memory saved (26-34% at 4
threads) within 1-2.5% of `main`'s build time.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
On 4 CPUs, like CI's runners, 64MB and 96MB keep the same memory (19%
less across json, legacy-json, legacy-html, orama-db and web) and 96MB
was marginally faster. It's also what V8 gives a worker with a 512MB
old generation on Node.js 26, so the cap only changes Node.js 24.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
@ovflowd
ovflowd marked this pull request as ready for review October 26, 2026 12:21
@ovflowd
ovflowd requested a review from a team as a code owner October 26, 2026 12:22
Building a page splices the heading out of its entries' content and
replaces the stability nodes, and on the main thread those entries are
the metadata other generators read. With one thread, building
`legacy-html` with other targets crashed (`Cannot read properties of
undefined (reading 'tagName')`), on `main` too. It now works on a copy
there, as it already does in a worker.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
- With one thread, chunks are processed one at a time, in order.
- Workers get both the old and young generation limits.
- jsx-ast and legacy-html leave their input unchanged on the main thread.
- resolveBundler sets the bundler's thread and allocator variables from
  `threads`, keeps any already set, and leaves a custom bundler alone.

Each fails against `main`.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – api-docs-tooling — 18caa4c3 Deployed Oct 26, 2026 by vercel[bot]
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.

1 participant