Repository navigation
perf(relay): index relay table for O(1) hot-path lookups - #83
krazyjakee wants to merge 2 commits into
Conversation
The UDP relay hot path (`UDPRelayHandler.relay`) previously did two
`Array.prototype.find` linear scans over the relay table on *every*
packet — one to resolve the sender, one for the target. Cost grew
O(N) in active relays, so per-packet throughput collapsed as the
relay count rose (~44x slower from 10 to 10,000 relays in the
included benchmark).
Add two indexes maintained alongside the relay table:
- `_byAddress`: nested Map (address -> port -> entry), so the hot
path can look up by the raw sender address + port with no
per-packet string concatenation
- `_byPort`: Map (port -> entry) for the target lookup
Also split `relay()` into a thin NetAddress wrapper over a new
allocation-free `relayRaw()`, and have the socket data callback call
`relayRaw` directly — removing the per-packet `new NetAddress`. A
NetAddress is still constructed on the (cold) drop path to preserve
the `drop` event contract.
Result: hot-path throughput stays flat (~1.3-1.7M ops/s) regardless
of table size instead of collapsing — ~33x at 10,000 relays. All
existing spec/unit tests pass unchanged.
Adds `scripts/bench.relay.ts` to reproduce the measurements.
Add unit coverage for the maintained relay indexes introduced alongside relayRaw(): - direct relayRaw() transmit (the shipped hot path, not just the relay(NetAddress) wrapper) - drop after a target is freed (deindex from _byPort) - shared-address bucket integrity: freeing one port keeps the sibling routable; freeing the last port cleans up the empty _byAddress bucket Also assert the drop event's sender is a value-equal NetAddress, pinning the contract dynamic relaying keys off.
c375ee7 to
294600d
Compare
elementbound
left a comment
There was a problem hiding this comment.
Thanks for the PR! Added some notes for myself.
There was a problem hiding this comment.
I like the idea of introducing perf tests! I think we can merge a test like this in a follow-up PR, mainly because the current architecture doesn't support this well.
In order to execute a test like this, it needs to mess with internals. Also, I don't think we need to keep two separate implementations, even though it is nice to compare them. But ultimately, I'll keep only one merged.
Then there's also the question of whether we want to mock some layers for perf testing, or run everything on a complete network stack. In this case I think it's OK to mock, since we can't control the speed of the network stack.
So my idea is:
- Pull in tinybench to handle benchmarking
- Make
SocketPoolan interface, with its defaultUDPSocketPoolimplementation, and aTestingSocketPool, that keeps no-op "sockets" around - Implement a perf test similar to this one, with a testing socket pool
I'm also thinking of refactoring UDPRelayHandler so that it doesn't touch socket, only receives a sender address, and returns a target address where the data should be forwarded. That would probably lend itself to even simpler performance testing. Whichever comes first!
| * Relay a message, addressing the sender by its raw address + port. | ||
| * | ||
| * This is the allocation-free hot path: it avoids constructing a NetAddress | ||
| * per packet. A NetAddress is only built on the (cold) drop path, to preserve |
There was a problem hiding this comment.
We can probably refactor that as well, to avoid having to build a NetAddress in the drop event. I'll see what I can do!
Summary
The UDP relay hot path (
UDPRelayHandler.relay) did twoArray.prototype.findlinear scans over the relay table on every packet — one to resolve the sender, one for the target. Cost is O(N) in active relays, so per-packet throughput collapses as the relay count grows.This PR replaces those scans with maintained indexes, keeping the hot path O(1) regardless of table size.
Changes
_byAddress— nestedMap(address → port → entry), so the hot path looks up the sender by raw address + port with no per-packet string concatenation._byPort—Map(port → entry) for the target lookup.createRelay/freeRelay/clear(cost paid once per relay, not per packet).relay()into a thinNetAddresswrapper over a new allocation-freerelayRaw(); the socketdatacallback now callsrelayRawdirectly, removing the per-packetnew NetAddress. ANetAddressis still built on the (cold) drop path to preserve thedropevent contract.Benchmark
bun scripts/bench.relay.ts(added). Measures the realrelay/relayRawpath (metrics + event emit intact) with a no-op socket injected, so it isolates relay-logic CPU cost rather than loopback syscalls.Before: ~44x throughput collapse from 10 → 10,000 relays. After: flat ~1.3–1.7M ops/s.
Testing
bun test test/spec→ 144 pass, 3 pre-existing skips).relayRawpath.test/spec/relay/udp.relay.handler.test.ts:relayRaw()transmit — exercises the shipped hot path itself, not just therelay(NetAddress)wrapper._byPort, so a subsequentrelayRaw()to that port drops (no send,dropemitted)._byAddressbucket (theports.size === 0branch).dropevent's sender is a value-equalNetAddress, which dynamic relaying keys off.