perf: SoA expire deadlines + write-coalescing + forced-minimum NAT GC + pool health metrics#75
Merged
Merged
Conversation
… + pool health metrics 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>
w180112
added a commit
that referenced
this pull request
Jul 9, 2026
…82) Two residual gaps from the test-coverage analysis of PR #72/#75's conntrack work (existing tests only exercised seq deltas far from the wrap point, and the constructor-built dispatch index was never scanned against the full state x event space): - test_seq_wraparound (8 assertions): tcp_conntrack_seq_update advances max_seq_end/max_ack across the 0xFFFFFFFF→0 boundary via the wrap-aware signed compare; stale pre-wrap seq/ack cannot roll the max back after the baseline crosses 0; the explicit ==0 seed guard fires for an ISN >= 2^31 (which looks "older than zero" under signed math). tcp_conntrack_seq_valid accepts in-window seq/ack just past the wrap (unsigned distance ~4G, signed delta tiny) and still drops seq >16MB and ack >0xFFFF past the wrap. - test_fsm_index_full_scan (11 assertions): mirrors tcp_conntrack_tbl as an expected-next-state matrix and drives tcp_conntrack_fsm through all 10x6 (state, event) combinations — defined rows must land on the table's next_state, undefined rows must keep the state and return SUCCESS (no state may hit the no-rows ERROR contract since every state has rows). Catches future table edits or index-build drift. Existing cases untouched. make test 1404/1404; e2e 73/73 PASS 0 SKIP. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
w180112
added a commit
that referenced
this pull request
Jul 9, 2026
…OSPC (#83) The existing enospc test only exercised the "every port reserved" source. The other two sources in nat_learning_port_reuse were untested (flagged in the coverage analysis of PR #73-#75): free-ring exhaustion inside nat_slot_alloc, and rte_hash_add_key_data failure for each hash. New cases in unit_test/pppd/nat_test.c (22 assertions): - test_pool_dry_enospc: drains the free ring (with one expired flow left behind), then a new learn must fail with nat_enospc+1 while the emergency GC inside nat_slot_alloc demonstrably runs (expired flow's keys deleted, nat_gc_reclaimed+1) yet cannot return the slot in-call because the RCU defer queue needs reader quiescence. After an explicit quiesce+reclaim the slot returns and the same flow learns successfully. - test_reverse_hash_full_enospc / test_forward_hash_full_enospc: the real hashes (262144-entry cuckoo, multi-writer) have no deterministic fill-to-fail point, so these use a small hand-built fixture (8-entry single-writer hashes → exactly 8 adds succeed, the 9th fails with ENOSPC) driving the same nat_learning_port_reuse code: - reverse full: every candidate port fails, one enospc after the full port wrap, the never-published slot returns to the ring on every iteration (no slot leak), no forward-key pollution. - forward full: reverse key gets unpublished (WAN side cannot reach the dead slot), flow fails immediately with one enospc, and the slot goes to deferred reclaim rather than straight back to the ring. Existing cases untouched. make test 1426/1426; e2e 73/73 PASS 0 SKIP. 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
Step 3 of the NAT rework (step 1: #73, step 2: #74). Closes the two remaining gaps after the dual-hash rework:
addr_tableentries just to read one 8-byteexpire_at, sharing cache lines with fields packet processing writes.expire_atwas stored on every packet, including twice per TCP data packet (once from the NAT-level refresh, once from the TCP conntrackESTABLISHEDself-loop), with no coalescing — and GC only ran onnb_rx == 0(pure idle), so sustained near-line-rate traffic could starve reclaim with zero visibility into pool health.Structure-of-arrays (SoA) split
expire_atmoves out ofaddr_table_tinto a parallel per-subscriber array,nat_expire_at[MAX_NAT_ENTRIES](8 deadlines/cache-line vs. 1 per 64B entry). The GC hot loop now reads only this dense array —0means free, skip — and only touches the full entry once a deadline is confirmed expired: an 8x reduction in scan footprint. Eachaddr_table_tkeeps a back-pointer,expire_slot, bound once innat_table_resetand read-only after, sotcp_conntrack.c's handlers (which only see a barestruct addr_table *, not the owningppp_ccb) can still reach their own SoA slot.Write-coalescing
Two disciplines replace the single unconditional atomic write:
nat_expire_set()— unconditional store, for transitions where a deadline must shrink immediately (e.g.ESTABLISHED -> FIN_WAIT).nat_expire_refresh()— reads the current value first, only stores when the new target exceedscur + 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 innat_learning_port_reuse) and to TCP conntrack: a new handler,tcp_act_refresh_established, replacestcp_act_timeout_establishedon the(ESTABLISHED, ACK) -> ESTABLISHEDself-loop — the row hit by every data packet of an established flow — while transitions intoESTABLISHEDkeep 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 == 0tonb_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) andnat_gc_reclaimed(entries reclaimed by GC), both relaxed atomics on cold paths. Exposed as three new Prometheus metrics and documented indocs/metrics.md:fastrg_node_per_user_nat_entries_usedfastrg_node_per_user_nat_alloc_fail_totalfastrg_node_per_user_nat_gc_reclaimed_totalTests
Add-only, no existing case modified:
test_expire_refresh_coalescingandtest_stats_countersinnat_test.c;test_established_refresh_coalescedintcp_conntrack_test.c(verifies a fresh deadline skips the coalesced refresh, a >1s-stale one forces a store, and theFIN -> FIN_WAITshortening transition stays immediate). Mechanical migration of existingexpire_ataccesses in both test files fromrte_atomic64_set/readtonat_expire_set/expire_slot.Test plan
make— clean production buildmake test— 1167/1167e2e_test/run_e2e_test.sh— 73/73 pass, 0 fail, 0 skip🤖 Generated with Claude Code