Repository navigation
Speed up parsing and serializing small frames - #208
Open
DicksonWu654 wants to merge 3 commits into
Open
DicksonWu654 wants to merge 3 commits into
DicksonWu654 wants to merge 3 commits into
Conversation
FrameDecoder.parse_header() rebuilt the same FIN/RSV/opcode fields, including a new RsvBits tuple, for every frame. There are only 256 possible first bytes, so look the fields up in a precomputed table. _serialize_frame() built the output for short frames with chained concatenation (header + mask + payload, then another bytearray copy). Append to the fresh header bytearray instead. Frames with extended payload lengths and payloads of other types keep the original expression, which is faster for large bytearrays on CPython. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A complete, unfragmented text or binary frame with a short payload went through the staged parser: header parsing, a Frame, a second Frame from the message decoder, and for text a fresh incremental UTF-8 decoder, even though every byte is already buffered. When no extensions are in use and no frame or message is in progress, decode such a frame straight from the buffer and return the same Frame. Everything else (partial input, fragmented messages, control frames, extended lengths, reserved bits, wrong masking, invalid UTF-8) is left to the staged parser before any input is consumed, so errors and close codes are unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Control frames and continuation frames still go through parse_header(), which consumes the header in several small slices. For a complete header with a 7-bit length and no extensions in use, decode it in place using a 16-entry opcode table. Partial headers, extended lengths, reserved bits, invalid opcodes and fragmented control frames take the original path before anything is consumed. received_frames() also skips the staged parser when the buffer is empty. It drops its reference to the previous frame before yielding, as the staged loop does, so a delivered payload is not kept alive while waiting for more data. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Most WebSocket traffic is small messages (chat, JSON events, pings). For those, the frame parser's fixed per-frame overhead is most of the CPU time. This adds fast paths for short frames in
frame_protocol.py. Everything outside them (partial input, extended lengths, extensions such as permessage-deflate, reserved bits, invalid input) goes through the existing code before any input is consumed. Three commits:FrameDecoder.parse_header()looks the FIN/RSV/opcode fields up in a 256-entry table instead of building a newRsvBitsfor every frame._serialize_frame()appends mask and payload to the fresh headerbytearrayfor frames with a 7-bit length, instead ofbytearray(header + masking_key + payload). Frames with extended lengths keep the original expression: appending is slower for large payloads on CPython 3.12.Frame, a secondFramefromMessageDecoder, and a new incremental UTF-8 decoder for text, even though all the bytes are already buffered. When no extensions are in use and no frame or message is in progress,_parse_more_gen()now unmasks and decodes such a frame from the buffer and yields the sameFrame. Invalid UTF-8 and wrong masking direction return to the staged parser, so error messages and close codes do not change.parse_header(). For a complete header with a 7-bit length, valid opcode, no reserved bits and no extensions in use, it now decodes the header in place._parse_more_gen()also skips the staged parser when the buffer is empty. Before yielding in that case it drops its reference to the previous frame, as the staged loop does, so a delivered payload is not kept alive while waiting for data (there is a weakref test for this).The fast paths are only taken for the stock
FrameDecoder,MessageDecoderandBufferwith aboolclientflag, and they are re-checked between frames, so extensions added mid-stream and replaced decoders are still honoured (there are tests for both).Benchmarks
Each workload connects a client and a server
Connection: the client sends, the server echoes every event back and the client receives it.small_textis a 48-character message,small_binary100 bytes,coalesced_32is 32 small messages delivered in onereceive_data()call,small_text_bytewisedelivers the small message one byte at a time, anddeflate_small_text_echonegotiates permessage-deflate.project_bench_connectionmirrorsbench/connection.py(eight messages of ~125 KiB on average, fixed seed).master and this branch were imported side by side in the same interpreter and timed in alternating ~20 ms CPU-time blocks (40 rounds in each of 6 processes, 4 on PyPy, pinned to one core). The table shows the median per-round speedup with a 95% bootstrap interval. The machine was shared with other work, so the absolute times are only indicative.
small_text_echosmall_binary_echocoalesced_32_text_echoping_pongmedium_text_1k_echolarge_binary_64k_echosmall_text_bytewisedeflate_small_text_echoproject_bench_connectionFrames above 125 bytes and connections with extensions take the existing path, so those workloads are unchanged within noise.
Behaviour
clientvalues, extensions added between frames, control frames delivered byte by byte, a replaced frame decoder, aBuffersubclass and payload release.python -bb -m pytestpasses on CPython 3.10, 3.11, 3.12, 3.13, 3.14 and PyPy 3.11, and each of the three commits passes on its own.mypy src/(thelinttox env) passes. Coverage offrame_protocol.pyis unchanged at 99% (the only missed line is the existingTYPE_CHECKINGimport).ConnectionandFrameProtocol, recording every event, payload type,ParseFailedcode and message and the final state. The sending side (all event types,bytes/bytearray/strpayloads, client masking with a seeded key source, optional permessage-deflate) was compared the same way. All outputs were identical; as a sanity check, a deliberately broken decoder (latin-1 instead of UTF-8) was flagged on 4,645 results.ruff check src(not run in CI) reports four new complexity findings (C901/PLR0912/PLR0915) forparse_header()and_serialize_frame(), on top of the 22 findings already on main. I kept the fast paths inline, since moving them into helpers costs a call per frame; happy to restructure if you prefer.🤖 Generated with Claude Code