From 032f1aa95b6845a46282a5d4b169cc7c6454cbb4 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Thu, 30 Jul 2026 12:11:43 -0700 Subject: [PATCH 1/3] kerf: Make NUMA topology support functional end to end The topology models, allocation policies and validators already existed but were dead code in practice: the baseline DTB generator never emitted the topology section, so every create/update reading state back from the kernel saw no topology, and nothing ever discovered it from the host. Make the support real: - Emit the topology section (NUMA nodes, per-node memory range, CPUs as 64-bit physical ID cells, distance matrix as pairs, memory type) and device numa-node into the baseline DTB, and parse them back, so topology survives the kernel round trip. - Add kerf/topology.py to discover the host topology at kerf init time: NUMA nodes and distances from /sys/devices/system/node, per-node memory ranges from /proc/zoneinfo, and PCI device locality from sysfs. Sysfs is keyed by logical CPU while kerf speaks physical APIC IDs, so discovery translates through the processor/apicid pairs in /proc/cpuinfo. - Fix DTS parsing bugs that broke hand-written topology sections: the topology/numa-nodes/cores regexes could not handle nested braces (only the first node parsed), comments inside multi-line property values broke value parsing, and the pool memory-base regex could match a NUMA node's memory-base instead. - Keep manual allocation authoritative: explicit --cpus no longer gets a compact affinity policy attached implicitly, so deliberate topology-crossing layouts do not accumulate spurious warnings. Auto-allocation (--cpu-count) still defaults to compact. Topology violations remain warnings; only impossible requests are errors. - Fold the topology documentation into README.md, replacing the stale docs/CPU_NUMA_TOPOLOGY.md, and update the NUMA examples to the supported format. Instance memory is still allocated first-fit from a single pool; NUMA-aware per-node pools via /dev/lazy_cma's node parameter are the next step. --- README.md | 47 +++- docs/CPU_NUMA_TOPOLOGY.md | 328 ------------------------ examples/numa_topology.dts | 41 +-- examples/simple_numa.dts | 8 +- src/kerf/create/main.py | 16 +- src/kerf/dtc/extractor.py | 33 +++ src/kerf/dtc/parser.py | 141 +++++++---- src/kerf/init/main.py | 14 +- src/kerf/models.py | 1 + src/kerf/topology.py | 209 ++++++++++++++++ tests/test_topology.py | 500 +++++++++++++++++++++++++++++++++++++ 11 files changed, 923 insertions(+), 415 deletions(-) delete mode 100644 docs/CPU_NUMA_TOPOLOGY.md create mode 100644 src/kerf/topology.py create mode 100644 tests/test_topology.py diff --git a/README.md b/README.md index adba3a7..9aeb889 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Baseline DTB (static) - **Format Support**: DTS to DTB compilation for baseline configuration - **Error Reporting**: Detailed error messages with actionable suggestions - **Resource Analysis**: Complete resource utilization reporting -- **CPU & NUMA Topology**: Full support for CPU topology and NUMA-aware resource allocation +- **CPU & NUMA Topology**: Automatic host topology discovery and NUMA-aware CPU auto-allocation ### Command Line Interface ```bash @@ -137,6 +137,9 @@ kerf init --cpus=4-31 --devices=enp9s0_dev,nvme0 kerf create web-server --cpus=4-7 --memory=2GB kerf create database --cpu-count=8 --memory=16GB +# Topology-aware auto-allocation (see CPU and NUMA Topology Support) +kerf create database --cpu-count=8 --memory=16GB --numa-nodes=0 --memory-policy=local + # Load kernel image with initrd and boot parameters kerf load --kernel=/boot/vmlinuz --initrd=/boot/initrd.img \ --cmdline="root=/dev/sda1 ro" --id=1 @@ -330,6 +333,7 @@ The `examples/` directory contains sample baseline Device Tree Source (DTS) file - **`baseline.dts`** - Complete baseline with CPU, memory, and device resources (32 CPUs, 16GB memory) - **`minimal.dts`** - Simple baseline for testing and development (8 CPUs, 8GB memory) - **`edge_computing.dts`** - Edge computing baseline with GPU support for AI inference (16 CPUs, 32GB memory) +- **`simple_numa.dts`** - Basic NUMA baseline with 2 NUMA nodes and device locality - **`numa_topology.dts`** - Advanced NUMA topology baseline with 4 NUMA nodes and topology-aware allocation - **`system.dts`** - Example baseline with various device configurations - **`conflict_example.dts`** - Intentionally invalid baseline demonstrating common validation errors @@ -338,15 +342,42 @@ The `examples/` directory contains sample baseline Device Tree Source (DTS) file ## CPU and NUMA Topology Support -Kerf provides comprehensive support for CPU and NUMA topology management: +Kerf tracks the host's NUMA topology in the baseline device tree and uses it in three separable ways: + +1. **Discovery**: `kerf init` records the host topology automatically: NUMA nodes and distances from `/sys/devices/system/node/`, per-node memory ranges from `/proc/zoneinfo`, and PCI device locality from sysfs `numa_node`. All CPU values are physical CPU IDs (APIC IDs on x86), translated from logical CPU numbers via `/proc/cpuinfo`. +2. **Auto-allocation**: `kerf create --cpu-count=N` places CPUs according to a topology-aware policy. +3. **Validation**: every operation reports topology violations as warnings. + +### Topology-Aware Allocation + +```bash +# 8 CPUs from NUMA node 0, memory policy local (auto-allocated, compact by default) +kerf create database --cpu-count=8 --memory=16GB --numa-nodes=0 --memory-policy=local + +# 16 CPUs spread across NUMA nodes 0 and 1 +kerf create compute --cpu-count=16 --memory=32GB --numa-nodes=0,1 --cpu-affinity=spread + +# All CPUs from a single node that can satisfy the request +kerf create realtime --cpu-count=4 --memory=8GB --cpu-affinity=local +``` + +`--cpu-affinity` policies (auto-allocation defaults to `compact`): +- `compact`: same NUMA node, consecutive IDs where possible; best cache locality +- `spread`: round-robin across the requested NUMA nodes; throughput workloads +- `local`: all CPUs from one node that can satisfy the request; fails if no single node can + +### Manual Allocation Stays Authoritative + +```bash +# Deliberately cross topology boundaries: honored, warnings only +kerf create web-server --cpus=128,136 --memory=2GB --memory-base=0x100000000 +``` + +Explicit resource specs (`--cpus`, `--memory-base`, explicit device names) are used verbatim, and no placement policy is attached unless `--cpu-affinity` is passed explicitly. Topology violations (CPUs outside the configured NUMA nodes, affinity mismatches, remote memory) are reported as warnings and never block. Hard errors are reserved for impossible requests: nonexistent APIC IDs or NUMA nodes, and conflicts with other instances. -### Key Features -- **CPU Topology**: Socket, core, and thread mapping with SMT/hyperthreading support -- **NUMA Awareness**: NUMA node definition with memory regions and CPU assignments -- **Topology Policies**: CPU affinity (`compact`, `spread`, `local`) and memory policies (`local`, `interleave`, `bind`) -- **Performance Validation**: Automatic validation of topology constraints and performance warnings +A hand-written topology section in the baseline DTS (see `examples/simple_numa.dts` and `examples/numa_topology.dts`) overrides discovery when using `kerf init --input=...`. -For detailed information about CPU and NUMA topology support, see [CPU_NUMA_TOPOLOGY.md](docs/CPU_NUMA_TOPOLOGY.md). +Current limitation: instance memory is still allocated first-fit from a single contiguous pool; `--memory-policy` is recorded and validated but does not yet drive placement. Per-NUMA-node memory pools are planned (`/dev/lazy_cma` already accepts a NUMA node). ## References diff --git a/docs/CPU_NUMA_TOPOLOGY.md b/docs/CPU_NUMA_TOPOLOGY.md deleted file mode 100644 index ede2c93..0000000 --- a/docs/CPU_NUMA_TOPOLOGY.md +++ /dev/null @@ -1,328 +0,0 @@ -# CPU and NUMA Topology Support in Kerf - -## Overview - -Kerf now provides comprehensive support for CPU and NUMA topology management in multikernel systems. This enables optimal resource allocation based on hardware topology, ensuring that kernel instances are placed on appropriate CPUs and memory regions for maximum performance. - -## Key Features - -### 1. CPU Topology Awareness -- **Socket identification**: Track which socket each CPU belongs to -- **Core mapping**: Understand CPU core relationships and SMT/hyperthreading -- **Cache hierarchy**: Model CPU cache levels and sizes -- **NUMA node association**: Map CPUs to their NUMA nodes - -### 2. NUMA Topology Support -- **NUMA node definition**: Specify memory regions and CPU assignments per NUMA node -- **Distance matrix**: Model NUMA node distances for optimal placement -- **Memory types**: Support different memory types (DRAM, HBM, CXL) -- **Memory locality**: Ensure memory and CPU allocations are co-located - -### 3. Topology-Aware Allocation Policies -- **CPU affinity**: `compact`, `spread`, `local` policies for CPU placement -- **Memory policy**: `local`, `interleave`, `bind` policies for memory allocation -- **NUMA constraints**: Specify preferred NUMA nodes for instances -- **Performance optimization**: Automatic validation of topology constraints - -## Device Tree Format - -### Basic NUMA Topology - -```dts -resources { - cpus { - total = <32>; - host-reserved = <0 1 2 3>; - available = <4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 - 20 21 22 23 24 25 26 27 28 29 30 31>; - }; - - topology { - numa-nodes { - node@0 { - node-id = <0>; - memory-base = <0x0 0x0>; - memory-size = <0x0 0x800000000>; // 16GB - cpus = <0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15>; - }; - - node@1 { - node-id = <1>; - memory-base = <0x0 0x800000000>; - memory-size = <0x0 0x800000000>; // 16GB - cpus = <16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31>; - }; - }; - }; -}; -``` - -### Advanced CPU Topology - -```dts -resources { - cpus { - total = <32>; - host-reserved = <0 1 2 3>; - available = <4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 - 20 21 22 23 24 25 26 27 28 29 30 31>; - - // CPU core topology (SMT/Hyperthreading) - cores { - // Socket 0, NUMA node 0 cores - core@0 { cpus = <0 1>; }; // SMT siblings - core@1 { cpus = <2 3>; }; - core@2 { cpus = <4 5>; }; - core@3 { cpus = <6 7>; }; - // ... more cores ... - }; - }; - - topology { - numa-nodes { - node@0 { - node-id = <0>; - memory-base = <0x0 0x0>; - memory-size = <0x0 0x800000000>; - cpus = <0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15>; - }; - - node@1 { - node-id = <1>; - memory-base = <0x0 0x800000000>; - memory-size = <0x0 0x800000000>; - cpus = <16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31>; - }; - }; - }; -}; -``` - -### Instance Topology Configuration - -```dts -instances { - web-server { - id = <1>; - resources { - cpus = <4 5 6 7 8 9 10 11>; // NUMA node 0 - memory-base = <0x0 0x800000000>; - memory-bytes = <0x0 0x200000000>; // 8GB - numa-nodes = <0>; // Preferred NUMA nodes - cpu-affinity = "compact"; // CPU placement policy - memory-policy = "local"; // Memory allocation policy - devices = <ð0_vf1>; - }; - }; - - database { - id = <2>; - resources { - cpus = <20 21 22 23 24 25 26 27>; // NUMA node 1 - memory-base = <0x0 0x800000000>; - memory-bytes = <0x0 0x400000000>; // 16GB - numa-nodes = <1>; - cpu-affinity = "compact"; - memory-policy = "local"; - devices = <ð1_vf1>; - }; - }; - - compute { - id = <3>; - resources { - cpus = <12 13 14 15 16 17 18 19 28 29 30 31>; // Cross-NUMA - memory-base = <0x0 0xA00000000>; - memory-bytes = <0x0 0x200000000>; // 8GB - numa-nodes = <0 1>; // Multiple NUMA nodes - cpu-affinity = "spread"; // Spread across NUMA nodes - memory-policy = "interleave"; // Interleave memory allocation - }; - }; -}; -``` - -## CPU Affinity Policies - -### `compact` -- **Purpose**: Minimize NUMA node crossings and maximize cache locality -- **Behavior**: Allocates CPUs from the same NUMA node and preferably the same core -- **Use case**: High-performance, latency-sensitive workloads -- **Example**: Database workloads, real-time applications - -### `spread` -- **Purpose**: Distribute workload across multiple NUMA nodes -- **Behavior**: Allocates CPUs from different NUMA nodes -- **Use case**: Throughput-oriented workloads that can benefit from parallel processing -- **Example**: Batch processing, analytics workloads - -### `local` -- **Purpose**: Co-locate CPUs and memory on the same NUMA node -- **Behavior**: Ensures CPUs and memory are from the same NUMA node -- **Use case**: Memory-intensive workloads requiring low latency access -- **Example**: In-memory databases, high-performance computing - -## Memory Policies - -### `local` -- **Purpose**: Allocate memory from the same NUMA node as CPUs -- **Behavior**: Ensures memory is local to the CPU cores -- **Use case**: Performance-critical applications -- **Benefits**: Lowest memory access latency - -### `interleave` -- **Purpose**: Distribute memory allocation across multiple NUMA nodes -- **Behavior**: Memory is allocated from different NUMA nodes -- **Use case**: Large memory allocations that exceed single NUMA node capacity -- **Benefits**: Higher total memory bandwidth - -### `bind` -- **Purpose**: Bind memory allocation to specific NUMA nodes -- **Behavior**: Memory is allocated only from specified NUMA nodes -- **Use case**: Workloads with specific memory requirements -- **Benefits**: Predictable memory placement - -## Validation and Error Detection - -Kerf automatically validates topology constraints and provides detailed error messages: - -### NUMA Constraint Validation -``` -ERROR: Instance database: NUMA node 3 does not exist in hardware topology -WARNING: Instance web-server: CPU 8 is in NUMA node 1, but instance is configured for NUMA nodes [0]. This may cause performance issues due to remote memory access. -``` - -### CPU Affinity Validation -``` -WARNING: Instance compute: Compact CPU affinity requested but CPUs span multiple NUMA nodes: [0, 1] -WARNING: Instance analytics: Spread CPU affinity requested but CPUs are from single NUMA node 0 -``` - -### Memory Policy Validation -``` -WARNING: Instance database: Local memory policy requested but CPUs are from NUMA nodes [1] while memory is on NUMA node 0 -``` - -## Performance Considerations - -### NUMA Locality -- **Local access**: Memory access within the same NUMA node (fastest) -- **Remote access**: Memory access across NUMA nodes (slower) -- **Cross-socket access**: Memory access across different sockets (slowest) - -### CPU Placement Strategies -1. **Compact placement**: Best for single-threaded or small multi-threaded workloads -2. **Spread placement**: Best for large multi-threaded workloads -3. **Local placement**: Best for memory-intensive workloads - -### Memory Allocation Strategies -1. **Local memory**: Fastest access, limited by NUMA node capacity -2. **Interleaved memory**: Higher bandwidth, higher latency -3. **Bound memory**: Predictable placement, may limit flexibility - -## Best Practices - -### 1. Workload Analysis -- **CPU-bound**: Use `compact` affinity with `local` memory policy -- **Memory-bound**: Use `local` affinity with `local` memory policy -- **I/O-bound**: Use `spread` affinity with `interleave` memory policy - -### 2. Resource Planning -- **Small instances**: Prefer single NUMA node allocation -- **Large instances**: Consider multi-NUMA node allocation -- **Critical instances**: Use `local` policies for best performance - -### 3. Topology Awareness -- **Understand your hardware**: Know your NUMA topology before configuration -- **Test configurations**: Validate performance with different topology settings -- **Monitor utilization**: Use system tools to verify optimal placement - -## Example Configurations - -### High-Performance Database -```dts -database { - resources { - cpus = <4 5 6 7 8 9 10 11>; // Single NUMA node - memory-base = <0x0 0x800000000>; - memory-bytes = <0x0 0x400000000>; // 16GB - numa-nodes = <0>; - cpu-affinity = "compact"; - memory-policy = "local"; - }; -}; -``` - -### Distributed Analytics -```dts -analytics { - resources { - cpus = <12 13 14 15 16 17 18 19 28 29 30 31>; // Cross-NUMA - memory-base = <0x0 0xA00000000>; - memory-bytes = <0x0 0x800000000>; // 32GB - numa-nodes = <0 1>; - cpu-affinity = "spread"; - memory-policy = "interleave"; - }; -}; -``` - -### Real-Time Processing -```dts -realtime { - resources { - cpus = <20 21 22 23>; // Single core, single NUMA node - memory-base = <0x0 0x1000000000>; - memory-bytes = <0x0 0x200000000>; // 8GB - numa-nodes = <1>; - cpu-affinity = "compact"; - memory-policy = "local"; - }; -}; -``` - -## Troubleshooting - -### Common Issues - -1. **NUMA node mismatch**: CPUs and memory on different NUMA nodes - - **Solution**: Use `local` affinity and memory policy - - **Check**: Verify NUMA node assignments in configuration - -2. **Performance degradation**: Remote memory access - - **Solution**: Ensure memory and CPUs are co-located - - **Check**: Use `numactl` to verify actual NUMA placement - -3. **Resource conflicts**: Multiple instances on same NUMA node - - **Solution**: Distribute instances across NUMA nodes - - **Check**: Monitor NUMA node utilization - -### Debugging Commands - -```bash -# Check NUMA topology -numactl --hardware - -# Check CPU topology -lscpu - -# Check memory allocation -numactl --show - -# Monitor NUMA statistics -cat /proc/vmstat | grep numa -``` - -## Future Enhancements - -### Planned Features -- **Dynamic topology discovery**: Automatic hardware topology detection -- **Performance profiling**: Integration with performance monitoring tools -- **Advanced policies**: More sophisticated allocation algorithms -- **Migration support**: Runtime topology-aware instance migration - -### Research Areas -- **Machine learning**: AI-driven topology optimization -- **Workload characterization**: Automatic policy selection based on workload patterns -- **Energy efficiency**: Power-aware topology management -- **Heterogeneous systems**: Support for different CPU types and memory hierarchies diff --git a/examples/numa_topology.dts b/examples/numa_topology.dts index 1424653..763b14f 100644 --- a/examples/numa_topology.dts +++ b/examples/numa_topology.dts @@ -2,7 +2,16 @@ * NUMA Topology Baseline Example * * Baseline defines hardware resources with NUMA topology information. - * Usage: kerf init --input=numa_topology.dts --apply + * All CPU values are physical CPU IDs (APIC IDs on x86), not logical + * CPU numbers. + * + * Note: `kerf init --cpus=...` discovers the host topology automatically; + * a hand-written topology section is only needed for explicit control. + * + * The per-node distance-matrix property is a flat list of + * pairs. + * + * Usage: kerf init --input=numa_topology.dts */ /multikernel-v1/; @@ -11,11 +20,11 @@ compatible = "linux,multikernel-host"; resources { - cpus = <4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 + cpus = <4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63>; - + topology { numa-nodes { node@0 { @@ -24,43 +33,41 @@ memory-size = <0x0 0x800000000>; // 32GB memory-type = "dram"; cpus = <0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15>; - distance-matrix = < - 0 10 20 30 // Node 0 distances - 10 0 20 30 // Node 1 distances - 20 20 0 20 // Node 2 distances - 30 30 20 0 // Node 3 distances - >; + distance-matrix = <0 10 1 21 2 21 3 31>; }; - + node@1 { node-id = <1>; memory-base = <0x0 0x800000000>; memory-size = <0x0 0x800000000>; // 32GB memory-type = "dram"; cpus = <16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31>; + distance-matrix = <0 21 1 10 2 31 3 21>; }; - + node@2 { node-id = <2>; memory-base = <0x0 0x1000000000>; memory-size = <0x0 0x800000000>; // 32GB memory-type = "dram"; cpus = <32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47>; + distance-matrix = <0 21 1 31 2 10 3 21>; }; - + node@3 { node-id = <3>; memory-base = <0x0 0x1800000000>; memory-size = <0x0 0x800000000>; // 32GB memory-type = "dram"; cpus = <48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63>; + distance-matrix = <0 31 1 21 2 21 3 10>; }; }; }; - + memory-base = <0x0 0x800000000>; memory-bytes = <0x0 0x1800000000>; // 96GB - + devices { eth0: ethernet@0 { compatible = "intel,i40e"; @@ -70,7 +77,7 @@ host-reserved-vf = <0>; available-vfs = <1 2 3 4 5 6 7>; }; - + eth1: ethernet@1 { compatible = "intel,i40e"; pci-id = "0000:02:00.0"; @@ -79,7 +86,7 @@ host-reserved-vf = <0>; available-vfs = <1 2 3 4 5 6 7>; }; - + nvme0: storage@0 { compatible = "nvme"; pci-id = "0000:03:00.0"; @@ -88,7 +95,7 @@ host-reserved-ns = <1>; available-ns = <2 3 4>; }; - + nvme1: storage@1 { compatible = "nvme"; pci-id = "0000:04:00.0"; diff --git a/examples/simple_numa.dts b/examples/simple_numa.dts index 59e08fa..7b93071 100644 --- a/examples/simple_numa.dts +++ b/examples/simple_numa.dts @@ -2,7 +2,13 @@ * Simple NUMA Baseline Example * * Baseline defines hardware resources with basic NUMA configuration. - * Usage: kerf init --input=simple_numa.dts --apply + * All CPU values are physical CPU IDs (APIC IDs on x86), not logical + * CPU numbers. + * + * Note: `kerf init --cpus=...` discovers the host topology automatically; + * a hand-written topology section is only needed for explicit control. + * + * Usage: kerf init --input=simple_numa.dts */ /multikernel-v1/; diff --git a/src/kerf/create/main.py b/src/kerf/create/main.py index 6ec93be..45121cd 100644 --- a/src/kerf/create/main.py +++ b/src/kerf/create/main.py @@ -412,9 +412,11 @@ def dump_overlay_for_debug( @click.option( "--cpu-affinity", type=click.Choice(["compact", "spread", "local"]), - default="compact", + default=None, help="CPU affinity policy: compact (same NUMA node, consecutive), " - "spread (across NUMA nodes), or local (co-locate with memory)", + "spread (across NUMA nodes), or local (co-locate with memory). " + "Auto-allocation (--cpu-count) defaults to compact; explicit --cpus " + "follows the requested CPUs exactly unless a policy is given", ) @click.option( "--numa-nodes", @@ -625,17 +627,21 @@ def create_instance_operation(current): else: final_instance_id = None - # Allocate CPUs based on specification + # Allocate CPUs based on specification. Explicit CPU lists are + # authoritative: the user may deliberately cross topology + # boundaries, so no placement policy is attached unless asked for. if is_count: # Allocate CPUs automatically from available pool with topology awareness + effective_affinity = cpu_affinity or "compact" cpu_list = allocate_cpus_from_pool( modified, cpu_spec_value, # cpu_spec_value is int (count) - cpu_affinity=cpu_affinity, + cpu_affinity=effective_affinity, numa_nodes=numa_node_list, ) else: # Use explicitly specified CPUs + effective_affinity = cpu_affinity cpu_list = cpu_spec_value # cpu_spec_value is List[int] # Validate CPU allocation (against baseline and existing instances) @@ -662,7 +668,7 @@ def create_instance_operation(current): memory_bytes=memory_bytes, devices=device_list, numa_nodes=numa_node_list, - cpu_affinity=cpu_affinity, + cpu_affinity=effective_affinity, memory_policy=memory_policy, uring=uring_enabled, uring_sq_entries=uring_sq_entries or (256 if uring_enabled else None), diff --git a/src/kerf/dtc/extractor.py b/src/kerf/dtc/extractor.py index c7bfb0d..1480227 100644 --- a/src/kerf/dtc/extractor.py +++ b/src/kerf/dtc/extractor.py @@ -103,6 +103,9 @@ def _create_comprehensive_fdt(self, tree: GlobalDeviceTree) -> bytes: self._add_cpu_properties_sw(fdt_sw, tree.hardware.cpus) self._add_memory_properties_sw(fdt_sw, tree.hardware.memory) + if tree.hardware.topology and tree.hardware.topology.numa_nodes: + self._add_topology_section_sw(fdt_sw, tree.hardware.topology) + if tree.hardware.devices: self._add_devices_section_sw(fdt_sw, tree.hardware.devices) @@ -129,6 +132,33 @@ def _add_memory_properties_sw(self, fdt_sw, memory): fdt_sw.property_u64("memory-base", memory.memory_pool_base) fdt_sw.property_u64("memory-bytes", memory.memory_pool_bytes) + def _add_topology_section_sw(self, fdt_sw, topology): + """Add topology section (NUMA nodes) using FdtSw.""" + import struct + + fdt_sw.begin_node("topology") + fdt_sw.begin_node("numa-nodes") + + for node_id in sorted(topology.numa_nodes): + node = topology.numa_nodes[node_id] + fdt_sw.begin_node(f"node@{node_id}") + fdt_sw.property_u32("node-id", node_id) + fdt_sw.property_u64("memory-base", node.memory_base) + fdt_sw.property_u64("memory-size", node.memory_size) + if node.cpus: + fdt_sw.property("cpus", pack_cpu_ids(node.cpus)) + if node.distance_matrix: + pairs = [] + for target in sorted(node.distance_matrix): + pairs.extend([target, node.distance_matrix[target]]) + fdt_sw.property("distance-matrix", struct.pack(">" + "I" * len(pairs), *pairs)) + if node.memory_type: + fdt_sw.property_string("memory-type", node.memory_type) + fdt_sw.end_node() + + fdt_sw.end_node() # End numa-nodes + fdt_sw.end_node() # End topology + def _add_devices_section_sw(self, fdt_sw, devices): """Add devices section using FdtSw.""" fdt_sw.begin_node("devices") @@ -154,6 +184,9 @@ def _add_devices_section_sw(self, fdt_sw, devices): if device_info.device_id is not None: fdt_sw.property_u32("device-id", device_info.device_id) + if device_info.numa_node is not None and device_info.numa_node >= 0: + fdt_sw.property_u32("numa-node", device_info.numa_node) + if device_info.sriov_vfs is not None: fdt_sw.property_u32("sriov-vfs", device_info.sriov_vfs) diff --git a/src/kerf/dtc/parser.py b/src/kerf/dtc/parser.py index 0184739..13addd2 100644 --- a/src/kerf/dtc/parser.py +++ b/src/kerf/dtc/parser.py @@ -49,6 +49,10 @@ def parse_dts(self, dts_content: str) -> GlobalDeviceTree: # Create a simple DTS parser that can handle our multikernel format # This is a production-ready implementation for the specific DTS format we use + # Strip comments first: they may appear inside multi-line property values + dts_content = re.sub(r'/\*.*?\*/', '', dts_content, flags=re.DOTALL) + dts_content = re.sub(r'//[^\n]*', '', dts_content) + # Parse the DTS content using regex and string parsing # Extract hardware inventory @@ -291,6 +295,7 @@ def _parse_device_info(self, node_offset: int, name: str) -> DeviceInfo: pci_id = None vendor_id = None device_id = None + numa_node = None sriov_vfs = None host_reserved_vf = None available_vfs = None @@ -323,6 +328,11 @@ def _parse_device_info(self, node_offset: int, name: str) -> DeviceInfo: except libfdt.FdtException: pass + try: + numa_node = self.fdt.getprop(node_offset, 'numa-node').as_uint32() + except libfdt.FdtException: + pass + try: sriov_vfs = self.fdt.getprop(node_offset, 'sriov-vfs').as_uint32() except libfdt.FdtException: @@ -361,6 +371,7 @@ def _parse_device_info(self, node_offset: int, name: str) -> DeviceInfo: pci_id=pci_id, vendor_id=vendor_id, device_id=device_id, + numa_node=numa_node, sriov_vfs=sriov_vfs, host_reserved_vf=host_reserved_vf, available_vfs=available_vfs, @@ -722,30 +733,47 @@ def _parse_hardware_from_dts(self, dts_content: str) -> HardwareInventory: devices=devices ) - def _extract_resources_section(self, dts_content: str) -> Optional[str]: - """Extract the resources section content with proper brace matching.""" - - resources_start = re.search(r'resources\s*\{', dts_content) - if not resources_start: + def _extract_braced_block(self, text: str, name: str) -> Optional[str]: + """Extract the body of a named `name { ... }` block with balanced braces.""" + start = re.search(re.escape(name) + r'\s*\{', text) + if not start: return None - start_pos = resources_start.end() - 1 + start_pos = start.end() - 1 brace_count = 0 - end_pos = start_pos - for i, char in enumerate(dts_content[start_pos:], start_pos): + for i, char in enumerate(text[start_pos:], start_pos): if char == '{': brace_count += 1 elif char == '}': brace_count -= 1 if brace_count == 0: - end_pos = i - break - - if brace_count == 0: - return dts_content[start_pos+1:end_pos] + return text[start_pos+1:i] return None + def _strip_nested_blocks(self, text: str) -> str: + """Remove nested `{ ... }` blocks, leaving only direct properties. + + Needed because sub-sections such as topology NUMA nodes carry + properties (cpus, memory-base) that shadow the direct resource + properties under naive regex matching. + """ + result = [] + depth = 0 + for char in text: + if char == '{': + depth += 1 + elif char == '}': + depth = max(0, depth - 1) + continue + if depth == 0: + result.append(char) + return ''.join(result) + + def _extract_resources_section(self, dts_content: str) -> Optional[str]: + """Extract the resources section content with proper brace matching.""" + return self._extract_braced_block(dts_content, 'resources') + def _parse_cpus_from_dts(self, dts_content: str) -> CPUAllocation: """Parse CPU allocation from DTS content.""" @@ -753,7 +781,7 @@ def _parse_cpus_from_dts(self, dts_content: str) -> CPUAllocation: if not resources_text: raise ParseError("Missing /resources section in DTS") - cpus_match = re.search(r'cpus\s*=\s*<([^>]+)>', resources_text) + cpus_match = re.search(r'cpus\s*=\s*<([^>]+)>', self._strip_nested_blocks(resources_text)) if not cpus_match: raise ParseError("Missing 'cpus' property in /resources") @@ -781,11 +809,13 @@ def _parse_memory_from_dts(self, dts_content: str) -> MemoryAllocation: if not resources_text: raise ParseError("Missing /resources section in DTS") - memory_base_match = re.search(r'memory-base\s*=\s*<([^>]+)>', resources_text) + direct_properties = self._strip_nested_blocks(resources_text) + + memory_base_match = re.search(r'memory-base\s*=\s*<([^>]+)>', direct_properties) if not memory_base_match: raise ParseError("Missing 'memory-base' property in /resources") - memory_bytes_match = re.search(r'memory-bytes\s*=\s*<([^>]+)>', resources_text) + memory_bytes_match = re.search(r'memory-bytes\s*=\s*<([^>]+)>', direct_properties) if not memory_bytes_match: raise ParseError("Missing 'memory-bytes' property in /resources") @@ -875,6 +905,7 @@ def _parse_device_info_from_dts(self, name: str, content: str) -> DeviceInfo: pci_id = None vendor_id = None device_id = None + numa_node = None sriov_vfs = None host_reserved_vf = None available_vfs = None @@ -902,6 +933,10 @@ def _parse_device_info_from_dts(self, name: str, content: str) -> DeviceInfo: if device_id_match: device_id = self._parse_hex_value(device_id_match.group(1)) + numa_node_match = re.search(r'numa-node\s*=\s*<(\d+)>', content) + if numa_node_match: + numa_node = int(numa_node_match.group(1)) + sriov_vfs_match = re.search(r'sriov-vfs\s*=\s*<(\d+)>', content) if sriov_vfs_match: sriov_vfs = int(sriov_vfs_match.group(1)) @@ -934,6 +969,7 @@ def _parse_device_info_from_dts(self, name: str, content: str) -> DeviceInfo: pci_id=pci_id, vendor_id=vendor_id, device_id=device_id, + numa_node=numa_node, sriov_vfs=sriov_vfs, host_reserved_vf=host_reserved_vf, available_vfs=available_vfs, @@ -1184,13 +1220,10 @@ def _parse_device_references_from_dts(self, dts_content: str) -> Dict[str, Dict] def _parse_topology_from_dts(self, dts_content: str) -> Optional[TopologySection]: """Parse topology section from DTS content.""" - # Look for topology section - topology_section = re.search(r'topology\s*\{([^}]+)\}', dts_content, re.DOTALL) - if not topology_section: + topology_text = self._extract_braced_block(dts_content, 'topology') + if topology_text is None: return None - topology_text = topology_section.group(1) - # Parse NUMA nodes from topology section numa_nodes = self._parse_numa_nodes_from_dts(topology_text) @@ -1201,15 +1234,12 @@ def _parse_numa_nodes_from_dts(self, topology_text: str) -> Optional[Dict[int, N numa_nodes = {} - # Look for numa-nodes subsection - numa_section = re.search(r'numa-nodes\s*\{([^}]+)\}', topology_text, re.DOTALL) - if not numa_section: + numa_text = self._extract_braced_block(topology_text, 'numa-nodes') + if numa_text is None: return None - numa_text = numa_section.group(1) - - # Find all NUMA node definitions - node_pattern = r'node@(\d+)\s*\{([^}]+)\}' + # Find all NUMA node definitions (node bodies contain no nested blocks) + node_pattern = r'node@(\d+)\s*\{([^{}]*)\}' node_matches = re.finditer(node_pattern, numa_text, re.DOTALL) for match in node_matches: @@ -1233,17 +1263,16 @@ def _parse_numa_nodes_from_dts(self, topology_text: str) -> Optional[Dict[int, N if memory_size_match: memory_size = self._parse_hex_value(memory_size_match.group(1)) - # Parse CPUs + # Parse CPUs (physical CPU IDs, decimal or hex) cpus_match = re.search(r'cpus\s*=\s*<([^>]+)>', node_content) if cpus_match: - cpus = [int(x.strip()) for x in cpus_match.group(1).split()] + cpus = [int(x.strip(), 0) for x in cpus_match.group(1).split()] - # Parse distance matrix (optional) + # Parse distance matrix, encoded as (target-node, distance) pairs distance_match = re.search(r'distance-matrix\s*=\s*<([^>]+)>', node_content) if distance_match: - distances = [int(x.strip()) for x in distance_match.group(1).split()] - # Simple distance matrix parsing - would need more sophisticated logic for full matrix - _ = distances # Mark as intentionally unused for now + distances = [int(x.strip(), 0) for x in distance_match.group(1).split()] + distance_matrix = dict(zip(distances[0::2], distances[1::2])) # Parse memory type memory_type_match = re.search(r'memory-type\s*=\s*"([^"]+)"', node_content) @@ -1267,21 +1296,18 @@ def _parse_cpu_topology_from_dts(self, dts_content: str) -> Optional[Dict[int, ' topology = {} - # Look for cores section - cores_section = re.search(r'cores\s*\{([^}]+)\}', dts_content, re.DOTALL) - if not cores_section: + cores_text = self._extract_braced_block(dts_content, 'cores') + if cores_text is None: return None - cores_text = cores_section.group(1) - - # Find all core definitions + # Find all core definitions (core bodies contain no nested blocks) core_pattern = r'core@(\d+)\s*\{\s*cpus\s*=\s*<([^>]+)>\s*;\s*\}' core_matches = re.finditer(core_pattern, cores_text, re.DOTALL) for match in core_matches: core_id = int(match.group(1)) cpus_str = match.group(2) - cpus = [int(x.strip()) for x in cpus_str.split()] + cpus = [int(x.strip(), 0) for x in cpus_str.split()] # Create topology entries for each CPU in this core for i, cpu_id in enumerate(cpus): @@ -1318,18 +1344,23 @@ def _parse_numa_nodes_from_topology(self, topology_node: int) -> Optional[Dict[i nodes = {} - # Iterate through NUMA node definitions - offset = self.fdt.first_subnode(numa_nodes_node) + try: + offset = self.fdt.first_subnode(numa_nodes_node) + except libfdt.FdtException: + return None + while offset >= 0: - try: - node_name = self.fdt.get_name(offset) - if node_name.startswith('node@'): + node_name = self.fdt.get_name(offset) + if node_name.startswith('node@'): + try: node_id = int(node_name.split('@')[1]) - node_info = self._parse_numa_node_info(offset, node_id) - nodes[node_id] = node_info - offset = self.fdt.next_subnode(offset) - except Exception: + nodes[node_id] = self._parse_numa_node_info(offset, node_id) + except ValueError: + pass + try: offset = self.fdt.next_subnode(offset) + except libfdt.FdtException: + break return nodes if nodes else None @@ -1349,18 +1380,18 @@ def _parse_numa_node_info(self, node_offset: int, node_id: int) -> NUMANode: except libfdt.FdtException: pass - # Parse CPUs + # Parse CPUs (physical CPU IDs, 64-bit cells like all other cpus properties) cpus = [] try: - cpus = self.fdt.getprop(node_offset, 'cpus').as_uint32_list() + cpus = unpack_cpu_ids(self.fdt.getprop(node_offset, 'cpus')) except libfdt.FdtException: pass - # Parse distance matrix (optional) + # Parse distance matrix, encoded as (target-node, distance) u32 pairs distance_matrix = {} try: - _ = self.fdt.getprop(node_offset, 'distance-matrix').as_uint32_list() - # Simple distance matrix parsing - would need more sophisticated logic for full matrix + values = self.fdt.getprop(node_offset, 'distance-matrix').as_uint32_list() + distance_matrix = dict(zip(values[0::2], values[1::2])) except libfdt.FdtException: pass diff --git a/src/kerf/init/main.py b/src/kerf/init/main.py index 5756b2e..0dc252b 100644 --- a/src/kerf/init/main.py +++ b/src/kerf/init/main.py @@ -49,6 +49,7 @@ HardwareInventory, MemoryAllocation, ) +from ..topology import discover_numa_topology, read_pci_numa_node MULTIKERNEL_MOUNT_POINT = "/sys/fs/multikernel" @@ -262,7 +263,8 @@ def detect_pci_device(device_name: str) -> Optional[DeviceInfo]: device_type="pci", pci_id=pci_slot, vendor_id=vendor_id, - device_id=device_id + device_id=device_id, + numa_node=read_pci_numa_node(pci_device_path) ) except (OSError, IOError, ValueError, AttributeError, pyudev.DeviceNotFoundError): return None @@ -511,9 +513,19 @@ def build_baseline_from_cmdline( f"Please ensure the device exists and is accessible, or use --input with a DTS file to specify device details." ) + topology = discover_numa_topology() + if verbose and topology and topology.numa_nodes: + click.echo(f"Discovered NUMA topology: {len(topology.numa_nodes)} node(s)") + for node_id, node in sorted(topology.numa_nodes.items()): + click.echo( + f" Node {node_id}: APIC IDs {node.cpus}, " + f"memory {hex(node.memory_base)}-{hex(node.memory_base + node.memory_size)}" + ) + hardware = HardwareInventory( cpus=cpu_allocation, memory=memory_allocation, + topology=topology, devices=device_dict ) diff --git a/src/kerf/models.py b/src/kerf/models.py index 35667a3..e1d404a 100644 --- a/src/kerf/models.py +++ b/src/kerf/models.py @@ -127,6 +127,7 @@ class DeviceInfo: pci_id: Optional[str] = None vendor_id: Optional[int] = None device_id: Optional[int] = None + numa_node: Optional[int] = None sriov_vfs: Optional[int] = None host_reserved_vf: Optional[int] = None available_vfs: Optional[List[int]] = None diff --git a/src/kerf/topology.py b/src/kerf/topology.py new file mode 100644 index 0000000..b808234 --- /dev/null +++ b/src/kerf/topology.py @@ -0,0 +1,209 @@ +# Copyright 2026 Multikernel Technologies, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Host topology discovery. + +Builds a TopologySection describing the host's NUMA layout from sysfs and +procfs. The multikernel stack identifies CPUs by physical ID (APIC ID on +x86), while sysfs topology files are keyed by logical CPU number, so +discovery translates every CPU list through the logical-to-physical map +from /proc/cpuinfo. +""" + +import re +from pathlib import Path +from typing import Dict, Optional, Union + +from .models import NUMANode, TopologySection + +PAGE_SIZE = 4096 + +SYSFS_NODE_DIR = Path("/sys/devices/system/node") +PROC_CPUINFO = Path("/proc/cpuinfo") +PROC_ZONEINFO = Path("/proc/zoneinfo") + +PathLike = Union[str, Path] + + +def parse_cpu_list(cpulist: str) -> list: + """Parse a kernel cpulist string ("0-3,8,10-11") into logical CPU IDs.""" + cpus = [] + for part in cpulist.strip().split(","): + part = part.strip() + if not part: + continue + if "-" in part: + start, end = part.split("-", 1) + cpus.extend(range(int(start), int(end) + 1)) + else: + cpus.append(int(part)) + return cpus + + +def read_logical_to_physical_cpu_map(cpuinfo_path: PathLike = PROC_CPUINFO) -> Dict[int, int]: + """ + Build the logical CPU to physical CPU ID (APIC ID) map from /proc/cpuinfo. + + Returns an empty dict if the file is unavailable or contains no + processor/apicid pairs. + """ + mapping: Dict[int, int] = {} + current_processor = None + try: + with open(cpuinfo_path, "r", encoding="utf-8") as f: + for line in f: + if ":" not in line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key == "processor": + try: + current_processor = int(value) + except ValueError: + current_processor = None + elif key == "apicid" and current_processor is not None: + try: + mapping[current_processor] = int(value) + except ValueError: + pass + except OSError: + return {} + return mapping + + +def _read_node_memory_ranges(zoneinfo_path: PathLike) -> Dict[int, tuple]: + """ + Derive per-node physical memory spans from /proc/zoneinfo. + + Returns {node_id: (base_bytes, size_bytes)}. The span may include + holes; it is the [min(start_pfn), max(start_pfn + spanned)) envelope + across the node's zones, which is what NUMA-aware placement needs to + classify an address. + """ + node_pfns: Dict[int, list] = {} + current_node = None + start_pfn = None + spanned = None + + def commit(): + if current_node is None or start_pfn is None or not spanned: + return + node_pfns.setdefault(current_node, []).append((start_pfn, start_pfn + spanned)) + + try: + with open(zoneinfo_path, "r", encoding="utf-8") as f: + for line in f: + header = re.match(r"Node (\d+), zone", line) + if header: + commit() + current_node = int(header.group(1)) + start_pfn = None + spanned = None + continue + spanned_match = re.match(r"\s+spanned\s+(\d+)", line) + if spanned_match: + spanned = int(spanned_match.group(1)) + continue + start_match = re.match(r"\s+start_pfn:\s+(\d+)", line) + if start_match: + start_pfn = int(start_match.group(1)) + commit() + except OSError: + return {} + + ranges = {} + for node_id, spans in node_pfns.items(): + base = min(s for s, _ in spans) + end = max(e for _, e in spans) + ranges[node_id] = (base * PAGE_SIZE, (end - base) * PAGE_SIZE) + return ranges + + +def discover_numa_topology( + node_dir: PathLike = SYSFS_NODE_DIR, + cpuinfo_path: PathLike = PROC_CPUINFO, + zoneinfo_path: PathLike = PROC_ZONEINFO, +) -> Optional[TopologySection]: + """ + Discover the host NUMA topology. + + Returns a TopologySection whose CPU lists contain physical CPU IDs + (APIC IDs), or None if the host exposes no NUMA information. + """ + node_dir = Path(node_dir) + if not node_dir.is_dir(): + return None + + node_ids = [] + for entry in node_dir.iterdir(): + match = re.fullmatch(r"node(\d+)", entry.name) + if match and entry.is_dir(): + node_ids.append(int(match.group(1))) + if not node_ids: + return None + node_ids.sort() + + cpu_map = read_logical_to_physical_cpu_map(cpuinfo_path) + memory_ranges = _read_node_memory_ranges(zoneinfo_path) + + numa_nodes: Dict[int, NUMANode] = {} + for node_id in node_ids: + node_path = node_dir / f"node{node_id}" + + physical_cpus = [] + try: + cpulist = (node_path / "cpulist").read_text(encoding="utf-8") + for logical_cpu in parse_cpu_list(cpulist): + if logical_cpu in cpu_map: + physical_cpus.append(cpu_map[logical_cpu]) + except OSError: + pass + + distance_matrix: Dict[int, int] = {} + try: + distances = (node_path / "distance").read_text(encoding="utf-8").split() + distance_matrix = { + target: int(distance) for target, distance in zip(node_ids, distances) + } + except (OSError, ValueError): + pass + + memory_base, memory_size = memory_ranges.get(node_id, (0, 0)) + + numa_nodes[node_id] = NUMANode( + node_id=node_id, + memory_base=memory_base, + memory_size=memory_size, + cpus=sorted(physical_cpus), + distance_matrix=distance_matrix, + memory_type="dram", + ) + + return TopologySection(numa_nodes=numa_nodes) + + +def read_pci_numa_node(device_sys_path: PathLike) -> Optional[int]: + """ + Read the NUMA node of a PCI device from its sysfs directory. + + Returns None when the file is missing or the kernel reports -1 + (no locality information). + """ + try: + value = int((Path(device_sys_path) / "numa_node").read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None + return value if value >= 0 else None diff --git a/tests/test_topology.py b/tests/test_topology.py new file mode 100644 index 0000000..87cd140 --- /dev/null +++ b/tests/test_topology.py @@ -0,0 +1,500 @@ +# Copyright 2026 Multikernel Technologies, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Tests for topology support: DTB round-trip, DTS parsing, and host discovery. +""" + +import pytest + +from kerf.dtc.extractor import InstanceExtractor +from kerf.dtc.parser import DeviceTreeParser +from kerf.models import ( + CPUAllocation, + DeviceInfo, + GlobalDeviceTree, + HardwareInventory, + MemoryAllocation, + NUMANode, + TopologySection, +) + + +def make_tree_with_topology(): + """Build a tree with a two-node NUMA topology using physical APIC IDs.""" + cpus = CPUAllocation( + total=16, + host_reserved=[0], + available=[128, 130, 132, 134, 136, 138, 140, 142], + ) + memory = MemoryAllocation( + total_bytes=64 * 1024**3, + host_reserved_bytes=16 * 1024**3, + memory_pool_base=0x4_0000_0000, + memory_pool_bytes=48 * 1024**3, + ) + topology = TopologySection( + numa_nodes={ + 0: NUMANode( + node_id=0, + memory_base=0x0, + memory_size=32 * 1024**3, + cpus=[128, 130, 132, 134], + distance_matrix={0: 10, 1: 21}, + memory_type="dram", + ), + 1: NUMANode( + node_id=1, + memory_base=32 * 1024**3, + memory_size=32 * 1024**3, + cpus=[136, 138, 140, 142], + distance_matrix={0: 21, 1: 10}, + memory_type="hbm", + ), + } + ) + devices = { + "enp9s0_dev": DeviceInfo( + name="enp9s0_dev", + compatible="pci-network", + device_type="pci", + pci_id="0000:09:00.0", + vendor_id=0x8086, + device_id=0x1572, + numa_node=1, + ) + } + hardware = HardwareInventory(cpus=cpus, memory=memory, topology=topology, devices=devices) + return GlobalDeviceTree(hardware=hardware, instances={}, device_references={}) + + +class TestTopologyDtbRoundtrip: + """Topology must survive tree -> DTB -> tree, the path used for kernel state.""" + + def roundtrip(self, tree): + dtb = InstanceExtractor().generate_global_dtb(tree) + return DeviceTreeParser().parse_dtb_from_bytes(dtb) + + def test_numa_nodes_survive_roundtrip(self): + tree = make_tree_with_topology() + parsed = self.roundtrip(tree) + + assert parsed.hardware.topology is not None + nodes = parsed.hardware.topology.numa_nodes + assert set(nodes.keys()) == {0, 1} + + node0 = nodes[0] + assert node0.node_id == 0 + assert node0.memory_base == 0x0 + assert node0.memory_size == 32 * 1024**3 + assert node0.cpus == [128, 130, 132, 134] + + node1 = nodes[1] + assert node1.memory_base == 32 * 1024**3 + assert node1.cpus == [136, 138, 140, 142] + + def test_memory_type_survives_roundtrip(self): + parsed = self.roundtrip(make_tree_with_topology()) + assert parsed.hardware.topology.numa_nodes[0].memory_type == "dram" + assert parsed.hardware.topology.numa_nodes[1].memory_type == "hbm" + + def test_distance_matrix_survives_roundtrip(self): + parsed = self.roundtrip(make_tree_with_topology()) + assert parsed.hardware.topology.numa_nodes[0].distance_matrix == {0: 10, 1: 21} + assert parsed.hardware.topology.numa_nodes[1].distance_matrix == {0: 21, 1: 10} + + def test_device_numa_node_survives_roundtrip(self): + parsed = self.roundtrip(make_tree_with_topology()) + assert parsed.hardware.devices["enp9s0_dev"].numa_node == 1 + + def test_absent_topology_stays_absent(self): + tree = make_tree_with_topology() + tree.hardware.topology = None + tree.hardware.devices["enp9s0_dev"].numa_node = None + parsed = self.roundtrip(tree) + assert parsed.hardware.topology is None + assert parsed.hardware.devices["enp9s0_dev"].numa_node is None + + +NUMA_BASELINE_DTS = """ +/multikernel-v1/; + +/ { + compatible = "linux,multikernel-host"; + + resources { + cpus = <128 130 132 134 136 138 140 142>; + + topology { + numa-nodes { + node@0 { + node-id = <0>; + memory-base = <0x0 0x0>; + memory-size = <0x8 0x00000000>; + cpus = <128 130 132 134>; + distance-matrix = <0 10 1 21>; + }; + + node@1 { + node-id = <1>; + memory-base = <0x8 0x00000000>; + memory-size = <0x8 0x00000000>; + cpus = <136 138 140 142>; + distance-matrix = <0 21 1 10>; + memory-type = "hbm"; + }; + }; + }; + + memory-base = <0x4 0x00000000>; + memory-bytes = <0xC 0x00000000>; + + devices { + eth0: ethernet@0 { + compatible = "pci-network"; + device-type = "pci"; + pci-id = "0000:09:00.0"; + numa-node = <1>; + }; + }; + }; +}; +""" + + +class TestTopologyDtsParsing: + """The DTS parser must handle a real nested topology section.""" + + def parse(self): + return DeviceTreeParser().parse_dts(NUMA_BASELINE_DTS) + + def test_all_numa_nodes_parsed(self): + tree = self.parse() + topology = tree.hardware.topology + assert topology is not None + assert set(topology.numa_nodes.keys()) == {0, 1} + assert topology.numa_nodes[0].cpus == [128, 130, 132, 134] + assert topology.numa_nodes[1].cpus == [136, 138, 140, 142] + assert topology.numa_nodes[1].memory_base == 0x8_0000_0000 + assert topology.numa_nodes[1].memory_type == "hbm" + + def test_distance_matrix_parsed(self): + tree = self.parse() + assert tree.hardware.topology.numa_nodes[0].distance_matrix == {0: 10, 1: 21} + assert tree.hardware.topology.numa_nodes[1].distance_matrix == {0: 21, 1: 10} + + def test_pool_memory_not_confused_with_node_memory(self): + """Pool memory-base follows the topology section in real baselines; + the parser must not pick up node@0's memory-base instead.""" + tree = self.parse() + assert tree.hardware.memory.memory_pool_base == 0x4_0000_0000 + assert tree.hardware.memory.memory_pool_bytes == 0xC_0000_0000 + + def test_available_cpus_not_confused_with_node_cpus(self): + tree = self.parse() + assert tree.hardware.cpus.available == [128, 130, 132, 134, 136, 138, 140, 142] + + def test_device_numa_node_parsed(self): + tree = self.parse() + eth0 = next(iter(tree.hardware.devices.values())) + assert eth0.numa_node == 1 + + def test_all_cores_parsed_from_cores_section(self): + dts = NUMA_BASELINE_DTS.replace( + "cpus = <128 130 132 134 136 138 140 142>;", + "cpus = <128 130 132 134 136 138 140 142>;\n" + " cores {\n" + " core@0 { cpus = <128 130>; };\n" + " core@1 { cpus = <132 134>; };\n" + " };", + ) + tree = DeviceTreeParser().parse_dts(dts) + cpu_topology = tree.hardware.cpus.topology + assert cpu_topology is not None + assert set(cpu_topology.keys()) == {128, 130, 132, 134} + assert cpu_topology[132].core_id == 1 + + def test_comments_inside_property_values(self): + """DTS comments may appear inside multi-line property values.""" + dts = NUMA_BASELINE_DTS.replace( + "distance-matrix = <0 10 1 21>;", + "distance-matrix = <\n" + " 0 10 /* local */\n" + " 1 21 // remote\n" + ">;", + ) + tree = DeviceTreeParser().parse_dts(dts) + assert tree.hardware.topology.numa_nodes[0].distance_matrix == {0: 10, 1: 21} + + +CPUINFO = """\ +processor\t: 0 +vendor_id\t: AuthenticAMD +apicid\t\t: 128 +power management: + +processor\t: 1 +vendor_id\t: AuthenticAMD +apicid\t\t: 130 +power management: + +processor\t: 2 +vendor_id\t: AuthenticAMD +apicid\t\t: 132 +power management: + +processor\t: 3 +vendor_id\t: AuthenticAMD +apicid\t\t: 134 +power management: +""" + +ZONEINFO = """\ +Node 0, zone DMA + pages free 3968 + spanned 4095 + present 3998 + managed 3977 + start_pfn: 1 +Node 0, zone DMA32 + pages free 100000 + spanned 1044480 + present 500000 + managed 480000 + start_pfn: 4096 +Node 0, zone Normal + pages free 100000 + spanned 3097152 + present 3000000 + managed 2900000 + start_pfn: 1048576 +Node 0, zone Movable + pages free 0 + spanned 0 + present 0 + managed 0 + start_pfn: 0 +Node 1, zone Normal + pages free 100000 + spanned 4194304 + present 4100000 + managed 4000000 + start_pfn: 4194304 +""" + + +@pytest.fixture +def fake_host(tmp_path): + """Fake sysfs NUMA layout: 2 nodes, 2 logical CPUs each, APIC IDs 128-134.""" + node_dir = tmp_path / "node" + for node_id, cpulist, distance in [ + (0, "0-1", "10 21"), + (1, "2-3", "21 10"), + ]: + d = node_dir / f"node{node_id}" + d.mkdir(parents=True) + (d / "cpulist").write_text(cpulist + "\n") + (d / "distance").write_text(distance + "\n") + + cpuinfo = tmp_path / "cpuinfo" + cpuinfo.write_text(CPUINFO) + zoneinfo = tmp_path / "zoneinfo" + zoneinfo.write_text(ZONEINFO) + return {"node_dir": node_dir, "cpuinfo": cpuinfo, "zoneinfo": zoneinfo} + + +class TestHostTopologyDiscovery: + """Discovery must translate sysfs (logical CPUs) into APIC-ID topology.""" + + def discover(self, fake_host): + from kerf.topology import discover_numa_topology + + return discover_numa_topology( + node_dir=fake_host["node_dir"], + cpuinfo_path=fake_host["cpuinfo"], + zoneinfo_path=fake_host["zoneinfo"], + ) + + def test_logical_to_physical_cpu_map(self, fake_host): + from kerf.topology import read_logical_to_physical_cpu_map + + mapping = read_logical_to_physical_cpu_map(fake_host["cpuinfo"]) + assert mapping == {0: 128, 1: 130, 2: 132, 3: 134} + + def test_nodes_use_physical_cpu_ids(self, fake_host): + topology = self.discover(fake_host) + assert set(topology.numa_nodes.keys()) == {0, 1} + assert topology.numa_nodes[0].cpus == [128, 130] + assert topology.numa_nodes[1].cpus == [132, 134] + + def test_distance_matrix(self, fake_host): + topology = self.discover(fake_host) + assert topology.numa_nodes[0].distance_matrix == {0: 10, 1: 21} + assert topology.numa_nodes[1].distance_matrix == {0: 21, 1: 10} + + def test_node_memory_ranges_from_zoneinfo(self, fake_host): + topology = self.discover(fake_host) + page = 4096 + node0 = topology.numa_nodes[0] + assert node0.memory_base == 1 * page + assert node0.memory_size == (1048576 + 3097152 - 1) * page + node1 = topology.numa_nodes[1] + assert node1.memory_base == 4194304 * page + assert node1.memory_size == 4194304 * page + + def test_missing_node_dir_returns_none(self, tmp_path, fake_host): + from kerf.topology import discover_numa_topology + + assert ( + discover_numa_topology( + node_dir=tmp_path / "does-not-exist", + cpuinfo_path=fake_host["cpuinfo"], + zoneinfo_path=fake_host["zoneinfo"], + ) + is None + ) + + +class TestInitTopologyWiring: + """kerf init must attach discovered topology to the baseline.""" + + def test_build_baseline_attaches_discovered_topology(self, monkeypatch): + from kerf.init import main as init_main + + section = TopologySection( + numa_nodes={ + 0: NUMANode( + node_id=0, + memory_base=0, + memory_size=32 * 1024**3, + cpus=[128, 130], + distance_matrix={0: 10}, + memory_type="dram", + ) + } + ) + monkeypatch.setattr(init_main, "get_valid_apic_ids_from_system", lambda: {0, 128, 130}) + monkeypatch.setattr( + init_main, + "get_multikernel_memory_pool_from_iomem", + lambda: (0x4_0000_0000, 0x1_0000_0000), + ) + monkeypatch.setattr(init_main, "discover_numa_topology", lambda: section) + + tree = init_main.build_baseline_from_cmdline("128,130") + assert tree.hardware.topology is section + + def test_build_baseline_without_numa_host(self, monkeypatch): + from kerf.init import main as init_main + + monkeypatch.setattr(init_main, "get_valid_apic_ids_from_system", lambda: {0, 128, 130}) + monkeypatch.setattr( + init_main, + "get_multikernel_memory_pool_from_iomem", + lambda: (0x4_0000_0000, 0x1_0000_0000), + ) + monkeypatch.setattr(init_main, "discover_numa_topology", lambda: None) + + tree = init_main.build_baseline_from_cmdline("128,130") + assert tree.hardware.topology is None + + def test_detect_pci_device_discovers_numa_node(self, tmp_path, monkeypatch): + from kerf.init import main as init_main + + dev_dir = tmp_path / "0000:09:00.0" + dev_dir.mkdir() + (dev_dir / "vendor").write_text("0x8086\n") + (dev_dir / "device").write_text("0x1572\n") + (dev_dir / "class").write_text("0x020000\n") + (dev_dir / "numa_node").write_text("1\n") + + class FakeDevice: + sys_path = str(dev_dir) + sys_name = "0000:09:00.0" + + class FakeDevices: + @staticmethod + def from_path(context, path): + return FakeDevice() + + class FakePyudev: + Context = staticmethod(lambda: None) + Devices = FakeDevices + + class DeviceNotFoundError(Exception): + pass + + monkeypatch.setattr(init_main, "pyudev", FakePyudev) + + info = init_main.detect_pci_device("0000:09:00.0") + assert info is not None + assert info.compatible == "pci-network" + assert info.numa_node == 1 + + +class TestManualAllocationStaysAuthoritative: + """Explicit resource specs must not have placement policies attached + implicitly; policies apply only when requested or when auto-allocating.""" + + def run_create(self, monkeypatch, args): + from click.testing import CliRunner + from kerf.create import main as create_main + + tree = make_tree_with_topology() + + class FakeManager: + def read_baseline(self): + return tree + + def has_instance(self, name): + return False + + monkeypatch.setattr(create_main, "DeviceTreeManager", FakeManager) + runner = CliRunner() + result = runner.invoke(create_main.create, args + ["--dry-run"], obj={}) + assert result.exit_code == 0, result.output + return result.output + + def test_manual_cpus_record_no_affinity(self, monkeypatch): + output = self.run_create(monkeypatch, ["web", "--cpus=128,136", "--memory=1GB"]) + assert "CPU Affinity" not in output + + def test_auto_allocation_defaults_to_compact(self, monkeypatch): + output = self.run_create(monkeypatch, ["web", "--cpu-count=2", "--memory=1GB"]) + assert "CPU Affinity: compact" in output + + def test_manual_cpus_with_explicit_affinity_kept(self, monkeypatch): + output = self.run_create( + monkeypatch, ["web", "--cpus=128,136", "--cpu-affinity=spread", "--memory=1GB"] + ) + assert "CPU Affinity: spread" in output + + +class TestPciNumaNodeDiscovery: + def test_reads_numa_node(self, tmp_path): + from kerf.topology import read_pci_numa_node + + (tmp_path / "numa_node").write_text("1\n") + assert read_pci_numa_node(tmp_path) == 1 + + def test_negative_means_unknown(self, tmp_path): + from kerf.topology import read_pci_numa_node + + (tmp_path / "numa_node").write_text("-1\n") + assert read_pci_numa_node(tmp_path) is None + + def test_missing_file_means_unknown(self, tmp_path): + from kerf.topology import read_pci_numa_node + + assert read_pci_numa_node(tmp_path) is None From 1d39a725019f0afb5355c35cdfe09ade1157db31 Mon Sep 17 00:00:00 2001 From: Nikolay Nikolaev Date: Thu, 30 Jul 2026 08:24:44 +0300 Subject: [PATCH 2/3] kerf: preserve PCI host bridge metadata Kerf rebuilds an instance device tree from the parsed baseline. Dropping pci-host-bridges during that round trip leaves the spawned kernel without the immutable ECAM descriptors required to construct its synthetic PCI roots. Add a PCI host-bridge model, parse it from binary and source device trees, validate segment and bus ranges, ECAM alignment, and overlap, and emit the metadata into generated instance trees. Cover valid round trips and malformed descriptors in the baseline tests. Signed-off-by: Nikolay Nikolaev --- src/kerf/dtc/extractor.py | 17 ++++++ src/kerf/dtc/parser.py | 110 +++++++++++++++++++++++++++++++++++++- src/kerf/models.py | 10 +++- tests/test_baseline.py | 22 ++++++++ 4 files changed, 156 insertions(+), 3 deletions(-) diff --git a/src/kerf/dtc/extractor.py b/src/kerf/dtc/extractor.py index 1480227..730d956 100644 --- a/src/kerf/dtc/extractor.py +++ b/src/kerf/dtc/extractor.py @@ -105,6 +105,8 @@ def _create_comprehensive_fdt(self, tree: GlobalDeviceTree) -> bytes: if tree.hardware.topology and tree.hardware.topology.numa_nodes: self._add_topology_section_sw(fdt_sw, tree.hardware.topology) + if tree.hardware.pci_host_bridges: + self._add_pci_host_bridges_sw(fdt_sw, tree.hardware.pci_host_bridges) if tree.hardware.devices: self._add_devices_section_sw(fdt_sw, tree.hardware.devices) @@ -159,6 +161,21 @@ def _add_topology_section_sw(self, fdt_sw, topology): fdt_sw.end_node() # End numa-nodes fdt_sw.end_node() # End topology + def _add_pci_host_bridges_sw(self, fdt_sw, bridges): + """Add architecture-neutral PCI host bridge discovery metadata.""" + import struct + + fdt_sw.begin_node("pci-host-bridges") + for bridge in bridges: + fdt_sw.begin_node(f"host@{bridge.segment:04x},{bridge.bus_start:02x}") + fdt_sw.property_u32("segment", bridge.segment) + fdt_sw.property( + "bus-range", struct.pack(">II", bridge.bus_start, bridge.bus_end) + ) + fdt_sw.property_u64("ecam-base", bridge.ecam_base) + fdt_sw.end_node() + fdt_sw.end_node() + def _add_devices_section_sw(self, fdt_sw, devices): """Add devices section using FdtSw.""" fdt_sw.begin_node("devices") diff --git a/src/kerf/dtc/parser.py b/src/kerf/dtc/parser.py index 13addd2..21db101 100644 --- a/src/kerf/dtc/parser.py +++ b/src/kerf/dtc/parser.py @@ -34,6 +34,7 @@ NUMANode, OverlayInstanceData, TopologySection, + PCIHostBridge, ) @@ -197,14 +198,52 @@ def _parse_hardware_inventory(self) -> HardwareInventory: # Parse devices devices = self._parse_devices(resources_node) + pci_host_bridges = self._parse_pci_host_bridges(resources_node) return HardwareInventory( cpus=cpus, memory=memory, topology=topology, - devices=devices + devices=devices, + pci_host_bridges=pci_host_bridges ) + def _parse_pci_host_bridges(self, resources_node: int) -> List[PCIHostBridge]: + """Parse and validate PCI host bridge discovery metadata.""" + try: + bridges_node = self.fdt.subnode_offset(resources_node, 'pci-host-bridges') + except libfdt.FdtException: + return [] + + bridges = [] + try: + offset = self.fdt.first_subnode(bridges_node) + except libfdt.FdtException: + return bridges + + while offset >= 0: + name = self.fdt.get_name(offset) + try: + segment = self.fdt.getprop(offset, 'segment').as_uint32() + bus_range = self.fdt.getprop(offset, 'bus-range').as_uint32_list() + ecam_base = self.fdt.getprop(offset, 'ecam-base').as_uint64() + except libfdt.FdtException as exc: + raise ParseError(f"Invalid PCI host bridge '{name}': {exc}") from exc + + if len(bus_range) != 2: + raise ParseError(f"Invalid bus-range for PCI host bridge '{name}'") + + bridge = PCIHostBridge(segment, bus_range[0], bus_range[1], ecam_base) + self._validate_pci_host_bridge(bridge, bridges, name) + bridges.append(bridge) + + try: + offset = self.fdt.next_subnode(offset) + except libfdt.FdtException: + break + + return bridges + def _parse_cpu_allocation(self, resources_node: int) -> CPUAllocation: """Parse CPU allocation from resources node.""" try: @@ -725,14 +764,81 @@ def _parse_hardware_from_dts(self, dts_content: str) -> HardwareInventory: # Parse devices devices = self._parse_devices_from_dts(dts_content) + pci_host_bridges = self._parse_pci_host_bridges_from_dts(dts_content) return HardwareInventory( cpus=cpus, memory=memory, topology=topology, - devices=devices + devices=devices, + pci_host_bridges=pci_host_bridges ) + def _parse_pci_host_bridges_from_dts(self, dts_content: str) -> List[PCIHostBridge]: + """Parse PCI host bridge metadata from DTS source.""" + resources_text = self._extract_resources_section(dts_content) + if not resources_text: + return [] + + section_match = re.search(r'pci-host-bridges\s*\{', resources_text) + if not section_match: + return [] + + start = section_match.end() - 1 + depth = 0 + end = start + for index, char in enumerate(resources_text[start:], start): + if char == '{': + depth += 1 + elif char == '}': + depth -= 1 + if depth == 0: + end = index + break + if depth != 0: + raise ParseError("Unterminated pci-host-bridges section") + + section = resources_text[start + 1:end] + bridges = [] + for match in re.finditer(r'([\w@,.-]+)\s*\{([^{}]*)\}', section, re.DOTALL): + name, body = match.groups() + segment_match = re.search(r'segment\s*=\s*<([^>]+)>', body) + bus_match = re.search(r'bus-range\s*=\s*<([^>]+)>', body) + ecam_match = re.search(r'ecam-base\s*=.*?<([^>]+)>', body) + if not segment_match or not bus_match or not ecam_match: + raise ParseError(f"Invalid PCI host bridge '{name}'") + + bus_cells = bus_match.group(1).split() + if len(bus_cells) != 2: + raise ParseError(f"Invalid bus-range for PCI host bridge '{name}'") + + bridge = PCIHostBridge( + self._parse_hex_value(segment_match.group(1)), + int(bus_cells[0], 0), + int(bus_cells[1], 0), + self._parse_hex_value(ecam_match.group(1)), + ) + self._validate_pci_host_bridge(bridge, bridges, name) + bridges.append(bridge) + return bridges + + @staticmethod + def _validate_pci_host_bridge(bridge, existing, name): + if not 0 <= bridge.segment <= 0xffff: + raise ParseError(f"Invalid segment for PCI host bridge '{name}'") + if not 0 <= bridge.bus_start <= bridge.bus_end <= 0xff: + raise ParseError(f"Invalid bus-range for PCI host bridge '{name}'") + if not bridge.ecam_base or bridge.ecam_base % (1024 * 1024): + raise ParseError(f"Invalid ECAM base for PCI host bridge '{name}'") + + for other in existing: + if (other.segment == bridge.segment and + bridge.bus_start <= other.bus_end and + bridge.bus_end >= other.bus_start): + raise ParseError( + f"Overlapping PCI host bridge bus ranges in segment {bridge.segment:04x}" + ) + def _extract_braced_block(self, text: str, name: str) -> Optional[str]: """Extract the body of a named `name { ... }` block with balanced braces.""" start = re.search(re.escape(name) + r'\s*\{', text) diff --git a/src/kerf/models.py b/src/kerf/models.py index e1d404a..eb6bf03 100644 --- a/src/kerf/models.py +++ b/src/kerf/models.py @@ -16,7 +16,7 @@ Data models for multikernel device tree representation. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List, Dict, Optional, Set, Tuple from enum import Enum @@ -134,6 +134,13 @@ class DeviceInfo: namespaces: Optional[int] = None host_reserved_ns: Optional[int] = None available_ns: Optional[List[int]] = None +@dataclass(frozen=True) +class PCIHostBridge: + """Architecture-neutral PCI host bridge discovery metadata.""" + segment: int + bus_start: int + bus_end: int + ecam_base: int @dataclass @@ -201,6 +208,7 @@ class HardwareInventory: memory: MemoryAllocation topology: Optional[TopologySection] = None devices: Dict[str, DeviceInfo] = None + pci_host_bridges: List[PCIHostBridge] = field(default_factory=list) @dataclass diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 1a1a027..afc2188 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -118,6 +118,28 @@ def test_read_baseline_not_found(self): with pytest.raises(KernelInterfaceError, match="not found"): manager.read_baseline() + def test_write_and_read_pci_host_bridge_metadata(self, sample_hardware): + """PCI host bridge records survive the baseline model round trip.""" + from kerf.models import GlobalDeviceTree, PCIHostBridge + + sample_hardware.pci_host_bridges = [ + PCIHostBridge(segment=0, bus_start=0, bus_end=255, ecam_base=0xb0000000) + ] + with tempfile.NamedTemporaryFile(delete=False) as f: + baseline_path = f.name + try: + tree = GlobalDeviceTree( + hardware=sample_hardware, instances={}, device_references={} + ) + manager = BaselineManager(baseline_path=baseline_path) + manager.write_baseline(tree) + assert manager.read_baseline().hardware.pci_host_bridges == ( + sample_hardware.pci_host_bridges + ) + finally: + if os.path.exists(baseline_path): + os.unlink(baseline_path) + def test_write_baseline_invalid_tree(self, sample_tree): """Test writing baseline with invalid tree (has instances).""" with tempfile.NamedTemporaryFile(delete=False) as f: From 5104ec1f4054382b3ec90f671d6996e304da9914 Mon Sep 17 00:00:00 2001 From: Nikolay Nikolaev Date: Thu, 30 Jul 2026 19:41:14 +0300 Subject: [PATCH 3/3] kerf: parse explicit-width CPU cells in DTS inputs The device-tree compiler can render CPU lists with an explicit /bits/ width and hexadecimal cells. Treating that syntax as plain decimal values makes Kerf reject valid extracted instance trees.\n\nAccept the 32-bit and 64-bit forms, parse each cell with base detection, and limit the lookup to direct /resources properties so nested NUMA CPU lists cannot shadow the global inventory. Cover the legacy and explicit-width forms in the parser tests.\n\nSigned-off-by: Nikolay Nikolaev --- src/kerf/dtc/parser.py | 7 +++++-- tests/test_parser.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/kerf/dtc/parser.py b/src/kerf/dtc/parser.py index 21db101..35335ee 100644 --- a/src/kerf/dtc/parser.py +++ b/src/kerf/dtc/parser.py @@ -887,11 +887,14 @@ def _parse_cpus_from_dts(self, dts_content: str) -> CPUAllocation: if not resources_text: raise ParseError("Missing /resources section in DTS") - cpus_match = re.search(r'cpus\s*=\s*<([^>]+)>', self._strip_nested_blocks(resources_text)) + cpus_match = re.search( + r'cpus\s*=\s*(?:/bits/\s+(?:32|64)\s*)?<([^>]+)>', + self._strip_nested_blocks(resources_text), + ) if not cpus_match: raise ParseError("Missing 'cpus' property in /resources") - available = [int(x.strip()) for x in cpus_match.group(1).split()] + available = [int(x, 0) for x in cpus_match.group(1).split()] if available: total = max(available) + 1 else: diff --git a/tests/test_parser.py b/tests/test_parser.py index 3cbdeb3..f569d1c 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -108,6 +108,20 @@ def test_parse_dtb_with_devices(self, sample_tree): assert device.compatible == "intel,i40e" assert device.sriov_vfs == 8 + @pytest.mark.parametrize( + ("declaration", "expected"), + [ + ("<2 3>", [2, 3]), + ("/bits/ 64 <0x2 0x3>", [2, 3]), + ], + ) + def test_parse_cpu_ids_from_dts(self, declaration, expected): + """Test legacy and explicit-width CPU cells in DTS sources.""" + dts = f"/dts-v1/; / {{ resources {{ cpus = {declaration}; }}; }};" + cpus = DeviceTreeParser()._parse_cpus_from_dts(dts) + + assert cpus.available == expected + class TestInstanceExtractor: """Test instance extraction."""