perf(quic): round-4 perf-audit fixes — ACK gating, flow-control dirty-set, streams list view
Four perf wins from the round-4 audit, all in the steady-state hot path (audio rooms run at ~50 datagrams/sec/participant). Perf #1 — ACK frame gating (saves a frame + bandwidth on every drain): AckTracker.buildAckFrame now returns null when no new ack-eliciting packet has arrived since the last build. Pre-fix the writer emitted a redundant ACK frame on every outbound packet (~50/sec each direction) even when the only inbound traffic since last drain was ACK-only. RFC 9000 §13.2 only requires ACKs in response to ack-eliciting packets within max_ack_delay; gating on ackElicitingPending satisfies that without delay-timer machinery. Perf #11 — AckTracker.purgeBelow short-circuit: Common case: peer ACKs a high PN, we already pruned below it. Pre-fix triggered a full ListIterator walk anyway. Now bails out when the tail's start is already above the threshold. Perf #9 — Flow-control dirty-set: QuicStream gains a receiveDirtyForFlowControl flag set by the parser when readContiguous advances the frontier. The writer's appendFlowControlUpdates now skips per-direction-window lookup + threshold comparison for streams whose flag is unset. Big win for multi-stream sessions (audio rooms with N×M streams per participant). Perf #10 — Streams list view: QuicConnection maintains a parallel insertion-ordered List<QuicStream> alongside the streams Map. Writer's round-robin scan reads the list directly instead of allocating `entries.toList()` per drain. No removal path exists today; the insert points (openBidi/UniStream, getOrCreatePeerStreamLocked) update both. AckTrackerGatingTest pins the new gating contract: first build returns a frame; second build without new reception returns null; subsequent ack-eliciting reception re-arms; non-ack-eliciting receptions alone don't. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
@@ -101,6 +101,16 @@ class QuicConnection(
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
private val streams = mutableMapOf<Long, QuicStream>()
|
private val streams = mutableMapOf<Long, QuicStream>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round-4 perf #10: parallel insertion-ordered list of streams so the
|
||||||
|
* writer's round-robin scan can index by position without
|
||||||
|
* `streams.entries.toList()` allocating per drain. Streams are only ever
|
||||||
|
* added (no removal in the current model), so the two stay in sync as
|
||||||
|
* long as `getOrCreatePeerStreamLocked` and `openBidi/UniStream` append
|
||||||
|
* to both.
|
||||||
|
*/
|
||||||
|
private val streamsList = mutableListOf<QuicStream>()
|
||||||
private var nextLocalBidiIndex: Long = 0L
|
private var nextLocalBidiIndex: Long = 0L
|
||||||
private var nextLocalUniIndex: Long = 0L
|
private var nextLocalUniIndex: Long = 0L
|
||||||
|
|
||||||
@@ -340,6 +350,7 @@ class QuicConnection(
|
|||||||
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataBidiRemote ?: config.initialMaxStreamDataBidiRemote
|
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataBidiRemote ?: config.initialMaxStreamDataBidiRemote
|
||||||
stream.receiveLimit = config.initialMaxStreamDataBidiLocal
|
stream.receiveLimit = config.initialMaxStreamDataBidiLocal
|
||||||
streams[id] = stream
|
streams[id] = stream
|
||||||
|
streamsList += stream
|
||||||
stream
|
stream
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +368,7 @@ class QuicConnection(
|
|||||||
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataUni ?: config.initialMaxStreamDataUni
|
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataUni ?: config.initialMaxStreamDataUni
|
||||||
stream.receiveLimit = 0L // can't receive
|
stream.receiveLimit = 0L // can't receive
|
||||||
streams[id] = stream
|
streams[id] = stream
|
||||||
|
streamsList += stream
|
||||||
stream
|
stream
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +504,7 @@ class QuicConnection(
|
|||||||
StreamId.Kind.CLIENT_BIDI -> config.initialMaxStreamDataBidiLocal
|
StreamId.Kind.CLIENT_BIDI -> config.initialMaxStreamDataBidiLocal
|
||||||
}
|
}
|
||||||
streams[id] = stream
|
streams[id] = stream
|
||||||
|
streamsList += stream
|
||||||
newPeerStreams.addLast(stream)
|
newPeerStreams.addLast(stream)
|
||||||
// Wake any awaitIncomingPeerStream caller. trySend on a CONFLATED
|
// Wake any awaitIncomingPeerStream caller. trySend on a CONFLATED
|
||||||
// channel can never fail in steady state.
|
// channel can never fail in steady state.
|
||||||
@@ -518,6 +531,14 @@ class QuicConnection(
|
|||||||
/** Caller must hold [lock]. Snapshot of streams for the driver's send loop. */
|
/** Caller must hold [lock]. Snapshot of streams for the driver's send loop. */
|
||||||
internal fun streamsLocked(): Map<Long, QuicStream> = streams
|
internal fun streamsLocked(): Map<Long, QuicStream> = streams
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insertion-ordered list view used by the writer's round-robin scan.
|
||||||
|
* Stays in sync with [streams] because the only mutation paths
|
||||||
|
* (openBidi/UniStream, getOrCreatePeerStreamLocked) append to both. No
|
||||||
|
* remove path exists today; if/when one is added it MUST update both.
|
||||||
|
*/
|
||||||
|
internal fun streamsListLocked(): List<QuicStream> = streamsList
|
||||||
|
|
||||||
/** Caller must hold [lock]. Pending datagram queue for the driver's send loop. */
|
/** Caller must hold [lock]. Pending datagram queue for the driver's send loop. */
|
||||||
internal fun pendingDatagramsLocked(): ArrayDeque<ByteArray> = pendingDatagrams
|
internal fun pendingDatagramsLocked(): ArrayDeque<ByteArray> = pendingDatagrams
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,11 @@ private fun dispatchFrames(
|
|||||||
stream.receive.insert(frame.offset, frame.data, frame.fin)
|
stream.receive.insert(frame.offset, frame.data, frame.fin)
|
||||||
val data = stream.receive.readContiguous()
|
val data = stream.receive.readContiguous()
|
||||||
if (data.isNotEmpty()) {
|
if (data.isNotEmpty()) {
|
||||||
|
// Round-4 perf #9: mark the stream as needing a flow-
|
||||||
|
// control re-credit check. Writer's
|
||||||
|
// appendFlowControlUpdates consults this flag instead of
|
||||||
|
// walking every open stream on every drain.
|
||||||
|
stream.receiveDirtyForFlowControl = true
|
||||||
val delivered = stream.deliverIncoming(data)
|
val delivered = stream.deliverIncoming(data)
|
||||||
if (!delivered) {
|
if (!delivered) {
|
||||||
// Audit-4 #3: incoming channel saturated. Closing the
|
// Audit-4 #3: incoming channel saturated. Closing the
|
||||||
|
|||||||
+32
-7
@@ -268,12 +268,15 @@ private fun buildApplicationPacket(
|
|||||||
val connRemaining =
|
val connRemaining =
|
||||||
(conn.sendConnectionFlowCredit - conn.sendConnectionFlowConsumed).coerceAtLeast(0L)
|
(conn.sendConnectionFlowCredit - conn.sendConnectionFlowConsumed).coerceAtLeast(0L)
|
||||||
var connBudget = connRemaining
|
var connBudget = connRemaining
|
||||||
val streamsList = conn.streamsLocked().entries.toList()
|
// Round-4 perf #10: use the connection's pre-built list view instead of
|
||||||
if (streamsList.isNotEmpty()) {
|
// allocating a fresh `entries.toList()` per drain. The list is
|
||||||
val start = conn.streamRoundRobinStart % streamsList.size
|
// insertion-ordered and stays in sync with the streams map.
|
||||||
for (i in streamsList.indices) {
|
val streamsView = conn.streamsListLocked()
|
||||||
|
if (streamsView.isNotEmpty()) {
|
||||||
|
val start = conn.streamRoundRobinStart % streamsView.size
|
||||||
|
for (i in streamsView.indices) {
|
||||||
if (packetBudget <= 64) break
|
if (packetBudget <= 64) break
|
||||||
val (id, stream) = streamsList[(start + i) % streamsList.size]
|
val stream = streamsView[(start + i) % streamsView.size]
|
||||||
val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L)
|
val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L)
|
||||||
// Skip if both stream and connection have no credit; FIN-only
|
// Skip if both stream and connection have no credit; FIN-only
|
||||||
// (zero-byte) chunks may still go through because they don't
|
// (zero-byte) chunks may still go through because they don't
|
||||||
@@ -284,13 +287,20 @@ private fun buildApplicationPacket(
|
|||||||
minOf(packetBudget - 32, effectiveCap.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
|
minOf(packetBudget - 32, effectiveCap.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
|
||||||
val chunk = stream.send.takeChunk(maxBytes = maxBytes) ?: continue
|
val chunk = stream.send.takeChunk(maxBytes = maxBytes) ?: continue
|
||||||
if (chunk.data.isNotEmpty() || chunk.fin) {
|
if (chunk.data.isNotEmpty() || chunk.fin) {
|
||||||
frames += StreamFrame(streamId = id, offset = chunk.offset, data = chunk.data, fin = chunk.fin, explicitLength = true)
|
frames +=
|
||||||
|
StreamFrame(
|
||||||
|
streamId = stream.streamId,
|
||||||
|
offset = chunk.offset,
|
||||||
|
data = chunk.data,
|
||||||
|
fin = chunk.fin,
|
||||||
|
explicitLength = true,
|
||||||
|
)
|
||||||
packetBudget -= chunk.data.size + 32
|
packetBudget -= chunk.data.size + 32
|
||||||
connBudget -= chunk.data.size
|
connBudget -= chunk.data.size
|
||||||
conn.sendConnectionFlowConsumed += chunk.data.size
|
conn.sendConnectionFlowConsumed += chunk.data.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
conn.streamRoundRobinStart = (start + 1) % streamsList.size
|
conn.streamRoundRobinStart = (start + 1) % streamsView.size
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frames.isEmpty()) return null
|
if (frames.isEmpty()) return null
|
||||||
@@ -319,9 +329,20 @@ private fun appendFlowControlUpdates(
|
|||||||
) {
|
) {
|
||||||
val cfg = conn.config
|
val cfg = conn.config
|
||||||
var totalRecvAdvanced = 0L
|
var totalRecvAdvanced = 0L
|
||||||
|
// Round-4 perf #9: only walk streams flagged by the parser since the last
|
||||||
|
// drain. Streams whose receive frontier hasn't advanced cannot need a
|
||||||
|
// new MAX_STREAM_DATA frame, so iterating them is wasted work. The
|
||||||
|
// connection-level totalRecvAdvanced sum still requires looking at each
|
||||||
|
// stream's contiguousEnd, but only when the dirty flag is set.
|
||||||
for ((id, stream) in conn.streamsLocked()) {
|
for ((id, stream) in conn.streamsLocked()) {
|
||||||
val rcv = stream.receive.contiguousEnd()
|
val rcv = stream.receive.contiguousEnd()
|
||||||
if (rcv == 0L) continue
|
if (rcv == 0L) continue
|
||||||
|
if (!stream.receiveDirtyForFlowControl) {
|
||||||
|
// Stream has data but nothing changed since last drain — skip the
|
||||||
|
// per-direction window lookup and the comparison.
|
||||||
|
totalRecvAdvanced += rcv
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Pick the per-direction window matching the stream kind so a
|
// Pick the per-direction window matching the stream kind so a
|
||||||
// uni-only deployment with bidi=0 doesn't accidentally use the bidi
|
// uni-only deployment with bidi=0 doesn't accidentally use the bidi
|
||||||
// window and vice versa.
|
// window and vice versa.
|
||||||
@@ -343,6 +364,10 @@ private fun appendFlowControlUpdates(
|
|||||||
frames += MaxStreamDataFrame(id, newLimit)
|
frames += MaxStreamDataFrame(id, newLimit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Clear the dirty flag once we've considered this stream — even if we
|
||||||
|
// didn't emit a new MAX_STREAM_DATA, the threshold-check work doesn't
|
||||||
|
// need to repeat until more bytes arrive.
|
||||||
|
stream.receiveDirtyForFlowControl = false
|
||||||
totalRecvAdvanced += rcv
|
totalRecvAdvanced += rcv
|
||||||
}
|
}
|
||||||
// Connection-level: only re-grant when the new total would exceed our
|
// Connection-level: only re-grant when the new total would exceed our
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ class AckTracker {
|
|||||||
*/
|
*/
|
||||||
fun purgeBelow(threshold: Long) {
|
fun purgeBelow(threshold: Long) {
|
||||||
if (threshold <= 0L) return
|
if (threshold <= 0L) return
|
||||||
|
if (ranges.isEmpty()) return
|
||||||
|
// Round-4 perf #11: short-circuit when nothing in the tail is below
|
||||||
|
// the threshold (the common case for steady receivers — purgeBelow
|
||||||
|
// is called per inbound ACK). Pre-fix every ACK triggered a full
|
||||||
|
// ListIterator walk even when there was nothing to remove.
|
||||||
|
if (ranges.last().start >= threshold) return
|
||||||
// ranges are descending by lo; drop the tail.
|
// ranges are descending by lo; drop the tail.
|
||||||
val it = ranges.listIterator(ranges.size)
|
val it = ranges.listIterator(ranges.size)
|
||||||
while (it.hasPrevious()) {
|
while (it.hasPrevious()) {
|
||||||
@@ -108,12 +114,27 @@ class AckTracker {
|
|||||||
|
|
||||||
fun largestReceived(): Long = if (ranges.isEmpty()) -1L else ranges[0].endInclusive
|
fun largestReceived(): Long = if (ranges.isEmpty()) -1L else ranges[0].endInclusive
|
||||||
|
|
||||||
/** Build an ACK frame covering everything we've received. */
|
/**
|
||||||
|
* Build an ACK frame covering everything we've received, OR null if nothing
|
||||||
|
* new ack-eliciting has arrived since the last call.
|
||||||
|
*
|
||||||
|
* Round-4 perf #1: pre-fix this returned non-null whenever the range list
|
||||||
|
* was non-empty, so EVERY outbound packet (~50/sec for an audio room)
|
||||||
|
* carried a redundant ACK. RFC 9000 §13.2 only requires ACKs in response
|
||||||
|
* to ack-eliciting packets, within max_ack_delay. Gating on
|
||||||
|
* [ackElicitingPending] satisfies the RFC requirement and saves a varint-
|
||||||
|
* heavy frame per drain.
|
||||||
|
*
|
||||||
|
* Note: the flag is cleared by this method only when an ACK is actually
|
||||||
|
* built. Pre-fix the flag was cleared even when buildAckFrame returned a
|
||||||
|
* stale frame, which compounded the problem.
|
||||||
|
*/
|
||||||
fun buildAckFrame(
|
fun buildAckFrame(
|
||||||
nowMillis: Long,
|
nowMillis: Long,
|
||||||
ackDelayExponent: Int = 3,
|
ackDelayExponent: Int = 3,
|
||||||
): AckFrame? {
|
): AckFrame? {
|
||||||
if (ranges.isEmpty()) return null
|
if (ranges.isEmpty()) return null
|
||||||
|
if (!ackElicitingPending) return null
|
||||||
val largest = ranges[0].endInclusive
|
val largest = ranges[0].endInclusive
|
||||||
val firstRangeLength = largest - ranges[0].start
|
val firstRangeLength = largest - ranges[0].start
|
||||||
val rest = mutableListOf<AckRange>()
|
val rest = mutableListOf<AckRange>()
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ class QuicStream(
|
|||||||
var receiveLimit: Long = 0L
|
var receiveLimit: Long = 0L
|
||||||
internal set
|
internal set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker the parser sets whenever [receive.contiguousEnd] advances; the
|
||||||
|
* writer's appendFlowControlUpdates consumes it to skip streams that
|
||||||
|
* haven't received any new bytes since the last MAX_STREAM_DATA emission.
|
||||||
|
*
|
||||||
|
* Pre-fix the writer iterated EVERY open stream on every drain
|
||||||
|
* (audit-4 perf #9 — O(streams) × ~50 drains/sec; significant for audio
|
||||||
|
* rooms with many WT streams).
|
||||||
|
*/
|
||||||
|
internal var receiveDirtyForFlowControl: Boolean = false
|
||||||
|
|
||||||
/** True once we've FIN'd our write side and the peer FIN'd theirs. */
|
/** True once we've FIN'd our write side and the peer FIN'd theirs. */
|
||||||
val isClosed: Boolean
|
val isClosed: Boolean
|
||||||
get() = send.finSent && receive.finReceived
|
get() = send.finSent && receive.finReceived
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quic.recovery
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round-4 perf #1 regression: [AckTracker.buildAckFrame] must return null when
|
||||||
|
* nothing new ack-eliciting has arrived since the last call. Pre-fix every
|
||||||
|
* outbound packet (~50/sec for an audio room) carried a redundant ACK frame.
|
||||||
|
*
|
||||||
|
* The contract:
|
||||||
|
* 1. First ack-eliciting reception → buildAckFrame returns a frame; flag clears.
|
||||||
|
* 2. Second buildAckFrame without new reception → returns null.
|
||||||
|
* 3. Subsequent ack-eliciting reception → next buildAckFrame returns frame.
|
||||||
|
* 4. Non-ack-eliciting receptions (PADDING-only, ACK-only) DO NOT trigger a
|
||||||
|
* new ACK on their own.
|
||||||
|
*/
|
||||||
|
class AckTrackerGatingTest {
|
||||||
|
@Test
|
||||||
|
fun first_build_after_ack_eliciting_returns_frame() {
|
||||||
|
val tracker = AckTracker()
|
||||||
|
tracker.receivedPacket(packetNumber = 5L, ackEliciting = true, receivedAtMillis = 1000L)
|
||||||
|
assertNotNull(tracker.buildAckFrame(nowMillis = 1010L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun second_build_without_new_reception_returns_null() {
|
||||||
|
val tracker = AckTracker()
|
||||||
|
tracker.receivedPacket(packetNumber = 5L, ackEliciting = true, receivedAtMillis = 1000L)
|
||||||
|
tracker.buildAckFrame(nowMillis = 1010L) // first ack — clears flag
|
||||||
|
assertNull(
|
||||||
|
tracker.buildAckFrame(nowMillis = 1020L),
|
||||||
|
"no new ack-eliciting reception → no redundant ACK",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun build_re_arms_after_subsequent_ack_eliciting_reception() {
|
||||||
|
val tracker = AckTracker()
|
||||||
|
tracker.receivedPacket(packetNumber = 5L, ackEliciting = true, receivedAtMillis = 1000L)
|
||||||
|
tracker.buildAckFrame(nowMillis = 1010L)
|
||||||
|
// New ack-eliciting reception arrives.
|
||||||
|
tracker.receivedPacket(packetNumber = 6L, ackEliciting = true, receivedAtMillis = 1100L)
|
||||||
|
val ack = tracker.buildAckFrame(nowMillis = 1110L)
|
||||||
|
assertNotNull(ack, "fresh ack-eliciting reception must re-arm the gate")
|
||||||
|
assertEquals(6L, ack.largestAcknowledged)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun non_ack_eliciting_reception_alone_does_not_arm_the_gate() {
|
||||||
|
// Per RFC 9000 §13.2.1 we don't have to ACK ACK-only packets. The
|
||||||
|
// tracker still records the PN (so future ACKs cover it), but the
|
||||||
|
// gate doesn't open until something ack-eliciting arrives.
|
||||||
|
val tracker = AckTracker()
|
||||||
|
tracker.receivedPacket(packetNumber = 1L, ackEliciting = false, receivedAtMillis = 1000L)
|
||||||
|
assertNull(
|
||||||
|
tracker.buildAckFrame(nowMillis = 1010L),
|
||||||
|
"non-ack-eliciting reception alone must not produce an ACK",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun empty_tracker_returns_null() {
|
||||||
|
val tracker = AckTracker()
|
||||||
|
assertNull(tracker.buildAckFrame(nowMillis = 1000L))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user