Skip to content

requestDevice() starts a permanent ~1-core busy loop on the main thread (Linux/Vulkan) #17

Description

@kevinsproles

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions