Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions fiber-await/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Measuring the fiber-await prototype

Measurement tooling for running `await` continuations on fibers ([node#7](https://github.com/qualialabs/node/pull/7) + [node#9](https://github.com/qualialabs/node/pull/9), [node-fibers#6](https://github.com/qualialabs/node-fibers/pull/6) + [#8](https://github.com/qualialabs/node-fibers/pull/8)). It lives on its own branch so it never ships: build fibers from this branch only in environments you want to measure.

This branch adds, on top of #8:
- `Fiber.microtaskDrains`, `Fiber.drainsWithDispatch`, `Fiber.redispatches` (C++ counters; `Fiber.microtasksDispatched` and `Fiber.microtasksBatched` already exist)
- `fiber-await/test.js`, `fiber-await/bench.js`, `fiber-await/app_stats.mjs`

## What gets measured

A promise callback (the code after an `await`, a `.then` callback) is **tagged** if it was registered while a fiber was running. Tagged callbacks are run on a hidden *dispatch fiber*; untagged ones run on the main stack as on stock node.

| Counter | Meaning |
|---|---|
| `microtasksDispatched` | Switches onto a dispatch fiber |
| `microtasksBatched` | Tagged callbacks run on the dispatch fiber without a switch (they were queued right behind another tagged one) |
| `microtaskDrains` | Microtask checkpoints on the default queue, including empty ones (node checkpoints about twice per event-loop turn) |
| `drainsWithDispatch` | Drains that switched onto a dispatch fiber at least once |
| `redispatches` | Further switches within the same drain: an untagged callback between two tagged ones sent us back to the main stack |

Each switch costs about one fiber `run()` + `yield()` round trip. On the `CORO_PTHREAD` backend (Linux, arm64) every fiber is an OS thread, so that's a thread handoff.

## Behaviour checks

```sh
node --no-deprecation fiber-await/test.js # expect: all passed
FIBERS_AWAIT_REUSE=0 node --no-deprecation fiber-await/test.js # one new fiber per job; all passed
FIBERS_AWAIT_DISPATCH=0 node --no-deprecation fiber-await/test.js # baseline: 7 fail, as on stock node
```

znewsham's scenarios from [node-fibers#7](https://github.com/qualialabs/node-fibers/pull/7) (`test-microtask-fibers.js`) also run here once `Fiber.current` is replaced with `(Fiber.currentIncludingHidden || Fiber.current)`, since this design hides the fiber from `Fiber.current`. With `Fiber.poolSize = 1e9`, 7/7 behaviour tests pass. Its 100k park/resume test takes about 10 s here, just over its 10 s timeout.

## Microbenchmarks

```sh
node --no-deprecation fiber-await/bench.js
BENCH=switch node --no-deprecation fiber-await/bench.js # one benchmark; also works on stock node + fibers
```

Run it with the same binary and `FIBERS_AWAIT_REUSE=0` / `FIBERS_AWAIT_DISPATCH=0`, and with stock node, to compare. `switch` needs no patch, so it can measure the thread-handoff floor on any environment before deploying anything.

Reference numbers from one `bench.js` run per column (arm64 Docker Desktop VM on an M-series Mac, node 18.16.1, 2026-10-02; expect run-to-run variance of 10-30%):

| Benchmark | stock node + fibers | patched, `FIBERS_AWAIT_REUSE=0` | patched (reuse on) |
|---|---|---|---|
| `switch` (run+yield round trip) | 20.4 us | 24.7 us | 20.6 us |
| `chain` | 0.04 us | 24.8 us | 0.13 us |
| `io` | 0.61 us | 31.8 us | 20.8 us |
| `interleaved` | 0.09 us | 28.3 us | 21.8 us |
| `fiberless` | 0.04 us | 0.04 us | 0.05 us |
| `drains` | 0.57 us | 0.70 us | 0.74 us |
| `park` | skipped | 158.9 us | 145.9 us |

The harness runs inside one long-lived async loop, so its counter deltas can show a `redispatches` count where a real app would see a new drain. Use the per-op times from `bench.js` and the counters from `app_stats.mjs`.

## Sampling a running app

From a Meteor shell (or another REPL in the app process). If `fibers/...` doesn't resolve from the shell, import the file by absolute path instead:

```js
const s = await import('fibers/fiber-await/app_stats.mjs');
s.start(); // then use the app
s.stop(); // counts and percentages for the window
s.measureSwitch(); // { roundTripUs } in this process; blocks the event loop briefly
s.stop({ roundTripUs }) // after another start(): also estimates the time spent switching
```

`start()` counts every promise callback with a `v8.promiseHooks` `before` hook (one JS call per promise job), so stop it when you're done. Non-promise `queueMicrotask` callbacks are not counted; they're never tagged.

Reference (local qualia, one user clicking for 88 s, 2026-10-02): 13.7% of promise callbacks tagged, 3.2% of untagged callbacks caused a switch, 1.9% of drains used the dispatch fiber, 71% of tagged callbacks batched; 4,738 switches ≈ 0.1–0.25% of a core at 20–45 us each. Idle: 6.9% tagged, 0.29% of untagged caused a switch.
84 changes: 84 additions & 0 deletions fiber-await/app_stats.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Sample await-dispatch activity in a running app. See README.md.
//
// From a Meteor shell (or any REPL inside the app process):
// const s = await import('fibers/fiber-await/app_stats.mjs');
// s.start(); // ...use the app...
// s.stop(); // counts and percentages for the window
// s.measureSwitch(); // optional: fiber switch cost in this process (blocks ~0.1-0.5s)
//
// start() adds a promise `before` hook that counts every promise callback; it costs a JS call per
// promise job while armed, so leave it off when you're not sampling.
import { createRequire } from 'node:module';
import { promiseHooks } from 'node:v8';

// The native fibers module every copy of fibers in the process shares. Read it rather than loading
// fibers_async.js, which would register a dispatcher of its own.
const Fiber = process.fiberLib || createRequire(import.meta.url)('../fibers_sync.js');

let armed = null;

function snapshot() {
return {
at: process.hrtime.bigint(),
drains: Fiber.microtaskDrains,
drainsWithDispatch: Fiber.drainsWithDispatch,
dispatched: Fiber.microtasksDispatched,
redispatches: Fiber.redispatches,
batched: Fiber.__microtasksBatched || 0,
};
}

export function start() {
if (Fiber.microtasksDispatched === undefined || Fiber.redispatches === undefined) {
throw new Error('this fibers build has no dispatch counters (needs the fiber-await measurement branch)');
}
if (armed) armed.stopHook();
const state = { callbacks: 0 };
state.stopHook = promiseHooks.onBefore(() => { state.callbacks++; });
state.start = snapshot();
armed = state;
return 'armed';
}

export function stop({ roundTripUs } = {}) {
if (!armed) throw new Error('call start() first');
const end = snapshot();
armed.stopHook();
const { start: begin, callbacks } = armed;
armed = null;
const d = (k) => end[k] - begin[k];
const pct = (part, whole) => (whole ? `${(100 * part / whole).toFixed(2)}%` : 'n/a');
const tagged = d('dispatched') + d('batched');
const untagged = callbacks - tagged;
const result = {
seconds: Number(end.at - begin.at) / 1e9,
promiseCallbacks: callbacks,
tagged,
taggedShare: pct(tagged, callbacks),
untagged,
// Each redispatch is a gap of untagged callbacks between two tagged ones in the same drain.
untaggedThatCausedASwitch: d('redispatches'),
untaggedThatCausedASwitchShare: pct(d('redispatches'), untagged),
drains: d('drains'),
drainsWithDispatch: d('drainsWithDispatch'),
drainsWithDispatchShare: pct(d('drainsWithDispatch'), d('drains')),
switchesOntoDispatchFiber: d('dispatched'),
batchedWithoutSwitch: d('batched'),
batchedShareOfTagged: pct(d('batched'), tagged),
};
if (roundTripUs) {
result.estimatedSwitchMs = Number((d('dispatched') * roundTripUs / 1000).toFixed(1));
result.estimatedRedispatchMs = Number((d('redispatches') * roundTripUs / 1000).toFixed(1));
}
return result;
}

// Cost of one fiber run+yield round trip in this process (the cost of each switch onto the dispatch
// fiber and back). Blocks the event loop while it runs.
export function measureSwitch(n = 5000) {
const fiber = Fiber(() => { for (;;) Fiber.yield(); });
fiber.run();
const begin = process.hrtime.bigint();
for (let i = 0; i < n; i++) fiber.run();
return { roundTripUs: Number((Number(process.hrtime.bigint() - begin) / 1e3 / n).toFixed(2)) };
}
133 changes: 133 additions & 0 deletions fiber-await/bench.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Microbenchmarks for running await continuations on fibers. See README.md.
// Run: node --no-deprecation fiber-await/bench.js (all)
// BENCH=switch,io node --no-deprecation fiber-await/bench.js
// Compare the same binary with FIBERS_AWAIT_REUSE=0 or FIBERS_AWAIT_DISPATCH=0, and with stock node.
const path = require('path');
const Fiber = require(path.join(__dirname, '..'));

Fiber.poolSize = 1e9; // production setting; also avoids the pthread coro_destroy crash in test/pool.js

// The fiber code after an await runs on, whether or not Fiber.current shows it.
const runningFiber = () => Fiber.currentIncludingHidden || Fiber.current;
const dispatchAvailable = typeof Fiber.__setMicrotaskDispatcher === 'function';
const now = () => process.hrtime.bigint();
const msSince = (start) => Number(now() - start) / 1e6;
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));

function inFiber(fn) {
return new Promise((resolve, reject) => Fiber(() => { fn().then(resolve, reject); }).run());
}

function counters() {
return {
dispatched: Fiber.microtasksDispatched || 0,
batched: Fiber.microtasksBatched || 0,
redispatches: Fiber.redispatches || 0,
};
}

const benches = {
// Two fiber switches (run + yield), with fibers' async-hooks stack save/restore. The floor for any
// design that moves work onto a fiber; works on stock node + fibers.
async switch() {
const n = 50000;
const fiber = Fiber(() => { for (;;) Fiber.yield(); });
fiber.run();
const start = now();
for (let i = 0; i < n; i++) fiber.run();
return { n, ms: msSince(start), unit: 'run+yield round trip' };
},

// A chain of awaits in a fiber with nothing else queued: batching's best case.
async chain() {
const n = 100000;
let ms;
await inFiber(async () => {
const start = now();
for (let i = 0; i < n; i++) await null;
ms = msSince(start);
});
return { n, ms, unit: 'await in a fiber' };
},

// Each await resumes from its own macrotask, like a DB call: one dispatch per await.
async io() {
const n = 20000;
let ms;
await inFiber(async () => {
const start = now();
for (let i = 0; i < n; i++) await nextTurn();
ms = msSince(start);
});
return { n, ms, unit: 'I/O-style await in a fiber' };
},

// A fibered chain alongside a fiberless one, so tagged and untagged jobs alternate.
async interleaved() {
const n = 50000;
const chain = async () => { for (let i = 0; i < n; i++) await null; };
const start = now();
await Promise.all([inFiber(chain), chain()]);
return { n, ms: msSince(start), unit: 'await in a fiber, interleaved with fiberless' };
},

// Awaits in code that never had a fiber: should cost the same as stock.
async fiberless() {
const n = 1000000;
const start = now();
for (let i = 0; i < n; i++) await null;
return { n, ms: msSince(start), unit: 'await outside any fiber' };
},

// One non-empty microtask drain per macrotask, in fiberless code (znewsham's drain benchmark).
async drains() {
const n = 200000;
const start = now();
await new Promise((resolve) => {
let i = 0;
(function next() {
if (++i === n) return resolve();
Promise.resolve().then(() => setImmediate(next));
})();
});
return { n, ms: msSince(start), unit: 'macrotask with a microtask drain' };
},

// Park the fiber after an await and resume it from the next macrotask, 1000 at a time.
async park() {
if (!dispatchAvailable) return { skipped: 'needs the fiber-await node + fibers' };
const n = 20000;
const batch = 1000;
const start = now();
for (let i = 0; i < n; i += batch) {
await Promise.all(Array.from({ length: batch }, () => inFiber(async () => {
await null;
const fiber = runningFiber();
setImmediate(() => fiber.run());
Fiber.yield();
})));
}
return { n, ms: msSince(start), unit: 'park/resume cycle after await' };
},
};

(async () => {
const selected = process.env.BENCH ? process.env.BENCH.split(',') : Object.keys(benches);
console.log(`node ${process.version}; dispatch API: ${dispatchAvailable}; ` +
`FIBERS_AWAIT_DISPATCH=${process.env.FIBERS_AWAIT_DISPATCH ?? '(unset)'} ` +
`FIBERS_AWAIT_REUSE=${process.env.FIBERS_AWAIT_REUSE ?? '(unset)'}`);
for (const name of selected) {
await benches[name](); // warm up
const before = counters();
const result = await benches[name]();
const after = counters();
if (result.skipped) {
console.log(`${name.padEnd(12)} skipped: ${result.skipped}`);
continue;
}
const delta = Object.fromEntries(Object.keys(after).map((k) => [k, after[k] - before[k]]));
const us = (result.ms * 1000 / result.n).toFixed(2);
console.log(`${name.padEnd(12)} ${us.padStart(8)} us per ${result.unit}` +
(dispatchAvailable ? ` ${JSON.stringify(delta)}` : ''));
}
})();
Loading