Summary
Once adapter.requestDevice() resolves, the process starts a permanent busy pump on the main JS thread that never idles: ~1.09–1.10 cores of pure spin on Linux/Vulkan (NVIDIA), ~0.25 cores on macOS/Metal — with zero work submitted. It runs until device.destroy(), which stops it completely.
For a headless Node.js server-side rendering deployment this is severe: N worker processes burn N × 1.1 cores doing nothing. On one 32-thread host we measured 10.6 of 30.7 cores consumed by 9 completely idle processes.
Repro
node spin-repro.mjs (webgpu@0.4.0, Node 22):
// Does merely CREATING a device spin the Node main thread? Each stage measured identically.
const cpu = () => { const u = process.cpuUsage(); return (u.user + u.system) / 1e6 };
async function measure(label, ms = 8000) {
const c0 = cpu(), t0 = performance.now();
await new Promise((r) => setTimeout(r, ms));
console.log(` ${label.padEnd(34)} ${((cpu() - c0) / ((performance.now() - t0) / 1000)).toFixed(3)} cores`);
}
await measure("baseline (bare node)");
const { create, globals } = await import("webgpu");
Object.assign(globalThis, globals);
await measure("after import webgpu");
const gpu = create([]);
await measure("after create() instance");
const adapter = await gpu.requestAdapter({ powerPreference: "high-performance" });
await measure("after requestAdapter");
const device = await adapter.requestDevice({});
await measure("after requestDevice <-- the tell");
const enc = device.createCommandEncoder();
device.queue.submit([enc.finish()]);
await device.queue.onSubmittedWorkDone();
await measure("after one empty submit");
device.destroy();
await measure("after device.destroy()");
process.exit(0);
Measurements
| lifecycle stage |
Linux/Vulkan, NVIDIA (driver 595.x; RTX 4060 Ti and RTX 4090) |
macOS/Metal (Apple M4) |
| bare node |
0.000 cores |
0.000 cores |
after import "webgpu" |
0.000 |
0.000 |
after create() |
0.000 |
0.000 |
after requestAdapter() |
0.000 |
0.000 |
after requestDevice() |
1.094–1.100 |
0.254 |
after one empty submit + onSubmittedWorkDone |
unchanged |
0.251 |
after device.destroy() |
0.000 |
0.000 |
Thread attribution
Per-thread CPU from /proc/<pid>/task/*/stat on Linux: the spinner is the main JS thread (1.010 cores); libuv pool threads are ~0.038 cores each. In V8 CPU profiles of a busy server the spin shows up as processImmediate (up to 23.9% of self time), i.e. a setImmediate-driven "process events" pump that reschedules itself forever. That is consistent with the AsyncRunner/ProcessEvents polling loop in Dawn's dawn/node bindings never reaching an idle state — plausibly because the device.lost future is permanently pending, so there is always "a future in flight" and the runner never stops rescheduling.
The macOS-vs-Linux asymmetry (0.25 vs 1.1 cores) suggests the per-iteration cost is dominated by the backend's ProcessEvents/instance-poll cost, not the JS side — the loop itself appears to run at the same rate on both.
Ask
- Let the pump go idle when no user-visible future can make progress (e.g. event/callback-driven wakeup, or stop rescheduling when the only pending future is
device.lost), or
- failing that, expose a knob (env var /
create() flag) for the polling interval so servers can trade latency for not burning a core per process.
If the fix belongs in upstream Dawn's dawn/node AsyncRunner rather than this packaging repo, happy to have this forwarded / to file it there — reporting here first since this repo ships the binding as the webgpu npm package.
Environment
- webgpu 0.4.0 (npm), Node.js v22.x
- Linux x64, NVIDIA driver 595.x (RTX 4060 Ti, RTX 4090), Vulkan backend
- macOS (Apple M4), Metal backend
Summary
Once
adapter.requestDevice()resolves, the process starts a permanent busy pump on the main JS thread that never idles: ~1.09–1.10 cores of pure spin on Linux/Vulkan (NVIDIA), ~0.25 cores on macOS/Metal — with zero work submitted. It runs untildevice.destroy(), which stops it completely.For a headless Node.js server-side rendering deployment this is severe: N worker processes burn N × 1.1 cores doing nothing. On one 32-thread host we measured 10.6 of 30.7 cores consumed by 9 completely idle processes.
Repro
node spin-repro.mjs(webgpu@0.4.0, Node 22):Measurements
import "webgpu"create()requestAdapter()requestDevice()onSubmittedWorkDonedevice.destroy()Thread attribution
Per-thread CPU from
/proc/<pid>/task/*/staton Linux: the spinner is the main JS thread (1.010 cores); libuv pool threads are ~0.038 cores each. In V8 CPU profiles of a busy server the spin shows up asprocessImmediate(up to 23.9% of self time), i.e. asetImmediate-driven "process events" pump that reschedules itself forever. That is consistent with the AsyncRunner/ProcessEvents polling loop in Dawn'sdawn/nodebindings never reaching an idle state — plausibly because thedevice.lostfuture is permanently pending, so there is always "a future in flight" and the runner never stops rescheduling.The macOS-vs-Linux asymmetry (0.25 vs 1.1 cores) suggests the per-iteration cost is dominated by the backend's ProcessEvents/instance-poll cost, not the JS side — the loop itself appears to run at the same rate on both.
Ask
device.lost), orcreate()flag) for the polling interval so servers can trade latency for not burning a core per process.If the fix belongs in upstream Dawn's
dawn/nodeAsyncRunner rather than this packaging repo, happy to have this forwarded / to file it there — reporting here first since this repo ships the binding as thewebgpunpm package.Environment