Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Sources/SwiftNetwork/Protocols/Frame.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ public struct Frame: ~Copyable {
set { _endOffset = UInt32(newValue) }
}
private var _effectiveBufferLength: UInt32 = 0
var effectiveBufferLength: Int {
@usableFromInline var effectiveBufferLength: Int {
get { Int(_effectiveBufferLength) }
set { _effectiveBufferLength = UInt32(newValue) }
}
private var _aggregateBufferLength: UInt32 = 0
var aggregateBufferLength: Int {
@usableFromInline var aggregateBufferLength: Int {
get { Int(_aggregateBufferLength) }
set { _aggregateBufferLength = UInt32(newValue) }
}
Expand Down Expand Up @@ -261,6 +261,7 @@ public struct Frame: ~Copyable {
}
}

@inline(__always)
public mutating func claim(fromStart: Int, fromEnd: Int = 0, adjustSingleIPAggregate: Bool = true) -> Bool {
if adjustSingleIPAggregate && isSingleIPAggregate {
guard fromEnd == 0 else {
Expand Down Expand Up @@ -786,6 +787,7 @@ extension Frame {

@available(Network 0.1.0, *)
extension Frame {

// Copy length bytes from offset in this Frame into destination Frame.
// checking the source offset, length and destination fit.
// Return the length that it was able to copy into destination.
Expand Down
26 changes: 14 additions & 12 deletions Sources/SwiftNetwork/QUIC/PacketParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,11 +319,11 @@ struct PacketParser: ~Copyable, PrefixedLoggable {
private func parseHeader(frame: inout Frame, dcidLength: Int) throws(QUICError) -> Packet {
var firstOctet: UInt8 = 0
let originalLength = frame.unclaimedLength
let result = Deserializer.deserialize(&frame, claim: true) { read throws(DeserializationError) in
try read.uint8(&firstOctet)
do throws(DeserializationError) {
firstOctet = try FrameDeserializer.uint8(frame: &frame, claim: true)
} catch {
throw QUICError.packet(QUICPacketError.deserializationError)
}
try validateDeserializationResult(result)

// Common short/long header bits
let longHeader = (firstOctet & 0x80) != 0

Expand All @@ -342,7 +342,6 @@ struct PacketParser: ~Copyable, PrefixedLoggable {
originalLength: originalLength
)
}
packet.framesReceived.reserveCapacity(1)
return packet
}

Expand Down Expand Up @@ -523,16 +522,19 @@ struct PacketParser: ~Copyable, PrefixedLoggable {
log.error("Short header fixed bit is zero")
throw QUICError.packet(QUICPacketError.deserializationError)
}

var dcidStorage = QUICConnectionIDStorage.empty
let result = Deserializer.deserialize(&frame, claim: true) { read throws(DeserializationError) in
try read.connectionID(&dcidStorage, length: dcidLength)
do throws(DeserializationError) {
try FrameDeserializer.connectionID(
frame: &frame,
storage: &dcidStorage,
length: Int(dcidLength),
claim: true
)
} catch {
throw QUICError.packet(QUICPacketError.deserializationError)
}
try validateDeserializationResult(result)

let destinationConnectionID = QUICConnectionID(storage: dcidStorage, size: Int(dcidLength))
return Packet(
destinationConnectionID: destinationConnectionID,
destinationConnectionID: QUICConnectionID(storage: dcidStorage, size: Int(dcidLength)),
headerLength: UInt16(originalLength - frame.unclaimedLength),
spin: spinValue
)
Expand Down
20 changes: 15 additions & 5 deletions Sources/SwiftNetwork/QUIC/QUICConnectionID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ public struct QUICConnectionID: Sendable, Equatable, CustomStringConvertible {
// Creates a QUICConnectionID from an array.
public init?(_ connectionID: [UInt8]) {
guard connectionID.count <= QUICConnectionID.maximumSize else {
#if !DisableErrorLogging
let connectionIDCount = connectionID.count
Logger.proto.fault("Invalid QUICConnectionID length \(connectionIDCount)")
Logger.proto.error("Invalid QUICConnectionID length \(connectionIDCount)")
#endif
return nil
}
actualLength = connectionID.count
Expand All @@ -101,15 +103,19 @@ public struct QUICConnectionID: Sendable, Equatable, CustomStringConvertible {
if size <= QUICConnectionID.maximumSize {
actualLength = size
} else {
Logger.proto.fault("Invalid QUICConnectionID length \(size)")
#if !DisableErrorLogging
Logger.proto.error("Invalid QUICConnectionID length \(size)")
#endif
actualLength = QUICConnectionID.maximumSize
}
}

public init?(_ connectionID: Span<UInt8>) {
guard connectionID.count <= QUICConnectionID.maximumSize else {
#if !DisableErrorLogging
let connectionIDCount = connectionID.count
Logger.proto.fault("Invalid QUICConnectionID length \(connectionIDCount)")
Logger.proto.error("Invalid QUICConnectionID length \(connectionIDCount)")
#endif
return nil
}
actualLength = connectionID.count
Expand All @@ -120,7 +126,9 @@ public struct QUICConnectionID: Sendable, Equatable, CustomStringConvertible {
public init(_ size: Int) {
var size = size
if size > QUICConnectionID.maximumSize {
Logger.proto.fault("Invalid QUICConnectionID length \(size)")
#if !DisableErrorLogging
Logger.proto.error("Invalid QUICConnectionID length \(size)")
#endif
size = QUICConnectionID.maximumSize
}
if size != 0 && size < 4 {
Expand All @@ -133,7 +141,9 @@ public struct QUICConnectionID: Sendable, Equatable, CustomStringConvertible {
// Creates a QUICConnectionID from a buffer with a specific size.
init?(_ buffer: [UInt8], size: Int) {
guard size <= QUICConnectionID.maximumSize, buffer.count >= size else {
Logger.proto.fault("Invalid QUICConnectionID length \(size)")
#if !DisableErrorLogging
Logger.proto.error("Invalid QUICConnectionID length \(size)")
#endif
return nil
}
let cidBytes = Array(buffer[0..<min(size, QUICConnectionID.maximumSize)])
Expand Down
120 changes: 120 additions & 0 deletions Sources/SwiftNetwork/Utilities/Deserializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,126 @@ public enum DeserializationResult: CustomStringConvertible, Equatable, Sendable
}
}

@_spi(ProtocolProvider)
@available(Network 0.1.0, *)
public struct FrameDeserializer {}

@available(Network 0.1.0, *)
extension FrameDeserializer {
@inline(__always)
static func uint8(frame: inout Frame, claim: Bool = false) throws(DeserializationError) -> UInt8 {
guard frame._bytes.count > 0 else {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this check is incorrect — it's not checking the remaining bytes after the cursor, but the whole underlying buffer size.

All of the other functions also won't work correctly in the non-claiming mode, since they have no way of tracking cursor offsets between calls.

I'm not suggesting you fix this, since this I don't think this PR has the right approach right now, but calling it out.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this is a good call out and I would need to check the frame's unclaimed length here instead of the bytes.

throw DeserializationError.bufferTooShort
}
let value: UInt8 = frame._bytes[frame.startOffset]
if claim {
guard frame.claim(fromStart: 1) else {
throw DeserializationError.bufferTooShort
}
}
return value
}

@inline(__always)
static func uint16(frame: inout Frame, claim: Bool = false) throws(DeserializationError) -> UInt16 {
guard frame.startOffset + 2 <= frame._bytes.count else {
throw DeserializationError.bufferTooShort
}
let value = frame._bytes.span.bytes.unsafeLoadUnaligned(
fromByteOffset: frame.startOffset,
as: UInt16.self
)
if claim {
guard frame.claim(fromStart: 2) else {
throw DeserializationError.bufferTooShort
}
}
return value
}

@inline(__always)
static func uint16NetworkByteOrder(
frame: inout Frame,
claim: Bool = false
) throws(DeserializationError) -> UInt16 {
UInt16(bigEndian: try uint16(frame: &frame, claim: claim))
}

@inline(__always)
static func uint32(frame: inout Frame, claim: Bool = false) throws(DeserializationError) -> UInt32 {
guard frame.startOffset + 4 <= frame._bytes.count else {
throw DeserializationError.bufferTooShort
}
let value = frame._bytes.span.bytes.unsafeLoadUnaligned(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing the span/length/etc on the frame for each field, instead of doing it once for multiple fields, seems like it would be worse for efficiency in general.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing the length is roughly the same as what hasRoom is doing in readFixedSize. Accessing the span directly on frame._bytes is cheaper than computing the RawSpan from the frame that the Deserializer uses to read values.

Just to be sure I put together a benchmark that measures parsing 10,000,000 frames with FrameDeserializer against Deserializer.deserialize.

With Deserializer.deserialize we get:

1.90 G 100.0%	-	 DeserializerBenchmark (76446)	

And with FrameDeserializer we get:

279.77 M 100.0%	-	 DeserializerBenchmark (78442)	

So that's almost 7x more CPU parsing frames with the Deserializer.deserialize approach.
Here is how I parsed the frames with FrameDeserializer:

let iterationCount = 10_000_000
var frame = Frame(copyBuffer: testBytes)
defer { frame.finalize(success: false) }
for _ in 0..<iterationCount {
    _ = try? FrameDeserializer.uint8(frame: &frame, claim: false)
    _ = try? FrameDeserializer.uint16NetworkByteOrder(frame: &frame, claim: false)
    _ = try? FrameDeserializer.uint32NetworkByteOrder(frame: &frame, claim: false)
    _ = try? FrameDeserializer.uint64NetworkByteOrder(frame: &frame, claim: false)
    _ = try? FrameDeserializer.uint8(frame: &frame, claim: false)
}

And with Deserializer.deserialize:

let iterationCount = 10_000_000
var frame = Frame(copyBuffer: testBytes)
defer { frame.finalize(success: false) }
for _ in 0..<iterationCount {
    var f1: UInt8 = 0
    var f2: UInt16 = 0
    var f3: UInt32 = 0
    var f4: UInt64 = 0
    var f5: UInt8 = 0
    _ = Deserializer.deserialize(&frame, claim: false) { read throws(DeserializationError) in
        try read.uint8(&f1)
        try read.uint16NetworkByteOrder(&f2)
        try read.uint32NetworkByteOrder(&f3)
        try read.uint64NetworkByteOrder(&f4)
        try read.uint8(&f5)
    }
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a couple "always inline" marks to some of the private functions in Deserializer, and that alone changed the overall CPU time from 1.5G to 665M. So I think we can and should take the approach of optimizing the main deserializer path here. Forking to make things more specific to frame is more complex to read and is not going in the direction we want for being able to share parsing code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s great to hear but that is not enough. The main Deserializer has patterns we need to get away from if we want to compete with the performance of C or Rust. Namely, building a Span (bytes) and copying it to the Deserializer’s storage each time. This pattern cost way too much CPU when all you want to do is read a few bytes. We need to refactor that pattern in the Deserializer, and to do that I suspect we’d have to either do one of two things; one, rebuild the entire type from the ground up, or two switch to a new performant type.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sharing parsing code should be secondary to performance.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is not doing either a memmove or a copy there, it is grabbing a pointer. We can look at ways to ensure the span view creation is optimized by the compiler.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point I am making here is that we should not even build this span in the Frame and have the Deserializer reference it. This uses too much CPU. Instead we should do the parsing directly on the Frame.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with that analysis. Let's discuss next week.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to just hit 243M for this same benchmark using the span in normal deserializer, with a few other optimizations in deserializer that will apply to all existing usage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets discuss in detail next week your optimizations.

fromByteOffset: frame.startOffset,
as: UInt32.self
)
if claim {
guard frame.claim(fromStart: 4) else {
throw DeserializationError.bufferTooShort
}
}
return value
}

@inline(__always)
static func uint32NetworkByteOrder(
frame: inout Frame,
claim: Bool = false
) throws(DeserializationError) -> UInt32 {
UInt32(bigEndian: try uint32(frame: &frame, claim: claim))
}

@inline(__always)
static func uint64(frame: inout Frame, claim: Bool = false) throws(DeserializationError) -> UInt64 {
guard frame.startOffset + 8 <= frame._bytes.count else {
throw DeserializationError.bufferTooShort
}
let value = frame._bytes.span.bytes.unsafeLoadUnaligned(
fromByteOffset: frame.startOffset,
as: UInt64.self
)
if claim {
guard frame.claim(fromStart: 8) else {
throw DeserializationError.bufferTooShort
}
}
return value
}

@inline(__always)
static func uint64NetworkByteOrder(
frame: inout Frame,
claim: Bool = false
) throws(DeserializationError) -> UInt64 {
UInt64(bigEndian: try uint64(frame: &frame, claim: claim))
}

@inline(__always)
static func connectionID(
frame: inout Frame,
storage: inout [20 of UInt8],
length: Int,
claim: Bool = false
) throws(DeserializationError) {
guard frame.startOffset + length <= frame._bytes.count else {
return
}
for i in 0..<length {
storage[i] = frame._bytes[frame.startOffset + i]
}
if claim {
guard frame.claim(fromStart: length) else {
throw DeserializationError.bufferTooShort
}
}
}

static func claim(frame: inout Frame, length: Int) -> Bool {
frame.claim(fromStart: length)
}
}

@_spi(ProtocolProvider)
@available(Network 0.1.0, *)
public struct Deserializer<Factory: DeserializerSpanFactory & ~Copyable & ~Escapable>: ~Copyable, ~Escapable {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import XCTest

#if canImport(SwiftNetwork)
@_spi(Essentials) @_spi(ProtocolProvider) @testable import SwiftNetwork
#elseif canImport(Network)
@_spi(Essentials) @_spi(ProtocolProvider) @testable import Network
#endif

@available(Network 0.1.0, *)
final class SwiftNetworkFrameDeserializerTests: NetTestCase {

func testUInt8InlineValue() throws {
var frame = Frame(copyBuffer: [0xAB] as [UInt8])
defer { frame.finalize(success: false) }
do throws(DeserializationError) {
let value = try FrameDeserializer.uint8(frame: &frame, claim: true)
XCTAssertEqual(value, 0xAB)
} catch {
XCTFail("Unexpected deserialization error: \(error)")
}
}

func testUInt8PeekDoesNotAdvanceOffset() throws {
var frame = Frame(copyBuffer: [0xCD, 0xEF] as [UInt8])
defer { frame.finalize(success: false) }
do throws(DeserializationError) {
let firstUnclaimed = try FrameDeserializer.uint8(frame: &frame, claim: false)
let nextClaimed = try FrameDeserializer.uint8(frame: &frame, claim: true)
XCTAssertEqual(firstUnclaimed, 0xCD)
XCTAssertEqual(nextClaimed, 0xCD)
XCTAssertEqual(frame.unclaimedLength, 1)
} catch {
XCTFail("Unexpected deserialization error: \(error)")
}
}

func testUInt64InlineValue() throws {
let bytes: [UInt8] = [0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41]
var frame = Frame(copyBuffer: bytes)
defer { frame.finalize(success: false) }
do throws(DeserializationError) {
let value = try FrameDeserializer.uint64(frame: &frame, claim: true)
XCTAssertEqual(value, 0x4141_4141_4141_4141)
XCTAssertEqual(frame.unclaimedLength, 0)
} catch {
XCTFail("Unexpected deserialization error: \(error)")
}
}

func testUInt64NetworkByteOrderInlineValue() throws {
let bytes: [UInt8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
var frame = Frame(copyBuffer: bytes)
defer { frame.finalize(success: false) }
do throws(DeserializationError) {
let value = try FrameDeserializer.uint64NetworkByteOrder(frame: &frame, claim: true)
XCTAssertEqual(value, 0x0102_0304_0506_0708)
XCTAssertEqual(frame.unclaimedLength, 0)
} catch {
XCTFail("Unexpected deserialization error: \(error)")
}
}

func testUInt64NetworkByteOrderThenUInt8Sequential() throws {
let bytes: [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x42]
var frame = Frame(copyBuffer: bytes)
defer { frame.finalize(success: false) }
do throws(DeserializationError) {
let high = try FrameDeserializer.uint64NetworkByteOrder(frame: &frame, claim: true)
let low = try FrameDeserializer.uint8(frame: &frame, claim: true)
XCTAssertEqual(high, 0x0000_0000_0000_00FF)
XCTAssertEqual(low, 0x42)
XCTAssertEqual(frame.unclaimedLength, 0)
} catch {
XCTFail("Unexpected deserialization error: \(error)")
}
}

}
Loading