Skip to content

perf(qwp): event-driven I/O loop, zero CPU when idle; producer fast paths - #116

Draft
jerrinot wants to merge 6 commits into
mainfrom
perf/qwp-idle-cpu-low-risk
Draft

jerrinot wants to merge 6 commits into
mainfrom
perf/qwp-idle-cpu-low-risk

Conversation

@jerrinot

Copy link
Copy Markdown
Contributor

Problem

A QWP sender burns ~70% of a core per connection even when idle: the I/O thread polls the socket and the segment ring every 50 µs.

Change

  • Event-driven I/O loop (perf(qwp): event-driven I/O loop, zero CPU when idle #114, squashed into one commit). The I/O thread blocks until the socket is readable, a producer published a frame, close() runs, or a PING is due. Producers ring a native doorbell (eventfd on Linux, kqueue EVFILT_USER on macOS/FreeBSD, a kernel Event on Windows) only when the thread is parked. Ack waiters, the segment manager and the dispatchers are event-driven too.
  • Producer fast paths, no behaviour change:
    • one-compare capacity check in OffHeapAppendMemory; identity check first in column lookup
    • ASCII fast path in OffHeapAppendMemory.putUtf8
    • 8-byte word moves for native copies up to 64 bytes (Vect.memcpy)
    • no frame CRC in memory-backed store-and-forward segments: they are never recovered; disk-backed segments are unchanged

No change to the Sender API or configuration.

Numbers

  • I/O loop: see perf(qwp): event-driven I/O loop, zero CPU when idle #114. Idle CPU 0%, flush-to-ack latency 3–15x lower, saturation +20%. Above ~25k flushes/s per connection the client uses 1.1–1.45x the CPU of main.
  • Fast paths: each was measured on its own against a mock server, on a longer branch: string columns +8.6% rows/s (putUtf8), 1 row/flush +8.3% (small copies) and +7.2% (CRC skip). They have not been re-measured together on this branch.

Testing

  • mvn -pl core test: 3748 pass on JDK 25; JDK 8 test-compile clean.
  • New: randomized putUtf8 against String.getBytes(UTF_8); Vect.memcpy over every length 0–160 at every source and destination alignment.

A QWP sender burned ~70% of a core per connection even when idle: the
I/O thread polled the socket and the segment ring every 50 us. Kafka
Connect users saw 30% CPU on QWP vs 3% on HTTP for the same quiet sink.

The I/O thread now blocks until there is something to do: socket
readable, a producer published a frame, close(), or a PING is due. The
producer wakes it through a native doorbell (eventfd on Linux, kqueue
EVFILT_USER on macOS/FreeBSD, a kernel Event on Windows), and only when
the thread is parked, so a busy connection never pays a syscall per
flush. Ack waiters, the segment manager and the dispatchers are
event-driven as well: a waiter in awaitAckedFsn/drain is unparked on
every ack advance, terminal and durability latch; the segment manager
parks until a rotation, ACK or timer needs it and persists the ack
watermark on a 1 ms cadence while ACKs stream; the progress dispatcher
thread starts only when a handler is installed.

No change to the Sender API or configuration.

Idle CPU drops to 0, flush-to-ack latency falls 3-15x and saturation
throughput rises ~20%. Above ~25k small flushes per second per
connection the client uses 1.1-1.45x the CPU of the polling loop: the
I/O thread takes two prompt wakes per flush (doorbell, then ACK).

design/tla holds TLA+ models of the two wake protocols.
… in column lookup

ensureCapacity() carried the realloc in the same body, which was large
enough that the JIT would not inline it into every putLong/putDouble. The
check is now one compare against a precomputed limit address with the
realloc in grow(). lookupColumn() first compares the column name by
identity, which callers passing String constants hit every row.

asipc, mock server, 60M rows, 1000 rows/flush, 3 interleaved reps:
instructions/row -26..-68, rows/s +1..2% across numeric/symbol/string.
Only recovery reads a frame's CRC, and a memory-backed segment is never
recovered, yet every frame paid a CRC pass over all its bytes: 13% of
the producer's cycles at one row per flush. The CRC field stays, zeroed,
so both backings keep one frame layout; disk segments are unchanged
(inverting the condition fails the recovery tests).

Measured on asipc against the batching mock, producer and I/O thread
pinned to CPUs 2 and 4, 5 interleaved reps, medians:
  1 row/flush:      rows/s +7.2%, producer instructions/row -4.4%
  1000-row numeric: rows/s +2.5% against the baseline's fast mode
                    (+22.9% against its median: the baseline runs are
                    bimodal, 28.0-28.8M or 34.6M; this one is steady
                    at 34.8-36.4M), instructions/row -3.2%
  1000-row symbol:  +4.3%;  1000-row string: +3.9%
Encode on a local cursor and leave for the general loop at the first
non-ASCII char. Writing the appendAddress field per char, interleaved
with Unsafe stores, kept the JIT from holding the address in a register.

String column (80M rows, 1000 rows/flush, pinned): +8.6% rows/s,
producer instructions -9.0%. Other shapes unchanged.
Vect.memcpy copies up to 64 bytes with 8-byte moves instead of the
Unsafe.copyMemory stub, and NativeBufferWriter.putBlockOfBytes goes
through it. At one row per flush each column is a few bytes, and the
stub's call and dispatch cost more than the copy: 7.3% of the producer.

One row per flush, pinned: +8.3% rows/s, producer instructions/row
-7.5%. 1000-row shapes unchanged.
@jerrinot

Copy link
Copy Markdown
Contributor Author

Review (level 3): approve with comments

Reviewed HEAD 5981feab against BASE 82b2e144. No submodule pointers moved.

Nothing blocks the merge. Two Moderate findings have runtime evidence on HEAD and base; please address #1 before merging.

Critical

None.

Moderate

1. A failed rotation followed by close leaks one segment from the pool's shared budget

  • Problem: A segment kept after a failed manifest update is never released from the budget.
  • Net impact: Pooled disk-mode senders permanently lose one segment (4 MiB by default) of sf_max_total_bytes per occurrence.
  • Evidence: A scratch test ran the same trigger on both revisions. After deregister + close, the budget read 65536 on HEAD and 0 on base.

Path:

  1. The manifest write or fsync fails during a rotation (EIO/ENOSPC).
  2. SegmentRing.appendOrFsn calls SegmentProvisioner.discard(), which puts the segment in retained. It is still charged to the budget.
  3. The flush throws, and PooledSender.close() discards the sender.
  4. CursorSendEngine.close calls SegmentManager.deregister, which releases only ring.totalSegmentBytes(). That covers active, sealed and pending trims, not retained.
  5. SegmentProvisioner.close() closes the segment without releasing its charge.

SenderPool keeps one SegmentBudget for its whole lifetime (SenderPool.java:560, handed to every sender at :2086). The leak therefore lasts until the pool restarts.

Base, same trigger: the spare stayed installed as hotSpare. Base totalSegmentBytes() counted it, so deregister released it.

Limits:

  • It needs a real manifest I/O failure.
  • A successful retry on the same sender reuses the segment and restores the balance.
  • The liveness floor still lets a ring go one segment past the cap, so the result is earlier backpressure, not a deadlock.

Suggested fix: make SegmentRing.totalSegmentBytes() include the source's retained bytes, e.g. through SegmentSource.retainedBytes(). That keeps base's rule that deregister releases everything the ring holds. Add a discard → deregister → close test that checks budget.getSegmentBytes() goes back to its pre-register value.

Repro output (scratch test: disk mode, 64 KiB segments, shared budget, manifest fsync made to fail)
HEAD:
FZ1 budget after register=65536 ringBytes=65536
FZ1 appended=63 thrown=...MmapSegmentException: could not sync SF manifest
FZ1 budget after throw=131072 ringBytes=65536
FZ1 budget after deregister=65536
FZ1 budget after ring+manager close=65536 (pre-register=0)

BASE:
FZ1 budget after register=65536 ringBytes=65536
FZ1 appended=63 thrown=...MmapSegmentException: could not sync SF manifest
FZ1 budget after throw=131072 ringBytes=131072
FZ1 budget after deregister=0
FZ1 budget after ring+manager close=0 (pre-register=0)

2. With sf_dir on tmpfs, producer-thread segment creation adds about 0.4–0.7 ms to every rotation

  • Problem: SegmentProvisioner.next() now runs open, posix_fallocate, mmap and the fsyncs inside Sender.flush().
  • Net impact: Disk-mode SF with sf_dir on tmpfs. A rotation append goes from about 25 µs to 340–510 µs, and unpaced throughput drops by about 24%.
  • Evidence: 5 interleaved runs per revision (JDK 25, Ryzen 9950X, 4 MiB segments, 64 KiB appends). strace shows fallocate on tmpfs averaging 419 µs.
Setup Base HEAD
tmpfs, unpaced 3009–3042 MB/s, rotation 24 µs 2192–2443 MB/s, rotation 338–496 µs
tmpfs, 100 MB/s 0.63 ms/s in rotations 11.7 ms/s
tmpfs, 300 MB/s 1.8 ms/s 33.7 ms/s
ext4/NVMe — equal or better: no spare-wait stalls, lower max latency, about 1.75× unpaced throughput
  • Magnitude: one stall per 4 MiB of encoded data, about 1–3% of producer time at 100–300 MB/s. The stall lands on whichever flush() triggers the rotation.
  • Base: the manager thread pre-created a hot spare, so the producer only rebased it and updated the manifest. Nothing rejects tmpfs for sf_dir.
  • Optional fix: keep synchronous creation as the fallback, but have the manager pre-create a spare off the producer thread once the active segment is about ¾ full. That keeps the ext4 gain and takes the tmpfs fallocate out of flush().
  • Description: the PR description doesn't mention this design change. Please add it there: the hot spare is gone, segments are created on the producer thread, and the ack watermark is now written by the I/O thread on every ACK.

Minor

None.

Coverage

  • Test gate: passes. There is no Critical coverage gap.
  • Moderate gap, no tandem: the change touches store-and-forward behaviour (idle wait, reconnect replay, ack waiters), but there is no questdb or questdb-enterprise PR on perf/qwp-idle-cpu-low-risk (searched with gh pr list --head).
    • The local tests and the TLA+ models cover the wake protocol.
    • A real-server failover/reconnect run against the event-driven loop is missing.
    • Please run the Enterprise failover suite against this branch before bumping the submodule.
  • CI: GitHub Actions, the macOS Azure leg and the coverage check are green. The Windows and linux-x64 Azure legs were still pending when I checked; the Windows WSAEventSelect wait code only runs there.

Summary

  • Verdict: approve with comments. Please address ci(build): set file encoding to utf-8 for logging #1.
  • Findings: 0 Critical, 2 Moderate, 0 Minor. Both are in the diff itself.
  • Tradeoff: idle CPU drops to zero and disk-mode throughput on ext4/NVMe goes up. In exchange, rotations are slower when sf_dir is on tmpfs (chore(conf): update JDK version from 17 to 11 and change tag to 1.0.0 #2).
  • Checked, no defect found:
    • the I/O loop's park/publish handshake (ioParked against publishStamp)
    • the doorbell's lifecycle against close
    • recvDrained and TLS buffered input
    • AckWaiters
    • concurrent writes and syncs of the ack watermark (a torn record only causes duplicates)
    • manager wakeups covering every trim case
    • all Vect.memcpy callers (none depends on overlap-safe copies at 64 bytes or less)
    • OffHeapAppendMemory's capacity limit staying consistent
    • readers of the frame CRC that memory segments now leave at 0
    • the store-and-forward invariants: no reconnect time budget, and no transport errors surfaced to the producer
  • The perf(qwp): event-driven I/O loop, zero CPU when idle #114 review items are fixed on this branch.

A rotation whose manifest update throws hands its new segment back to
the SegmentProvisioner, still charged to the SegmentBudget, so a retry
can reuse it. SegmentManager.deregister released only
SegmentRing.totalSegmentBytes(), which did not count that segment, and
SegmentProvisioner.close() closed it without a release. A pooled sender
closed after such a flush failure therefore left one segment charged to
the pool's shared budget for the life of the pool.

SegmentSource gains heldBytes(), which the provisioner reports for its
retained segment, and totalSegmentBytes() includes it. Deregister now
releases the whole ring, as it did for the hot spare before. Close
still does not release, so the charge is returned exactly once.

SegmentManagerSharedBudgetTest injects a manifest fsync failure and
checks that deregister returns the budget to zero, and that repeated
failed rotations and the retry that finally commits charge one segment
only.
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 394 / 435 (90.57%)

file detail

path covered line new line coverage
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java 51 71 71.83%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java 25 29 86.21%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java 23 26 88.46%
🔵 io/questdb/client/cutlass/http/client/WebSocketClient.java 26 29 89.66%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java 61 67 91.04%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java 57 60 95.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java 24 25 96.00%
🔵 io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java 55 56 98.21%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java 1 1 100.00%
🔵 io/questdb/client/network/JavaTlsClientSocket.java 4 4 100.00%
🔵 io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java 1 1 100.00%
🔵 io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java 44 44 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java 1 1 100.00%
🔵 io/questdb/client/std/Vect.java 7 7 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SenderConnectionDispatcher.java 1 1 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java 10 10 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java 1 1 100.00%
🔵 io/questdb/client/network/Socket.java 1 1 100.00%
🔵 io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java 1 1 100.00%

This branch has not been deployed

No deployments
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