[PATCH 14/36] tuna: Add NUMA-aware memory node assignment for cpusets

John Kacur <[email protected]> Fri, 10 Jul 2026 10:14:52 -0400
Newsgroups org.kernel.vger.linux-rt-users
Message-ID <[email protected]>
Implement smart NUMA memory node detection and assignment for cpusets
to optimize memory performance by avoiding cross-NUMA access.

Changes to tuna/cpuset.py:
- Add NumaNode class to represent NUMA nodes and their CPUs
- Add discover_numa_nodes() to detect NUMA topology from sysfs
  - Reads from /sys/devices/system/node/node*/cpulist on multi-node systems
  - Falls back to /sys/devices/system/cpu/possible for single-node systems
- Add get_numa_nodes_for_cpus() to auto-detect memory nodes for CPU lists
  - Maps CPUs to their NUMA nodes
  - Returns appropriate cpuset.mems string (e.g., "0", "0-1", "0,2")
- Adapted from rteval.systopology but simplified for tuna's needs

Changes to tuna-cmd.py:
- Add --memory-nodes parameter to 'tuna cpuset create' command
- Update cpuset_create() to use NUMA auto-detection by default
  - Auto-detects memory nodes from assigned CPUs
  - Allows manual override with --memory-nodes
  - Replaces hardcoded memnode='0' for better NUMA performance

Example usage:
  # Auto-detect memory nodes (recommended):
  tuna cpuset create -c 0-3 -n myapp

  # Manual override if needed:
  tuna cpuset create -c 0-7,16-23 -n myapp --memory-nodes 0,1

Benefits:
- Better performance by keeping memory local to CPUs
- Works correctly on both single-node and multi-node systems
- Smart defaults with expert override capability

Assisted-by: Claude:claude-sonnet-4-5
Signed-off-by: John Kacur <[email protected]>
---
 tuna-cmd.py    |  15 +++++--
 tuna/cpuset.py | 109 +++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 120 insertions(+), 4 deletions(-)

diff --git a/tuna-cmd.py b/tuna-cmd.py
index 0e70b9208665..04c72a5352ac 100755
--- a/tuna-cmd.py
+++ b/tuna-cmd.py
@@ -276,6 +276,8 @@ def gen_parser():
     cpuset_create.add_argument('-c', '--cpus', **MODS['cpus'], required=True)
     cpuset_create.add_argument('-n', '--name', type=str, metavar='NAME', help='Cpuset name (default: auto-generate tunaN)')
     cpuset_create.add_argument('-i', '--isolated', action='store_true', help='Set CPU partition to isolated')
+    cpuset_create.add_argument('-m', '--memory-nodes', type=str, metavar='MEM-NODES', dest='memory_nodes',
+                                help='Memory nodes (default: auto-detect from CPUs, e.g., "0" or "0-1")')
 
     # cpuset list
     cpuset_list = cpuset_subparser.add_parser('list', description='List cpusets', help='List cpusets')
@@ -829,13 +831,14 @@ def get_next_tuna_cpuset_name():
             return f'tuna{i}'
 
 
-def cpuset_create(cpu_list, name, isolated):
+def cpuset_create(cpu_list, name, isolated, memory_nodes=None):
     """Handler for 'tuna cpuset create' command.
 
     Args:
         cpu_list: List of CPU numbers to assign to cpuset
         name: Cpuset name (None for auto-generated tunaN)
         isolated: If True, set CPU partition to isolated
+        memory_nodes: Memory nodes string (None for auto-detect from CPUs)
     """
     # Check cgroup v2 support
     ci = cpuset.CpusetsInit()
@@ -847,6 +850,10 @@ def cpuset_create(cpu_list, name, isolated):
     if name is None:
         name = get_next_tuna_cpuset_name()
 
+    # Auto-detect NUMA memory nodes if not specified
+    if memory_nodes is None:
+        memory_nodes = cpuset.get_numa_nodes_for_cpus(cpu_list)
+
     # Convert CPU list to string format (e.g., [0,1,2,3] -> "0-3")
     cpu_str = tuna.collapse_cpulist(cpu_list)
 
@@ -855,14 +862,14 @@ def cpuset_create(cpu_list, name, isolated):
         cs = cpuset.Cpuset(name)
 
         # Configure it (must set memnode before CPUs in cgroup v2)
-        cs.write_memnode('0')
+        cs.write_memnode(memory_nodes)
         cs.assign_cpus(cpu_str)
 
         # Set isolated partition if requested
         if isolated:
             cs.set_cpu_exclusive()
 
-        print(f"Created cpuset '{name}' with CPUs {cpu_str}" +
+        print(f"Created cpuset '{name}' with CPUs {cpu_str}, memory nodes {memory_nodes}" +
               (f" (isolated)" if isolated else ""))
 
     except (OSError, ValueError) as e:
@@ -1308,7 +1315,7 @@ def main():
     elif args.command == 'cpuset':
         # Handle cpuset subcommands
         if args.cpuset_command == 'create':
-            cpuset_create(args.cpu_list, args.name, args.isolated)
+            cpuset_create(args.cpu_list, args.name, args.isolated, args.memory_nodes)
 
         elif args.cpuset_command == 'list':
             cpuset_list(args.pattern, args.verbose, args.skip_empty, args.recursive)
diff --git a/tuna/cpuset.py b/tuna/cpuset.py
index 1039eb6c2c49..513e1b198c38 100644
--- a/tuna/cpuset.py
+++ b/tuna/cpuset.py
@@ -325,6 +325,115 @@ class TaskMigrate:
         return self.migrated, self.failed
 
 
+#
+# NUMA topology detection (adapted from rteval/systopology.py)
+#
+
+class NumaNode:
+    """Represents a NUMA node with its CPUs.
+
+    Simplified from rteval.systopology.NumaNode for cpuset memory node detection.
+    """
+
+    def __init__(self, nodeid, cpus):
+        """Initialize NUMA node.
+
+        Args:
+            nodeid: NUMA node ID (integer)
+            cpus: Set of CPU numbers in this node
+        """
+        self.nodeid = nodeid
+        self.cpus = cpus
+
+    def __contains__(self, cpu):
+        """Check if CPU is in this NUMA node."""
+        return cpu in self.cpus
+
+    def __int__(self):
+        """Return node ID as integer."""
+        return self.nodeid
+
+
+def discover_numa_nodes():
+    """Discover NUMA topology.
+
+    Returns dict mapping node IDs to NumaNode objects.
+    Handles both multi-node systems and single-node systems.
+
+    Adapted from rteval.systopology.SysTopology.
+    """
+    nodes = {}
+    node_paths = glob('/sys/devices/system/node/node[0-9]*')
+
+    if node_paths:
+        # Multi-node NUMA system
+        for path in sorted(node_paths):
+            nodeid = int(os.path.basename(path)[4:])
+            cpulist_file = os.path.join(path, 'cpulist')
+            try:
+                with open(cpulist_file, 'r', encoding='utf-8') as f:
+                    cpulist_str = f.read().strip()
+                    # Parse CPU list string (e.g., "0-7" or "0,2,4-7")
+                    from tuna import tuna
+                    cpu_list = tuna.cpustring_to_list(cpulist_str)
+                    nodes[nodeid] = NumaNode(nodeid, set(cpu_list))
+            except (OSError, ValueError):
+                # Skip nodes we can't read
+                continue
+    else:
+        # Single-node system (no /sys/devices/system/node/node*)
+        # Fallback like rteval.systopology.SimNumaNode
+        try:
+            with open('/sys/devices/system/cpu/possible', 'r', encoding='utf-8') as f:
+                cpulist_str = f.read().strip()
+                from tuna import tuna
+                cpu_list = tuna.cpustring_to_list(cpulist_str)
+                nodes[0] = NumaNode(0, set(cpu_list))
+        except (OSError, ValueError):
+            # Last resort: assume node 0 exists
+            nodes[0] = NumaNode(0, set())
+
+    return nodes
+
+
+def get_numa_nodes_for_cpus(cpu_list):
+    """Determine which NUMA nodes contain the given CPUs.
+
+    Args:
+        cpu_list: List of CPU numbers (e.g., [0, 1, 8, 9])
+
+    Returns:
+        String suitable for cpuset.mems (e.g., "0", "0-1", "0,2")
+
+    Example:
+        On a 2-node system with CPUs 0-7 on node0, 8-15 on node1:
+        - get_numa_nodes_for_cpus([0,1,2]) returns "0"
+        - get_numa_nodes_for_cpus([8,9,10]) returns "1"
+        - get_numa_nodes_for_cpus([0,1,8,9]) returns "0-1"
+    """
+    if not cpu_list:
+        # No CPUs specified, default to node 0
+        return "0"
+
+    nodes = discover_numa_nodes()
+
+    # Find which nodes contain our CPUs
+    required_nodes = set()
+    for cpu in cpu_list:
+        for nodeid, node in nodes.items():
+            if cpu in node:
+                required_nodes.add(nodeid)
+                break
+
+    if not required_nodes:
+        # Fallback if no matches found (shouldn't happen)
+        return "0"
+
+    # Convert node IDs to string format
+    from tuna import tuna
+    return tuna.collapse_cpulist(sorted(required_nodes))
+
+
 #
 # Tuna-specific utility functions for cpuset discovery and management
 #
-- 
2.54.0