-
Notifications
You must be signed in to change notification settings - Fork 13
SwiftQUIC: PERF: Reduce CPU by 63% parsing QUIC header #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eb3ba9c
7136e9f
8193070
7b6687a
c722052
2b716c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
| 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( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Accessing the length is roughly the same as what Just to be sure I put together a benchmark that measures parsing 10,000,000 frames with With And with So that's almost 7x more CPU parsing frames with the And with
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sharing parsing code should be secondary to performance.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I disagree with that analysis. Let's discuss next week.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
|
||
| 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)") | ||
| } | ||
| } | ||
|
|
||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.