perf: O(1) established-flow NAT forward path via per-subscriber 5-tuple hash#74
Merged
Conversation
…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>
Merged
3 tasks
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>
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.
Summary
nat_forward_hash: key = 5-tuple(src_ip, dst_ip, src_port, dst_port)(protocol-blind, matching the existingnat_entry_same_flow()semantics), value = the sameaddr_tableslot idx the reverse hash stores. SameRW_CONCURRENCY_LF+MULTI_WRITER_ADDflags 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).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.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.nat_table_reset()refilled the free ring while deferred frees could still be pending in the RCU defer queues (visible asHASH: 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.nat_entry_matches_key()(unused since step 1) plus four stale doc blocks still describing pre-step-1 signatures.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) andtest_gc_clears_forward_key(a leaked forward key would fast-path into a reclaimed slot and skip restoring reverse reachability — inbound blackhole). Thenat_env_resethelper 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_atSoA 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/errorsmake test— 1158/1158 unit tests pass (1148 existing + 10 new assertions; no existing case modified), controller/etcd integration tests passe2e_test/run_e2e_test.sh— 73/73 pass, 0 fail, 0 skip🤖 Generated with Claude Code