From 9909dc0585fdd85364fc9ad5b2a7226e9ca8366f Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Thu, 1 Oct 2026 15:01:18 -0400 Subject: [PATCH] Add a singular accessor for acquiring one datagram frame QUIC builds one packet at a time when its prefetched batch runs dry, and the only way down the stack was `getDatagramsToSend`, which built a `FrameArray` to carry a single frame. Added `getDatagramToSend` beside it through the datagram linkage, the base linkage's dispatch and the path-labelled QUIC helper, with defaults that forward to a batch of one so existing linkages and protocols are unaffected. UDP, IP and the in-process bridge override it, and `buildSinglePacketForKeyState` now takes that route. The batched path is untouched. The bridge's two paths share the check that withholds datagrams when a test blocks packet generation. Measured on my Mac against `main` with the package's QUIC benchmark tools. Allocation counts come from full malloc stack logging, which records every allocation: QUICTransfer -size 1200, per message 24.37 -> 24.35 QUICTransfer, per 500 KB transfer 6,187.6 -> 6,179.5 QUICHandshake, per connection 1,947.4 -> 1,945.8 QUICStreamLoad, per stream 111.8 -> 109.9 The package benchmarks build most packets in batches and run on a fast allocator, so they show little: only the stream load, which builds packets one at a time, moves much. The saving is larger where a bottom protocol hands out its own buffers one frame at a time, where traffic is dominated by packets built singly, such as acknowledgment-driven or bursty sends, and where heap allocation is costly. Wall-clock time comes from running the two builds alternately for 9 rounds and comparing the two runs within each round. Nothing changed beyond noise. --- .../Protocols/BaseProtocolLinkages.swift | 42 +++++++++++++++ .../Protocols/BottomProtocol.swift | 37 +++++++++++++ .../Protocols/BridgeProtocol.swift | 38 +++++++++---- .../SwiftNetwork/Protocols/IPProtocol.swift | 54 +++++++++++++++---- .../Protocols/ManyToManyProtocol.swift | 14 +++++ .../Protocols/OneToOneProtocol.swift | 49 +++++++++++++++++ .../Protocols/ProtocolLinkage.swift | 52 ++++++++++++++++++ .../SwiftNetwork/Protocols/UDPProtocol.swift | 24 +++++++++ .../SwiftNetwork/QUIC/QUICConnection.swift | 6 +-- 9 files changed, 292 insertions(+), 24 deletions(-) diff --git a/Sources/SwiftNetwork/Protocols/BaseProtocolLinkages.swift b/Sources/SwiftNetwork/Protocols/BaseProtocolLinkages.swift index 8e328124..bbddd08c 100644 --- a/Sources/SwiftNetwork/Protocols/BaseProtocolLinkages.swift +++ b/Sources/SwiftNetwork/Protocols/BaseProtocolLinkages.swift @@ -433,6 +433,48 @@ public struct BaseOutboundDatagramLinkage: OutboundDatagramLinkage { } } + public func getDatagramToSend( + minimumDatagramSize: Int, + for instance: InstanceIdentifier, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + switch protocolType { + case .udp(let index): + return try storage!.udpInstances[index].getDatagramToSend( + minimumDatagramSize: minimumDatagramSize, + for: instance, + in: &eventContext + ) + case .ip(let index): + return try storage!.ipInstances[index].getDatagramToSend( + minimumDatagramSize: minimumDatagramSize, + for: instance, + in: &eventContext + ) + case .bridgeDatagram(let index): + return try storage!.bridgeDatagramInstances[index].getDatagramToSend( + minimumDatagramSize: minimumDatagramSize, + for: instance, + in: &eventContext + ) + #if NETWORK_RTKIT + case .netif(let box): + var protocolInstance = box.instance + return try protocolInstance.getDatagramToSend( + minimumDatagramSize: minimumDatagramSize, + for: instance, + in: &eventContext + ) + #endif + default: + return try getDatagramToSendFromBatch( + minimumDatagramSize: minimumDatagramSize, + for: instance, + in: &eventContext + ) + } + } + public func sendDatagrams( _ datagrams: consuming FrameArray, from instance: InstanceIdentifier, diff --git a/Sources/SwiftNetwork/Protocols/BottomProtocol.swift b/Sources/SwiftNetwork/Protocols/BottomProtocol.swift index e333ab05..e3f7ca05 100644 --- a/Sources/SwiftNetwork/Protocols/BottomProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/BottomProtocol.swift @@ -234,6 +234,15 @@ where LinkageType: OutboundDatagramLinkage, LinkageType.PairedUpperLinkage == Up in eventContext: inout NetworkContext.EventContext ) throws(NetworkError) -> FrameArray? + /// Returns a single datagram frame the upper protocol can use to send. + /// + /// Protocols can implement this function to customize behavior; the default implementation + /// asks `getDatagramsToSend` for a batch of one. + mutating func getDatagramToSend( + minimumDatagramSize: Int, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? + /// Sends datagrams created by the upper protocol. /// /// Protocols can implement this function to customize behavior. @@ -413,6 +422,34 @@ extension BottomDatagramProtocol where Self: ~Copyable { in: &eventContext ) } + public mutating func getDatagramToSend( + minimumDatagramSize: Int, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + guard + var datagrams = try self.getDatagramsToSend( + maximumDatagramCount: 1, + minimumDatagramSize: minimumDatagramSize, + in: &eventContext + ) + else { + return nil + } + let datagram = datagrams.popFirst() + // A batch of one must hold at most one frame; anything left here would trap in + // `Frame.deinit` instead of reaching the driver. + precondition(datagrams.isEmpty) + return datagram + } + public mutating func getDatagramToSend( + minimumDatagramSize: Int, + for instance: InstanceIdentifier, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + do { try validate(upper: instance, #function) } catch { throw NetworkError.posix(EINVAL) } + guard isConnected(in: &eventContext) else { throw NetworkError.posix(ENOTCONN) } + return try self.getDatagramToSend(minimumDatagramSize: minimumDatagramSize, in: &eventContext) + } public mutating func sendDatagrams( _ datagrams: consuming FrameArray, from instance: InstanceIdentifier, diff --git a/Sources/SwiftNetwork/Protocols/BridgeProtocol.swift b/Sources/SwiftNetwork/Protocols/BridgeProtocol.swift index 98119e81..eb15832b 100644 --- a/Sources/SwiftNetwork/Protocols/BridgeProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/BridgeProtocol.swift @@ -269,15 +269,8 @@ public struct BridgeDatagramProtocol: NetworkProtocol { minimumDatagramSize: Int, in eventContext: inout NetworkContext.EventContext ) throws(NetworkError) -> FrameArray? { - if datagramDrops?.blockPacketGeneration ?? false { - if datagramDrops?.shouldDropPacket() ?? false { - log.datapath("blocking \(maximumDatagramCount) datagrams to port: \(self.remoteEndpoint!.port)") - self.async(in: &eventContext) { eventContext in - self.log.datapath("unblocking outbound data") - self.upper.deliverOutboundRoomAvailableEvent(from: self.identifier, in: &eventContext) - } - return nil - } + if blocksPacketGeneration(datagramCount: maximumDatagramCount, in: &eventContext) { + return nil } let frameSize = min(minimumDatagramSize, self.maximumOutputSize) @@ -289,6 +282,33 @@ public struct BridgeDatagramProtocol: NetworkProtocol { return frameArray } + public func getDatagramToSend( + minimumDatagramSize: Int, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + if blocksPacketGeneration(datagramCount: 1, in: &eventContext) { + return nil + } + return Frame(count: min(minimumDatagramSize, self.maximumOutputSize)) + } + + /// Whether the configured drops withhold the next `datagramCount` datagrams. When they do, the upper + /// protocol is told it has room again on the next turn, so that it retries. + private func blocksPacketGeneration( + datagramCount: Int, + in eventContext: inout NetworkContext.EventContext + ) -> Bool { + guard datagramDrops?.blockPacketGeneration ?? false, datagramDrops?.shouldDropPacket() ?? false else { + return false + } + log.datapath("blocking \(datagramCount) datagrams to port: \(self.remoteEndpoint!.port)") + self.async(in: &eventContext) { eventContext in + self.log.datapath("unblocking outbound data") + self.upper.deliverOutboundRoomAvailableEvent(from: self.identifier, in: &eventContext) + } + return true + } + public func sendDatagrams( _ datagrams: consuming FrameArray, in eventContext: inout NetworkContext.EventContext diff --git a/Sources/SwiftNetwork/Protocols/IPProtocol.swift b/Sources/SwiftNetwork/Protocols/IPProtocol.swift index 33499c19..37464026 100644 --- a/Sources/SwiftNetwork/Protocols/IPProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/IPProtocol.swift @@ -986,9 +986,13 @@ public struct IPProtocol: NetworkProtocol { inboundFrames.add(frames: processedFrames) } + func prepareOutboundFrame(_ outboundFrame: inout Frame) { + _ = outboundFrame.claim(fromStart: IPv4Instance.headerLength) + } + func prepareOutboundFrames(_ outboundFrames: inout FrameArray) { outboundFrames.iterateMutableFrames { frame in - _ = frame.claim(fromStart: IPv4Instance.headerLength) + prepareOutboundFrame(&frame) return true } } @@ -1832,18 +1836,22 @@ public struct IPProtocol: NetworkProtocol { inboundFrames.add(frames: processedFrames) } + func prepareOutboundFrame(_ outboundFrame: inout Frame) { + if flags.useMinimumMTU { + var trailerClaim = 0 + let frameLength = outboundFrame.unclaimedLength + if frameLength > IPv6Instance.minimalMTU { + trailerClaim = frameLength - IPv6Instance.minimalMTU + } + _ = outboundFrame.claim(fromStart: IPv6Instance.headerLength, fromEnd: trailerClaim) + } else { + _ = outboundFrame.claim(fromStart: IPv6Instance.headerLength) + } + } + func prepareOutboundFrames(_ outboundFrames: inout FrameArray) { outboundFrames.iterateMutableFrames { frame in - if flags.useMinimumMTU { - var trailerClaim = 0 - let frameLength = frame.unclaimedLength - if frameLength > IPv6Instance.minimalMTU { - trailerClaim = frameLength - IPv6Instance.minimalMTU - } - _ = frame.claim(fromStart: IPv6Instance.headerLength, fromEnd: trailerClaim) - } else { - _ = frame.claim(fromStart: IPv6Instance.headerLength) - } + prepareOutboundFrame(&frame) return true } } @@ -2232,6 +2240,30 @@ public struct IPProtocol: NetworkProtocol { } } + func getDatagramToSend( + minimumDatagramSize: Int, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + switch self.instanceType { + case .ipv4(let instance): + let outboundFrame = try invokeGetDatagramToSend( + minimumDatagramSize: instance.incrementByHeaderLength(minimumDatagramSize), + in: &eventContext + ) + guard var outboundFrame else { return nil } + instance.prepareOutboundFrame(&outboundFrame) + return outboundFrame + case .ipv6(let instance): + let outboundFrame = try invokeGetDatagramToSend( + minimumDatagramSize: instance.incrementByHeaderLength(minimumDatagramSize), + in: &eventContext + ) + guard var outboundFrame else { return nil } + instance.prepareOutboundFrame(&outboundFrame) + return outboundFrame + } + } + mutating func sendDatagrams( _ datagrams: consuming FrameArray, in eventContext: inout NetworkContext.EventContext diff --git a/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift b/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift index 99717834..020002fe 100644 --- a/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift @@ -2189,6 +2189,20 @@ extension ManyToManyOutboundDatagramProtocol where Path: AutomaticLowerDatagramP ) } + /// Fetches a single datagram to send using an event context the caller already holds. + public func getDatagramToSend( + path pathID: MultiplexingPathIdentifier, + minimumDatagramSize: Int, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + guard let path = self.path(for: pathID) else { throw NetworkError.posix(EINVAL) } + return try path.lower.invokeGetDatagramToSend( + minimumDatagramSize: minimumDatagramSize, + for: path.identifier, + in: &eventContext + ) + } + public func enqueueOutboundDatagrams( path pathID: MultiplexingPathIdentifier, datagrams: consuming FrameArray diff --git a/Sources/SwiftNetwork/Protocols/OneToOneProtocol.swift b/Sources/SwiftNetwork/Protocols/OneToOneProtocol.swift index 05be2255..bed63356 100644 --- a/Sources/SwiftNetwork/Protocols/OneToOneProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/OneToOneProtocol.swift @@ -256,6 +256,15 @@ where UpperProtocol: InboundDatagramLinkage, LowerProtocol: OutboundDatagramLink in eventContext: inout NetworkContext.EventContext ) throws(NetworkError) -> FrameArray? + /// Returns a single datagram frame the upper protocol can use to send. + /// + /// Protocols can implement this function to customize behavior; the default implementation + /// asks `getDatagramsToSend` for a batch of one. + mutating func getDatagramToSend( + minimumDatagramSize: Int, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? + /// Sends datagrams created by the upper protocol. /// /// Protocols can implement this function to customize behavior. @@ -694,6 +703,18 @@ extension OneToOneProtocolHandler where Self: ~Copyable, LowerProtocol: Outbound ) } + @inline(always) + public func invokeGetDatagramToSend( + minimumDatagramSize: Int, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + try lower.invokeGetDatagramToSend( + minimumDatagramSize: minimumDatagramSize, + for: effectiveSelfInstance, + in: &eventContext + ) + } + public func invokeSendDatagrams( _ datagrams: consuming FrameArray, in eventContext: inout NetworkContext.EventContext @@ -727,6 +748,34 @@ extension OneToOneDatagramProtocol where Self: ~Copyable { in: &eventContext ) } + public mutating func getDatagramToSend( + minimumDatagramSize: Int, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + guard + var datagrams = try self.getDatagramsToSend( + maximumDatagramCount: 1, + minimumDatagramSize: minimumDatagramSize, + in: &eventContext + ) + else { + return nil + } + let datagram = datagrams.popFirst() + // A batch of one must hold at most one frame; anything left here would trap in + // `Frame.deinit` instead of reaching the driver. + precondition(datagrams.isEmpty) + return datagram + } + public mutating func getDatagramToSend( + minimumDatagramSize: Int, + for instance: InstanceIdentifier, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + do { try validate(upper: instance, #function) } catch { throw NetworkError.posix(EINVAL) } + guard passthroughEvents || isConnected(in: &eventContext) else { throw NetworkError.posix(ENOTCONN) } + return try self.getDatagramToSend(minimumDatagramSize: minimumDatagramSize, in: &eventContext) + } public mutating func sendDatagrams( _ datagrams: consuming FrameArray, from instance: InstanceIdentifier, diff --git a/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift b/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift index 8c8d0b47..6940e3c9 100644 --- a/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift +++ b/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift @@ -407,6 +407,16 @@ public protocol OutboundDatagramLinkage: OutboundDataLinkage where PairedUpperLi in eventContext: inout NetworkContext.EventContext ) throws(NetworkError) -> FrameArray? + /// Returns a single datagram frame the upper protocol can use to send. + /// + /// Linkages whose protocols can hand back one frame without building a container should implement this + /// function; the default implementation asks `getDatagramsToSend` for a batch of one. + func getDatagramToSend( + minimumDatagramSize: Int, + for instance: InstanceIdentifier, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? + func sendDatagrams( _ datagrams: consuming FrameArray, from instance: InstanceIdentifier, @@ -445,6 +455,48 @@ extension OutboundDatagramLinkage { } } + public func getDatagramToSend( + minimumDatagramSize: Int, + for instance: InstanceIdentifier, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + try getDatagramToSendFromBatch(minimumDatagramSize: minimumDatagramSize, for: instance, in: &eventContext) + } + + /// Takes a single frame from `getDatagramsToSend`, for protocols with no way to hand one back directly. + func getDatagramToSendFromBatch( + minimumDatagramSize: Int, + for instance: InstanceIdentifier, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + guard + var datagrams = try getDatagramsToSend( + maximumDatagramCount: 1, + minimumDatagramSize: minimumDatagramSize, + for: instance, + in: &eventContext + ) + else { + return nil + } + let datagram = datagrams.popFirst() + // A batch of one must hold at most one frame; anything left here would trap in + // `Frame.deinit` instead of reaching the driver. + precondition(datagrams.isEmpty) + return datagram + } + + public func invokeGetDatagramToSend( + minimumDatagramSize: Int, + for instance: InstanceIdentifier, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + guard !identifier.isNone else { return nil } + return try identifier.handleCallFromUpperProtocol(in: &eventContext) { eventContext throws(NetworkError) in + try self.getDatagramToSend(minimumDatagramSize: minimumDatagramSize, for: instance, in: &eventContext) + } + } + public func invokeSendDatagrams( _ datagrams: consuming FrameArray, from instance: InstanceIdentifier, diff --git a/Sources/SwiftNetwork/Protocols/UDPProtocol.swift b/Sources/SwiftNetwork/Protocols/UDPProtocol.swift index 1ba00a6f..cd8988a4 100644 --- a/Sources/SwiftNetwork/Protocols/UDPProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/UDPProtocol.swift @@ -424,6 +424,30 @@ public struct UDPProtocol: NetworkProtocol { return outputFrames } + mutating func getDatagramToSend( + minimumDatagramSize: Int, + in eventContext: inout NetworkContext.EventContext + ) throws(NetworkError) -> Frame? { + if self.flags.contains(.flowControlled) { + // Wait until UDP flow is allowed + self.flags.insert(.outputPending) + return nil + } + + guard + var outputFrame = try invokeGetDatagramToSend( + minimumDatagramSize: incrementByUDPHeaderLength(minimumDatagramSize), + in: &eventContext + ) + else { + return nil + } + // The lower protocol can hand back a frame too short for the header; the caller + // reads the remaining length and adapts, as it does for a batch. + _ = outputFrame.claim(fromStart: UDPProtocol.headerLength) + return outputFrame + } + mutating func sendDatagrams( _ datagrams: consuming FrameArray, in eventContext: inout NetworkContext.EventContext diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index 59a166b2..35c7065a 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -4136,13 +4136,11 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, outFrame = batchFrame } else { guard - var outFrames = try? getDatagramsToSend( + let newFrame = try? getDatagramToSend( path: path.pathIdentifier, - maximumDatagramCount: 1, minimumDatagramSize: requestedFrameLength, in: &eventContext - ), - let newFrame = outFrames.popFirst() + ) else { // Note: handleOutboundRoomAvailableEvent will restart sending log.debug("QUIC path failed to get a frame")