infra,l2,ip: fix flow hashing for ports without RSS - #701
hcaldicott wants to merge 7 commits into
Conversation
rjarry
left a comment
There was a problem hiding this comment.
There is a bit too much code churn in the successive commits. Could you try and make this more incremental?
Did you test if this series introduces any performance change? I am concerned about the re-computation of RSS after decap/encap even with hardware drivers that support L4 UDP ports.
Also, please drop the Assisted-by: <LLM> trailers.
1054ad2 to
87dced9
Compare
|
Thanks for the review - I will re-work and clean this up. This was cherry-picked from other development work on our MH implementation, so it could use some more scrutiny - sit tight. |
87dced9 to
664be0c
Compare
Could you confirm if the commit history is acceptable now? I updated Patch 1 so that it uses the final shape from the beginning to remove most of the churn. This was due to cherry-picking from my upstream MH codebase - apologies.
Fair call - I have reworked so that decap no longer recomputes anything.
Done. |
664be0c to
bc5b23d
Compare
|
Okay, I have thrown together some AI tests and sanity checked them, seems good to me. I benchmarked the series against v0.17.1 on a pair of Xeon D-2143IT boxes (X722 10GbE SFP+, back to back), using the per-node cycle counters from Two topologies: plain routed forwarding (kernel TAP in, 10G wire, TAP out) and a bridged VXLAN overlay mirroring a production EVPN setup. 30 s measured runs after warmup, 3 reps per build per scenario, medians below. X722 RSS was confirmed active (flows spread across both RX queues).
Notes:
Throughput was generator-limited and identical on both builds. The fixed build showed lower loss in the vxlan scenario (<=0.01% vs up to 0.77%): per-flow source ports let the receiving NIC spread flows across both RSS queues instead of piling them onto one. Incidental finding from the same rig: the X722 PF firmware rejects Crux of this: I see about a 62ns (at 2.2ghz) performance decrease with the corrected hashing function in place. However I feel like it could be worthwhile for the correctness aspect of this fix? |
christophefontaine
left a comment
There was a problem hiding this comment.
With the introduction of gr_mbuf_flow_hash_get to get the mbuf->hash.rss, we should also update process_behav_decap in modules/srv6/datapath/srv6_local.c to set the hash.
WDYT ?
And to update modules/ip/datapath/icmp_local_send.c as well as icmp6_local_send.c for consistency.
| eth = rte_pktmbuf_mtod(m, const struct rte_ether_hdr *); | ||
| tuple.l2.mac = eth->dst_addr; | ||
| if (eth->ether_type == RTE_BE16(RTE_ETHER_TYPE_VLAN)) { | ||
| vlan = (const struct rte_vlan_hdr *)(eth + 1); |
There was a problem hiding this comment.
vlan = PAYLOAD(eth);
There was a problem hiding this comment.
Done in v4 (with gr_macro.h included directly — nothing in the datapath headers re-exports it).
| l3.ip6 = rte_pktmbuf_mtod_offset(m, const struct rte_ipv6_hdr *, l3_offset); | ||
| tuple.v6.src_addr = l3.ip6->src_addr; | ||
| tuple.v6.dst_addr = l3.ip6->dst_addr; | ||
| switch (l3.ip6->proto) { |
There was a problem hiding this comment.
Outside of this commit, and eligible for a future discussion / PR.
As we only look at ip6->proto, we do not take into account the extension headers which could be before a valid IPPROTO_*.
So, as part of the hash computation, what should we do ?
There was a problem hiding this comment.
Agreed, follow-up material. Right now anything that is not immediately TCP/UDP falls through to the default arm and gets a L3-only hash: less entropy, but stable, so flows with extension headers degrade the same way fragments do instead of risking reordering. For a future PR I see two options: a bounded walk over the extension chain (stopping at a fragment header and staying L3-only in that case, same rule this series applies to IPv4 fragments), or using the flow label per RFC 6438 when it is non-zero, which trusts the sender to have derived it from the inner flow. The walk is probably the safer default. Happy to open that discussion separately.
| } | ||
|
|
||
| rte_pktmbuf_adj(m, sizeof(struct rte_udp_hdr) + sizeof(*vh)); | ||
| gr_mbuf_flow_hash_invalidate(m); |
There was a problem hiding this comment.
I'm not comfortable in always invalidating the hash, as it may have been computed by the hardware on the inner packet.
Also, relying on the PTYPE may not be good either: as you said, even if the hash isn't computed on the inner, the remote VTEP may have spread the source UDP port.
// keep a hardware-provided inner-flow hash; only invalidate an outer one
if (!(m->packet_type & RTE_PTYPE_INNER_L4_MASK))
gr_mbuf_flow_hash_invalidate(m);
And I know that @david-marchand is not fan of using packet_type as well :)
There was a problem hiding this comment.
Hum, I did not look too much in detail at the full PR sorry.
I am trying to go back to the actual problem you are facing.
The issue seems to be that the rss hash is not a function of the inner packet.
This should be the case for routers implementing the recommendation in RFC7348.
Outer UDP Header: This is the outer UDP header with a source port
provided by the VTEP and the destination port being a well-known
UDP port.
...
- Source Port: It is recommended that the UDP source port number
be calculated using a hash of fields from the inner packet --
one example being a hash of the inner Ethernet frame's headers.
This is to enable a level of entropy for the ECMP/load-
balancing of the VM-to-VM traffic across the VXLAN overlay.
When calculating the UDP source port number in this manner, it
is RECOMMENDED that the value be in the dynamic/private port
range 49152-65535 [[RFC6335](https://datatracker.ietf.org/doc/html/rfc6335)].
If you are receiving traffic from another grout instance, the rss hash should have a source port that depends on the inner packet RSS (https://github.com/DPDK/grout/blob/main/modules/l2/datapath/vxlan_output.c#L90).
Is it that in your usecase the inner (meaning, before encapsulation on the transmitter side) packet rss 0 because it is received from a net/virtio (including virtio-user) port?
If this is the case, you may want to compute a RSS when it is not set at the RX side (see rx_virtio_process/rx_bond_virtio_process).
There was a problem hiding this comment.
@david-marchand yes, that is exactly the use case: the access ports here are virtio/vhost and TAP, so pre-encap packets carry no RSS and vxlan_output was reading hash.rss == 0 for every flow - which is why all encapsulated flows shared one source port (the red test in the PR description).
On where to fix it: rather than computing eagerly in rx_virtio_process/rx_bond_virtio_process, the series computes the same software hash lazily at the first consumer (vxlan_output, bond_output, the fib lookups) through gr_mbuf_flow_hash_get(), which stores it in m->hash.rss and sets RTE_MBUF_F_RX_RSS_HASH exactly as a driver would. The end state is the same as computing it at RX, but packets that never reach a hash consumer do not pay for it. I measured the software hash at roughly 136 cycles/packet on Xeon D / X722; charged at RX it would tax every packet on virtio ports whether or not anything downstream needs a hash. If you would still rather see it in the RX path for uniformity I can move it, but the lazy placement was deliberate.
On the invalidation: fair point about RFC 7348. When the remote VTEP derives the source port from the inner flow (kernel vxlan, grout with this series), the outer RSS is already a function of the inner flow, so keeping it preserves both per-flow affinity and distribution after decap, and invalidating just forces a pointless software rehash - in our deployment that is every decapped packet that egresses over a bond. It also discards a hardware inner hash on NICs that parse the tunnel, per @christophefontaine's comment, without needing any packet_type check. The one thing the invalidation protected against is a remote VTEP using a fixed source port (which includes any pre-fix grout feeding from ports without RSS), but that traffic already polarizes across every underlay ECMP hop, so I agree it is not worth making the common case pay. The invalidation is dropped in v4; vxlan_input is now untouched by the series.
SRv6 decap is a different story (see Christophe's review comment on process_behav_decap): there the outer header carries no inner entropy at all, so that path does need to drop the stale hash. Handled in v4.
There was a problem hiding this comment.
@david-marchand yes, that is exactly the use case: the access ports here are virtio/vhost and TAP, so pre-encap packets carry no RSS and vxlan_output was reading
hash.rss == 0for every flow - which is why all encapsulated flows shared one source port (the red test in the PR description).
Ok, thanks for confirming.
On where to fix it: rather than computing eagerly in
rx_virtio_process/rx_bond_virtio_process, the series computes the same software hash lazily at the first consumer (vxlan_output, bond_output, the fib lookups) throughgr_mbuf_flow_hash_get(), which stores it inm->hash.rssand setsRTE_MBUF_F_RX_RSS_HASHexactly as a driver would. The end state is the same as computing it at RX, but packets that never reach a hash consumer do not pay for it. I measured the software hash at roughly 136 cycles/packet on Xeon D / X722; charged at RX it would tax every packet on virtio ports whether or not anything downstream needs a hash. If you would still rather see it in the RX path for uniformity I can move it, but the lazy placement was deliberate.
There is always a tradeoff, unfortunately.
I am a bit scared at touching the stack to deal with a driver issue.
Do you have an idea of the performance impact of adding the branches in the RSS hash users?
On the invalidation: fair point about RFC 7348. When the remote VTEP derives the source port from the inner flow (kernel vxlan, grout with this series), the outer RSS is already a function of the inner flow, so keeping it preserves both per-flow affinity and distribution after decap, and invalidating just forces a pointless software rehash - in our deployment that is every decapped packet that egresses over a bond. It also discards a hardware inner hash on NICs that parse the tunnel, per @christophefontaine's comment, without needing any packet_type check. The one thing the invalidation protected against is a remote VTEP using a fixed source port (which includes any pre-fix grout feeding from ports without RSS), but that traffic already polarizes across every underlay ECMP hop, so I agree it is not worth making the common case pay. The invalidation is dropped in v4; vxlan_input is now untouched by the series.
Ack.
SRv6 decap is a different story (see Christophe's review comment on
process_behav_decap): there the outer header carries no inner entropy at all, so that path does need to drop the stale hash. Handled in v4.
I had not considered SRv6 so far.
That seems problematic on the SRv6 receiving node.
https://learn.microsoft.com/en-us/windows-hardware/drivers/network/rss-hashing-types#ndis-hash-ipv6
https://learn.microsoft.com/en-us/windows-hardware/drivers/network/rss-hashing-types#ndis_hash_ipv6_ex
Nothing seems to look at the ipv6 flow label (that's me assuming grout sets it based on rss hash, @christophefontaine @rjarry ?).
On the other hand, I am not sure grout sets the described IPv6 options by Microsoft specification.
On DPDK side, it seems really few drivers support such rss on IPv6 extensions, and that seems to be for old hardware.
net/bnxt caught my eyes, as it seems to do something unspecified.. hashing ipv6 flow label (oh oh).
@christophefontaine had some funny idea, but this is something for another thread.
| o->nh = fib4_lookup( | ||
| o->iface->vrf_id, | ||
| ip->dst_addr, | ||
| gr_mbuf_flow_hash_get_l3(m, RTE_BE16(RTE_ETHER_TYPE_IPV4)) | ||
| ); |
There was a problem hiding this comment.
(not introduced by this commit, but to open the discussion for a future PR)
The fib4_lookup gets the nh based on the hash. Yet, this hash is still the one computed by the hardware, pre-nat.
Shouldn't we update the RSS field to reflect the modified dst_ip & dsp_port?
There was a problem hiding this comment.
I think keeping the pre-NAT hash is actually correct: the hash is a stable per-flow identity for member selection, not a digest of the current header bytes. A given flow always produces the same pre-NAT tuple and distinct flows have distinct pre-NAT tuples, so both affinity and distribution hold; recomputing after the rewrite would spend cycles without changing either.
One inconsistency worth noting for that future discussion: the software fallback in this series computes after the rewrite (the lookup sits below the NAT code), so RSS ports hash pre-NAT and non-RSS ports hash post-NAT. Each flow still sees one stable value since a port either has RSS or it does not - but if we ever want the two paths to agree, the cheap fix is seeding the software hash before the rewrite, not recomputing after it.
| d->nh = fib4_lookup( | ||
| d->iface->vrf_id, | ||
| ip->dst_addr, | ||
| gr_mbuf_flow_hash_get_l3(mbuf, RTE_BE16(RTE_ETHER_TYPE_IPV4)) | ||
| ); |
There was a problem hiding this comment.
same comment as dnat44_dynamic
There was a problem hiding this comment.
Same reasoning as on the dnat44_dynamic thread: the pre-NAT hash is still a stable per-flow identity, so I would leave both as they are and take the wider question to a follow-up discussion.
bc5b23d to
2b56ebf
Compare
@christophefontaine agreed, and it is a bit worse than an inconsistency. I kept your flow-label approach for the case where the encapsulating node filled it in: a non-zero label is now stored through
Sure — the "fake RSS" writes there are exactly what v4 is pushed (7 patches now). Summary of the changes since v3:
Revalidated in the AlmaLinux 9 container: full unit suite, vxlan, vxlan6 and ip_loadbalance smoke tests pass; the new srv6 test fails with only the fix reverted. PR description updated to match. |
Extract the bond Toeplitz hashing logic for reuse by other datapaths. Prefer a hardware RSS value when available and retain a software L3/L4 fallback for TAP and other virtual devices without RSS. Keep the L3/L4 tuple hashing in a separate helper taking an explicit L3 offset and EtherType, so a later patch can also hash packets whose data starts directly at the IP header. Add focused tests for flow stability, UDP differentiation, L2 behaviour and hardware RSS precedence. Signed-off-by: Harrison Caldicott <harrison@itsfubar.com.au>
The fragment check included the DF flag, so ordinary packets with DF set fell back to an L3-only hash. Mask the fragment offset before deciding whether transport ports are available. Fixes: e2953be ("lacp: only use tcp/udp ports for non-fragmented packets") Signed-off-by: Harrison Caldicott <harrison@itsfubar.com.au>
The port availability check only masked the fragment offset, so a first fragment (offset zero, MF set) was hashed with its UDP or TCP ports while the following fragments of the same datagram were hashed on addresses only, steering them onto different paths. Include the MF flag in the check so every fragment of a datagram shares one L3-only hash. Fixes: e2953be ("lacp: only use tcp/udp ports for non-fragmented packets") Signed-off-by: Harrison Caldicott <harrison@itsfubar.com.au>
Several datapaths read m->hash.rss directly for ECMP and load balancing decisions. On ports without RSS that field holds stale or zero data, and every consumer that falls back to a software hash recomputes it from scratch. Store the software Toeplitz hash in m->hash.rss and mark it valid with RTE_MBUF_F_RX_RSS_HASH, exactly as a hardware driver would. gr_mbuf_flow_hash_get() returns any hash already present untouched and only computes the software fallback once per packet. gr_mbuf_flow_hash_get_l3() does the same for packets whose data starts at an IPv4 or IPv6 header. Both getters are inline so packets carrying a valid hash only pay one flag test, never a function call. gr_mbuf_flow_hash_invalidate() drops a hash that no longer describes the packet, for example after tunnel decapsulation; the next consumer recomputes it on demand, so packets that never reach a hash consumer cost nothing. Cover hardware precedence, single computation, L3 entry points, reset and invalidation with unit tests. Signed-off-by: Harrison Caldicott <harrison@itsfubar.com.au>
vxlan encapsulation reads m->hash.rss for the underlay route lookup and the UDP source port even when the port computed no RSS, so flows entering through TAP or other virtual devices load balance on stale or zero data. Switch bond member selection and vxlan output to the cached canonical hash. Decapsulation keeps the outer hash untouched: a VTEP following RFC 7348 derives the outer UDP source port from the inner flow, so an outer RSS value is already a function of the inner flow and stays valid for member selection after decapsulation. Extend the vxlan smoke test with a bridged port injecting distinct UDP flows and check the encapsulated packets no longer share a single source port. Signed-off-by: Harrison Caldicott <harrison@itsfubar.com.au>
The fib4/fib6 ECMP lookups in input, ICMP, error, NAT, IP-in-IP and SRv6 paths read m->hash.rss directly. On ports without RSS the field holds stale or zero data, so multipath selection is either unstable or collapses onto a single nexthop, and locally generated packets never had a meaningful hash at all. Use gr_mbuf_flow_hash_get_l3() in these nodes so every lookup shares the packet's cached canonical hash regardless of how it entered the graph. The ping request nodes already faked a hardware RSS value by writing the mbuf fields directly; store it through the shared helper instead. Extend the load balance smoke test to send distinct UDP flows through an ECMP route and check that both group members carry traffic. Signed-off-by: Harrison Caldicott <harrison@itsfubar.com.au>
The decap behaviors copy the outer flow label into m->hash.usr, which aliases m->hash.rss, without touching RTE_MBUF_F_RX_RSS_HASH. When the port computed RSS on the outer packet the flag stays set, so the fib lookups keep trusting the value; an encapsulating node that leaves the flow label zero (as our own SRH encap does) then collapses every inner flow onto hash 0. Unlike vxlan, the outer headers carry no other inner entropy: there is no UDP source port and RSS cannot parse past the routing header. Keep using a non-zero flow label as the flow entropy (RFC 6438), now stored through the shared helper so the valid flag is set coherently. When the label is zero, invalidate the hash instead so the first consumer computes one from the inner packet. Fixes: a7ede13 ("srv6: update mbuf hash on decap action") Signed-off-by: Harrison Caldicott <harrison@itsfubar.com.au>
2b56ebf to
5ba090f
Compare
| return rte_softrss_be(&tuple.u32, len / sizeof(uint32_t), rss_key); | ||
| } | ||
|
|
||
| uint32_t gr_mbuf_flow_hash(const struct rte_mbuf *m, gr_mbuf_flow_hash_mode_t mode) { |
There was a problem hiding this comment.
I think it makes sense to move this function as static inline in flow_hash.h to benefit from the shortcut when the RSS hash has been computed by hardware. flow_hash_l3 can remain in flow_hash.c.
| rte_be16_t eth_type; | ||
|
|
||
| if (mode == GR_MBUF_FLOW_HASH_RSS && (m->ol_flags & RTE_MBUF_F_RX_RSS_HASH)) | ||
| return m->hash.rss; |
There was a problem hiding this comment.
if (likely(mode == GR_MBUF_FLOW_HASH_RSS && (m->ol_flags & RTE_MBUF_F_RX_RSS_HASH)))
return m->hash.rss;| if (l3.ip4->fragment_offset == 0) { | ||
| if ((rte_be_to_cpu_16(l3.ip4->fragment_offset) & RTE_IPV4_HDR_OFFSET_MASK) | ||
| == 0) { |
There was a problem hiding this comment.
Sometimes clang-format makes me sad :(
Do you think we could use rte_ipv4_frag_pkt_is_fragmented instead?
if (!rte_ipv4_frag_pkt_is_fragmented(l3.ip4)) {It checks for the MF flag too which looks correct.
| if (l3.ip4->fragment_offset == 0) { | ||
| if ((rte_be_to_cpu_16(l3.ip4->fragment_offset) & RTE_IPV4_HDR_OFFSET_MASK) | ||
| == 0) { |
| ip4_addr_t src, dst; | ||
| uint32_t hash; | ||
| rte_edge_t edge; | ||
| unsigned len; |
There was a problem hiding this comment.
Reverse x-mas tree please.
ip4_addr_t src, dst;
rte_edge_t edge;
+ uint32_t hash;
unsigned len;| const struct iface *iface; | ||
| struct rte_mbuf *mbuf; | ||
| uint32_t hash; | ||
| rte_edge_t edge; |
| uint32_t optlen, plen; | ||
| struct rte_mbuf *m; | ||
| uint8_t proto, n_omitted_segs; | ||
| rte_edge_t edge; |
There was a problem hiding this comment.
Oh dear, this function is already looking bad. Could you add the new variables roughly in the correct spot for reverse xmas tree?
struct rte_ipv6_routing_ext *srh;
struct rte_ipv6_hdr *outer_ip6;
const struct nexthop *nh;
uint32_t optlen, plen;
+ rte_be16_t eth_type;
struct rte_mbuf *m;
uint8_t proto, n_omitted_segs;
rte_edge_t edge;
+ uint32_t hash;
Several datapaths read
m->hash.rssdirectly for ECMP and load-balancingdecisions (fib4/fib6 lookups, vxlan underlay routing and source port
selection). On ports without RSS — TAP and other virtual devices — that
field holds stale or zero data, so every flow shares one value: ECMP
collapses onto a single nexthop and all vxlan flows share one UDP source
port. After SRv6 decapsulation, the outer flow label is copied over the
hash while a hardware valid flag survives; with a zero label (as grout's
own encap emits) every inner flow shares hash 0.
The series first extracts the bond Toeplitz hashing into a shared helper
and fixes two fragment-handling bugs in it: packets with the DF flag set
were treated as fragments and hashed L3-only, and first fragments (offset
zero, MF set) were hashed with their L4 ports unlike the rest of their
datagram. It then caches one canonical per-packet hash by storing the
software result in
m->hash.rssand marking it valid withRTE_MBUF_F_RX_RSS_HASH, exactly as a hardware driver would — no newmbuf metadata. The bond, vxlan and L3 consumers read it through one
helper. vxlan decapsulation leaves the outer hash in place: a VTEP
following RFC 7348 derives the outer UDP source port from the inner
flow, so an outer RSS value is already a function of the inner flow and
stays valid after decapsulation. SRv6 decapsulation keeps using a
non-zero outer flow label as the flow entropy, now stored through the
shared helper so the valid flag is coherent, and invalidates the hash
when the label is zero so the first consumer hashes the inner packet.
For packets whose driver does provide RSS, the datapath is unchanged:
the same flag test and the same
m->hash.rssread as before the series,with no recomputation after encap or decap.
Found while prototyping EVPN all-active multihoming (#698), where bridged
flows must keep per-flow path affinity across bond and vxlan ECMP, but
everything above is reproducible on plain upstream.
Testing
single computation and caching, hardware RSS precedence, hash
invalidation.
hash with the valid flag set; a zero label drops a stale outer RSS
value.
smoke/vxlan_test.sh: a second bridged port injects 32distinct UDP flows and the test asserts the encapsulated packets use
several distinct source ports.
smoke/ip_loadbalance_test.sh: 64 distinct UDP flows throughthe ECMP route, asserting both group members carry traffic.
main): the vxlan test failswith "32 flows shared 1 vxlan source ports", the load-balance test
fails with "member p1 carried 2 of 64 distinct flows", the MF fragment
unit test fails, and the srv6 decap test fails with the stale outer
hash still marked valid.
ip_loadbalance smoke tests pass (AlmaLinux 9 container, arm64).
Related: #698