From 5e4408abf586d74a2c8b2a73ee01ceb33049ead3 Mon Sep 17 00:00:00 2001 From: Krisztian Litkey Date: Tue, 25 Aug 2026 13:32:53 +0300 Subject: [PATCH 1/5] irq: add lookup by number, expose [Ii]sAllowed(). Add irq.Interrupt(num int) for looking up interrupts by numbers. Expose irq.Irq.IsAllowed() for external use. Signed-off-by: Krisztian Litkey --- pkg/irq/irq-cache.go | 19 +++++++++++++ pkg/irq/irq-cache_test.go | 2 +- pkg/irq/irq.go | 16 +++++++++-- pkg/irq/irq_test.go | 60 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/pkg/irq/irq-cache.go b/pkg/irq/irq-cache.go index 03aa27c41..05cd25310 100644 --- a/pkg/irq/irq-cache.go +++ b/pkg/irq/irq-cache.go @@ -197,6 +197,25 @@ func (c *irqCache) forEachInterrupt(fn func(*Irq) error, allow []string) error { return nil } +// irqByNum returns the interrupt for the given IRQ number. +func (c *irqCache) irqByNum(num int, allow []string) (*Irq, error) { + infos, err := c.interruptInfo() + if err != nil { + return nil, err + } + + info, ok := infos[num] + if !ok { + return nil, fmt.Errorf("%w: irq %d not found", ErrNoSuchInterrupt, num) + } + + return &Irq{ + num: info.num, + description: info.description, + denied: !isAllowedInterruptBy(info.description, allow), + }, nil +} + // affinityOf returns the CPUs in the affinity of the given interrupt. // The affinity is read from procfs only until it is known, and // affinities set through the cache are visible before they have been diff --git a/pkg/irq/irq-cache_test.go b/pkg/irq/irq-cache_test.go index e85e4c8c8..b22a220dc 100644 --- a/pkg/irq/irq-cache_test.go +++ b/pkg/irq/irq-cache_test.go @@ -233,7 +233,7 @@ func TestAllowedPatternsCalculatedPerCall(t *testing.T) { denied := map[int]bool{} if err := ForEachInterrupt(func(irq *Irq) error { - denied[irq.Num()] = !irq.isAllowed() + denied[irq.Num()] = !irq.IsAllowed() return nil }); err != nil { t.Fatalf("ForEachInterrupt() failed: %v", err) diff --git a/pkg/irq/irq.go b/pkg/irq/irq.go index beba4dfc9..786cfb3f0 100644 --- a/pkg/irq/irq.go +++ b/pkg/irq/irq.go @@ -45,6 +45,9 @@ var ( // ErrDeniedInterrupt is the error returned for attempts to reference or control // globally disallowed interrupts. ErrDeniedInterrupt = errors.New("denied interrupt") + // ErrNoSuchInterrupt is the error returned for attempts to look up a + // nonexistent interrupt by number. + ErrNoSuchInterrupt = errors.New("no such interrupt") ) // SetProcRoot sets the procfs root directory and proc mountpoint. All @@ -176,6 +179,12 @@ func Interrupts() ([]*Irq, error) { return allowedInterrupts(allowed) } +// Interrupt returns the IRQ corresponding to the given interrupt number. +func Interrupt(num int) (*Irq, error) { + irq, err := cache.irqByNum(num, allowed) + return irq, err +} + // allowedInterrupts collects and returns the numbered interrupts // listed in /proc/interrupts which can be controlled by this package // according to the given allow patterns. @@ -268,7 +277,10 @@ func (irq *Irq) Match(pattern string) bool { return err == nil && match } -func (irq *Irq) isAllowed() bool { +// IsAllowed returns true if this interrupt may be controlled +// by the package according to the allow patterns in effect +// when the Irq was introspected. +func (irq *Irq) IsAllowed() bool { return !irq.denied } @@ -283,7 +295,7 @@ func (irq *Irq) AffinityCpus() (cpuset.CPUSet, error) { // While writes are blocked, the affinity is only buffered and write // errors are logged instead of being returned. func (irq *Irq) SetAffinityCpus(cpus cpuset.CPUSet) error { - if !irq.isAllowed() { + if !irq.IsAllowed() { return fmt.Errorf("%w: refusing to set affinity of irq %d", ErrDeniedInterrupt, irq.num) } if cpus.IsEmpty() { diff --git a/pkg/irq/irq_test.go b/pkg/irq/irq_test.go index aef9e92eb..27eb41fb9 100644 --- a/pkg/irq/irq_test.go +++ b/pkg/irq/irq_test.go @@ -15,6 +15,7 @@ package irq import ( + "errors" "os" "path/filepath" "testing" @@ -80,6 +81,65 @@ func TestInterruptsAndMatch(t *testing.T) { } } +func TestInterrupt(t *testing.T) { + dir := t.TempDir() + SetProcRoot(dir) + if err := os.Mkdir(filepath.Join(dir, "proc"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "proc", "interrupts"), []byte(sampleInterrupts), 0644); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + num int + description string + err error + }{ + { + name: "IRQ 1, i8042", + num: 1, + description: "IR-IO-APIC 1-edge i8042", + err: nil, + }, + { + name: "IRQ 9, acpi", + num: 9, + description: "IR-IO-APIC 9-fasteoi acpi", + err: nil, + }, + { + name: "IRQ 16, processor_thermal_device_pci", + num: 16, + description: "IR-IO-APIC 16-fasteoi i801_smbus, processor_thermal_device_pci", + err: nil, + }, + { + name: "non-existent IRQ 666", + num: 666, + description: "non-existent IRQ 666", + err: ErrNoSuchInterrupt, + }, + } { + t.Run(tc.name, func(t *testing.T) { + irq, err := Interrupt(tc.num) + switch { + case err != nil && tc.err == nil: + t.Fatalf("unexpected failure for Interrupt(%d): %v", tc.num, err) + case err != nil && !errors.Is(err, tc.err): + t.Fatalf("wrong error for Interrupt(%d): %v, expecting %v", + tc.num, err, tc.err) + case err == nil: + if irq.Num() != tc.num || irq.Description() != tc.description { + t.Fatalf("Interrupt(%d) = %v, want %d, %q", tc.num, + irq, tc.num, tc.description) + } + } + }) + } +} + func TestAffinityReadWrite(t *testing.T) { dir := t.TempDir() SetProcRoot(dir) From 888f32194c903e2e6ead6aa4a7a739d4f11b3328 Mon Sep 17 00:00:00 2001 From: Krisztian Litkey Date: Tue, 25 Aug 2026 13:35:02 +0300 Subject: [PATCH 2/5] topology-aware: add IRQ claims from topology hints. Extend annotated `irq-affinity` to allow matching device IRQs to be claimed from topology-hinted devices. If a container is annotated so try resolving the device IRQs and claiming them. Signed-off-by: Krisztian Litkey --- .../topology-aware/policy/irq-affinity.go | 52 +++++++++++++++++-- .../topology-aware/policy/pod-preferences.go | 4 ++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/cmd/plugins/topology-aware/policy/irq-affinity.go b/cmd/plugins/topology-aware/policy/irq-affinity.go index 69dcf3f02..c09bd099a 100644 --- a/cmd/plugins/topology-aware/policy/irq-affinity.go +++ b/cmd/plugins/topology-aware/policy/irq-affinity.go @@ -16,16 +16,19 @@ package topologyaware import ( "fmt" + "strconv" "github.com/containers/nri-plugins/pkg/irq" + "github.com/containers/nri-plugins/pkg/topology" "github.com/containers/nri-plugins/pkg/utils/cpuset" "sigs.k8s.io/yaml" ) type IrqAffinity struct { - Claim []string `json:"claim,omitempty"` - Mask []string `json:"mask,omitempty"` - Mode IrqMode `json:"mode,omitempty"` + Claim []string `json:"claim,omitempty"` + Devices []string `json:"devices,omitempty"` + Mask []string `json:"mask,omitempty"` + Mode IrqMode `json:"mode,omitempty"` } type IrqMode string @@ -60,6 +63,49 @@ func parseIrqAffinity(raw []byte) (*IrqAffinity, error) { return parsed, nil } +func addIrqAffinityForHints(a *IrqAffinity, hints topology.Hints) error { + if len(hints) == 0 || a == nil || len(a.Devices) == 0 { + return nil + } + + if err := irq.ValidateAllowedPatterns(a.Devices); err != nil { + return fmt.Errorf("invalid IRQ affinity devices pattern: %w", err) + } + + for source, h := range hints { + for _, num := range h.IRQs { + irq, err := irq.Interrupt(num) + switch { + case err != nil: + log.Errorf("irq: skipping %s-hinted IRQ %d: %v", source, num, err) + continue + case !irq.IsAllowed(): + log.Warnf("irq: skipping denied %s-hinted IRQ %d", source, num) + continue + } + + matched := false + for _, p := range a.Devices { + if irq.Match(p) { + matched = true + break + } + } + if !matched { + log.Debugf("irq: skipping unmatched %s-hinted IRQ %d (%q)", + source, num, irq.Description()) + continue + } + + log.Infof("irq: claim matching %s-hinted IRQ %d (%q)", + source, num, irq.Description()) + a.Claim = append(a.Claim, strconv.Itoa(irq.Num())) + } + } + + return nil +} + func (p *policy) irqCpus(hwIrq *irq.Irq) (preMask, claim, mask cpuset.CPUSet) { preMask, claim, mask = cpuset.New(), cpuset.New(), cpuset.New() for _, g := range p.allocations.grants { diff --git a/cmd/plugins/topology-aware/policy/pod-preferences.go b/cmd/plugins/topology-aware/policy/pod-preferences.go index 0584b71a7..754688caf 100644 --- a/cmd/plugins/topology-aware/policy/pod-preferences.go +++ b/cmd/plugins/topology-aware/policy/pod-preferences.go @@ -330,6 +330,10 @@ func irqAffinityPreference(ctr cache.Container) (*IrqAffinity, bool, error) { switch { case qos == corev1.PodQOSGuaranteed: + err := addIrqAffinityForHints(a, ctr.GetTopologyHints()) + if err != nil { + return nil, scope == cache.ContainerScopedAnnotation, err + } return a, scope == cache.ContainerScopedAnnotation, nil case scope == cache.ContainerScopedAnnotation: return nil, true, fmt.Errorf("invalid IRQ affinity, QoS class %v is not Guaranteed", qos) From 2e1a8bf5d3d2ef82534d6c1e2a764f9f8b9a43bf Mon Sep 17 00:00:00 2001 From: Krisztian Litkey Date: Wed, 26 Aug 2026 15:53:45 +0300 Subject: [PATCH 3/5] e2e: use ${pod} to keys right, cleanup [un]used ANN* from env. Signed-off-by: Krisztian Litkey --- .../n4c16/test25-irq/code.var.sh | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/test/e2e/policies.test-suite/topology-aware/n4c16/test25-irq/code.var.sh b/test/e2e/policies.test-suite/topology-aware/n4c16/test25-irq/code.var.sh index 18bc54e8f..18eefca6b 100644 --- a/test/e2e/policies.test-suite/topology-aware/n4c16/test25-irq/code.var.sh +++ b/test/e2e/policies.test-suite/topology-aware/n4c16/test25-irq/code.var.sh @@ -158,13 +158,13 @@ ALLCPUS=0-15 # the allocated CPUs. pod=pod0 -ANN0=$(cat <<'EOF' -irq-affinity.resource-policy.nri.io/container.pod0c0: | +ANN0=$(cat < Date: Tue, 25 Aug 2026 20:31:34 +0300 Subject: [PATCH 4/5] e2e: add device IRQ affinity tests. Signed-off-by: Krisztian Litkey --- .../n4c16/test25-irq/code.var.sh | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/test/e2e/policies.test-suite/topology-aware/n4c16/test25-irq/code.var.sh b/test/e2e/policies.test-suite/topology-aware/n4c16/test25-irq/code.var.sh index 18eefca6b..412de9d2b 100644 --- a/test/e2e/policies.test-suite/topology-aware/n4c16/test25-irq/code.var.sh +++ b/test/e2e/policies.test-suite/topology-aware/n4c16/test25-irq/code.var.sh @@ -389,3 +389,102 @@ grep -q "denied interrupt: .* denied but matched by user pattern .*" <<< $COMMAN cleanup unset ANN0 + +# +# Test IRQ affinity from topology hints. +# + +CONTROLLABLE_INTERRUPTS="[\"*ttyS*\"]" + +helm_config=$(COLOCATE_PODS=false \ + DEBUG_LOGGERS="$DEBUG_LOGGERS,policy" \ + instantiate helm-config.yaml) helm-launch topology-aware + +# Create Guaranteed pod annotated to take IRQ affinity for hinted devices, +# and with a test topology-hint with IRQ for an allowed ttyS0. +pod=pod9 +ANN0=$(cat < Date: Tue, 25 Aug 2026 19:27:01 +0300 Subject: [PATCH 5/5] docs: update topology-aware IRQ affinity docs. Signed-off-by: Krisztian Litkey --- docs/resource-policy/policy/topology-aware.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/resource-policy/policy/topology-aware.md b/docs/resource-policy/policy/topology-aware.md index f1ff67b1e..8ece885dd 100644 --- a/docs/resource-policy/policy/topology-aware.md +++ b/docs/resource-policy/policy/topology-aware.md @@ -739,7 +739,7 @@ Containers eligible for exclusive CPU allocation can be annotated with IRQ tuning to claim selected IRQs, mask selected IRQs, or do both, using the `irq-affinity.resource-policy.nri.io` annotation key. -The annotation has 3 fields: +The annotation has 4 fields: **`claim`** (list of strings) - Lists IRQs handled by the exclusive CPUs allocated to the container. - Each item refers to IRQs either by an exact number, or by a pattern @@ -747,6 +747,9 @@ The annotation has 3 fields: instance "*nvme*". - The affinity of a claimed IRQ is set to the union of CPUs of all containers that claim it. +**`devices`** (list of strings) +- The same as `claim`, but listed IRQs match only devices assigned to + the container instead of all devices in the system. **`mask`** (list of strings) - Lists IRQs which should not be handled by the exclusive CPUs allocated to the container.