Skip to content

Unmask WebSocket server frames in blocks instead of byte by byte - #47555

Merged
Jarred-Sumner merged 1 commit into
mainfrom
claude/x64-cpu-instructions-bun-1ssjwh
Oct 31, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
claude/x64-cpu-instructions-bun-1ssjwh

Conversation

@dylan-conway

Copy link
Copy Markdown
Member

What does this PR do?

Makes Bun.serve unmask incoming WebSocket frames up to 10x faster on x64, which is 26-64% more messages per second for payloads of 256 bytes and up.

The three loops in WebSocketProtocol.h that unmask client frames (unmaskImprecise for a whole frame and for the first part of a split one, unmaskInplace for the rest of a split one) XORed one byte at a time. On x64, clang compiles them to code that runs at 2-5 GB/s. At 16 KB messages, this change removes about 40% of the CPU time the server spends per received kilobyte.

They now go through one function, unmaskWords, which works in 64 byte blocks with a 32/16/8/4 byte tail. A block is loaded whole before it is stored, so it stays correct where the destination trails the source by 4, 6, 8 or 14 bytes. It is plain C++ (memcpy to and from a local array): no intrinsics and no runtime dispatch. With our flags it compiles to four 16 byte loads, four XORs and four stores per block on x64 (movdqu/pxor) and on arm64 (ldp q/eor/stp q).

Every hunk:

  • unmaskBlock, unmaskTail, unmaskWords: the new code. unmaskImprecise and unmaskInplace keep their signatures, and unmaskImprecise still handles exactly ((length >> 2) + 1) * 4 bytes, so it still cannot reach the header of the next frame in the same read.
  • unmaskAll and UnrolledXor are deleted. They were the fast path for a continuation read that fills the 512 KiB receive buffer. The general path is now as fast (numbers below), so the branch has nothing left to add.
  • A continuation read rounds its length up to a multiple of 4 ((length + 3) & ~3u) instead of always going 1-4 bytes past it. Without this, dropping unmaskAll would have made a full read write 4 bytes into the buffer's padding, which it did not do before.
  • #include <libusockets.h> goes, because LIBUS_RECV_BUFFER_LENGTH was its only use in this header. wtf/Assertions.h comes in for the ASSERT on the preconditions of unmaskWords.
  • raw-peer.ts: encodeFrame takes an optional masking key. It uses the all-zero key above 64 KiB, so tests built on it never exercised the XOR for frames with a 14 byte header.

End to end

Messages per second received by Bun.serve. Two release builds from the same tree that differ only in this header. The server is pinned to one core and its message handler counts bytes. A client in another process, on another core, writes prebuilt masked frames to a raw socket. Best of 5 by server CPU time, averaged over two interleaved before/after rounds. Intel Cascade Lake, Linux.

payload before after
32 B 4.76M 4.86M +2%
256 B 2.65M 3.34M +26%
1 KB 0.96M 1.41M +46%
16 KB 81.8K 134K +64%
1 MB 1515 2114 +40%

64 KB improved by at least 60%, but the two "after" rounds disagreed (32.3K and 47.0K, from 20.0K), so it is not in the table.

Parser only

The real WebSocketProtocol<true, Impl>::consume() of each version, with a stub Impl, fed back-to-back masked frames in reads of at most 512 KiB. The buffer is refilled from a pristine copy before every read and the cost of that copy is subtracted. clang 23, -O3 -march=nehalem, AMD Zen 4. Both versions report the same payload checksums.

payload before after
8 B 1211 ns/KB 1180 ns/KB
32 B 442 ns/KB 297 ns/KB
125 B 257 ns/KB 79 ns/KB
256 B 230 ns/KB 46 ns/KB
1 KB 207 ns/KB 25 ns/KB
16 KB 198 ns/KB 20 ns/KB
64 KB 197 ns/KB 19 ns/KB
64 MB (127 of its 129 reads took unmaskAll before) 23.2 ns/KB 21.5 ns/KB

Nothing measured got slower. The last row is the deleted fast path against what replaces it.

Source of the parser benchmark

Put each version of WebSocketProtocol.h in old/ and new/. stub/ holds an empty mimalloc.h, a wtf/Assertions.h that defines ASSERT(x) as ((void)0), and a wtf/SIMDUTF.h with a simdutf::validate_utf8 that returns true.

clang++ -O3 -w -march=nehalem -std=c++23 -Inew -Istub -Ipackages/bun-usockets/src bench.cpp -o bench_new
// Drives the real uWS::WebSocketProtocol<true, Impl>::consume() of whichever header -I selects.
#include "WebSocketProtocol.h"
#include <libusockets.h>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <vector>
using namespace uWS;
static uint64_t g_bytes, g_sum;
struct Impl {
    static bool setCompressed(WebSocketState<true>*, void*) { return false; }
    static void failConnection(WebSocketState<true>*, void*, const WebSocketFailure&, unsigned = 0) { puts("FAILED CONNECTION"); abort(); }
    static bool refusePayloadLength(uint64_t, WebSocketState<true>*, void*) { return false; }
    __attribute__((noinline)) static bool handleFragment(char* data, size_t length, unsigned, int, bool, WebSocketState<true>*, void*) {
        g_bytes += length; if (length) g_sum += (unsigned char) data[0] + (unsigned char) data[length - 1] + (unsigned char) data[length / 2]; return false; }
};
using P = WebSocketProtocol<true, Impl>;
static void appendFrame(std::vector<char>& out, size_t n) {
    const unsigned char key[4] = {0xa7, 0x3c, 0xe1, 0x5a};
    out.push_back((char) 0x82);
    if (n < 126) out.push_back((char) (0x80 | n));
    else if (n < 65536) { out.push_back((char) (0x80 | 126)); out.push_back((char) (n >> 8)); out.push_back((char) n); }
    else { out.push_back((char) (0x80 | 127)); for (int s = 56; s >= 0; s -= 8) out.push_back((char) ((uint64_t) n >> s)); }
    for (auto k : key) out.push_back((char) k);
    for (size_t i = 0; i < n; i++) out.push_back((char) ((unsigned char) (i * 131 + n) ^ key[i & 3]));
}
int main() {
    std::vector<char> recv(LIBUS_RECV_BUFFER_LENGTH + 2 * LIBUS_RECV_BUFFER_PADDING);
    char* buf = recv.data() + LIBUS_RECV_BUFFER_PADDING;
    struct Case { const char* name; size_t payload; size_t stream; };
    // "stream" bytes of back-to-back frames, handed to consume() in reads of at most the receive buffer's size.
    for (Case c : {Case{"8 B", 8, 1 << 18}, {"32 B", 32, 1 << 18}, {"125 B", 125, 1 << 18}, {"256 B", 256, 1 << 18}, {"1 KB", 1024, 1 << 18}, {"16 KB", 16384, 1 << 18},
                   {"64 KB", 65536, 1 << 19}, {"64 MB", 64 << 20, 0}}) {
        std::vector<char> wire; size_t payloadBytes = 0;
        do { appendFrame(wire, c.payload); payloadBytes += c.payload; } while (wire.size() + c.payload + 14 <= c.stream);
        auto pass = [&](bool parse) { WebSocketState<true> st;
            for (size_t off = 0; off < wire.size(); off += LIBUS_RECV_BUFFER_LENGTH) { size_t n = std::min<size_t>(LIBUS_RECV_BUFFER_LENGTH, wire.size() - off);
                memcpy(buf, wire.data() + off, n); asm volatile("" :: "r"(buf) : "memory"); if (parse) P::consume(buf, (unsigned) n, &st, nullptr); } };
        auto time = [&](bool parse) { int iters = (int) std::max<size_t>(8, (300u << 20) / wire.size() / 7); double best = 1e9;
            for (int r = 0; r < 7; r++) { auto t0 = std::chrono::steady_clock::now(); for (int i = 0; i < iters; i++) pass(parse);
                best = std::min(best, std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count() / iters); } return best; };
        g_bytes = g_sum = 0; pass(true); uint64_t bytes = g_bytes, sum = g_sum;
        double copy = time(false), total = time(true), parse = total - copy;
        printf("%-26s %8.2f ns/KB  %7.2f GB/s   (payload %llu, checksum %llu)\n", c.name, parse * 1e9 / (payloadBytes / 1024.0), payloadBytes / parse / 1e9, (unsigned long long) bytes, (unsigned long long) sum);
    }
}

What this does not show

  • arm64 is not measured. The new code compiles to clean vector code there, but clang already vectorized unmaskInplace and one of the two uses of unmaskImprecise on arm64, so expect less than on x64.
  • It stays at 16 byte vectors. highway_fill_with_skip_mask, which the WebSocket client uses, would pick AVX2 or AVX-512 at run time. On the Cascade Lake machine the new code runs at about 40 GB/s (25 ns/KB) and the server now spends about 455 ns of CPU per KB at 16 KB messages, so unmasking is around 5% of that. Wider vectors would buy little, and the indirect call would cost small frames.

How did you verify your code works?

The behavior does not change, so the new tests also pass without this change. They cannot fail on the unfixed build. What shows that they test something is that they fail when the new code is broken on purpose (below).

New tests, in test/js/bun/websocket/websocket-server.test.ts under unmasks client frames. A raw client sends frames masked with a non-zero key and compares what an echo server returns:

  • frames of every length from 0 to 299, then 65535, 65536, 65537 and 70001 with tiny frames between them, all in one write, so that going too far past one payload corrupts the next header
  • a frame larger than the receive buffer. strace shows the server's socket getting a read of exactly 524288 bytes in the middle of it, in 3 runs of 3 on Linux, which is the case unmaskAll used to handle
  • for each header size (6, 8, 14 bytes), a payload that arrives in three separate reads, over 48 to 56 combinations of piece sizes that cover every remainder modulo 4 and both sides of the 16 and 64 byte block sizes

Mutations, each run against the first and third groups on a debug + ASAN build. They were run before two later edits, the (length + 3) & ~3u rounding and the ASSERT, and not repeated after them:

mutation result
unmaskImprecise handles 8 bytes past the payload instead of at most 4 the single-write test fails
the first part of a split frame is moved but not unmasked every split with a non-empty first piece fails (42, 49 and 49)
a middle continuation read is not unmasked every split fails (48, 56 and 56)

That every split fails, not only some, also shows that the pieces really do reach the server as separate reads. The test relies on a ping/pong round trip on a second connection for that. If the pieces were ever merged into one read (I have only checked Linux), the test would still pass and would only cover less.

Also:

  • The old loops and the new functions, taken from the real header, give identical bytes in 78,517 cases: lengths 0-700 at 16 alignments, every header size, plus a few large sizes. Built with ASAN and UBSAN at -O0 and -O3, with each buffer ending exactly where the code is allowed to stop.
  • A debug + ASAN build as the server, with a release Bun's WebSocket client in another process: 4,140 messages with random masks, from 0 bytes to 2.5 MB, including a burst of 2,000 small ones, echoed back unchanged over ws:// and wss://.
  • On the debug + ASAN build: websocket-server.test.ts and the -unmasked-frames, -rsv-frames, -upgrade-early-frames, -fail-close-codes and -permessage-deflate files next to it, and ws-server-compat-receive.test.ts: 627 pass, 0 fail.
  • Of the other 29 test files that import raw-peer.ts, 28 passed (3,353 tests). That run was also before those two edits. The 29th, ws-server-compat-transports.test.ts, could not load on my machine because @fastify/websocket was not installed.

Generated by Claude Code

The three loops that unmask client frames XORed one byte at a time. On
x64 clang compiles them to code that runs at 2-3 GB/s, which was close to
half of the CPU time a server spent per received kilobyte.

Unmask in 64 byte blocks with a 32/16/8/4 tail. A block is loaded whole
before it is stored, so it stays correct where the destination trails the
source by 4 to 14 bytes. It compiles to plain 16 byte vector loads, XORs
and stores on x64 and arm64: no intrinsics, no runtime dispatch.

unmaskAll, the special case for a read that fills the receive buffer, has
nothing left to add and is gone. A continuation now rounds its length up
to 4 rather than always going past it, so such a read still ends exactly
at the end of the buffer.

Messages per second received by Bun.serve on one core, release build,
from a client that sends prebuilt masked frames (x64, Cascade Lake):

    32 B    4.76M -> 4.86M   +2%
    256 B   2.65M -> 3.34M   +26%
    1 KB    0.96M -> 1.41M   +46%
    16 KB   81.8K -> 134K    +64%
    1 MB    1515  -> 2114    +40%

Not measured on arm64, where clang already vectorized some of the old
loops.

The behavior does not change, so the new tests pass without this change
too. They fail when the new code is broken on purpose: unmasking 8 bytes
past a payload, skipping the first fragment, or skipping a continuation
(every split case fails, so each piece does arrive as its own read).
@robobun

robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 10:22 AM PT - Oct 31st, 2026

❌ @claude, your commit 8e215e5 has 1 failures in Build #136119 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47555

That installs a local version of the PR into your bun-47555 executable, so you can run:

bun-47555 --bun

@coderabbitai

coderabbitai Bot commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 34cbc70 and 8e215e5.

Files selected for processing: 3
  • packages/bun-uws/src/WebSocketProtocol.h
  • test/js/bun/websocket/websocket-server.test.ts
  • test/js/web/websocket/raw-peer.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Walkthrough

WebSocketProtocol replaces byte-wise masking operations with word-based unmasking for masked client frames. New echo-server tests check payload integrity for varied frame lengths, large payloads, and frames split across reads. The raw-peer helper now accepts an explicit masking key.

Changes

Masked frame unmasking

Layer / File(s) Summary
Word-based unmasking
packages/bun-uws/src/WebSocketProtocol.h
The protocol adds helpers that process 64-byte blocks and remaining four-byte-aligned data. In-place unmasking and partial server continuation data use this path. The previous byte-wise and receive-buffer-sized helpers are removed.
Masked frame regression tests
test/js/web/websocket/raw-peer.ts, test/js/bun/websocket/websocket-server.test.ts
The raw-peer helper accepts an optional masking key and uses it for large payloads when supplied. Echo tests cover lengths from 0–299, selected boundary lengths, payloads larger than 512 KiB, and frames split across reads.

Possibly related issues

  • oven-sh/bun#46968 — This issue also concerns WebSocket server throughput. This change updates masked-frame processing, but the issue may involve event-loop behavior and is not shown to be resolved here.

Suggested reviewers: alii, jarred-sumner

Merge Risk: Minimal · up to 8e215

The block-based unmasking preserves the inspected frame-processing contracts. No actionable merge-blocking issue was found; merge after normal checks pass.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: replacing byte-by-byte WebSocket server-frame unmasking with block-based processing.
Description check Passed The description includes both required sections, explains the implementation and performance impact, and provides detailed verification results, test coverage, limitations, and known environment const…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description includes both required sections, explains the implementation and performance impact, and provides detailed verification results, test coverage, limitations, and known environment constraints.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this PR and did not find any bugs. Because it rewrites the unmasking hot path of the vendored uWS frame parser with an overlapping-copy invariant and lands on a performance claim, a human look at the approach and the numbers would still be worthwhile.

What was reviewed:

  • Traced all three callers of the new unmaskWords: both unmaskImprecise sites and the exact-tail unmaskInplace read and write the same byte ranges as the old byte loops, and dst <= src holds at each (src-4, frame start vs. src+header, in place).
  • Checked the overlap claim: every block is fully loaded into a local before any store, and a store at dst..dst+63 with dst <= src cannot reach the next block at src+64.
  • Checked the partial-continuation change to (length + 3) & ~3u: it differs from the old ((length >> 2) + 1) * 4 only when length % 4 == 0, by 4 bytes past the received data that are never delivered or spilled; rotateMask is already skipped in that case.
  • Confirmed the dropped libusockets.h include is still reached transitively by all four includers via WebSocketContextData.h, and that the tests exercise concatenated frames, a payload over the receive buffer, and rotated-key continuation reads with non-zero keys.
Extended reasoning...

The diff replaces the byte-wise client-frame unmasking in packages/bun-uws/src/WebSocketProtocol.h (used by Bun.serve's WebSocket server) with a memcpy-based 64-byte block scheme plus a 32/16/8/4 tail, deletes the unmaskAll/UnrolledXor fast path, and adds tests in test/js/bun/websocket/websocket-server.test.ts and a key option in test/js/web/websocket/raw-peer.ts. It touches a parser over peer-controlled network bytes, but the read and write extents per call are identical to the base code and no new bounds depend on untrusted input, so no new security-sensitive surface is opened. I deferred rather than approved because this is a performance rewrite of a vendored hot path whose correctness rests on an overlap invariant enforced only by a debug ASSERT and on perf numbers I cannot reproduce here; no CODEOWNERS entry covers the changed paths and no third-party objections exist in the timeline.

Copy link
Copy Markdown
Member Author

Build #136119 is red for test/aot/engine/ir-6.test.ts, so far on Debian 13 x64-asan and on Alpine 3.23 x64 and aarch64. This PR does not cause it. The build is still running, and this is its only error so far.

What fails: one of the 267 IR tests in that file.

FAIL pipeline/gc/custom-accessor-reached-computed-key.ir:220: pipeline: output line 63 does not match: v55: undefined|string|builtin(Object)|builtin(ObjectPrototype)|…

Line 220 is a CHECK-NEXT that spells out the inferred type of process.env[key] as a union of built-ins, and the compiler's output no longer matches that text.

Why it is not this PR's:

Fix: I found none, on main or in an open PR, so there is nothing to bring into this branch. The fix belongs in a separate PR that updates the expectation or the compiler, not in this one.

I can't re-run BuildKite jobs from here. I'll go through the rest of the build when it finishes.


Generated by Claude Code

@Jarred-Sumner
Jarred-Sumner merged commit 31d9e12 into main Oct 31, 2026
6 of 7 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/x64-cpu-instructions-bun-1ssjwh branch October 31, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants