From 66892d877c8c921f993bff5be9e2978ea215537b Mon Sep 17 00:00:00 2001 From: Quentin Deslandes Date: Mon, 16 Feb 2026 19:39:04 +0100 Subject: [PATCH 1/5] udev/net: add IRQAffinityPolicy= option for .link files Add support for configuring IRQ affinity for network interfaces via systemd .link files. For now, the new IRQAffinityPolicy= option in the [Link] section only accepts "single", which pins all MSI IRQs to CPU 0. This allows declarative IRQ affinity configuration for network devices during udev processing, which is useful for optimizing network performance on multi-core systems. Further commits will expand the options supported by IRQAffinityPolicy=. --- src/udev/net/link-config-gperf.gperf | 2 + src/udev/net/link-config.c | 107 +++++++++++++++ src/udev/net/link-config.h | 11 ++ src/udev/net/test-link-config-tables.c | 1 + test/units/TEST-17-UDEV.irq-affinity.sh | 171 ++++++++++++++++++++++++ 5 files changed, 292 insertions(+) create mode 100755 test/units/TEST-17-UDEV.irq-affinity.sh diff --git a/src/udev/net/link-config-gperf.gperf b/src/udev/net/link-config-gperf.gperf index ef68dab339..0b63a1e446 100644 --- a/src/udev/net/link-config-gperf.gperf +++ b/src/udev/net/link-config-gperf.gperf @@ -132,6 +132,8 @@ Link.TxMaxCoalescedHighFrames, config_parse_coalesce_u32, Link.CoalescePacketRateSampleIntervalSec, config_parse_coalesce_sec, 0, offsetof(LinkConfig, coalesce.rate_sample_interval) /* Rx RPS CPU mask */ Link.ReceivePacketSteeringCPUMask, config_parse_rps_cpu_mask, 0, offsetof(LinkConfig, rps_cpu_mask) +/* IRQ affinity settings */ +Link.IRQAffinityPolicy, config_parse_irq_affinity_policy, 0, offsetof(LinkConfig, irq_affinity_policy) /* SR-IOV settings */ Link.SR-IOVVirtualFunctions, config_parse_sr_iov_num_vfs, 0, offsetof(LinkConfig, sr_iov_num_vfs) SR-IOV.VirtualFunction, config_parse_sr_iov_uint32, 0, offsetof(LinkConfig, sr_iov_by_section) diff --git a/src/udev/net/link-config.c b/src/udev/net/link-config.c index 3c4b886e67..4a1512365d 100644 --- a/src/udev/net/link-config.c +++ b/src/udev/net/link-config.c @@ -15,6 +15,7 @@ #include "creds-util.h" #include "device-private.h" #include "device-util.h" +#include "dirent-util.h" #include "escape.h" #include "ether-addr-util.h" #include "ethtool-util.h" @@ -270,6 +271,7 @@ int link_load_one(LinkConfigContext *ctx, const char *filename) { .eee_enabled = -1, .eee_tx_lpi_enabled = -1, .eee_tx_lpi_timer_usec = USEC_INFINITY, + .irq_affinity_policy = _IRQ_AFFINITY_POLICY_INVALID, }; FOREACH_ELEMENT(feature, config->features) @@ -922,6 +924,96 @@ static int link_apply_sr_iov_config(Link *link) { return 0; } +static int set_irq_affinity(Link *link, unsigned irq, unsigned cpu) { + _cleanup_free_ char *affinity_path = NULL, *mask_str = NULL; + unsigned n_groups = cpu / 32; + int r; + + assert(link); + + if (asprintf(&affinity_path, "/proc/irq/%u/smp_affinity", irq) < 0) + return log_oom(); + + /* Convert CPU number to hex bitmask. + * For CPU N, set bit N (1 << N). CPUs are split by comma-separated + * 32-bits groups. To assign CPU 32, we should write 1,00000000 */ + + if (asprintf(&mask_str, "%x", 1U << (cpu % 32)) < 0) + return log_oom(); + + for (unsigned i = 0; i < n_groups; i++) + if (!strextend(&mask_str, ",00000000")) + return log_oom(); + + r = write_string_file(affinity_path, mask_str, WRITE_STRING_FILE_DISABLE_BUFFER); + if (r < 0) + return log_link_warning_errno(link, r, "Failed to set IRQ %u affinity to CPU %u: %m", irq, cpu); + + log_link_debug(link, "Set IRQ %u affinity to CPU %u.", irq, cpu); + + return 0; +} + +static int link_apply_irq_affinity_single(Link *link) { + _cleanup_closedir_ DIR *dir = NULL; + int r; + + assert(link); + assert(link->config); + assert(ASSERT_PTR(link->event)->dev); + + r = device_opendir(link->event->dev, "device/msi_irqs", &dir); + if (r < 0) { + if (r != -ENOENT) + return log_link_error_errno(link, r, "Failed to open device/msi_irqs: %m"); + log_link_debug_errno(link, r, "No MSI IRQs found, skipping IRQ affinity configuration: %m"); + return 0; + } + + FOREACH_DIRENT(de, dir, return log_link_error_errno(link, errno, "Failed to read directory device/msi_irqs: %m")) { + unsigned irq; + + r = safe_atou(de->d_name, &irq); + if (r < 0) + return log_link_error_errno(link, r, "Failed to convert parse IRQ number: %s", de->d_name); + + (void) set_irq_affinity(link, irq, /* cpu= */ 0); + } + + log_link_info(link, "Applied IRQ affinity policy 'single' (pinning to CPU 0)."); + + return 0; +} + +static int link_apply_irq_affinity(Link *link) { + _cleanup_(cpu_set_done) CPUSet effective_cpus = {}; + const char *syspath; + int r; + + assert(link); + assert(link->config); + assert(ASSERT_PTR(link->event)->dev); + + if (link->event->event_mode != EVENT_UDEV_WORKER) { + log_link_debug(link, "Running in test mode, skipping application of IRQ affinity settings."); + return 0; + } + + if (link->config->irq_affinity_policy < 0) + return 0; + + r = sd_device_get_syspath(link->event->dev, &syspath); + if (r < 0) + return log_link_warning_errno(link, r, "Failed to get syspath: %m"); + + switch (link->config->irq_affinity_policy) { + case IRQ_AFFINITY_POLICY_SINGLE: + return link_apply_irq_affinity_single(link); + default: + assert_not_reached(); + } +} + static int link_apply_rps_cpu_mask(Link *link) { _cleanup_free_ char *mask_str = NULL; LinkConfig *config; @@ -1051,6 +1143,10 @@ int link_apply_config(LinkConfigContext *ctx, Link *link) { if (r < 0) return r; + r = link_apply_irq_affinity(link); + if (r < 0) + return r; + return link_apply_udev_properties(link); } @@ -1400,3 +1496,14 @@ DEFINE_CONFIG_PARSE_ENUMV(config_parse_name_policy, name_policy, NamePolicy, DEFINE_CONFIG_PARSE_ENUMV(config_parse_alternative_names_policy, alternative_names_policy, NamePolicy, _NAMEPOLICY_INVALID); + +static const char* const irq_affinity_policy_table[_IRQ_AFFINITY_POLICY_MAX] = { + [IRQ_AFFINITY_POLICY_SINGLE] = "single", +}; + +DEFINE_STRING_TABLE_LOOKUP(irq_affinity_policy, IRQAffinityPolicy); +DEFINE_CONFIG_PARSE_ENUM_WITH_DEFAULT( + config_parse_irq_affinity_policy, + irq_affinity_policy, + IRQAffinityPolicy, + _IRQ_AFFINITY_POLICY_INVALID); diff --git a/src/udev/net/link-config.h b/src/udev/net/link-config.h index e00327c015..b6853cd922 100644 --- a/src/udev/net/link-config.h +++ b/src/udev/net/link-config.h @@ -22,6 +22,12 @@ typedef enum MACAddressPolicy { _MAC_ADDRESS_POLICY_INVALID = -EINVAL, } MACAddressPolicy; +typedef enum IRQAffinityPolicy { + IRQ_AFFINITY_POLICY_SINGLE, + _IRQ_AFFINITY_POLICY_MAX, + _IRQ_AFFINITY_POLICY_INVALID = -EINVAL, +} IRQAffinityPolicy; + typedef struct Link { UdevEvent *event; LinkConfig *config; @@ -113,6 +119,9 @@ struct LinkConfig { /* Rx RPS CPU mask */ CPUSet rps_cpu_mask; + /* IRQ affinity */ + IRQAffinityPolicy irq_affinity_policy; + /* SR-IOV */ uint32_t sr_iov_num_vfs; OrderedHashmap *sr_iov_by_section; @@ -136,6 +145,7 @@ int link_get_config(LinkConfigContext *ctx, Link *link); int link_apply_config(LinkConfigContext *ctx, Link *link); DECLARE_STRING_TABLE_LOOKUP(mac_address_policy, MACAddressPolicy); +DECLARE_STRING_TABLE_LOOKUP(irq_affinity_policy, IRQAffinityPolicy); /* gperf lookup function */ const struct ConfigPerfItem* link_config_gperf_lookup(const char *str, GPERF_LEN_TYPE length); @@ -150,3 +160,4 @@ CONFIG_PARSER_PROTOTYPE(config_parse_mac_address_policy); CONFIG_PARSER_PROTOTYPE(config_parse_name_policy); CONFIG_PARSER_PROTOTYPE(config_parse_alternative_names_policy); CONFIG_PARSER_PROTOTYPE(config_parse_rps_cpu_mask); +CONFIG_PARSER_PROTOTYPE(config_parse_irq_affinity_policy); diff --git a/src/udev/net/test-link-config-tables.c b/src/udev/net/test-link-config-tables.c index 499778505c..58b628bc9b 100644 --- a/src/udev/net/test-link-config-tables.c +++ b/src/udev/net/test-link-config-tables.c @@ -8,6 +8,7 @@ int main(int argc, char **argv) { test_setup_logging(LOG_DEBUG); test_table(MACAddressPolicy, mac_address_policy, MAC_ADDRESS_POLICY); + test_table(IRQAffinityPolicy, irq_affinity_policy, IRQ_AFFINITY_POLICY); return EXIT_SUCCESS; } diff --git a/test/units/TEST-17-UDEV.irq-affinity.sh b/test/units/TEST-17-UDEV.irq-affinity.sh new file mode 100755 index 0000000000..5fa8bd331f --- /dev/null +++ b/test/units/TEST-17-UDEV.irq-affinity.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +set -eux +set -o pipefail + +# shellcheck source=test/units/util.sh +. "$(dirname "$0")"/util.sh + +# Test IRQAffinityPolicy configuration + +# Find a network interface with MSI IRQs (typically virtio-net in the VM) +# We need a real device to test actual IRQ affinity application +find_interface_with_msi_irqs() { + for iface in /sys/class/net/*; do + [[ -e "$iface" ]] || continue + iface_name=$(basename "$iface") + [[ "$iface_name" == "lo" ]] && continue + msi_irqs_path="$iface/device/msi_irqs" + if [[ -d "$msi_irqs_path" ]] && [[ -n "$(ls -A "$msi_irqs_path" 2>/dev/null)" ]]; then + echo "$iface_name" + return 0 + fi + done + return 1 +} + +get_interface_irqs() { + local iface="$1" + local msi_irqs_path="/sys/class/net/$iface/device/msi_irqs" + if [[ -d "$msi_irqs_path" ]]; then + ls "$msi_irqs_path" + fi +} + +check_irq_affinity() { + local irq="$1" + local expected_mask="$2" + local affinity + affinity=$(cat "/proc/irq/$irq/smp_affinity") + # Remove leading zeros and commas for comparison + affinity=$(echo "$affinity" | sed 's/^[0,]*//;s/,//g') + expected_mask=$(echo "$expected_mask" | sed 's/^[0,]*//;s/,//g') + [[ "$affinity" == "$expected_mask" ]] +} + +# Test 1: Verify IRQ affinity is actually applied on a real device +if iface=$(find_interface_with_msi_irqs); then + echo "Found interface with MSI IRQs: $iface" + + # Get the MAC address of the interface + mac=$(cat "/sys/class/net/$iface/address") + + # Get IRQs before applying policy + irqs=$(get_interface_irqs "$iface") + echo "Interface $iface has IRQs: $irqs" + + # Test 1a: test single policy + mkdir -p /run/systemd/network/ + cat >/run/systemd/network/00-test-irq-affinity.link </run/systemd/network/10-test-irq.link <&1) +assert_in "ID_NET_LINK_FILE=/run/systemd/network/10-test-irq.link" "$output" + +# Test 3: Invalid policy values are rejected/warned +cat >/run/systemd/network/10-test-irq-invalid.link </run/systemd/network/10-test-irq-empty.link < Date: Mon, 16 Feb 2026 20:38:19 +0100 Subject: [PATCH 2/5] udev/net: implement IRQAffinityPolicy=spread with topology awareness Implement the spread policy for IRQ affinity distribution using a topology-aware algorithm. The algorithm: 1. Discovers CPU topology from sysfs (NUMA node, package, die/L3, core) 2. Groups CPUs by L3 cache domain (die) with equidistant ordering 3. Round-robins across dies, spreading IRQs across the system 4. Uses first hyperthread of each core before second hyperthreads 5. Applies IRQ affinity via /proc/irq//smp_affinity When there are more IRQs than CPUs, queues wrap around using round-robin. --- src/basic/sort-util.c | 9 + src/basic/sort-util.h | 1 + src/shared/numa-util.c | 57 +++ src/shared/numa-util.h | 2 + src/udev/net/link-config.c | 515 ++++++++++++++++++++++++ src/udev/net/link-config.h | 1 + test/units/TEST-17-UDEV.irq-affinity.sh | 41 ++ 7 files changed, 626 insertions(+) diff --git a/src/basic/sort-util.c b/src/basic/sort-util.c index a76a6c85a8..8aca5cac76 100644 --- a/src/basic/sort-util.c +++ b/src/basic/sort-util.c @@ -81,3 +81,12 @@ int cmp_uint16(const uint16_t *a, const uint16_t *b) { return CMP(*a, *b); } + +int cmp_unsigned(const unsigned *a, const unsigned *b) { + /* This is called from qsort()s inner loops. Correctly implemented qsort will never pass NULL so we + just suppress the check via POINTER_MAY_BE_NULL instead of assert() to avoid the runtime cost. */ + POINTER_MAY_BE_NULL(a); + POINTER_MAY_BE_NULL(b); + + return CMP(*a, *b); +} diff --git a/src/basic/sort-util.h b/src/basic/sort-util.h index 8e7a1991bb..37c196158e 100644 --- a/src/basic/sort-util.h +++ b/src/basic/sort-util.h @@ -44,3 +44,4 @@ void qsort_r_safe(void *base, size_t nmemb, size_t size, comparison_userdata_fn_ int cmp_int(const int *a, const int *b); int cmp_uint16(const uint16_t *a, const uint16_t *b); +int cmp_unsigned(const unsigned *a, const unsigned *b); diff --git a/src/shared/numa-util.c b/src/shared/numa-util.c index 228ea7ad2d..9097ccbc31 100644 --- a/src/shared/numa-util.c +++ b/src/shared/numa-util.c @@ -154,6 +154,63 @@ static int numa_max_node(void) { return max_node; } +int numa_get_node_from_cpu(unsigned cpu, unsigned *ret) { + _cleanup_closedir_ DIR *d = NULL; + int r; + + assert(ret); + + d = opendir("/sys/devices/system/node"); + if (!d) + return -errno; + + FOREACH_DIRENT(de, d, break) { + char p[STRLEN("/sys/devices/system/node/node/cpulist") + DECIMAL_STR_MAX(unsigned) + 1]; + _cleanup_(cpu_set_done) CPUSet cpus = {}; + _cleanup_free_ char *cpulist = NULL; + const char *n; + unsigned node; + + if (de->d_type != DT_DIR) + continue; + + n = startswith(de->d_name, "node"); + if (!n) + continue; + + r = safe_atou(n, &node); + if (r < 0) { + log_debug_errno(r, "Failed to parse node number %s to unsigned, ignoring: %m", n); + continue; + } + + xsprintf(p, "/sys/devices/system/node/node%u/cpulist", node); + + r = read_virtual_file(p, SIZE_MAX, &cpulist, /* ret_size= */ NULL); + if (r < 0) { + log_debug_errno(r, "Failed to read %s, ignoring: %m", p); + continue; + } + + r = parse_cpu_set(cpulist, &cpus); + if (r < 0) { + log_debug_errno(r, "Failed to parse cpu set %s, ignoring: %m", cpulist); + continue; + } + + if (CPU_ISSET_S(cpu, cpus.allocated, cpus.set)) { + *ret = node; + return 0; + } + } + + /* CPU not found in any NUMA node, assume node 0 */ + log_debug("CPU %u not found in any NUMA node, assuming node 0.", cpu); + *ret = 0; + + return 0; +} + int numa_mask_add_all(CPUSet *mask) { int m; diff --git a/src/shared/numa-util.h b/src/shared/numa-util.h index c684dea803..6fec7c587b 100644 --- a/src/shared/numa-util.h +++ b/src/shared/numa-util.h @@ -31,6 +31,8 @@ static inline void numa_policy_reset(NUMAPolicy *p) { int apply_numa_policy(const NUMAPolicy *policy); int numa_to_cpu_set(const NUMAPolicy *policy, CPUSet *ret); +int numa_get_node_from_cpu(unsigned cpu, unsigned *ret); + int numa_mask_add_all(CPUSet *mask); DECLARE_STRING_TABLE_LOOKUP(mpol, int); diff --git a/src/udev/net/link-config.c b/src/udev/net/link-config.c index 4a1512365d..6211081d79 100644 --- a/src/udev/net/link-config.c +++ b/src/udev/net/link-config.c @@ -1,6 +1,7 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ #include +#include #include #include @@ -32,13 +33,16 @@ #include "netif-util.h" #include "netlink-util.h" #include "network-util.h" +#include "numa-util.h" #include "parse-util.h" #include "path-util.h" #include "proc-cmdline.h" #include "random-util.h" #include "socket-util.h" +#include "sort-util.h" #include "specifier.h" #include "stat-util.h" +#include "stdio-util.h" #include "string-table.h" #include "string-util.h" #include "strv.h" @@ -924,6 +928,456 @@ static int link_apply_sr_iov_config(Link *link) { return 0; } +/* CPU topology information for IRQ affinity spread algorithm. */ +typedef struct CPUTopology { + unsigned cpu; + unsigned numa_node; + unsigned package_id; + unsigned die_id; /* L3 cache domain / chiplet */ + unsigned core_id; + bool is_first_thread; /* First hyperthread of a physical core */ +} CPUTopology; + +/* Die (L3 cache domain) information for spread algorithm */ +typedef struct DieInfo { + unsigned die_id; + unsigned *cpus; /* CPUs in this die (first HT only, sorted by core) */ + size_t cpu_count; + size_t next_idx; /* For round-robin within die */ +} DieInfo; + +/* Returns the first thread of a CPU siblings list */ +static int cpu_topology_get_first_thread(sd_device *cpu_node, unsigned *ret) { + const char *content, *end; + int r; + + assert(cpu_node); + assert(ret); + + r = sd_device_get_sysattr_value(cpu_node, "topology/thread_siblings_list", &content); + if (r < 0) + return r; + + end = content + strcspn(content, ",-"); + + _cleanup_free_ char *first = strndup(content, end - content); + if (!first) + return -ENOMEM; + + return safe_atou(first, ret); +} + +static int cpu_topology_compare(const CPUTopology *a, const CPUTopology *b) { + int r; + + assert(a); + assert(b); + + /* Sort by die first (for L3 cache grouping), then core, then CPU number */ + r = CMP(a->die_id, b->die_id); + if (r != 0) + return r; + + r = CMP(a->core_id, b->core_id); + if (r != 0) + return r; + + return CMP(a->cpu, b->cpu); +} + +/* Comparison function for sorting CPUs by CPU number (for die ID assignment) */ +static int cpu_number_compare(const CPUTopology *a, const CPUTopology *b) { + assert(a); + assert(b); + + return CMP(a->cpu, b->cpu); +} + +/* Assign logical die IDs based on L3 cache sharing topology. + * + * For IRQ spreading, the goal is to distribute interrupts across CPUs that + * don't share cache, minimizing cache line contention when processing packets. + * The L3 cache boundary is the key locality domain: CPUs sharing an L3 can + * exchange data cheaply, while cross-L3 communication is expensive. + * + * We use L3 shared_cpu_list rather than the kernel's physical die_id because: + * - On AMD EPYC, multiple CCXs on the same physical die have separate L3 caches + * - On Intel with Sub-NUMA Clustering, one die may have multiple L3 domains + * - L3 sharing reflects actual data locality, not physical packaging */ +static int assign_sequential_die_ids(CPUTopology *cpus, size_t count) { + _cleanup_strv_free_ char **l3_groups = NULL; + int r; + + assert(cpus); + + /* First, sort CPUs by CPU number for consistent discovery order */ + typesafe_qsort(cpus, count, cpu_number_compare); + + /* Assign die IDs based on order of L3 shared_cpu_list discovery */ + FOREACH_ARRAY(cpu, cpus, count) { + _cleanup_(sd_device_unrefp) sd_device *cpu_node = NULL; + char cpu_path[STRLEN("/sys/devices/system/cpu/cpu") + DECIMAL_STR_MAX(unsigned) + 1]; + const char *l3_list; + unsigned die_id = 0; + bool found = false; + + xsprintf(cpu_path, "/sys/devices/system/cpu/cpu%u", cpu->cpu); + r = sd_device_new_from_syspath(&cpu_node, cpu_path); + if (r < 0) + return r; + + r = sd_device_get_sysattr_value(cpu_node, "cache/index3/shared_cpu_list", &l3_list); + if (r < 0) { + /* No L3 info, fall back to package ID */ + cpu->die_id = cpu->package_id; + continue; + } + + /* Check if we've seen this L3 group before */ + STRV_FOREACH(g, l3_groups) { + if (streq(*g, l3_list)) { + cpu->die_id = die_id; + found = true; + break; + } + die_id++; + } + + if (!found) { + /* New L3 group, assign next sequential die ID */ + cpu->die_id = die_id; + r = strv_extend(&l3_groups, l3_list); + if (r < 0) + return r; + } + } + + return 0; +} + +static int discover_cpu_topology(CPUTopology **ret, size_t *ret_count) { + _cleanup_(sd_device_unrefp) sd_device *parent_node = NULL; + _cleanup_free_ CPUTopology *cpus = NULL; + const char *name; + size_t count = 0; + int r; + + assert(ret); + assert(ret_count); + + r = sd_device_new_from_syspath(&parent_node, "/sys/devices/system/cpu"); + if (r < 0) + return r; + + FOREACH_DEVICE_CHILD_WITH_SUFFIX(parent_node, cpu_node, name) { + char topo_path[STRLEN("/sys/devices/system/cpu/cpu/topology") + DECIMAL_STR_MAX(unsigned) + 1]; + const char *n; + unsigned cpu, online, first_thread; + + n = startswith(name, "cpu"); + if (!n) + continue; + + r = safe_atou(n, &cpu); + if (r < 0) { + log_debug_errno(r, "Failed to convert %s to unsigned, skipping: %m", n); + continue; + } + + r = device_get_sysattr_unsigned(cpu_node, "online", &online); + if (r == -ENOENT) + online = 1; /* CPU 0 lacks 'online' file, assume online */ + else if (r < 0 || online == 0) + continue; + + /* Check if topology directory exists (filters out cpu0 on some systems) */ + xsprintf(topo_path, "/sys/devices/system/cpu/cpu%u/topology", cpu); + if (access(topo_path, F_OK) < 0) { + log_debug_errno(errno, "Failed to access %s, ignoring: %m", topo_path); + continue; + } + + if (!GREEDY_REALLOC(cpus, count + 1)) + return -ENOMEM; + + cpus[count].cpu = cpu; + + r = numa_get_node_from_cpu(cpu, &cpus[count].numa_node); + if (r < 0) { + log_debug_errno(r, "Failed to get NUMA node for CPU %u, assuming NUMA node 0: %m", cpu); + cpus[count].numa_node = 0; + } + + r = device_get_sysattr_unsigned(cpu_node, "topology/physical_package_id", &cpus[count].package_id); + if (r < 0) { + log_device_debug_errno(cpu_node, r, "Failed to get physical_package_id, assuming package ID 0: %m"); + cpus[count].package_id = 0; + } + + /* die_id will be assigned later by assign_sequential_die_ids() */ + cpus[count].die_id = 0; + + r = device_get_sysattr_unsigned(cpu_node, "topology/core_id", &cpus[count].core_id); + if (r < 0) { + log_device_debug_errno(cpu_node, r, "Failed to get core_id, assuming core ID %u: %m", cpu); + cpus[count].core_id = cpu; + } + + r = cpu_topology_get_first_thread(cpu_node, &first_thread); + if (r < 0) + cpus[count].is_first_thread = true; + else + cpus[count].is_first_thread = (first_thread == cpu); + + count++; + } + + if (count == 0) + return -ENOENT; + + /* Assign sequential die IDs based on L3 discovery order */ + r = assign_sequential_die_ids(cpus, count); + if (r < 0) + return r; + + /* Sort CPUs by topology for consistent ordering */ + typesafe_qsort(cpus, count, cpu_topology_compare); + + *ret = TAKE_PTR(cpus); + *ret_count = count; + + return 0; +} + +/* Reorder indices so consecutive elements are maximally spread apart. + * + * Uses recursive divide-and-conquer: split in half, permute each half, + * then interleave. This ensures elements originally far apart become adjacent. + * + * Example trace for [0,1,2,3,4,5,6,7]: + * split into [0,1,2,3] and [4,5,6,7] + * recurse left: [0,1,2,3] -> [0,2,1,3] + * recurse right: [4,5,6,7] -> [4,6,5,7] + * interleave -> [0,4,2,6,1,5,3,7] + * + * The first N elements of the output are roughly evenly distributed across the + * original range, for any N. This is useful when assigning IRQs to CPUs: if a + * NIC has fewer IRQs than CPUs, the assigned CPUs will still be spread across + * the CPUs rather than all at the beginning. */ +static int equidist_permute(size_t *indices, size_t n_indices) { + _cleanup_free_ size_t *left = NULL, *right = NULL; + size_t left_count, right_count; + size_t li = 0, ri = 0, ti = 0; + int r; + + assert(indices); + + if (n_indices <= 1) + return 0; + + left_count = DIV_ROUND_UP(n_indices, 2); + right_count = n_indices - left_count; + + /* Recursively permute each half */ + left = newdup(size_t, indices, left_count); + right = newdup(size_t, &indices[left_count], right_count); + if (!left || !right) + return log_oom(); + + r = equidist_permute(left, left_count); + if (r < 0) + return r; + + r = equidist_permute(right, right_count); + if (r < 0) + return r; + + /* Interleave: left[0], right[0], left[1], right[1], ... */ + for (size_t i = 0; i < n_indices; i++) { + if (i % 2 == 0 && li < left_count) + indices[ti++] = left[li++]; + else if (ri < right_count) + indices[ti++] = right[ri++]; + else if (li < left_count) + indices[ti++] = left[li++]; + } + + return 0; +} + +static void die_info_free(DieInfo *dies, size_t count) { + assert(dies || count == 0); + + FOREACH_ARRAY(die, dies, count) + free(die->cpus); + free(dies); +} + +/* Build die information from topology, grouping CPUs by L3/die and filtering to first HT only */ +static int build_die_info(const CPUTopology *topology, size_t topology_count, DieInfo **ret, size_t *ret_count) { + DieInfo *dies = NULL; + size_t die_count = 0; + int r; + + assert(topology); + assert(ret); + assert(ret_count); + + CLEANUP_ARRAY(dies, die_count, die_info_free); + + FOREACH_ARRAY(cpu_topology, topology, topology_count) { + DieInfo *die = NULL; + + /* Only consider first hyperthreads for initial spread */ + if (!cpu_topology->is_first_thread) + continue; + + /* Find or create die entry */ + for (size_t j = 0; j < die_count; j++) + if (dies[j].die_id == cpu_topology->die_id) { + die = &dies[j]; + break; + } + + if (!die) { + if (!GREEDY_REALLOC(dies, die_count + 1)) + return log_oom(); + die = &dies[die_count++]; + *die = (DieInfo) { .die_id = cpu_topology->die_id }; + } + + if (!GREEDY_REALLOC(die->cpus, die->cpu_count + 1)) + return log_oom(); + + die->cpus[die->cpu_count++] = cpu_topology->cpu; + } + + /* Sort dies by die_id for determinism, then apply equidist to CPUs within each die */ + FOREACH_ARRAY(die, dies, die_count) { + _cleanup_free_ unsigned *reordered = NULL; + _cleanup_free_ size_t *indices = new(size_t, die->cpu_count); + if (!indices) + return log_oom(); + + for (size_t j = 0; j < die->cpu_count; j++) + indices[j] = j; + + r = equidist_permute(indices, die->cpu_count); + if (r < 0) + return r; + + /* Reorder CPUs according to equidist permutation */ + reordered = new(unsigned, die->cpu_count); + if (!reordered) + return log_oom(); + + for (size_t j = 0; j < die->cpu_count; j++) + reordered[j] = die->cpus[indices[j]]; + + memcpy(die->cpus, reordered, die->cpu_count * sizeof(unsigned)); + } + + *ret = TAKE_PTR(dies); + *ret_count = die_count; + + return 0; +} + +/* Select CPUs for IRQ affinity spreading with optimal topology distribution. + * + * Algorithm: + * 1. Group CPUs by die (L3 cache domain), using only first hyperthreads + * 2. Apply equidistant permutation to both die order and CPUs within each die, + * so consecutive selections are maximally spread (e.g., [0,1,2,3] -> [0,2,1,3]) + * 3. Round-robin across dies, picking one CPU per die per round + * 4. If more IRQs than physical cores, wrap around and reuse the same CPUs + * + * Ensures each IRQ gets a dedicated physical core before any core handles + * multiple IRQs. Two IRQs on one physical core time-share but benefit from warm + * cache, whereas spreading across SMT siblings causes resource contention with + * no cache benefit. + * Maximizes physical distance between consecutively assigned IRQs, improving + * cache distribution even when only a few IRQs are assigned. */ +static int select_spread_cpus( + const CPUTopology *topology, + size_t topology_count, + size_t n_irqs, + unsigned **ret, + size_t *ret_count) { + + _cleanup_free_ unsigned *selected = NULL; + _cleanup_free_ size_t *die_order = NULL; + DieInfo *dies = NULL; + size_t die_count = 0, selected_count = 0; + int r; + + assert(topology); + assert(ret); + assert(ret_count); + + CLEANUP_ARRAY(dies, die_count, die_info_free); + + selected = new(unsigned, n_irqs); + if (!selected) + return -ENOMEM; + + /* Build die information with first HT CPUs only */ + r = build_die_info(topology, topology_count, &dies, &die_count); + if (r < 0) + return r; + + if (die_count == 0) + return -ENOENT; + + /* Create equidistant die ordering */ + die_order = new(size_t, die_count); + if (!die_order) + return -ENOMEM; + + for (size_t i = 0; i < die_count; i++) + die_order[i] = i; + + r = equidist_permute(die_order, die_count); + if (r < 0) + return r; + + /* Round-robin across dies, picking one CPU from each die at a time */ + size_t dies_exhausted = 0; + while (selected_count < n_irqs) { + bool made_progress = false; + + for (size_t i = 0; i < die_count && selected_count < n_irqs; i++) { + DieInfo *die = &dies[die_order[i]]; + + if (die->next_idx >= die->cpu_count) + continue; + + selected[selected_count++] = die->cpus[die->next_idx++]; + made_progress = true; + + if (die->next_idx >= die->cpu_count) + dies_exhausted++; + } + + if (made_progress) + continue; + + /* All first HTs exhausted, wrap around for remaining IRQs */ + if (dies_exhausted < die_count) + break; + + /* Reset all dies for round-robin wrap */ + FOREACH_ARRAY(die, dies, die_count) + die->next_idx = 0; + dies_exhausted = 0; + } + + *ret = TAKE_PTR(selected); + *ret_count = selected_count; + + return 0; +} + static int set_irq_affinity(Link *link, unsigned irq, unsigned cpu) { _cleanup_free_ char *affinity_path = NULL, *mask_str = NULL; unsigned n_groups = cpu / 32; @@ -954,6 +1408,64 @@ static int set_irq_affinity(Link *link, unsigned irq, unsigned cpu) { return 0; } +static int link_apply_irq_affinity_spread(Link *link) { + _cleanup_closedir_ DIR *dir = NULL; + _cleanup_free_ CPUTopology *topology = NULL; + _cleanup_free_ unsigned *irqs = NULL; + _cleanup_free_ unsigned *spread_cpus = NULL; + size_t topology_count = 0, irq_count = 0, spread_count = 0; + int r; + + assert(link); + + r = device_opendir(link->event->dev, "device/msi_irqs", &dir); + if (r < 0) { + if (r != -ENOENT) + return log_link_error_errno(link, r, "Failed to open device/msi_irqs: %m"); + log_link_debug_errno(link, r, "No MSI IRQs found, skipping IRQ affinity configuration: %m"); + return 0; + } + + FOREACH_DIRENT(de, dir, return log_link_error_errno(link, errno, "Failed to read directory device/msi_irqs: %m")) { + unsigned irq; + + r = safe_atou(de->d_name, &irq); + if (r < 0) + return log_link_error_errno(link, r, "Failed to convert parse IRQ number: %s", de->d_name); + + if (!GREEDY_REALLOC(irqs, irq_count + 1)) + return log_oom(); + + irqs[irq_count++] = irq; + } + + if (irq_count == 0) { + log_link_debug(link, "No IRQs found, skipping spread."); + return 0; + } + + typesafe_qsort(irqs, irq_count, cmp_unsigned); + + r = discover_cpu_topology(&topology, &topology_count); + if (r < 0) + return log_link_error_errno(link, r, "Failed to discover CPU topology: %m"); + + log_link_debug(link, "Discovered %zu CPUs, spreading %zu IRQs.", topology_count, irq_count); + + /* Select CPUs using maximum distance algorithm */ + r = select_spread_cpus(topology, topology_count, irq_count, &spread_cpus, &spread_count); + if (r < 0) + return log_link_error_errno(link, r, "Failed to select spread CPUs: %m"); + + for (size_t i = 0; i < spread_count; i++) + (void) set_irq_affinity(link, irqs[i], spread_cpus[i]); + + log_link_info(link, "Applied IRQ affinity policy 'spread' across %zu CPUs for %zu IRQs.", + MIN(topology_count, irq_count), irq_count); + + return 0; +} + static int link_apply_irq_affinity_single(Link *link) { _cleanup_closedir_ DIR *dir = NULL; int r; @@ -1009,6 +1521,8 @@ static int link_apply_irq_affinity(Link *link) { switch (link->config->irq_affinity_policy) { case IRQ_AFFINITY_POLICY_SINGLE: return link_apply_irq_affinity_single(link); + case IRQ_AFFINITY_POLICY_SPREAD: + return link_apply_irq_affinity_spread(link); default: assert_not_reached(); } @@ -1499,6 +2013,7 @@ DEFINE_CONFIG_PARSE_ENUMV(config_parse_alternative_names_policy, alternative_nam static const char* const irq_affinity_policy_table[_IRQ_AFFINITY_POLICY_MAX] = { [IRQ_AFFINITY_POLICY_SINGLE] = "single", + [IRQ_AFFINITY_POLICY_SPREAD] = "spread", }; DEFINE_STRING_TABLE_LOOKUP(irq_affinity_policy, IRQAffinityPolicy); diff --git a/src/udev/net/link-config.h b/src/udev/net/link-config.h index b6853cd922..ff581d8b02 100644 --- a/src/udev/net/link-config.h +++ b/src/udev/net/link-config.h @@ -24,6 +24,7 @@ typedef enum MACAddressPolicy { typedef enum IRQAffinityPolicy { IRQ_AFFINITY_POLICY_SINGLE, + IRQ_AFFINITY_POLICY_SPREAD, _IRQ_AFFINITY_POLICY_MAX, _IRQ_AFFINITY_POLICY_INVALID = -EINVAL, } IRQAffinityPolicy; diff --git a/test/units/TEST-17-UDEV.irq-affinity.sh b/test/units/TEST-17-UDEV.irq-affinity.sh index 5fa8bd331f..f9dde104fa 100755 --- a/test/units/TEST-17-UDEV.irq-affinity.sh +++ b/test/units/TEST-17-UDEV.irq-affinity.sh @@ -85,6 +85,47 @@ EOF fi done + # Test 1b: test spread policy on the same interface + cat >/run/systemd/network/00-test-irq-affinity.link <1 CPU and >1 IRQ)" + fi + # Cleanup rm -f /run/systemd/network/00-test-irq-affinity.link udevadm control --reload From a9e821549bc757ed34b3f6dd7aee0997be126e1e Mon Sep 17 00:00:00 2001 From: Quentin Deslandes Date: Mon, 16 Feb 2026 20:40:38 +0100 Subject: [PATCH 3/5] udev/net: add IRQAffinity= option to filter eligible CPUs Add IRQAffinity= option to .link files that filters the set of CPUs eligible for IRQ placement. This works in conjunction with IRQAffinityPolicy= to constrain which CPUs receive network IRQs. When specified with spread policy, only the listed CPUs are considered for IRQ distribution. When specified with single policy, IRQs are pinned to the first CPU in the allowed set instead of CPU 0. --- src/udev/net/link-config-gperf.gperf | 1 + src/udev/net/link-config.c | 56 ++++++++++++++-- src/udev/net/link-config.h | 1 + test/units/TEST-17-UDEV.irq-affinity.sh | 89 +++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 7 deletions(-) diff --git a/src/udev/net/link-config-gperf.gperf b/src/udev/net/link-config-gperf.gperf index 0b63a1e446..90eebe120d 100644 --- a/src/udev/net/link-config-gperf.gperf +++ b/src/udev/net/link-config-gperf.gperf @@ -134,6 +134,7 @@ Link.CoalescePacketRateSampleIntervalSec, config_parse_coalesce_sec, Link.ReceivePacketSteeringCPUMask, config_parse_rps_cpu_mask, 0, offsetof(LinkConfig, rps_cpu_mask) /* IRQ affinity settings */ Link.IRQAffinityPolicy, config_parse_irq_affinity_policy, 0, offsetof(LinkConfig, irq_affinity_policy) +Link.IRQAffinity, config_parse_cpu_set, 0, offsetof(LinkConfig, irq_affinity_cpus) /* SR-IOV settings */ Link.SR-IOVVirtualFunctions, config_parse_sr_iov_num_vfs, 0, offsetof(LinkConfig, sr_iov_num_vfs) SR-IOV.VirtualFunction, config_parse_sr_iov_uint32, 0, offsetof(LinkConfig, sr_iov_by_section) diff --git a/src/udev/net/link-config.c b/src/udev/net/link-config.c index 6211081d79..5fd59f9453 100644 --- a/src/udev/net/link-config.c +++ b/src/udev/net/link-config.c @@ -83,6 +83,7 @@ static LinkConfig* link_config_free(LinkConfig *config) { free(config->wol_password_file); erase_and_free(config->wol_password); cpu_set_done(&config->rps_cpu_mask); + cpu_set_done(&config->irq_affinity_cpus); ordered_hashmap_free(config->sr_iov_by_section); @@ -1408,7 +1409,7 @@ static int set_irq_affinity(Link *link, unsigned irq, unsigned cpu) { return 0; } -static int link_apply_irq_affinity_spread(Link *link) { +static int link_apply_irq_affinity_spread(Link *link, const CPUSet *allowed_cpus) { _cleanup_closedir_ DIR *dir = NULL; _cleanup_free_ CPUTopology *topology = NULL; _cleanup_free_ unsigned *irqs = NULL; @@ -1450,7 +1451,30 @@ static int link_apply_irq_affinity_spread(Link *link) { if (r < 0) return log_link_error_errno(link, r, "Failed to discover CPU topology: %m"); - log_link_debug(link, "Discovered %zu CPUs, spreading %zu IRQs.", topology_count, irq_count); + /* Filter topology by allowed CPUs if specified */ + if (allowed_cpus && allowed_cpus->set) { + _cleanup_free_ CPUTopology *filtered_topology = new(CPUTopology, topology_count); + size_t filtered_count = 0; + + if (!filtered_topology) + return log_oom(); + + for (size_t i = 0; i < topology_count; i++) + if (CPU_ISSET_S(topology[i].cpu, allowed_cpus->allocated, allowed_cpus->set)) + filtered_topology[filtered_count++] = topology[i]; + + if (filtered_count == 0) { + log_link_warning(link, "IRQAffinity= filter excludes all CPUs, skipping spread."); + return 0; + } + + log_link_debug(link, "Filtered to %zu CPUs (from %zu) based on IRQAffinity=.", filtered_count, topology_count); + + free_and_replace(topology, filtered_topology); + topology_count = filtered_count; + } + + log_link_debug(link, "Spreading %zu IRQs across %zu CPUs.", irq_count, topology_count); /* Select CPUs using maximum distance algorithm */ r = select_spread_cpus(topology, topology_count, irq_count, &spread_cpus, &spread_count); @@ -1466,14 +1490,32 @@ static int link_apply_irq_affinity_spread(Link *link) { return 0; } -static int link_apply_irq_affinity_single(Link *link) { +static int link_apply_irq_affinity_single(Link *link, const CPUSet *allowed_cpus) { _cleanup_closedir_ DIR *dir = NULL; + unsigned target_cpu = 0; int r; assert(link); assert(link->config); assert(ASSERT_PTR(link->event)->dev); + /* If IRQAffinity= is specified, use the first allowed CPU instead of CPU 0 */ + if (allowed_cpus && allowed_cpus->set) { + bool found = false; + + for (unsigned cpu = 0; cpu < allowed_cpus->allocated * 8; cpu++) + if (CPU_ISSET_S(cpu, allowed_cpus->allocated, allowed_cpus->set)) { + target_cpu = cpu; + found = true; + break; + } + + if (!found) { + log_link_warning(link, "IRQAffinity= filter excludes all CPUs, skipping single."); + return 0; + } + } + r = device_opendir(link->event->dev, "device/msi_irqs", &dir); if (r < 0) { if (r != -ENOENT) @@ -1489,10 +1531,10 @@ static int link_apply_irq_affinity_single(Link *link) { if (r < 0) return log_link_error_errno(link, r, "Failed to convert parse IRQ number: %s", de->d_name); - (void) set_irq_affinity(link, irq, /* cpu= */ 0); + (void) set_irq_affinity(link, irq, target_cpu); } - log_link_info(link, "Applied IRQ affinity policy 'single' (pinning to CPU 0)."); + log_link_info(link, "Applied IRQ affinity policy 'single' (pinning to CPU %u).", target_cpu); return 0; } @@ -1520,9 +1562,9 @@ static int link_apply_irq_affinity(Link *link) { switch (link->config->irq_affinity_policy) { case IRQ_AFFINITY_POLICY_SINGLE: - return link_apply_irq_affinity_single(link); + return link_apply_irq_affinity_single(link, &link->config->irq_affinity_cpus); case IRQ_AFFINITY_POLICY_SPREAD: - return link_apply_irq_affinity_spread(link); + return link_apply_irq_affinity_spread(link, &link->config->irq_affinity_cpus); default: assert_not_reached(); } diff --git a/src/udev/net/link-config.h b/src/udev/net/link-config.h index ff581d8b02..da28f56980 100644 --- a/src/udev/net/link-config.h +++ b/src/udev/net/link-config.h @@ -122,6 +122,7 @@ struct LinkConfig { /* IRQ affinity */ IRQAffinityPolicy irq_affinity_policy; + CPUSet irq_affinity_cpus; /* SR-IOV */ uint32_t sr_iov_num_vfs; diff --git a/test/units/TEST-17-UDEV.irq-affinity.sh b/test/units/TEST-17-UDEV.irq-affinity.sh index f9dde104fa..7c3662243e 100755 --- a/test/units/TEST-17-UDEV.irq-affinity.sh +++ b/test/units/TEST-17-UDEV.irq-affinity.sh @@ -126,6 +126,69 @@ EOF echo "Skipping spread verification (need >1 CPU and >1 IRQ)" fi + # Test 1c: Test IRQAffinity= CPU filtering with single policy + # Pin to CPU 1 instead of default CPU 0 + cat >/run/systemd/network/00-test-irq-affinity.link <1 CPU)" + fi + + # Test 1d: Test IRQAffinity= with spread policy (restrict to subset of CPUs) + if [[ "$n_cpus" -ge 4 ]] && [[ "$irq_count" -gt 1 ]]; then + cat >/run/systemd/network/00-test-irq-affinity.link <=4 CPUs and >1 IRQ)" + fi + # Cleanup rm -f /run/systemd/network/00-test-irq-affinity.link udevadm control --reload @@ -200,13 +263,39 @@ udevadm wait --settle --timeout=30 /sys/class/net/testirq2 output=$(udevadm info --query property /sys/class/net/testirq2) assert_in "ID_NET_LINK_FILE=/run/systemd/network/10-test-irq-empty.link" "$output" +# Test 5: IRQAffinity= config parsing +cat >/run/systemd/network/10-test-irq-affinity-cpus.link <&1) +assert_in "ID_NET_LINK_FILE=/run/systemd/network/10-test-irq-affinity-cpus.link" "$output" + # Cleanup ip link del dev testirq0 ip link del dev testirq1 ip link del dev testirq2 +ip link del dev testirq3 rm -f /run/systemd/network/10-test-irq.link rm -f /run/systemd/network/10-test-irq-invalid.link rm -f /run/systemd/network/10-test-irq-empty.link +rm -f /run/systemd/network/10-test-irq-affinity-cpus.link exit 0 From 7dfbbfb90dd162ce14d94a4a18a5856235c3fac2 Mon Sep 17 00:00:00 2001 From: Quentin Deslandes Date: Mon, 16 Feb 2026 20:43:37 +0100 Subject: [PATCH 4/5] udev/net: add IRQAffinityNUMA= option for NUMA-aware filtering Add support for filtering IRQ affinity to CPUs on a specific NUMA node via the new IRQAffinityNUMA= option in .link files. The option accepts: - "local": use the NUMA node local to the NIC's PCIe slot - Explicit node number (0, 1, 2, ...): use CPUs on the specified node When both IRQAffinity= and IRQAffinityNUMA= are specified, their intersection is used. If the intersection is empty, an error is logged and IRQ affinity configuration is skipped. When "local" is specified but the device's NUMA node cannot be determined (numa_node shows -1), a warning is logged and IRQ affinity configuration is skipped. --- src/shared/numa-util.c | 27 +++-- src/shared/numa-util.h | 1 + src/udev/net/link-config-gperf.gperf | 1 + src/udev/net/link-config.c | 145 +++++++++++++++++++++++- src/udev/net/link-config.h | 8 ++ test/units/TEST-17-UDEV.irq-affinity.sh | 128 +++++++++++++++++++++ 6 files changed, 298 insertions(+), 12 deletions(-) diff --git a/src/shared/numa-util.c b/src/shared/numa-util.c index 9097ccbc31..34ddc0e547 100644 --- a/src/shared/numa-util.c +++ b/src/shared/numa-util.c @@ -89,6 +89,22 @@ int apply_numa_policy(const NUMAPolicy *policy) { return 0; } +int numa_node_get_cpus(size_t node, CPUSet *ret) { + char p[STRLEN("/sys/devices/system/node/node//cpulist") + DECIMAL_STR_MAX(size_t) + 1]; + _cleanup_free_ char *cpulist = NULL; + int r; + + assert(ret); + + xsprintf(p, "/sys/devices/system/node/node%zu/cpulist", node); + + r = read_virtual_file(p, SIZE_MAX, &cpulist, /* ret_size= */ NULL); + if (r < 0) + return r; + + return parse_cpu_set(cpulist, ret); +} + int numa_to_cpu_set(const NUMAPolicy *policy, CPUSet *ret) { _cleanup_(cpu_set_done) CPUSet s = {}; int r; @@ -97,20 +113,11 @@ int numa_to_cpu_set(const NUMAPolicy *policy, CPUSet *ret) { assert(ret); for (size_t i = 0; i < policy->nodes.allocated * 8; i++) { - _cleanup_free_ char *l = NULL; - char p[STRLEN("/sys/devices/system/node/node//cpulist") + DECIMAL_STR_MAX(size_t) + 1]; - if (!CPU_ISSET_S(i, policy->nodes.allocated, policy->nodes.set)) continue; - xsprintf(p, "/sys/devices/system/node/node%zu/cpulist", i); - - r = read_one_line_file(p, &l); - if (r < 0) - return r; - _cleanup_(cpu_set_done) CPUSet part = {}; - r = parse_cpu_set(l, &part); + r = numa_node_get_cpus(i, &part); if (r < 0) return r; diff --git a/src/shared/numa-util.h b/src/shared/numa-util.h index 6fec7c587b..01079351b0 100644 --- a/src/shared/numa-util.h +++ b/src/shared/numa-util.h @@ -29,6 +29,7 @@ static inline void numa_policy_reset(NUMAPolicy *p) { } int apply_numa_policy(const NUMAPolicy *policy); +int numa_node_get_cpus(size_t node, CPUSet *ret); int numa_to_cpu_set(const NUMAPolicy *policy, CPUSet *ret); int numa_get_node_from_cpu(unsigned cpu, unsigned *ret); diff --git a/src/udev/net/link-config-gperf.gperf b/src/udev/net/link-config-gperf.gperf index 90eebe120d..a005cadd49 100644 --- a/src/udev/net/link-config-gperf.gperf +++ b/src/udev/net/link-config-gperf.gperf @@ -135,6 +135,7 @@ Link.ReceivePacketSteeringCPUMask, config_parse_rps_cpu_mask, /* IRQ affinity settings */ Link.IRQAffinityPolicy, config_parse_irq_affinity_policy, 0, offsetof(LinkConfig, irq_affinity_policy) Link.IRQAffinity, config_parse_cpu_set, 0, offsetof(LinkConfig, irq_affinity_cpus) +Link.IRQAffinityNUMA, config_parse_irq_affinity_numa, 0, offsetof(LinkConfig, irq_affinity_numa) /* SR-IOV settings */ Link.SR-IOVVirtualFunctions, config_parse_sr_iov_num_vfs, 0, offsetof(LinkConfig, sr_iov_num_vfs) SR-IOV.VirtualFunction, config_parse_sr_iov_uint32, 0, offsetof(LinkConfig, sr_iov_by_section) diff --git a/src/udev/net/link-config.c b/src/udev/net/link-config.c index 5fd59f9453..306686845e 100644 --- a/src/udev/net/link-config.c +++ b/src/udev/net/link-config.c @@ -277,6 +277,7 @@ int link_load_one(LinkConfigContext *ctx, const char *filename) { .eee_tx_lpi_enabled = -1, .eee_tx_lpi_timer_usec = USEC_INFINITY, .irq_affinity_policy = _IRQ_AFFINITY_POLICY_INVALID, + .irq_affinity_numa = IRQ_AFFINITY_NUMA_UNSET, }; FOREACH_ELEMENT(feature, config->features) @@ -929,6 +930,28 @@ static int link_apply_sr_iov_config(Link *link) { return 0; } +/* Get the local NUMA node for a network device from sysfs. + * Returns -ENOENT if numa_node file doesn't exist or shows -1 (no NUMA). */ +static int link_get_device_numa_node(Link *link, unsigned *ret) { + int r, node; + + assert(link); + assert(link->event); + assert(link->event->dev); + assert(ret); + + r = device_get_sysattr_int(link->event->dev, "device/numa_node", &node); + if (r < 0) + return r; + + /* -1 means no NUMA node (non-NUMA system or device not associated with a node) */ + if (node < 0) + return -ENOENT; + + *ret = (unsigned) node; + return 0; +} + /* CPU topology information for IRQ affinity spread algorithm. */ typedef struct CPUTopology { unsigned cpu; @@ -1542,6 +1565,7 @@ static int link_apply_irq_affinity_single(Link *link, const CPUSet *allowed_cpus static int link_apply_irq_affinity(Link *link) { _cleanup_(cpu_set_done) CPUSet effective_cpus = {}; const char *syspath; + unsigned numa_node = IRQ_AFFINITY_NUMA_UNSET; int r; assert(link); @@ -1560,11 +1584,82 @@ static int link_apply_irq_affinity(Link *link) { if (r < 0) return log_link_warning_errno(link, r, "Failed to get syspath: %m"); + /* Compute effective CPU set from IRQAffinity= and IRQAffinityNUMA= */ + if (link->config->irq_affinity_numa != IRQ_AFFINITY_NUMA_UNSET) { + _cleanup_(cpu_set_done) CPUSet numa_cpus = {}; + + /* Resolve "local" to the actual NUMA node */ + if (link->config->irq_affinity_numa == IRQ_AFFINITY_NUMA_LOCAL) { + r = link_get_device_numa_node(link, &numa_node); + if (r < 0) { + log_link_warning_errno( + link, r, + "Failed to determine local NUMA node for device, skipping IRQ affinity configuration: %m"); + return 0; + } + log_link_debug(link, "Device is on NUMA node %u.", numa_node); + } else + numa_node = link->config->irq_affinity_numa; + + /* Get CPUs for the NUMA node */ + r = numa_node_get_cpus(numa_node, &numa_cpus); + if (r < 0) { + log_link_warning_errno( + link, r, + "Failed to get CPUs for NUMA node %u, skipping IRQ affinity configuration: %m", + numa_node); + return 0; + } + + /* If IRQAffinity= is also specified, compute intersection */ + if (link->config->irq_affinity_cpus.set) { + /* Compute intersection of IRQAffinity= and NUMA CPUs */ + size_t max_allocated = MAX(numa_cpus.allocated, link->config->irq_affinity_cpus.allocated); + + r = cpu_set_realloc(&effective_cpus, max_allocated * 8); + if (r < 0) + return log_oom(); + + for (size_t i = 0; i < max_allocated * 8; i++) { + bool in_numa = i < numa_cpus.allocated * 8 && + CPU_ISSET_S(i, numa_cpus.allocated, numa_cpus.set); + bool in_affinity = i < link->config->irq_affinity_cpus.allocated * 8 && + CPU_ISSET_S(i, link->config->irq_affinity_cpus.allocated, link->config->irq_affinity_cpus.set); + + if (in_numa && in_affinity) { + r = cpu_set_add(&effective_cpus, i); + if (r < 0) + return log_oom(); + } + } + + /* Check if intersection is empty */ + if (!effective_cpus.set || CPU_COUNT_S(effective_cpus.allocated, effective_cpus.set) == 0) { + log_link_warning( + link, + "IRQAffinity= and IRQAffinityNUMA= intersection is empty, skipping IRQ affinity configuration."); + return 0; + } + + log_link_debug(link, "Using intersection of IRQAffinity= and NUMA node %u CPUs.", numa_node); + } else { + /* Only NUMA filtering, use NUMA CPUs directly */ + effective_cpus = TAKE_STRUCT(numa_cpus); + log_link_debug(link, "Using CPUs from NUMA node %u.", numa_node); + } + } else if (link->config->irq_affinity_cpus.set) { + /* Only IRQAffinity= specified, copy it */ + r = cpu_set_add_set(&effective_cpus, &link->config->irq_affinity_cpus); + if (r < 0) + return log_oom(); + } + /* else: no filtering, effective_cpus remains empty (meaning use all CPUs) */ + switch (link->config->irq_affinity_policy) { case IRQ_AFFINITY_POLICY_SINGLE: - return link_apply_irq_affinity_single(link, &link->config->irq_affinity_cpus); + return link_apply_irq_affinity_single(link, effective_cpus.set ? &effective_cpus : NULL); case IRQ_AFFINITY_POLICY_SPREAD: - return link_apply_irq_affinity_spread(link, &link->config->irq_affinity_cpus); + return link_apply_irq_affinity_spread(link, effective_cpus.set ? &effective_cpus : NULL); default: assert_not_reached(); } @@ -2053,6 +2148,52 @@ DEFINE_CONFIG_PARSE_ENUMV(config_parse_name_policy, name_policy, NamePolicy, DEFINE_CONFIG_PARSE_ENUMV(config_parse_alternative_names_policy, alternative_names_policy, NamePolicy, _NAMEPOLICY_INVALID); +int config_parse_irq_affinity_numa( + const char *unit, + const char *filename, + unsigned line, + const char *section, + unsigned section_line, + const char *lvalue, + int ltype, + const char *rvalue, + void *data, + void *userdata) { + + unsigned tmp, *numa = ASSERT_PTR(data); + int r; + + assert(filename); + assert(lvalue); + assert(rvalue); + + if (isempty(rvalue)) { + *numa = IRQ_AFFINITY_NUMA_UNSET; + return 0; + } + + if (streq(rvalue, "local")) { + *numa = IRQ_AFFINITY_NUMA_LOCAL; + return 0; + } + + /* Parse as NUMA node number */ + r = safe_atou(rvalue, &tmp); + if (r < 0) + return log_syntax_parse_error(unit, filename, line, r, lvalue, rvalue); + + /* UINT_MAX and UINT_MAX-1 are used to flag "unset" and "local NUMA node" respectively. */ + if (tmp >= IRQ_AFFINITY_NUMA_LOCAL) { + log_syntax(unit, LOG_WARNING, filename, line, 0, + "Invalid NUMA node number %u, ignoring assignment: %s", tmp, rvalue); + return 0; + } + + *numa = tmp; + + return 0; +} + static const char* const irq_affinity_policy_table[_IRQ_AFFINITY_POLICY_MAX] = { [IRQ_AFFINITY_POLICY_SINGLE] = "single", [IRQ_AFFINITY_POLICY_SPREAD] = "spread", diff --git a/src/udev/net/link-config.h b/src/udev/net/link-config.h index da28f56980..a2b0def4c3 100644 --- a/src/udev/net/link-config.h +++ b/src/udev/net/link-config.h @@ -1,6 +1,8 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ #pragma once +#include + #include "sd-device.h" #include "cpu-set-util.h" @@ -29,6 +31,10 @@ typedef enum IRQAffinityPolicy { _IRQ_AFFINITY_POLICY_INVALID = -EINVAL, } IRQAffinityPolicy; +/* Special values for IRQAffinityNUMA= */ +#define IRQ_AFFINITY_NUMA_UNSET UINT_MAX +#define IRQ_AFFINITY_NUMA_LOCAL (IRQ_AFFINITY_NUMA_UNSET - 1) + typedef struct Link { UdevEvent *event; LinkConfig *config; @@ -123,6 +129,7 @@ struct LinkConfig { /* IRQ affinity */ IRQAffinityPolicy irq_affinity_policy; CPUSet irq_affinity_cpus; + unsigned irq_affinity_numa; /* SR-IOV */ uint32_t sr_iov_num_vfs; @@ -163,3 +170,4 @@ CONFIG_PARSER_PROTOTYPE(config_parse_name_policy); CONFIG_PARSER_PROTOTYPE(config_parse_alternative_names_policy); CONFIG_PARSER_PROTOTYPE(config_parse_rps_cpu_mask); CONFIG_PARSER_PROTOTYPE(config_parse_irq_affinity_policy); +CONFIG_PARSER_PROTOTYPE(config_parse_irq_affinity_numa); diff --git a/test/units/TEST-17-UDEV.irq-affinity.sh b/test/units/TEST-17-UDEV.irq-affinity.sh index 7c3662243e..863fc91a39 100755 --- a/test/units/TEST-17-UDEV.irq-affinity.sh +++ b/test/units/TEST-17-UDEV.irq-affinity.sh @@ -189,6 +189,67 @@ EOF echo "Skipping IRQAffinity= spread test (need >=4 CPUs and >1 IRQ)" fi + # Test 1e: Test IRQAffinityNUMA= if NUMA is available + if [[ -d /sys/devices/system/node/node0 ]]; then + # Get CPUs on NUMA node 0 + numa0_cpus=$(cat /sys/devices/system/node/node0/cpulist) + echo "NUMA node 0 has CPUs: $numa0_cpus" + + cat >/run/systemd/network/00-test-irq-affinity.link </run/systemd/network/00-test-irq-affinity.link </dev/null; then + echo "Empty intersection correctly detected and logged" + else + echo "Note: Empty intersection test - check journal for error message" + fi + else + echo "Skipping empty intersection test (need 2 NUMA nodes)" + fi + # Cleanup rm -f /run/systemd/network/00-test-irq-affinity.link udevadm control --reload @@ -287,15 +348,82 @@ assert_in "ID_NET_LINK_FILE=/run/systemd/network/10-test-irq-affinity-cpus.link" output=$(udevadm test-builtin --action add net_setup_link /sys/class/net/testirq3 2>&1) assert_in "ID_NET_LINK_FILE=/run/systemd/network/10-test-irq-affinity-cpus.link" "$output" +# Test 6: IRQAffinityNUMA= config parsing +cat >/run/systemd/network/10-test-irq-affinity-numa.link </run/systemd/network/10-test-irq-affinity-numa-explicit.link </run/systemd/network/10-test-irq-affinity-combined.link < Date: Thu, 8 Jan 2026 01:25:56 +0100 Subject: [PATCH 5/5] udev/net: document IRQAffinityPolicy=, IRQAffinity=, and IRQAffinityNUMA= --- man/systemd.link.xml | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/man/systemd.link.xml b/man/systemd.link.xml index d26431b2b2..1d6fbf9fb8 100644 --- a/man/systemd.link.xml +++ b/man/systemd.link.xml @@ -1080,6 +1080,62 @@ + + IRQAffinityPolicy= + + Specifies the IRQ distribution strategy for network interface MSI/MSI-X interrupts. Takes one + of spread or single. When set to spread, + queue IRQs are distributed across CPUs using a topology-aware maximum distance algorithm that + prefers CPUs on different NUMA nodes, then different physical cores, then different hyperthreads. + When set to single, all IRQs are pinned to a single CPU (CPU 0 by default, or + the first CPU in the set specified by IRQAffinity=). When there are more IRQs + than available CPUs, queues wrap around using round-robin assignment. When unset, no affinity + management is performed. Setting this to an empty string explicitly disables affinity management. + + This option only applies to devices with MSI/MSI-X interrupts discoverable via + /sys/class/net/iface/device/msi_irqs/. Virtual + devices (veth, tap) and legacy INTx devices are skipped with a notice logged. + + Note that if irqbalance1 + or similar IRQ management daemons are running, they may override the configured + affinity. Consider disabling such daemons or configuring them to exclude managed interfaces. + + + + + + IRQAffinity= + + Filters the set of CPUs eligible for IRQ placement. Takes a list of CPU indices or ranges + separated by either whitespace or commas (e.g., 0-3, 0,2,4,6, + 0-3,8-11). This option works in conjunction with + IRQAffinityPolicy= and IRQAffinityNUMA= to constrain which + CPUs receive network IRQs. When specified with spread policy, only the listed + CPUs are considered for IRQ distribution. When specified with single policy, + IRQs are pinned to the first CPU in the allowed set instead of CPU 0. Has no effect if + IRQAffinityPolicy= is not set. + + + + + + IRQAffinityNUMA= + + Filters CPUs to those belonging to the specified NUMA node. Takes either + local or an explicit NUMA node number (0, 1, 2, ...). When set to + local, the NUMA node local to the NIC's PCIe slot is used (determined from + /sys/class/net/iface/device/numa_node). If the + device's NUMA node cannot be determined (e.g., non-NUMA system), a warning is logged and IRQ + affinity configuration is skipped. + + When both IRQAffinity= and IRQAffinityNUMA= are + specified, their intersection is used. If the intersection results in an empty set, an error is + logged and no affinity is applied. Has no effect if IRQAffinityPolicy= is not + set. + + + + ReceiveVLANCTAGHardwareAcceleration=