Skip to content

perf: O(1) established-flow NAT forward path via per-subscriber 5-tuple hash#74

Merged
w180112 merged 1 commit into
masterfrom
perf/nat-fwd-hash
Jul 7, 2026
Merged

perf: O(1) established-flow NAT forward path via per-subscriber 5-tuple hash#74
w180112 merged 1 commit into
masterfrom
perf/nat-fwd-hash

Conversation

@w180112

@w180112 w180112 commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

  • Outbound packets had no forward index: every packet of an established flow recomputed its initial candidate port and re-walked the candidate sequence until the reverse key matched. A flow that skipped N conflicted ports at creation re-paid N reverse-hash lookups on every packet for its whole lifetime — and under CGNAT port pressure (hundreds of devices sharing one public IP) collisions are common, so the per-packet tax grows with exactly the load that matters.
  • Add a second per-subscriber cuckoo hash nat_forward_hash: key = 5-tuple (src_ip, dst_ip, src_port, dst_port) (protocol-blind, matching the existing nat_entry_same_flow() semantics), value = the same addr_table slot idx the reverse hash stores. Same RW_CONCURRENCY_LF + MULTI_WRITER_ADD flags and the same shared QSBR RCU in DQ mode, but with a NULL data callback — pool-slot reclaim stays owned solely by the reverse hash's defer queue (verified against DPDK source that a NULL callback is safe on every reclaim path).
  • Per-packet path becomes: one lock-free forward lookup, hit → refresh + return nat_port. Established flows never touch the candidate-port walk again; a forward miss means a genuinely new flow (or a transient GC race). With step 1, both directions are now exactly one hash lookup per packet.
  • Dual-key lifecycle invariant, mirrored: inserts reverse-first (a failed forward add unpublishes via the reverse key, whose dq owns the slot — it may already be visible to readers, so it must not go straight back to the free ring), deletions forward-first (by the time the reverse delete schedules slot reclaim, no forward key references it). All three delete sites (reverse expiry-as-miss, learning eviction, GC scan) route through one helper nat_entry_del_keys(); the insert lock's double-check now covers both hashes. Forward-add ENOSPC fails the flow outright — the forward key contains no port, so the next candidate port could not succeed either.
  • Fixes a step-1 latent corruption found by the new tests: nat_table_reset() refilled the free ring while deferred frees could still be pending in the RCU defer queues (visible as HASH: RCU reclaim all resources failed). A stale deferred free firing after the refill enqueued a duplicate slot index — two flows sharing one entry — and the same staleness corrupts the hashes' internal key-slot allocators. Reset now drains both defer queues first; the drain terminates because data lcores report quiescent every poll iteration (idle included) and the queues are empty at first init.
  • Dead code removed: nat_entry_matches_key() (unused since step 1) plus four stale doc blocks still describing pre-step-1 signatures.
  • Unit tests (add-only): test_evict_clears_forward_key (a leaked forward key would let a returning flow believe it owns a port that inbound now routes to someone else) and test_gc_clears_forward_key (a leaked forward key would fast-path into a reclaimed slot and skip restoring reverse reachability — inbound blackhole). The nat_env_reset helper now reports reader-0 quiescent before resetting so the new drain loop can complete; no existing test case modified.

This is step 2 of 3 of 項目一 of the datapath performance-improvement plan (NAT rte_hash rework; step 1: #73). Step 3 (expire_at SoA split + write-coalescing, GC forced minimum rate under sustained load, fill-rate/ENOSPC/GC stats) will come as a separate PR from a fresh branch.

Test plan

  • make — production build clean, no warnings/errors
  • make test — 1158/1158 unit tests pass (1148 existing + 10 new assertions; no existing case modified), controller/etcd integration tests pass
  • e2e_test/run_e2e_test.sh — 73/73 pass, 0 fail, 0 skip

🤖 Generated with Claude Code

…le hash

Step 2 of the NAT rework (step 1: PR #73).  Outbound packets had no
forward index: every packet of an established flow recomputed its initial
candidate port from (src_ip, src_port) and re-walked the candidate
sequence until the reverse key matched — a flow that skipped N conflicted
ports at creation re-paid N reverse-hash lookups on every packet for its
whole lifetime.  Under CGNAT port pressure (hundreds of devices sharing
one public IP) collisions are common, so the per-packet tax grows with
exactly the load that matters.

Add a second per-subscriber cuckoo hash "nat_forward_hash":

- key = 5-tuple (src_ip, dst_ip, src_port, dst_port), protocol-blind to
  match the existing nat_entry_same_flow() semantics; value = the same
  addr_table slot idx the reverse hash stores.  Same LF + multi-writer
  flags, same shared QSBR RCU in DQ mode, but a NULL data callback: pool
  slot reclaim stays owned solely by the reverse hash's dq (verified in
  DPDK source that a NULL callback is safe on every reclaim path).
- Per-packet path: one lock-free forward lookup, hit → refresh + return
  nat_port.  Established flows never touch the candidate-port walk again;
  a forward miss means a genuinely new flow (or a transient GC race).
- Dual-key lifecycle invariant, mirrored: inserts go reverse-first (a
  failed forward add unpublishes via the reverse key, whose dq owns the
  slot — it may already be visible to readers, so it must not go straight
  back to the free ring), deletions go forward-first (by the time the
  reverse delete schedules slot reclaim, no forward key references it).
  All three delete sites (reverse expiry-as-miss, learning eviction of an
  expired conflicting entry, GC scan) route through one helper,
  nat_entry_del_keys().  The insert lock's double-check now covers both
  hashes.  Forward-add ENOSPC fails the flow outright: the forward key
  contains no port, so trying the next candidate port could not succeed.

Also fixes a step-1 latent corruption found by the new tests:
nat_table_reset() reset the hashes and refilled the free ring while
deferred frees could still be pending in the RCU defer queues (visible as
"HASH: RCU reclaim all resources failed").  A stale deferred free firing
after the refill enqueued a duplicate slot index — two flows sharing one
entry — and the same staleness corrupts the hashes' internal key-slot
allocators.  Reset now drains both defer queues first; the drain
terminates because data lcores report quiescent every poll iteration
(idle included) and the queues are empty at first init.

Dead code removed: nat_entry_matches_key() (unused since step 1), plus
four stale doc blocks still describing pre-step-1 signatures.

Unit tests (add-only): test_evict_clears_forward_key — a leaked forward
key would let a returning flow revive its old entry and believe it owns a
port that inbound now routes to someone else; test_gc_clears_forward_key
— a leaked forward key would fast-path into a reclaimed slot and skip
restoring reverse reachability (inbound blackhole).  The nat_env_reset
helper now reports reader-0 quiescent before resetting so the new drain
loop can complete; no existing test case modified.

Verified: make test 1158/1158, e2e 73/73.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@w180112 w180112 merged commit d9f5d9f into master Jul 7, 2026
9 checks passed
@w180112 w180112 deleted the perf/nat-fwd-hash branch July 7, 2026 02:00
w180112 added a commit that referenced this pull request Jul 7, 2026
… + pool health metrics (#75)

Step 3 of the NAT rework (step 1: PR #73, step 2: PR #74).  Two problems
remained after the dual-hash rework: the GC scan still walked full 64B
addr_table entries to read one 8-byte deadline (cold entry fields sharing
lines with fields packet processing writes), and expire_at was written
unconditionally on every packet — including twice per TCP data packet,
once from the NAT-level refresh and once from the TCP conntrack
ESTABLISHED self-loop — with no coalescing.  GC itself only ran on
nb_rx == 0 (pure idle), so sustained near-line-rate traffic could starve
reclaim indefinitely with no visibility into how close the pool was to
exhaustion.

Structure-of-arrays (SoA) split: pull expire_at out of addr_table_t into
a parallel ppp_ccb array, nat_expire_at[MAX_NAT_ENTRIES] (8
deadlines/cache-line instead of 1 per 64B entry). The GC scan's hot loop
now reads only this dense array — 0 means free slot, skip — and only
touches the full entry once a deadline is confirmed expired, an 8x
reduction in scan footprint. Each addr_table_t keeps a back-pointer,
expire_slot, bound once in nat_table_reset and read-only after, so
tcp_conntrack.c's handlers (which only see a bare struct addr_table *,
not the owning ppp_ccb) can still reach their own SoA slot.

Write-coalescing: two disciplines replace the single unconditional
atomic write. nat_expire_set() stores unconditionally, for transitions
where a deadline may need to shrink immediately (e.g. ESTABLISHED ->
FIN_WAIT). nat_expire_refresh() reads the current value first and only
stores when the new target exceeds cur + NAT_EXPIRE_COALESCE_SEC (1s),
for same-state refreshes. Applied to the NAT-level per-packet refresh
paths (nat_reverse_lookup, both fast-path hits in
nat_learning_port_reuse) and to TCP conntrack: a new handler,
tcp_act_refresh_established, replaces tcp_act_timeout_established on the
(ESTABLISHED, ACK) -> ESTABLISHED self-loop — the row hit by every data
packet of an established flow — while transitions into ESTABLISHED keep
the unconditional setter. Net effect: per-flow expire writes drop from
once per packet to about once per second, removing the cross-core
cache-line ping-pong the SoA split would otherwise reintroduce under
concurrent refresh from RSS/distributor cores.

GC trigger: widened from nb_rx == 0 to nb_rx < BURST_SIZE (any
non-full burst, not just fully idle polls), moved to after burst/TX
processing so it never adds latency ahead of packets, plus a per-lcore
backpressure counter (NAT_GC_FORCE_PERIOD = 1024) that forces one GC
chunk every 1024 consecutive full-burst polls so sustained line-rate
traffic still gets a guaranteed minimum reclaim rate. Worst case: one
512-slot SoA scan per 1024x32 packets (~1.5%).

Pool health observability: two new per-subscriber counters,
ppp_ccb->nat_enospc (learning failures: ports exhausted, pool dry, or
hash full) and nat_gc_reclaimed (entries reclaimed by GC), both relaxed
atomics on cold paths. Exposed as three new Prometheus metrics
(fastrg_node_per_user_nat_entries_used,
fastrg_node_per_user_nat_alloc_fail_total,
fastrg_node_per_user_nat_gc_reclaimed_total) and documented in
docs/metrics.md.

Unit tests (add-only, no existing case modified): test_expire_refresh_coalescing
and test_stats_counters in nat_test.c; test_established_refresh_coalesced
in tcp_conntrack_test.c, verifying a fresh deadline skips the coalesced
refresh, a >1s-stale one forces a store, and the FIN -> FIN_WAIT
shortening transition remains immediate. Mechanical migration of existing
expire_at accesses in both test files from rte_atomic64_set/read to
nat_expire_set/expire_slot.

Verified: make test 1167/1167, e2e 73/73.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant