Skip to content

perf: cut per-frame cost in the websocket receive pipeline (release 0.1.4) - #83

Merged
joeblau merged 1 commit into
mainfrom
perf/websocket-receive-pipeline
Jul 28, 2026
Merged

perf: cut per-frame cost in the websocket receive pipeline (release 0.1.4)#83
joeblau merged 1 commit into
mainfrom
perf/websocket-receive-pipeline

Conversation

@joeblau

@joeblau joeblau commented Jul 28, 2026

Copy link
Copy Markdown

Bumps to 0.1.4. Removes duplicated machinery on the inbound frame path and the per-frame async wrapper on the heaviest channel, then restores two behaviours that regressed against 0.1.3.

Receive pipeline

change win
Socket no longer re-boxes every frame into a MessageEvent for the SDK's own consumer 1.42×, 305 ns/frame
fastAssetCtxs decodes via native inflateRawSync and delivers in the dispatch tick 7.6× delta, 1.8× snapshot
Lone listener stored unboxed; copy-on-write array on promotion (no snapshot copy/clear) 2.6× at 8 listeners, 1.18× at 1
Dispatcher reuses the id it already built by concatenation as the wire frame ~54 ns/subscribe

ReconnectingWebSocket was allocating a MessageEvent and paying a full EventTarget.dispatchEvent per frame purely to hand it to HyperliquidEventTarget — the exact per-frame allocation cost this SDK removed from _emit, still sitting one layer upstream. The public message event is still dispatched whenever anything outside the package listens.

Measured against the committed baseline: webData3_frame_dispatch_e2e −38%, l2book_dispatch_50_coins −21%, reconnect_resubscribe_burst −16%, subscribe_200_coins −16%, spotState_frame_dispatch_e2e −12%, ws_request_round_trip −5%, clearinghouseState_frame_dispatch_e2e −3%. Large frames move least because JSON.parse dominates them and the SDK can't touch that.

DecompressionStream remains the browser/RN fallback; both decode paths were differentially tested and agree on all 14 cases including 8000-coin multi-chunk payloads and every invalid input.

Fixes

  • Wrong-frame delivery on fastAssetCtxs. e.detail was read from the recycled event shell inside a queued continuation. Once frames arrived faster than inflate completed, the continuation read a later frame's payload — deltas silently dropped, others delivered twice, corrupting downstream price state. Reproduced across three arrival patterns before fixing.
  • Frame delivered after unsubscribe. A listener unsubscribed by an earlier listener mid-dispatch still received the in-flight frame. 0.1.3 matched EventTarget here; the snapshot-buffer rewrite broke it. A generation counter restores it at one integer compare per listener.
  • Unvalidated vault address. createL1ActionHash is publicly exported and took vaultAddress with no runtime validation — a 0x${string} template type constrains neither charset nor length at runtime, and not at all for JS callers. A malformed address silently hashed to different bytes than the caller described, and a 32-byte address hashed identically to its 20-byte truncation. Validation restored as it was at 0.1.3.

Tests

1652 pass / 0 fail. New coverage for the listener registry, the frame hook and public message behaviour, arrival order when synchronous and queued decodes interleave, plus two perf scenarios for the fastAssetCtxs decode path, which previously had none.

Reviewer notes

  • The Performance job is expected to flag this. It fails closed when the perf suite's source fingerprint differs between base and head, and this PR adds a scenario file. That's the designed "flag for explicit review" path, not a regression.
  • baseline.json still needs re-recording on a quiet machine. The committed one was recorded with dirty: true and its across-the-board gains are largely session drift — scenarios over untouched code (http_request −24%, parse_l2_book −15%) moved as much as the real wins. Its rme also inflated sharply on several scenarios, which widens the local gate's tolerance. It's now also stale against the new suite fingerprint. I did not re-record it: this machine was at load average 10 during the run, which would bake in the opposite bias.

🤖 Generated with Claude Code

Release 0.1.4.

Removes the duplicated machinery on the inbound frame path and the
per-frame async wrapper on the heaviest channel, then restores two
behaviours that regressed against 0.1.3.

Receive pipeline

- ReconnectingWebSocket re-boxed every inbound frame into a fresh
  MessageEvent and pushed it through native EventTarget.dispatchEvent
  purely to hand it to HyperliquidEventTarget, which then ran its own
  dispatch. The SDK's consumer now takes a direct frame hook and the
  public message event is built only when something outside the package
  is listening: 1.42x per frame, 305 ns saved.
- HyperliquidEventTarget stores a lone listener unboxed and promotes to
  a copy-on-write array on the second registration, so multi-listener
  dispatch no longer copies into a snapshot buffer and clears it:
  2.6x at 8 listeners, 2.2x at 2, 1.18x at 1.
- fastAssetCtxs decodes through node:zlib inflateRawSync where the
  runtime has it, collapsing a per-frame DecompressionStream, writer,
  reader and four promises into one call, and delivers in the dispatch
  tick instead of through the promise queue. DecompressionStream stays
  as the browser/RN fallback. End to end: 7.6x on delta frames, 1.8x on
  full snapshots.
- The dispatcher reuses the subscription id it already built by
  concatenation as the wire frame instead of stringifying the identical
  envelope a second time.

Measured against the committed baseline: webData3_frame_dispatch_e2e
-38%, l2book_dispatch_50_coins -21%, reconnect_resubscribe_burst -16%,
subscribe_200_coins -16%. Large frames move least because JSON.parse
dominates them, which the SDK cannot touch.

Fixes

- fastAssetCtxs read e.detail from the recycled event shell inside a
  queued continuation. Once frames arrived faster than inflate
  completed, the continuation read a later frame's payload: deltas were
  silently dropped and others delivered twice, corrupting downstream
  price state. The payload is now captured synchronously.
- Dispatch skipped the liveness check for listeners unsubscribed by an
  earlier listener mid-dispatch, so a listener could receive a frame
  after unsubscribing. 0.1.3 matched EventTarget here; a generation
  counter restores it at one integer compare per listener.
- createL1ActionHash is public and took vaultAddress with no runtime
  validation, so a malformed address silently hashed to different bytes
  than the caller described and a 32-byte address hashed identically to
  its 20-byte truncation. The 0x-prefix, length and hex charset are
  checked again, as they were at 0.1.3.

Tests

Adds coverage for the hand-rolled listener registry, the frame hook and
public message behaviour, arrival order when synchronous and queued
decodes interleave, and two perf scenarios for the fastAssetCtxs decode
path, which had none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joeblau
joeblau merged commit 7b5d0bb into main Jul 28, 2026
5 of 6 checks passed
@joeblau
joeblau deleted the perf/websocket-receive-pipeline branch July 28, 2026 02:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant