Skip to content

perf: a socket request/response loop whose next write is deferred to a microtask is 50–60× slower than Node (the wait driver parks 1 ms per turn with JS work pending) #10525

Description

@proggeramlug

Found by the package performance audit (real npm packages compiled from source, profiled against Node 26.5.1) and
re-measured on Perry 7661bc0 (v0.5.1589), Linux x64. When a net.Socket 'data' handler defers its next write()
to a microtask (queueMicrotask, promise.then, process.nextTick) or to setImmediate, each request/response turn
waits about 2.2 ms in epoll_wait before the queued JS runs. The loop is 50–60× slower than Node, and the process is
idle for ~95 % of its wall time.

Reproduction

bench.ts is self-contained. An in-process echo server plus a client does N round trips.

import * as net from "node:net";
const variant = process.argv[2] || "queueMicrotask"; const N = Number(process.argv[3] || "1000");
const defer: (f: () => void) => void =
  variant === "direct" ? (f) => f() : variant === "queueMicrotask" ? (f) => queueMicrotask(f)
  : variant === "promise" ? (f) => { Promise.resolve().then(f); } : variant === "nextTick" ? (f) => process.nextTick(f)
  : (f) => { setImmediate(f); };
const server = net.createServer((c) => { c.setNoDelay(true); c.on("data", (d) => c.write(d)); });
server.listen(0, "127.0.0.1", () => {
  const port = (server.address() as net.AddressInfo).port;
  const s = new net.Socket(); s.setNoDelay(true);
  let i = 0; let bytes = 0; let t0 = 0;
  const send = () => s.write("ping" + i);
  s.on("data", (d) => {
    bytes += d.length;
    if (++i === N) {
      const ms = performance.now() - t0;
      console.log(`variant=${variant} N=${N} checksum=${N} ms=${ms.toFixed(1)} rtt_per_s=${(N / ms * 1000).toFixed(0)}`);
      s.destroy(); server.close();
    } else defer(send);
  });
  s.connect(port, "127.0.0.1", () => { t0 = performance.now(); send(); });
});
PERRY_NO_AUTO_OPTIMIZE=1 perry compile bench.ts -o bench
for v in direct queueMicrotask promise nextTick setImmediate; do node bench.ts $v 1000; ./bench $v 1000; done
strace -f -T -e trace=epoll_wait ./bench queueMicrotask 200 2>&1 | grep 'epoll_wait(.*, 2) = 0'

Measurements

Median of 3 runs on a shared, loaded host (load average ~100 on 64 threads). Loop = 1,000 round trips. Perry CPU is
task-clock for the whole process.

variant Node loop ms Perry loop ms ratio Perry CPU ms Perry instructions Node wall Perry wall
direct write (control) 34.7 36.8 1.1× 47 0.09 G 153 ms 94 ms
queueMicrotask(send) 40.0 2,206 55× 121 0.12 G 181 ms 2,273 ms
Promise.resolve().then(send) 36.6 2,214 60× 125 0.12 G 235 ms 2,290 ms
process.nextTick(send) 46.7 2,259 48× 124 0.12 G 215 ms 2,333 ms
setImmediate(send) 41.6 2,213 53× 112 0.12 G 181 ms 2,275 ms

Checksums are identical.

  • Perry's CPU cost for the deferred loop is still only ~120 ms, close to the direct loop's 47 ms. The ~2.1 s difference is
    all idle wall time, 2.2 ms per round trip.
  • strace shows the stall. Each turn contains epoll_wait(3, [], 1024, 2) = 0 <0.002072>, a 2 ms kernel timeout (the 1 ms
    tokio timer rounded up) that expires with no events.
  • strace -c over 200 round trips: 1,014 epoll_wait calls in the deferred loop against 811 in the direct loop.

Impact

This stall is the largest single cost in the database drivers. Figures are from the audit profile (I/O group,
v0.5.1587, offcputime plus /usr/bin/time):

  • pg 8.22.0 (pool.query('SELECT $1::int',[i]) ×5000, 70.7× Node): 3.32 ms wall per query but only 1.22 ms CPU.
    63 % of wall is idle, and 2.41 s of a 4 s window sits in main → stdlib_fast_drive → block_on → time driver park → epoll_wait.
  • mysql2 3.23.2 (pool.execute ×5000, 51× Node): 47 % of wall is idle, 99.8 % of it in the same stack.
  • The audit estimated the effect of removing the stall alone: pg ~3.3 → ~1.25 ms/query, mysql2 ~4.5 → ~2.4 ms/query.

Both drivers hit the stall the same way. The socket 'data' handler resolves the query promise, and the application's
await continuation issues the next query, so the next write() always runs from a microtask. Any protocol client
built on net with promise-based request/response has this shape (Redis, MongoDB, SMTP and custom RPC clients).

A node:http server whose handler does await before res.end() did not stall in the same test (500 keep-alive
requests: 41 ms vs 45 ms sync). The stall is specific to the path that drives net sockets.

Mechanism

  • crates/perry-runtime/src/event_pump.rs:544 (verified): js_wait_for_event takes the fast path at line 577 when
    NOTIFIED is set or js_microtasks_pending() > 0. It calls invoke_wait_driver_fast() (line 581) and returns, so
    the pending JS runs only after the driver returns.
  • crates/perry-stdlib/src/common/async_bridge.rs:370-386 (verified): the registered fast driver stdlib_fast_drive
    (installed at line 477) checks native_fast_drive_needed (blocking tasks in flight, or js_aux_has_active(), which
    is true while an extension such as net has a live handle). If that returns true, it runs
    RUNTIME.block_on(timeout(Duration::from_millis(1), EVENT_READY.notified())) (line 384).
  • With a request/response protocol nothing native can arrive during that wait, because the peer is still waiting for the
    request that sits in the microtask queue. So every turn pays the full timer.
  • The comment above the fast path explains why it exists: it gives in-flight native tasks a turn so constant promise
    churn cannot starve them (the fetch hang). Its budget, though, is a blocking 1 ms timeout rather than a non-blocking
    poll.
  • (inferred) The same stall applies to any js_aux_has_active contributor, not only net.

What fast looks like

When JS work is already pending, the fast drive should poll the reactor without blocking: run ready tasks and I/O
events, then return immediately. It should keep a zero or near-zero timeout, and use a bounded tick count rather than a
wall-clock budget to preserve the anti-starvation guarantee.

Targets:

  • Microbenchmark: each deferred variant within 1.2× of the direct-write variant and within 1.5× of Node (≤ ~60 ms per
    1,000 round trips here).
  • Wall vs CPU: wall ≈ CPU, with no epoll_wait(..., 2) = 0 in the steady-state strace.
  • Workloads: pg and mysql2 idle share of wall under 10 %.

Notes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions