Skip to content

perf: stream dependencies between generators - #1205

Open
ovflowd wants to merge 7 commits into
mainfrom
perf/stream-dependencies
Open

ovflowd wants to merge 7 commits into
mainfrom
perf/stream-dependencies

Conversation

@ovflowd

@ovflowd ovflowd commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Description

This PR lets generators start on their dependency's first chunk instead of waiting for all of it, and brings the memory that costs back down. On Node.js's docs the site build gets 22–30% faster and the legacy one 9–10%, with the same output.

Every dependency edge was a full barrier: cache.consume collected all of a generator's chunks before its dependent started, so metadata waited for every file to be parsed, jsx-ast for all of the metadata, and html for every page. That's also why more threads barely helped: main only goes from 9.55s at 4 threads to 8.82s at 6.

  • Streaming: with streamsInput: true, a generator gets its dependency as an async iterable of chunks, read as they're produced. With one thread nothing runs alongside, so it still gets one array. metadata, section-pages, jsx-ast and html stream. The cache records a streamed generator once for all its readers, and lets go of each chunk once every reader has read it. worker.stream takes batches as they arrive, starts a few chunks per thread at a time (CHUNKS_PER_THREAD, 3) so generators running at once share the pool, and yields in submission order. Piscina now runs with stricterFIFO: by default it puts a task it can't hand out at the back of its queue, so early heavy tasks like module pages ran last. The pieces live in the core utils for any generator to use: toBatches takes an input whole or in batches, merge yields from several async iterables as items arrive, and createChannel, createReplay and createSignal are the queue, the recording and the wake-up they're built on.
  • jsx-ast's output order: pages now come out as they're built, in the order they arrived, plus a { manifest } item with every page's head once all have arrived. html bundles as soon as the manifest arrives and renders pages as they're compiled, and a page made of others (one with parts) once its parts are. To list pages as the site does, headingSortFn is now exported at @doc-kit/generator-react/jsx-ast/utils/getSortedHeadNodes. Bundler adapters' compile can now run before and alongside buildServer and buildClient (the html README says so).
  • Holding less in memory: streaming alone raised the site build's peak by 26–37%. These bring it back to +8–9%:
    • metadata drops position from every node once it's parsed: ~480k objects, ~80MB, and builds ~5% faster. json-simple writes the metadata as it is, so it no longer has them either and is a third of its size.
    • runGenerators(configuration, { results: false }) keeps none of the requested generators' output, and the CLI uses it.
    • A page made of others, such as all.html, renders one part at a time, rather than building every part's elements first. The worker rendering all.html went from 330–388MB to 176–189MB live.
  • Fewer grammars: JavaScript (js, mjs, cjs, javascript) is now highlighted with TypeScript's grammar by default, as types already are, and C++ with C's. JavaScript's grammar alone is ~60MB on every thread. In the light theme JavaScript comes out the same, except one block of console output marked as js; Nord, the dark theme, loses a few colors it only gives JavaScript's scopes, and C++-only keywords lose theirs. langAlias takes precedence, so a grammar in langs for one of these languages needs an alias to it. doc-kit now resolves aliases itself, since Shiki replaced configured ones with a grammar's own as it registered; aliases also follow other aliases and match in any case. resolveLanguage, and transformers, give the language code is highlighted as (typescript for mjs).
  • all.html moves to a Node.js generator: it's every module page on one page, so it's Node.js's, but html built it in behind generateAllPage, and streaming spread its bookkeeping through html. That option goes away, and @node-core/doc-kit gets an html-all generator, spliced between jsx-ast and html the way section-pages is between metadata and jsx-ast. It passes the pages on, adds all.html's head to the manifest, and right after the last module page adds all.html as a page made of theirs, in sidebar order. All html knows is that a page with parts is compiled from its parts' content and rendered first. Builds that want all.html add the html-all target, as CI's e2e build now does (-t web -t html-all); Node.js's config (doc: move to redesign node#66713) needs it in target too. This is a breaking change for @doc-kit/generator-react.
  • Up to 6 threads by default: with generators streaming into each other, more threads now pay off, so DEFAULT_MAX_THREADS goes from 4 to 6. 8 threads were no faster than 6 for the site build, and used ~15% more memory. Machines with fewer CPUs, like CI's 4, are unaffected. Rolldown and Rayon keep getting doc-kit's thread count: holding them at 4 was slower and saved no memory.

MALLOC_ARENA_MAX=2 saved another ~100MB, but glibc only reads it at startup. Re-executing the CLI with it broke node --watch, so it's left out: Node.js's Makefile is a better place to set it.

Validation

node --run test (845/845), node --run lint and node --run format:check are clean, and so is each commit on its own. New tests fail without their change: the replay and the cache's trimming (several readers, with GC checks), worker.stream's limit and refill, groupWholeModules, merge, the channel, the streamed and one-thread inputs of section-pages, jsx-ast, html-all and html (the manifest, all.html compiled early, failures leaving no temporary files, pages not held on Node.js 24), draining, dropped positions, all.html's ids, and the highlighter's aliases.

With the default aliases off, every target builds byte-identically to main at -p 1 and -p 4, with html-all added wherever all.html was built: the site, legacy-html-all and legacy-json-all, html, web, json-all, llms-txt-full, sitemap, legacy-html, legacy-json, man-page, addon-verify and api-links. The only differences are json-simple (positions) and the <api>.json files json and legacy-json both write, which already race on main.

Node.js's docs on Node.js 24, medians of 5 interleaved runs. The site is orama-db, json, llms-txt and section-pages (which pulls in jsx-ast and html), legacy is legacy-html-all and legacy-json-all:

Threads main This PR With nodejs/nodejs.org#9253
Site 4 9.55s, 1715MB 7.44s (−22%), 1867MB (+9%) 7.26s (−24%), 1928MB
Site 6 8.82s, 2143MB 6.18s (−30%), 2309MB (+8%) 6.04s (−32%), 2315MB
Legacy 4 4.57s, 1587MB 4.10s (−10%), 1540MB (−3%) 3.79s (−17%), 1558MB
Legacy 6 4.17s, 2022MB 3.78s (−9%), 1886MB (−7%) 3.63s (−13%), 1862MB

The site build also uses less CPU (52.3s to 49.8s at 6 threads).

The new default, on this PR, against thread counts and with Rolldown and Rayon held at 4:

4 threads 6 threads 8 threads 6, bundler at 4 8, bundler at 4
Site 7.64s, 1887MB 6.48s (−15%), 2281MB (+21%) 6.66s, 2655MB 6.58s, 2390MB 6.80s, 2630MB
Legacy 4.19s, 1538MB 3.96s (−5%), 1949MB 3.75s (−10%), 2273MB

-p 1 with --max-old-space-size=512 passes 3/3 for both, at ~1.0GB and ~0.9GB peak RSS.

Related Issues

Refs: nodejs/nodejs.org#9253, which caches tokens for both themes; doc-kit gets it once it's released.

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.

@vercel

vercel Bot commented Oct 31, 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 31, 2026 3:47pm UTC

Request Review

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 31, 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 ↗ e585611 2026-10-31T15:47:36.535Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ eaecb85 2026-10-31T14:45:37.455Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 5a9b862 2026-10-31T14:21:15.036Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ ad5bde5 2026-10-31T13:43:21.834Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 36b4da5 2026-10-31T13:37:23.941Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 3792e19 2026-10-31T13:26:56.734Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 482ec49 2026-10-31T13:02:43.842Z View logs ↗

@codecov

codecov Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.32689% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.34%. Comparing base (b8d7b77) to head (e585611).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
packages/core/src/__tests__/generators.test.mjs 96.77% 5 Missing ⚠️
...kages/core/src/utils/__tests__/generators.test.mjs 97.50% 4 Missing ⚠️
...generators/metadata/utils/__tests__/parse.test.mjs 90.32% 3 Missing ⚠️
packages/core/src/threading/parallel.mjs 97.97% 3 Missing ⚠️
packages/node/src/html-all/generate.mjs 96.84% 3 Missing ⚠️
packages/cli/bin/commands/generate.mjs 0.00% 1 Missing ⚠️
packages/cli/bin/commands/preview.mjs 0.00% 1 Missing ⚠️
packages/core/src/__tests__/caching.test.mjs 99.80% 1 Missing ⚠️
...ges/core/src/threading/__tests__/parallel.test.mjs 99.53% 1 Missing ⚠️
packages/core/src/utils/__tests__/replay.test.mjs 98.83% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1205      +/-   ##
==========================================
+ Coverage   93.77%   94.34%   +0.57%     
==========================================
  Files         289      296       +7     
  Lines       28813    31922    +3109     
  Branches     2913     3241     +328     
==========================================
+ Hits        27018    30116    +3098     
- Misses       1773     1784      +11     
  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 31, 2026 •

Copy link
Copy Markdown
Contributor

api-links Generator

Performance estimate (single CI run)

  • Generation time: unchanged (1.02 s → 1.02 s)
  • Peak memory: 4.7% higher (282.89 MB → 296.20 MB)

json Generator

Performance estimate (single CI run)

  • Generation time: 27.4% faster (9.67 s → 7.02 s)
  • Peak memory: 1.9% higher (952.80 MB → 970.46 MB)

legacy-html Generator

Output size: 10 files changed · net -52.57 KB

File size details
File Main PR Change
addons.html 374.23 KB 328.87 KB -45.36 KB (-12.1%)
embedding.html 64.38 KB 57.78 KB -6.60 KB (-10.3%)
async_context.html 247.17 KB 245.10 KB -2.08 KB (-0.8%)
tls.html 297.30 KB 298.85 KB +1.54 KB (+0.5%)
n-api.html 822.59 KB 822.23 KB -364.00 B (-0.0%)
module.html 364.54 KB 364.76 KB +224.00 B (+0.1%)
readline.html 219.10 KB 219.27 KB +168.00 B (+0.1%)
worker_threads.html 417.80 KB 417.64 KB -168.00 B (-0.0%)
repl.html 226.64 KB 226.75 KB +112.00 B (+0.0%)
test.html 1009.73 KB 1009.67 KB -56.00 B (-0.0%)

Performance estimate (single CI run)

  • Generation time: 5.8% faster (16.34 s → 15.39 s)
  • Peak memory: 12.4% lower (1.50 GB → 1.31 GB)

legacy-json Generator

Performance estimate (single CI run)

  • Generation time: 5.6% faster (9.43 s → 8.90 s)
  • Peak memory: 5.1% lower (907.74 MB → 861.82 MB)

llms-txt Generator

Performance estimate (single CI run)

  • Generation time: 34.0% slower (6.18 s → 8.28 s)
  • Peak memory: 9.4% lower (934.21 MB → 846.34 MB)

orama-db Generator

Performance estimate (single CI run)

  • Generation time: 70.4% slower (5.20 s → 8.86 s)
  • Peak memory: 11.8% lower (1.11 GB → 1004.27 MB)

web Generator

Output size: 46 files changed · net -34.83 MB

File size details
File Main PR Change
all.html 34.78 MB — -34.78 MB (-100.0%)
addons.html 389.73 KB 361.93 KB -27.80 KB (-7.1%)
embedding.html 73.02 KB 68.39 KB -4.63 KB (-6.3%)
process.html 1.20 MB 1.19 MB -4.16 KB (-0.3%)
child_process.html 706.47 KB 704.23 KB -2.24 KB (-0.3%)
tls.html 550.15 KB 552.33 KB +2.18 KB (+0.4%)
fs.html 2.36 MB 2.35 MB -1.32 KB (-0.1%)
crypto.html 2.03 MB 2.03 MB -1.21 KB (-0.1%)
assert.html 673.00 KB 671.89 KB -1.11 KB (-0.2%)
http.html 1.23 MB 1.23 MB -1.06 KB (-0.1%)
util.html 1.26 MB 1.26 MB -964.00 B (-0.1%)
async_hooks.html 293.14 KB 292.26 KB -898.00 B (-0.3%)
http2.html 1.31 MB 1.31 MB -788.00 B (-0.1%)
readline.html 380.82 KB 380.05 KB -788.00 B (-0.2%)
buffer.html 1.82 MB 1.82 MB -736.00 B (-0.0%)
dgram.html 313.49 KB 312.78 KB -728.00 B (-0.2%)
vm.html 656.77 KB 656.11 KB -680.00 B (-0.1%)
webstreams.html 565.36 KB 564.70 KB -674.00 B (-0.1%)
perf_hooks.html 910.85 KB 910.24 KB -620.00 B (-0.1%)
cluster.html 317.74 KB 317.14 KB -616.00 B (-0.2%)
worker_threads.html 662.16 KB 661.58 KB -588.00 B (-0.1%)
zlib.html 1.00 MB 1023.86 KB -504.00 B (-0.0%)
test.html 1.47 MB 1.47 MB -458.00 B (-0.0%)
ffi.html 204.00 KB 203.56 KB -448.00 B (-0.2%)
globals.html 302.35 KB 301.96 KB -394.00 B (-0.1%)
vfs.html 213.64 KB 213.31 KB -340.00 B (-0.2%)
url.html 533.89 KB 533.56 KB -338.00 B (-0.1%)
stream_iter.html 795.19 KB 794.90 KB -296.00 B (-0.0%)
v8.html 574.47 KB 574.19 KB -294.00 B (-0.0%)
async_context.html 329.94 KB 329.71 KB -234.00 B (-0.1%)
n-api.html 1.05 MB 1.05 MB -230.00 B (-0.0%)
dns.html 442.03 KB 441.81 KB -224.00 B (-0.0%)
sqlite.html 542.98 KB 542.81 KB -174.00 B (-0.0%)
domain.html 131.23 KB 131.06 KB -168.00 B (-0.1%)
quic.html 985.72 KB 985.56 KB -168.00 B (-0.0%)
stream.html 1.27 MB 1.27 MB -122.00 B (-0.0%)
repl.html 314.01 KB 313.90 KB -116.00 B (-0.0%)
modules.html 238.01 KB 237.90 KB -112.00 B (-0.0%)
tty.html 113.74 KB 113.63 KB -112.00 B (-0.1%)
module.html 533.05 KB 532.96 KB -84.00 B (-0.0%)
bench.html 213.44 KB 213.38 KB -60.00 B (-0.0%)
cli.html 678.78 KB 678.73 KB -56.00 B (-0.0%)
permissions.html 94.66 KB 94.60 KB -56.00 B (-0.1%)
esm.html 196.06 KB 196.05 KB -8.00 B (-0.0%)
single-executable-applications.html 170.64 KB 170.64 KB -2.00 B (-0.0%)
wasi.html 83.98 KB 83.98 KB -2.00 B (-0.0%)

Performance estimate (single CI run)

  • Generation time: 27.5% faster (25.60 s → 18.57 s)
  • Peak memory: 0.2% lower (1.55 GB → 1.55 GB)

Every dependency edge was a full barrier: `cache.consume` collected all
of a generator's chunks before its dependent started, so `metadata`
waited for every file to be parsed, `jsx-ast` for all of the metadata,
and `html` for every page. Chunks only streamed within a generator.

A generator can now set `streamsInput: true` to receive its dependency
as an async iterable of chunks, read as they are produced. With one
thread nothing runs alongside, so it still gets one array. `metadata`,
`section-pages`, `jsx-ast` and `html` stream:

- The cache records a streamed generator's chunks once for all its
  readers, and lets go of each chunk once every reader has read it.
- `worker.stream` takes batches as they arrive, starts a few chunks per
  thread at a time so generators running at once share the pool, and
  yields in submission order. Piscina now runs with `stricterFIFO`: by
  default it puts a task it can't hand out at the back of its queue, so
  early heavy tasks ran last.
- `jsx-ast` yields pages as they are built, and a `{ manifest }` with
  every page's head as soon as all have arrived. `html` bundles on it
  and renders pages as they are compiled, `all.html` once its parts are.
- Generators share the pieces: `toBatches` takes an input whole or in
  batches, `merge` yields from several async iterables as items arrive,
  and `createChannel`, `createReplay` and `createSignal` live in the
  core utils.

The output on Node.js's docs is byte-identical at 1 to 8 threads.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
Nothing after `metadata` reads where a node came from in the source:
the type warnings that print positions run while it parses. Dropping
them shrinks every entry the main thread holds and copies to and from
the workers. On Node.js's docs that is ~480k position objects, about
80MB, and builds are ~5% faster.

`json-simple`, which writes the metadata as it is, no longer has them
either, and is about a third of its size.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
`runGenerators` collected every requested generator's output into the
arrays it returns, though neither `generate` nor `preview` reads them.
With `{ results: false }` it drains them instead: each generator still
runs to its end, and once nothing else reads its output, its chunks go
as they are recorded. Both commands now use it.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
`all.html`'s program built every module's elements before rendering
any of them: `<>{content0()}{content1()}…</>` calls all 68 content
functions up front. Each part now renders through a component that
creates its elements as it renders and returns a single element, which
render-to-string doesn't keep on its parent the way it keeps a
fragment's children, so each module's tree can go once it's rendered.

On Node.js's docs the worker rendering `all.html` went from 330-388MB
to 176-189MB live, and peak RSS dropped ~140MB. The output is
identical, ids included, as `useId` counts in the same order. A
composed page also no longer scans its program for component names,
which would take the wrappers for configured components of that name.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
Each grammar costs every thread its own memory, about 60MB for
JavaScript's on Oniguruma, and time to register. Types and signatures
are already highlighted as TypeScript, whose grammar highlights
JavaScript too, so `js`, `mjs`, `cjs` and `javascript` code is now
highlighted with it by default, and C++ (`cpp`, `c++`) with C's. On
Node.js's docs that saves ~190MB of peak memory and 2-7% of build time.

In the light theme, the default, JavaScript comes out the same but for
one block of console output marked as `js`. Nord, the dark theme, has
a few colors only for JavaScript's scopes. C++-only keywords lose their
color.

`langAlias` takes precedence, and doc-kit now resolves aliases itself:
Shiki replaced configured aliases with a grammar's own as it
registered. Aliases now also lead on through other aliases and are
found in any case. Transformers, and the language `resolveLanguage`
returns, are the one code is highlighted as.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
With generators streaming into each other, more threads now pay off.
On Node.js's docs, medians of 5 runs, the site build took 7.64s at 4
threads, 6.48s at 6 and 6.66s at 8, peaking at 1.9GB, 2.3GB and 2.7GB.
The legacy build kept getting faster at 8, but by only another 5% for
320MB more. Machines with fewer CPUs are unaffected, as the default is
the CPU count up to this ceiling.

Keeping Rolldown and Rayon at 4 threads while doc-kit used 6 or 8 was
slower and saved no memory, so the bundler still gets doc-kit's count.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
all.html, every module page's content on one page, is Node.js's, but
html built it in, behind `generateAllPage`, and the streaming work
spread its bookkeeping through generate.mjs.

It's now a generator of @node-core/doc-kit, `html-all`, spliced between
jsx-ast and html the way section-pages is between metadata and jsx-ast.
It passes the pages on, adds all.html's metadata to the manifest, and
right after the last module page adds all.html as a page made of
theirs, in sidebar order. html only knows that an item with `parts`,
the pages before it that it's made of, is compiled from their content
and rendered first; `generateAllPage` goes away. Built with the Node.js
preset and the `html-all` target, the Node.js API docs come out
byte-identical, all.html included, at 1 and 4 threads.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
@ovflowd
ovflowd force-pushed the perf/stream-dependencies branch from eaecb85 to e585611 Compare October 31, 2026 15:46
@ovflowd
ovflowd marked this pull request as ready for review October 31, 2026 16:29
@ovflowd
ovflowd requested a review from a team as a code owner October 31, 2026 16:29

This branch was successfully deployed

1 active deployment
Preview – api-docs-tooling — e585611f Deployed Oct 31, 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