| #!/usr/bin/env python3 | |
| # SPDX-License-Identifier: GPL-2.0 | |
| """ | |
| GRO (Generic Receive Offload) conformance tests. | |
| Validates that GRO coalescing works correctly by running the gro | |
| binary in different configurations and checking for correct packet | |
| coalescing behavior. | |
| Test cases: | |
| - data_same: Same size data packets coalesce | |
| - data_lrg_sml: Large packet followed by smaller one coalesces | |
| - data_lrg_1byte: Large packet followed by 1B one coalesces (Ethernet padding) | |
| - data_sml_lrg: Small packet followed by larger one doesn't coalesce | |
| - ack: Pure ACK packets do not coalesce | |
| - flags_psh: Packets with PSH flag don't coalesce | |
| - flags_syn: Packets with SYN flag don't coalesce | |
| - flags_rst: Packets with RST flag don't coalesce | |
| - flags_urg: Packets with URG flag don't coalesce | |
| - flags_cwr: Packets with CWR flag don't coalesce | |
| - tcp_csum: Packets with incorrect checksum don't coalesce | |
| - tcp_seq: Packets with non-consecutive seqno don't coalesce | |
| - tcp_ts: Packets with different timestamp options don't coalesce | |
| - tcp_opt: Packets with different TCP options don't coalesce | |
| - ip_ecn: Packets with different ECN don't coalesce | |
| - ip_tos: Packets with different TOS don't coalesce | |
| - ip_ttl: (IPv4) Packets with different TTL don't coalesce | |
| - ip_opt: (IPv4) Packets with IP options don't coalesce | |
| - ip_frag4: (IPv4) IPv4 fragments don't coalesce | |
| - ip_id_df*: (IPv4) IP ID field coalescing tests | |
| - ip_frag6: (IPv6) IPv6 fragments don't coalesce | |
| - ip_v6ext_same: (IPv6) IPv6 ext header with same payload coalesces | |
| - ip_v6ext_diff: (IPv6) IPv6 ext header with different payload doesn't coalesce | |
| - large_max: Packets exceeding GRO_MAX_SIZE don't coalesce | |
| - large_rem: Large packet remainder handling | |
| """ | |
| import glob | |
| import os | |
| import re | |
| from lib.py import ksft_run, ksft_exit, ksft_pr | |
| from lib.py import NetDrvEpEnv, KsftFailEx, KsftXfailEx | |
| from lib.py import NetdevFamily, EthtoolFamily | |
| from lib.py import bkg, cmd, defer, ethtool, ip | |
| from lib.py import ksft_variants, KsftNamedVariant | |
| # gro.c uses hardcoded DPORT=8000 | |
| GRO_DPORT = 8000 | |
| def _resolve_dmac(cfg, ipver): | |
| """ | |
| Find the destination MAC address remote host should use to send packets | |
| towards the local host. It may be a router / gateway address. | |
| """ | |
| attr = "dmac" + ipver | |
| # Cache the response across test cases | |
| if hasattr(cfg, attr): | |
| return getattr(cfg, attr) | |
| route = ip(f"-{ipver} route get {cfg.addr_v[ipver]}", | |
| json=True, host=cfg.remote)[0] | |
| gw = route.get("gateway") | |
| # Local L2 segment, address directly | |
| if not gw: | |
| setattr(cfg, attr, cfg.dev['address']) | |
| return getattr(cfg, attr) | |
| # ping to make sure neighbor is resolved, | |
| # bind to an interface, for v6 the GW is likely link local | |
| cmd(f"ping -c1 -W0 -I{cfg.remote_ifname} {gw}", host=cfg.remote) | |
| neigh = ip(f"neigh get {gw} dev {cfg.remote_ifname}", | |
| json=True, host=cfg.remote)[0] | |
| setattr(cfg, attr, neigh['lladdr']) | |
| return getattr(cfg, attr) | |
| def _write_defer_restore(cfg, path, val, defer_undo=False): | |
| with open(path, "r", encoding="utf-8") as fp: | |
| orig_val = fp.read().strip() | |
| if str(val) == orig_val: | |
| return | |
| with open(path, "w", encoding="utf-8") as fp: | |
| fp.write(val) | |
| if defer_undo: | |
| defer(_write_defer_restore, cfg, path, orig_val) | |
| def _set_mtu_restore(dev, mtu, host): | |
| if dev['mtu'] < mtu: | |
| ip(f"link set dev {dev['ifname']} mtu {mtu}", host=host) | |
| defer(ip, f"link set dev {dev['ifname']} mtu {dev['mtu']}", host=host) | |
| def _set_ethtool_feat(dev, current, feats, host=None): | |
| s2n = {True: "on", False: "off"} | |
| new = ["-K", dev] | |
| old = ["-K", dev] | |
| no_change = True | |
| for name, state in feats.items(): | |
| new += [name, s2n[state]] | |
| old += [name, s2n[current[name]["active"]]] | |
| if current[name]["active"] != state: | |
| no_change = False | |
| if current[name]["fixed"]: | |
| raise KsftXfailEx(f"Device does not support {name}") | |
| if no_change: | |
| return | |
| eth_cmd = ethtool(" ".join(new), host=host) | |
| defer(ethtool, " ".join(old), host=host) | |
| # If ethtool printed something kernel must have modified some features | |
| if eth_cmd.stdout: | |
| ksft_pr(eth_cmd) | |
| def _get_queue_stats(cfg, queue_id): | |
| """Get stats for a specific Rx queue.""" | |
| cfg.wait_hw_stats_settle() | |
| data = cfg.netnl.qstats_get({"ifindex": cfg.ifindex, "scope": ["queue"]}, | |
| dump=True) | |
| for q in data: | |
| if q.get('queue-type') == 'rx' and q.get('queue-id') == queue_id: | |
| return q | |
| return {} | |
| def _require_ntuple(cfg): | |
| features = ethtool(f"-k {cfg.ifname}", json=True)[0] | |
| if not features["ntuple-filters"]["active"]: | |
| if features["ntuple-filters"]["fixed"]: | |
| raise KsftXfailEx("Device does not support ntuple-filters") | |
| ethtool(f"-K {cfg.ifname} ntuple-filters on") | |
| defer(ethtool, f"-K {cfg.ifname} ntuple-filters off") | |
| def _setup_isolated_queue(cfg): | |
| """Set up an isolated queue for testing using ntuple filter. | |
| Remove queue 1 from the default RSS context and steer test traffic to it. | |
| """ | |
| _require_ntuple(cfg) | |
| test_queue = 1 | |
| qcnt = len(glob.glob(f"/sys/class/net/{cfg.ifname}/queues/rx-*")) | |
| if qcnt < 2: | |
| raise KsftXfailEx(f"Need at least 2 queues, have {qcnt}") | |
| # Remove queue 1 from default RSS context by setting its weight to 0 | |
| weights = ["1"] * qcnt | |
| weights[test_queue] = "0" | |
| ethtool(f"-X {cfg.ifname} weight " + " ".join(weights)) | |
| defer(ethtool, f"-X {cfg.ifname} default") | |
| # Set up ntuple filter to steer our test traffic to the isolated queue | |
| flow = f"flow-type tcp{cfg.addr_ipver} " | |
| flow += f"dst-ip {cfg.addr} dst-port {GRO_DPORT} action {test_queue}" | |
| output = ethtool(f"-N {cfg.ifname} {flow}").stdout | |
| ntuple_id = int(output.split()[-1]) | |
| defer(ethtool, f"-N {cfg.ifname} delete {ntuple_id}") | |
| return test_queue | |
| def _setup_queue_count(cfg, num_queues): | |
| """Configure the NIC to use a specific number of queues.""" | |
| channels = cfg.ethnl.channels_get({'header': {'dev-index': cfg.ifindex}}) | |
| ch_max = channels.get('combined-max', 0) | |
| qcnt = channels['combined-count'] | |
| if ch_max < num_queues: | |
| raise KsftXfailEx(f"Need at least {num_queues} queues, max={ch_max}") | |
| defer(ethtool, f"-L {cfg.ifname} combined {qcnt}") | |
| ethtool(f"-L {cfg.ifname} combined {num_queues}") | |
| def _run_gro_bin(cfg, test_name, protocol=None, num_flows=None, | |
| order_check=False, verbose=False, fail=False): | |
| """Run gro binary with given test and return the process result.""" | |
| if not hasattr(cfg, "bin_remote"): | |
| cfg.bin_local = cfg.net_lib_dir / "gro" | |
| cfg.bin_remote = cfg.remote.deploy(cfg.bin_local) | |
| if protocol is None: | |
| ipver = cfg.addr_ipver | |
| protocol = f"ipv{ipver}" | |
| else: | |
| ipver = "6" if protocol[-1] == "6" else "4" | |
| dmac = _resolve_dmac(cfg, ipver) | |
| base_args = [ | |
| f"--{protocol}", | |
| f"--dmac {dmac}", | |
| f"--smac {cfg.remote_dev['address']}", | |
| f"--daddr {cfg.addr_v[ipver]}", | |
| f"--saddr {cfg.remote_addr_v[ipver]}", | |
| f"--test {test_name}", | |
| ] | |
| if num_flows: | |
| base_args.append(f"--num-flows {num_flows}") | |
| if order_check: | |
| base_args.append("--order-check") | |
| if verbose: | |
| base_args.append("--verbose") | |
| args = " ".join(base_args) | |
| rx_cmd = f"{cfg.bin_local} {args} --rx --iface {cfg.ifname}" | |
| tx_cmd = f"{cfg.bin_remote} {args} --iface {cfg.remote_ifname}" | |
| with bkg(rx_cmd, ksft_ready=True, exit_wait=True, fail=fail) as rx_proc: | |
| cmd(tx_cmd, host=cfg.remote) | |
| return rx_proc | |
| def _setup(cfg, mode, test_name): | |
| """ Setup hardware loopback mode for GRO testing. """ | |
| if not hasattr(cfg, "bin_remote"): | |
| cfg.bin_local = cfg.net_lib_dir / "gro" | |
| cfg.bin_remote = cfg.remote.deploy(cfg.bin_local) | |
| if not hasattr(cfg, "feat"): | |
| cfg.feat = ethtool(f"-k {cfg.ifname}", json=True)[0] | |
| cfg.remote_feat = ethtool(f"-k {cfg.remote_ifname}", | |
| host=cfg.remote, json=True)[0] | |
| # "large_*" tests need at least 4k MTU | |
| if test_name.startswith("large_"): | |
| _set_mtu_restore(cfg.dev, 4096, None) | |
| _set_mtu_restore(cfg.remote_dev, 4096, cfg.remote) | |
| if mode == "sw": | |
| flush_path = f"/sys/class/net/{cfg.ifname}/gro_flush_timeout" | |
| irq_path = f"/sys/class/net/{cfg.ifname}/napi_defer_hard_irqs" | |
| _write_defer_restore(cfg, flush_path, "200000", defer_undo=True) | |
| _write_defer_restore(cfg, irq_path, "10", defer_undo=True) | |
| _set_ethtool_feat(cfg.ifname, cfg.feat, | |
| {"generic-receive-offload": True, | |
| "rx-gro-hw": False, | |
| "large-receive-offload": False}) | |
| elif mode == "hw": | |
| _set_ethtool_feat(cfg.ifname, cfg.feat, | |
| {"generic-receive-offload": False, | |
| "rx-gro-hw": True, | |
| "large-receive-offload": False}) | |
| # Some NICs treat HW GRO as a GRO sub-feature so disabling GRO | |
| # will also clear HW GRO. Use a hack of installing XDP generic | |
| # to skip SW GRO, even when enabled. | |
| feat = ethtool(f"-k {cfg.ifname}", json=True)[0] | |
| if not feat["rx-gro-hw"]["active"]: | |
| ksft_pr("Driver clears HW GRO and SW GRO is cleared, using generic XDP workaround") | |
| prog = cfg.net_lib_dir / "xdp_dummy.bpf.o" | |
| ip(f"link set dev {cfg.ifname} xdpgeneric obj {prog} sec xdp") | |
| defer(ip, f"link set dev {cfg.ifname} xdpgeneric off") | |
| # Attaching XDP may change features, fetch the latest state | |
| feat = ethtool(f"-k {cfg.ifname}", json=True)[0] | |
| _set_ethtool_feat(cfg.ifname, feat, | |
| {"generic-receive-offload": True, | |
| "rx-gro-hw": True, | |
| "large-receive-offload": False}) | |
| elif mode == "lro": | |
| # netdevsim advertises LRO for feature inheritance testing with | |
| # bonding/team tests but it doesn't actually perform the offload | |
| cfg.require_nsim(nsim_test=False) | |
| _set_ethtool_feat(cfg.ifname, cfg.feat, | |
| {"generic-receive-offload": False, | |
| "rx-gro-hw": False, | |
| "large-receive-offload": True}) | |
| try: | |
| # Disable TSO for local tests | |
| cfg.require_nsim() # will raise KsftXfailEx if not running on nsim | |
| _set_ethtool_feat(cfg.remote_ifname, cfg.remote_feat, | |
| {"tcp-segmentation-offload": False}, | |
| host=cfg.remote) | |
| except KsftXfailEx: | |
| pass | |
| def _gro_variants(): | |
| """Generator that yields all combinations of protocol and test types.""" | |
| # Tests that work for all protocols | |
| common_tests = [ | |
| "data_same", "data_lrg_sml", "data_sml_lrg", "data_lrg_1byte", | |
| "data_burst", | |
| "ack", | |
| "flags_psh", "flags_syn", "flags_rst", "flags_urg", "flags_cwr", | |
| "tcp_csum", "tcp_seq", "tcp_ts", "tcp_opt", | |
| "ip_ecn", "ip_tos", | |
| "large_max", "large_rem", | |
| ] | |
| # Tests specific to IPv4 | |
| ipv4_tests = [ | |
| "ip_csum", | |
| "ip_ttl", "ip_opt", "ip_frag4", | |
| "ip_id_df1_inc", "ip_id_df1_fixed", | |
| "ip_id_df0_inc", "ip_id_df0_fixed", | |
| "ip_id_df1_inc_fixed", "ip_id_df1_fixed_inc", | |
| ] | |
| # Tests specific to IPv6 | |
| ipv6_tests = [ | |
| "ip_frag6", "ip_v6ext_same", "ip_v6ext_diff", | |
| ] | |
| # Tests specific to PPPoE | |
| pppoe_tests = [ | |
| "data_same", "data_lrg_sml", "data_sml_lrg", "data_lrg_1byte", | |
| "data_burst", "pppoe_sid", | |
| ] | |
| for mode in ["sw", "hw", "lro"]: | |
| for protocol in ["ipv4", "ipv6", "ipip", "ip6ip6"]: | |
| for test_name in common_tests: | |
| yield mode, protocol, test_name | |
| if protocol in ["ipv4", "ipip"]: | |
| for test_name in ipv4_tests: | |
| yield mode, protocol, test_name | |
| elif protocol == "ipv6": | |
| for test_name in ipv6_tests: | |
| yield mode, protocol, test_name | |
| for mode in ["sw"]: | |
| for protocol in ["pppoev4", "pppoev6"]: | |
| for test_name in pppoe_tests: | |
| yield mode, protocol, test_name | |
| @ksft_variants(_gro_variants()) | |
| def test(cfg, mode, protocol, test_name): | |
| """Run a single GRO test with retries.""" | |
| ipver = "6" if protocol[-1] == "6" else "4" | |
| cfg.require_ipver(ipver) | |
| _setup(cfg, mode, test_name) | |
| # Each test is run 6 times to deflake, because given the receive timing, | |
| # not all packets that should coalesce will be considered in the same flow | |
| # on every try. | |
| max_retries = 6 | |
| for attempt in range(max_retries): | |
| fail_now = attempt >= max_retries - 1 | |
| rx_proc = _run_gro_bin(cfg, test_name, protocol=protocol, | |
| verbose=True, fail=fail_now) | |
| if rx_proc.ret == 0: | |
| return | |
| ksft_pr(rx_proc) | |
| # ret==42 means the receiver detected over-coalescing. | |
| # This is unambiguous proof of a bug, retries can only cause | |
| # false negatives. | |
| if rx_proc.ret == 42: | |
| raise KsftFailEx(f"GRO over-coalesced in {protocol}/{test_name}") | |
| if test_name.startswith("large_") and os.environ.get("KSFT_MACHINE_SLOW"): | |
| ksft_pr(f"Ignoring {protocol}/{test_name} failure due to slow environment") | |
| return | |
| ksft_pr(f"Attempt {attempt + 1}/{max_retries} failed, retrying...") | |
| def _capacity_variants(): | |
| """Generate variants for capacity test: mode x queue setup.""" | |
| setups = [ | |
| ("isolated", _setup_isolated_queue), | |
| ("1q", lambda cfg: _setup_queue_count(cfg, 1)), | |
| ("8q", lambda cfg: _setup_queue_count(cfg, 8)), | |
| ] | |
| for mode in ["sw", "hw", "lro"]: | |
| for name, func in setups: | |
| yield KsftNamedVariant(f"{mode}_{name}", mode, func) | |
| @ksft_variants(_capacity_variants()) | |
| def test_gro_capacity(cfg, mode, setup_func): | |
| """ | |
| Probe GRO capacity. | |
| Start with 8 flows and increase by 2x on each successful run. | |
| Retry up to 3 times on failure. | |
| Variants combine mode (sw, hw, lro) with queue setup: | |
| - isolated: Use a single queue isolated from RSS | |
| - 1q: Configure NIC to use 1 queue | |
| - 8q: Configure NIC to use 8 queues | |
| """ | |
| max_retries = 3 | |
| _setup(cfg, mode, "capacity") | |
| queue_id = setup_func(cfg) | |
| num_flows = 8 | |
| while True: | |
| success = False | |
| for attempt in range(max_retries): | |
| if queue_id is not None: | |
| stats_before = _get_queue_stats(cfg, queue_id) | |
| rx_proc = _run_gro_bin(cfg, "capacity", num_flows=num_flows) | |
| output = rx_proc.stdout | |
| if queue_id is not None: | |
| stats_after = _get_queue_stats(cfg, queue_id) | |
| qstat_pkts = (stats_after.get('rx-packets', 0) - | |
| stats_before.get('rx-packets', 0)) | |
| gro_pkts = (stats_after.get('rx-hw-gro-packets', 0) - | |
| stats_before.get('rx-hw-gro-packets', 0)) | |
| qstat_str = f" qstat={qstat_pkts} hw-gro={gro_pkts}" | |
| else: | |
| qstat_str = "" | |
| # Parse and print STATS line | |
| match = re.search( | |
| r'STATS: received=(\d+) wire=(\d+) coalesced=(\d+)', output) | |
| if match: | |
| received = int(match.group(1)) | |
| wire = int(match.group(2)) | |
| coalesced = int(match.group(3)) | |
| status = "PASS" if received == num_flows else "MISS" | |
| ksft_pr(f"flows={num_flows} attempt={attempt + 1} " | |
| f"received={received} wire={wire} " | |
| f"coalesced={coalesced}{qstat_str} [{status}]") | |
| if received == num_flows: | |
| success = True | |
| break | |
| else: | |
| ksft_pr(rx_proc) | |
| ksft_pr(f"flows={num_flows} attempt={attempt + 1}" | |
| f"{qstat_str} [FAIL - can't parse stats]") | |
| if not success: | |
| ksft_pr(f"Stopped at {num_flows} flows") | |
| break | |
| num_flows *= 2 | |
| def main() -> None: | |
| """ Ksft boiler plate main """ | |
| with NetDrvEpEnv(__file__) as cfg: | |
| cfg.ethnl = EthtoolFamily() | |
| cfg.netnl = NetdevFamily() | |
| ksft_run(cases=[test, test_gro_capacity], args=(cfg,)) | |
| ksft_exit() | |
| if __name__ == "__main__": | |
| main() |