Skip to content

nat: lock-free SNAT port allocation (#2852 Phase 1) - #4648

Merged
psaab merged 1 commit into
masterfrom
fix/2852-phase1-lockfree
Jul 8, 2026
Merged

nat: lock-free SNAT port allocation (#2852 Phase 1)#4648
psaab merged 1 commit into
masterfrom
fix/2852-phase1-lockfree

Conversation

@psaab

@psaab psaab commented Jul 8, 2026

Copy link
Copy Markdown
Owner

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)

  • Lock-free claim: AddressOccupancy (Vec<AtomicU64> occupancy bitmap + atomic fresh-port cursor). A fetch_or CAS on the bit IS the ownership token (a set bit cannot be re-claimed), replacing the mutex-guarded owner_by_translated / addr_index_by_translated maps and next_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_flow insert.
  • F1 conditional bit-clear on release (guarded by the live_by_flow record).
  • F2 FIFO recycle (userspace-dp NAT: SNAT port recycling is LIFO (Vec push/pop at back) — favors 2MSL/TIME_WAIT port-reuse collisions; prefer FIFO (VecDeque) #3011) preserved exactly — a VecDeque behind 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).
  • F7 addr_index stored in LiveAllocation (O(1) release).
  • F4 global cap kept exact as live_by_flow.len() under the tiny insert mutex — no fetch_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_flow HA reservation (#4388), and lease reuse (#2397/#4643) all preserved on the bitmap.

Validation

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 the research/2852-portalloc branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi

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
Copilot AI review requested due to automatic review settings July 8, 2026 14:13

Copilot AI 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.

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-address Mutex<VecDeque<u16>> FIFO recycle) and removes owner_by_translated, addr_index_by_translated, and next_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");
@psaab
psaab merged commit f701469 into master Jul 8, 2026
1 check passed
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.

2 participants