Skip to content

perf: SoA expire deadlines + write-coalescing + forced-minimum NAT GC + pool health metrics#75

Merged
w180112 merged 1 commit into
masterfrom
perf/nat-expire-soa
Jul 7, 2026
Merged

perf: SoA expire deadlines + write-coalescing + forced-minimum NAT GC + pool health metrics#75
w180112 merged 1 commit into
masterfrom
perf/nat-expire-soa

Conversation

@w180112

@w180112 w180112 commented Jul 7, 2026

Copy link
Copy Markdown
Member

Summary

Step 3 of the NAT rework (step 1: #73, step 2: #74). Closes the two remaining gaps after the dual-hash rework:

  • GC scan cost: the amortized GC scan walked full 64B addr_table entries just to read one 8-byte expire_at, sharing cache lines with fields packet processing writes.
  • Unconditional per-packet expire writes: expire_at was stored on every packet, including twice per TCP data packet (once from the NAT-level refresh, once from the TCP conntrack ESTABLISHED self-loop), with no coalescing — and GC only ran on nb_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_at moves out of addr_table_t into 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 — 0 means free, 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() — 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 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 and documented in docs/metrics.md:

  • fastrg_node_per_user_nat_entries_used
  • fastrg_node_per_user_nat_alloc_fail_total
  • fastrg_node_per_user_nat_gc_reclaimed_total

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 (verifies a fresh deadline skips the coalesced refresh, a >1s-stale one forces a store, and the FIN -> FIN_WAIT shortening transition stays immediate). Mechanical migration of existing expire_at accesses in both test files from rte_atomic64_set/read to nat_expire_set/expire_slot.

Test plan

  • make — clean production build
  • make test — 1167/1167
  • e2e_test/run_e2e_test.sh — 73/73 pass, 0 fail, 0 skip

🤖 Generated with Claude Code

… + 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 w180112 merged commit b3d56c1 into master Jul 7, 2026
9 checks passed
@w180112 w180112 deleted the perf/nat-expire-soa branch July 7, 2026 08:27
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>
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