Skip to content

perf: let worker.stream take chunks as they arrive - #1237

Open
ovflowd wants to merge 6 commits into
mainfrom
perf/stream-worker
Open

ovflowd wants to merge 6 commits into
mainfrom
perf/stream-worker

Conversation

@ovflowd

@ovflowd ovflowd commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Description

This PR lets worker.stream take an async iterable of batches as well as an array, starting a batch's work while earlier ones still run. It's the first part of a stack that splits #1205, which lets generators start on their dependency's first chunk instead of waiting for all of it.

  • With more than one thread it keeps only a few chunks per thread in flight (CHUNKS_PER_THREAD, 3), whether its input is an array or streamed, so generators running at once share the worker pool instead of one filling it. It reads the next batch once there's room for its chunks, and yields results in the order the chunks were submitted.
  • Piscina runs with stricterFIFO. By default it puts a task it can't hand out yet at the back of its queue, so early heavy tasks like module pages ran last.
  • One enqueue starts a batch's chunks, waiting while the limit is reached, and the consumer waits on an inline Promise.withResolvers() as chunks start. An array is read as [items], so its first chunks start right away.

Nothing passes worker.stream a stream yet. With one thread nothing changes, and with more, an array's chunks now start a few per thread at a time instead of all at once, with the same output.

Validation

Added tests in parallel.test.mjs for the streamed input: the limit on chunks in flight and its refill, the order tasks start in, a failure reading the batches arriving after the chunks before it, a single thread running each batch as it arrives, and no batches. Built on 12 of Node.js's API docs at 1 and 4 threads, the output is byte-identical to main's.

Related Issues

Refs: #1205

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 Nov 3, 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 Nov 6, 2026 7:44pm UTC

Request Review

@ovflowd
ovflowd added this pull request to stack #1244 November 3, 2026 19:34
@ovflowd ovflowd changed the title perf/stream worker perf: let worker.stream take chunks as they arrive Nov 3, 2026
@codecov

codecov Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.40828% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.18%. Comparing base (61fa0b9) to head (03fafb9).

Files with missing lines Patch % Lines
...ges/core/src/threading/__tests__/parallel.test.mjs 99.50% 1 Missing ⚠️
packages/core/src/threading/parallel.mjs 99.21% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1237      +/-   ##
==========================================
+ Coverage   94.13%   94.18%   +0.04%     
==========================================
  Files         291      291              
  Lines       28900    29214     +314     
  Branches     2932     2969      +37     
==========================================
+ Hits        27206    27515     +309     
- Misses       1672     1677       +5     
  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 Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

api-links Generator

Performance estimate (single CI run)

  • Generation time: 17.1% slower (820.00 ms → 960.00 ms)
  • Peak memory: 2.5% lower (283.63 MB → 276.63 MB)

json Generator

Performance estimate (single CI run)

  • Generation time: 12.4% slower (5.98 s → 6.72 s)
  • Peak memory: 14.6% lower (1.00 GB → 874.91 MB)

legacy-html Generator

Performance estimate (single CI run)

  • Generation time: 44.5% slower (11.52 s → 16.65 s)
  • Peak memory: 2.0% higher (1.43 GB → 1.46 GB)

legacy-json Generator

Performance estimate (single CI run)

  • Generation time: 6.6% slower (8.23 s → 8.77 s)
  • Peak memory: 18.3% lower (1015.46 MB → 830.00 MB)

llms-txt Generator

Performance estimate (single CI run)

  • Generation time: 24.4% slower (6.10 s → 7.59 s)
  • Peak memory: 5.9% lower (866.38 MB → 815.02 MB)

orama-db Generator

Performance estimate (single CI run)

  • Generation time: 23.9% faster (8.52 s → 6.48 s)
  • Peak memory: 13.0% higher (945.88 MB → 1.04 GB)

web Generator

Performance estimate (single CI run)

  • Generation time: 1.5% faster (14.70 s → 14.48 s)
  • Peak memory: 1.9% higher (1.52 GB → 1.55 GB)

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 3, 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 ↗ 03fafb9 2026-11-06T19:44:25.939Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 1f798eb 2026-11-06T19:32:07.882Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 3db554e 2026-11-06T18:50:35.046Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 3acaa3d 2026-11-06T13:37:34.474Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ 51c7fb0 2026-11-04T18:25:18.317Z View logs ↗
  • Build: Success ✅

View logs ↗
No Preview URL. Enable ↗ e677946 2026-11-03T19:34:52.456Z View logs ↗

@ovflowd
ovflowd marked this pull request as ready for review November 3, 2026 20:39
@ovflowd
ovflowd requested a review from a team as a code owner November 3, 2026 20:39
let doneReading = false;

// Notified as chunks start, and once every batch is read
const changed = createSignal();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The waiting queue + settle/start/add + signal feels heavy. Could this just backpressure in one enqueue (await while at the in-flight limit) and wake the consumer with an inline resolvers pair?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair, it was heavy. There's a single enqueue now that waits while we're at the limit, and the consumer waits on an inline Promise.withResolvers(). No more waiting queue or signal. Arrays still kick off their first chunks right away, which the existing submission order tests rely on.

}

const chunks = createChunks(items.length, chunkSize);
const batches = toBatches(items);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: streamed ? items : [items] here would drop the need for toBatches.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yup, done. toBatches moved up to #1240, since groupWholeModules is the first thing that needs it (html and html-all use it too).

Comment thread .changeset/stream-worker-chunks.md Outdated
'@doc-kit/core': minor
---

`worker.stream` accepts an async iterable of batches, starting each one's work as it arrives, as well as an array. It keeps only a few chunks per thread in flight, so generators running at once share the worker pool, starts them in the order they were submitted and yields their results in that order. `toBatches`, from `@doc-kit/core/utils/generators.mjs`, gives an input that is either whole or streamed as batches.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If toBatches goes away, drop the mention from the changeset too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, it's gone from here and #1240's changeset mentions it now.

@ovflowd

ovflowd commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review! worker.stream is a fair bit simpler now, and since it doesn't need them anymore, createSignal moved to #1238 (with the replay) and toBatches to #1240 (with groupWholeModules). I also rebased the whole stack on main.

* @param {(items: number[]) => number[] | AsyncIterable<number[]>} toInput
* @returns {Promise<void>}
*/
async function checkChunksPerThread(toInput) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems extremely complex for a test...?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, fair, it was overkill. It's just a counter now: each task takes a tick, and the test checks that the pool fills up to the limit, never past it, and that results still come out in order.

Comment on lines +128 to +129
for await (const batch of batches) {
for (const indices of distribute(batch)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't love the double for loop + yield await, that feels slow, is it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not really. I measured it with a no-op processChunk: ~400ns per chunk vs ~370ns on main, and real chunks take milliseconds, so it's noise. With one thread it runs chunks one at a time on purpose, since nothing could run alongside and only one chunk's results are held at once.

Comment thread .changeset/stream-worker-chunks.md Outdated
@@ -0,0 +1,5 @@
---
'@doc-kit/core': minor

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
'@doc-kit/core': minor
'@doc-kit/core': patch

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, it's a patch now. Thanks for the cleanup commit too!

ovflowd and others added 6 commits November 6, 2026 20:42
worker.stream needed all of its input before it started any work. It
now also takes an async iterable of batches, as a streamed input yields
them, and starts each batch's work as it arrives. An input that arrives
whole, as it does with one thread, works as before.

Only a few chunks per thread are started at a time, and results are
yielded in the order the chunks were submitted. That keeps generators
that run at once from filling the pool with their own chunks. Piscina
runs with stricterFIFO for it: by default it puts a task it can't hand
out yet at the back of its queue, so early heavy tasks ran last.

toBatches gives an input that is whole or streamed as batches, and
createSignal is the wake-up the stream waits on.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: Claude Sonnet 5.5 <noreply@anthropic.com>
One enqueue now starts a batch's chunks, waiting for room while the
in-flight limit is reached, and the consumer waits on an inline
Promise.withResolvers() pair, in place of the waiting queue, settle,
start and add, and the signal. An array's chunks still start right
away, as before.

The input is read as `streamed ? items : [items]`, so toBatches and
createSignal move up the stack to their first callers.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
Since the reader waits for room, the comments and the changeset no
longer said what it does: a batch's chunks start while earlier ones
run, and the next batch is read once there's room for its chunks. The
completed-chunk debug log also counts against the chunks distributed so
far, rather than those started.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
Each task now takes a turn of the event loop and counts how many run at
once, instead of the test settling them by hand, newest first. It checks
that the pool fills up to the limit and never past it, and that results
come out in submission order; a stream that didn't refill the pool still
hangs into the timeout.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
As suggested in review.

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

This branch was successfully deployed

1 active deployment
Preview – api-docs-tooling — 03fafb9c Deployed Nov 6, 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.

2 participants