SwiftQUIC: PERF: Reduce CPU by 63% parsing QUIC header - #60
Conversation
|
Is there an approach we can use to make the actual deserialize operation cheaper here, as opposed to avoiding using it? |
I created a new type called |
|
Its also worth mentioning why I created a new type here (InlineDeserializer) instead of using the |
|
Interesting. Operating on a Span shouldn't be significantly more expensive than operating directly on the owned bytes. We should investigate this more. |
The reason it’s expensive is due to the operations needed to create the span that the Deserializer works with a frame. Those operations are:
And when you load this span into the Deserializer for multiple frame parsing routines the CPU starts adding up. When you use FrameDeserializer it just operates on the _bytes at a given index. So even if you did multiple reads from the frame in different places in the code the CPU impact is minimal. This should be very close to how C or Rust reads bytes from a buffer at a given index. |
| guard frame.startOffset + 4 <= frame._bytes.count else { | ||
| throw DeserializationError.bufferTooShort | ||
| } | ||
| let value = frame._bytes.span.bytes.unsafeLoadUnaligned( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
}
}
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Sharing parsing code should be secondary to performance.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I disagree with that analysis. Let's discuss next week.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Lets discuss in detail next week your optimizations.
| extension FrameDeserializer { | ||
| @inline(__always) | ||
| static func uint8(frame: inout Frame, claim: Bool = false) throws(DeserializationError) -> UInt8 { | ||
| guard frame._bytes.count > 0 else { |
There was a problem hiding this comment.
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.
Yep, this is a good call out and I would need to check the frame's unclaimed length here instead of the bytes.
Parsing QUIC headers with
Deserializer.deserializeuses a lot more CPU than expected for what should be a very fast operation. Parsing the firstOctet and short headers are by far the common cases when parsing QUIC packets, and the paths that should be the most performant. This change is to provide a fast-path option for these operations and cut the CPU usage here by 63% when parsing QUIC headers in the QUICTransfer benchmark.Savings of about 260 megacycles.
Current top of tree:
And with this change: