nat: lock-free SNAT port allocation (#2852 Phase 1) - #4648
Merged
Conversation
The pool-mode SNAT PortAllocator kept all live allocation state behind a single Mutex<PortAllocatorLiveState>, so every new-flow port allocation on every worker core serialized on one lock. The #4619 microbench proved this negative-scales: aggregate throughput drops 2.87M to 0.62M allocs/sec from M=1 to M=8, with the p99 allocate tail exploding ~100x. At high connection-setup rates the allocator caps at ~0.6M/sec regardless of core count, entirely on the lock. Phase 1 of the converged v3 plan (Claude-SMR + Codex + AGY) makes the port CLAIM lock-free while keeping only a tiny mutex around the map bookkeeping: - Port ownership is a per-pool-address atomic occupancy bitmap (AddressOccupancy: Vec<AtomicU64> + an atomic fresh-port cursor). A fetch_or CAS on the bit is the sole ownership arbiter -- a set bit cannot be re-claimed, which is the ABA-safe ownership token that replaces the mutex-guarded owner_by_translated / addr_index_by_translated maps and the next_port_offset_by_addr cursor. - The non-persistent new-flow hot path claims its port with no global mutex and takes the retained Mutex<PortAllocatorLiveState> only for a tiny reuse-check + exact-cap-check + live_by_flow insert. - F1: release conditionally clears the bit only after confirming the flow record still owns the tuple (the live_by_flow lookup under the mutex is the guard). - F2: FIFO recycle (#3011) is preserved exactly -- freed ports still reuse oldest-first via a VecDeque behind a per-ADDRESS mutex (not the global one). Lock ordering is always global -> recycle (recycle is innermost), so there is no deadlock and the plan's F5 hazard is sidestepped (Phase 1 has no two-map-shard path). A fully lock-free MPMC recycle ring is a Phase-2 option; crossbeam is not a dependency and a hand-rolled lock-free ring is not worth the risk on this hot path. - F7: LiveAllocation carries the pool-address index so release is O(1). - F4: the global tracked-flow cap is live_by_flow.len() re-checked under the tiny insert mutex, where the map length is authoritative. That is EXACT -- no fetch_add-reserve, so the M-in-flight overshoot the microbench surfaced (which can spuriously exhaust a tiny pool near capacity) does not exist here. This is strictly better than release-before-reserve and is available precisely because Phase 1 keeps the maps under one mutex. Persistent NAT keeps its lease-decision + claim atomic under the mutex (allocate_translation_locked, the cold path), so two flows sharing a lease cannot both claim a port. The non-persistent path falls back to the locked path only when every target address bitmap is full, to preserve the pre-claim expiry-GC-then-retry exhaustion behavior. Deterministic-v4 block allocation (#4559), reserve_flow HA reservation (#4388), and persistent lease reuse (#2397/#4643) all preserved on the bitmap. Removed owner_by_translated, addr_index_by_translated, next_port_offset_by_addr, and the AllocationOwner enum. Added #[cfg(test)] debug accessors (debug_is_port_occupied / debug_recycled_ports / debug_set_cursor / debug_set_recycled / debug_occupied_count) and rewrote the 10 white-box test sites that inspected the removed fields. Validation: full cargo test 3743/0; NAT suite 209/0. Added 4 RED-on-revert concurrency tests -- concurrent fill is exact + collision-free (no double-alloc, no false exhaustion, no over-cap), concurrent churn has no double-alloc and no leaked bit, release frees the bit and the port is reusable, and a tiny pool fills to exact capacity then exhausts. Verified RED-on-revert: breaking the free clears turns the release/churn tests red; breaking the CAS arbiter turns the #3047 forward-probe / recycle-retain and #4388 reserve tests red. The #3011 FIFO, #3047 collision, #4388 reserve, and #4559 deterministic tests are preserved. The loss-cluster new-flow-ceiling measurement (needs a connection-rate generator) and make test-failover remain as cluster smoke gates. Phase 2 (hash-shard the maps) stays deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Contributor
There was a problem hiding this comment.
Pull request overview
This PR implements #2852 Phase 1 by replacing the pool-mode SNAT allocator’s single hot-path Mutex<PortAllocatorLiveState> port-ownership tracking with a per-address lock-free atomic occupancy bitmap (AddressOccupancy). Port claim becomes lock-free (bitmap fetch_or), while the remaining mutex is kept for the smaller critical section around live_by_flow / persistent leases / exact cap checks.
Changes:
- Introduces
AddressOccupancy(atomic bitmap + atomic cursor + per-addressMutex<VecDeque<u16>>FIFO recycle) and removesowner_by_translated,addr_index_by_translated, andnext_port_offset_by_addr. - Reworks allocation/release/reserve paths to use bitmap ownership tokens, and updates white-box tests to use new debug accessors.
- Adds new concurrency “fail-on-revert” tests and updates documentation/research notes +
_Log.md.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| userspace-dp/src/nat/allocator.rs | Implements lock-free per-address port ownership via atomic bitmap + adjusts allocation/release/reserve logic and snapshot accounting. |
| userspace-dp/src/nat/tests_pool.rs | Updates white-box assertions to new debug accessors and adds new concurrency correctness tests for the lock-free claim path. |
| userspace-dp/README.md | Documents the new lock-free allocation design and references the new fail-on-revert tests. |
| docs/research/2852-portalloc/microbench-results.md | Records that Phase 1 shipped and explains the design + merge-gate status. |
| _Log.md | Logs the write/edit action and summarizes the Phase 1 change per repo convention. |
Comment on lines
+3676
to
+3687
| // No two live flows may hold the same translated tuple: | ||
| // the occupancy bit was exclusively ours, so the insert | ||
| // must be new. | ||
| assert!( | ||
| live_set.lock().unwrap().insert((t.ip, t.port)), | ||
| "double-allocation: (ip, port) already held by a live flow" | ||
| ); | ||
| // Remove from the live-set BEFORE freeing the bit, so a | ||
| // peer's legitimate reuse of the freed port is not a | ||
| // false collision. | ||
| live_set.lock().unwrap().remove(&(t.ip, t.port)); | ||
| assert!(alloc.release_flow(flow, t, 2_000), "release of a live flow"); |
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.
Addresses #2852 (Phase-1 lock-free allocation; Phase-2 map-sharding + the loss-cluster conn/sec ceiling remain deferred).
What
Replaces the single
Mutex<PortAllocatorLiveState>hot-path serialization of pool-mode SNAT port allocation with a per-pool-address atomic occupancy bitmap, so the port claim is lock-free. The #4619 microbench proved the mutex is a measurable bottleneck (current shape negative-scales 2.87M→0.62M allocs/sec M=1→8, p99 tail +100×; the new lock-free bitmap is 1.4–1.6× at M=6/8) — no PLAN-KILL.Design (converged v3 plan: Claude-SMR + Codex + AGY)
AddressOccupancy(Vec<AtomicU64>occupancy bitmap + atomic fresh-port cursor). Afetch_orCAS on the bit IS the ownership token (a set bit cannot be re-claimed), replacing the mutex-guardedowner_by_translated/addr_index_by_translatedmaps andnext_port_offset_by_addr. The non-persistent new-flow hot path claims lock-free and takes the retained mutex only for a tiny reuse-check + exact-cap-check +live_by_flowinsert.live_by_flowrecord).VecDequebehind a per-ADDRESS mutex (not the global one). Lock order is always global → recycle (innermost), so no deadlock and F5 is sidestepped (Phase 1 has no two-map-shard path).addr_indexstored inLiveAllocation(O(1) release).live_by_flow.len()under the tiny insert mutex — nofetch_add-reserve, so the microbench's M-in-flight overshoot cannot falsely exhaust a tiny pool near capacity. Strictly better than release-before-reserve, and available because Phase 1 keeps the maps under one mutex.Persistent NAT keeps its lease-decision + claim atomic under the mutex (
allocate_translation_locked, cold path). Deterministic-v4 (#4559),reserve_flowHA reservation (#4388), and lease reuse (#2397/#4643) all preserved on the bitmap.Validation
cargo test: 3743 passed / 0 failed. NAT suite 209/0.make test-failover(exercises thereserve_flowHA path) and the loss-cluster new-flow-ceiling measurement (needs a connection-rate generator, distinct from the bulk-throughputperf-testskill) remain lab-deferred gates.Deferred
Phase 2 (hash-shard
live_by_flow+ the persistent-lease maps) and the loss-cluster conn/sec ceiling measurement. The residual tiny map mutex is now the serialization point; Phase 2 is warranted only if the lab shows it as the next bottleneck. Design is in the converged v3 plan on theresearch/2852-portallocbranch.🤖 Generated with Claude Code
https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi