[PATCH 09/36] tuna: Add CLI commands for cpuset management
John Kacur <[email protected]> Fri, 10 Jul 2026 10:14:47 -0400
| Newsgroups | org.kernel.vger.linux-rt-users |
|---|---|
| Message-ID | <[email protected]> |
Add nested subcommands under 'tuna cpuset' for managing CPU sets using cgroup v2. The implementation provides three main commands: create, list, and destroy. Command structure: tuna cpuset create --cpus <cpu-list> [--name <name>] [--isolated] tuna cpuset list [--pattern <pattern>] [--verbose] [--skip-empty] [--no-recursive] tuna cpuset destroy <name> [--force] tuna cpuset destroy --pattern <pattern> [--force] [--skip-empty] [--no-recursive] Key features: - Auto-naming: Creates cpusets as tuna0, tuna1, etc. with gap-filling logic when no name is specified - Safety: force=False by default, requiring explicit --force to migrate tasks before destroying cpusets - Filtering: --skip-empty option to filter out systemd infrastructure cgroups that have no CPUs assigned - Pattern matching: Destroy multiple cpusets using glob patterns (e.g., tuna*) - Validation: Enforces mutual exclusion between NAME and --pattern arguments The destroy command combines single cpuset destruction with pattern-based bulk cleanup, validated to prevent accidentally destroying the wrong cpusets. Assisted-by: Claude:claude-sonnet-4-5 Signed-off-by: John Kacur <[email protected]> --- tuna-cmd.py | 251 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/tuna-cmd.py b/tuna-cmd.py index b9a071414125..2ff73426ba4f 100755 --- a/tuna-cmd.py +++ b/tuna-cmd.py @@ -22,6 +22,7 @@ import tuna.new_eth as ethtool import tuna.tuna_sched as tuna_sched import procfs from tuna import tuna, sysfs, utils +from tuna import cpuset import logging import time import shutil @@ -175,6 +176,7 @@ def gen_parser(): show_configs = subparser.add_parser('show_configs', description='List preloaded profiles', help='List preloaded profiles') what_is = subparser.add_parser('what_is', description='Provides help about selected entities', help='Provides help about selected entities') gui = subparser.add_parser('gui', description="Start the GUI", help="Start the GUI") + cpuset = subparser.add_parser('cpuset', description='Manage CPU sets (cgroup v2)', help='Manage CPU sets') isolate_group = isolate.add_mutually_exclusive_group(required=True) isolate_group.add_argument('-c', '--cpus', **MODS['cpus']) @@ -266,6 +268,30 @@ def gen_parser(): gui.add_argument('-U', '--no_uthreads', **MODS['no_uthreads']) gui.add_argument('-K', '--no_kthreads', **MODS['no_kthreads']) + # Cpuset nested subcommands + cpuset_subparser = cpuset.add_subparsers(dest='cpuset_command', required=True) + + # cpuset create + cpuset_create = cpuset_subparser.add_parser('create', description='Create a new cpuset', help='Create a new cpuset') + 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 list + cpuset_list = cpuset_subparser.add_parser('list', description='List cpusets', help='List cpusets') + cpuset_list.add_argument('-p', '--pattern', type=str, metavar='PATTERN', help='Filter by glob pattern (e.g., tuna*)') + cpuset_list.add_argument('-v', '--verbose', action='store_true', help='Show detailed information (CPUs, tasks, partition type)') + cpuset_list.add_argument('--skip-empty', action='store_true', help='Skip cpusets with no CPUs assigned') + cpuset_list.add_argument('--no-recursive', dest='recursive', action='store_false', default=True, help='Only search top-level cpusets') + + # cpuset destroy + cpuset_destroy = cpuset_subparser.add_parser('destroy', description='Destroy cpuset(s)', help='Destroy cpuset(s)') + cpuset_destroy.add_argument('name', nargs='?', type=str, metavar='NAME', help='Cpuset name to destroy (use this OR --pattern)') + cpuset_destroy.add_argument('-p', '--pattern', type=str, metavar='PATTERN', help='Glob pattern to destroy multiple cpusets (e.g., tuna*)') + cpuset_destroy.add_argument('-f', '--force', action='store_true', help='Migrate tasks to root cgroup before destroying') + cpuset_destroy.add_argument('--skip-empty', action='store_true', help='When using --pattern, skip cpusets with no CPUs assigned') + cpuset_destroy.add_argument('--no-recursive', dest='recursive', action='store_false', default=True, help='Only destroy top-level cpusets when using --pattern') + return parser @@ -769,6 +795,220 @@ def nohz_full_to_cpu(): sys.exit(2) +def get_next_tuna_cpuset_name(): + """Find next available tunaN name for auto-generated cpusets. + + Scans existing cpusets matching pattern 'tuna[0-9]*' and returns + the first available number in the sequence (fills gaps). + + Returns: + str: Next available cpuset name (e.g., 'tuna0', 'tuna1') + """ + existing = cpuset.list_cpusets(pattern='tuna[0-9]*', recursive=False) + + # Extract numbers from tuna0, tuna1, etc. + numbers = [] + for name in existing: + match = re.match(r'tuna(\d+)', name) + if match: + numbers.append(int(match.group(1))) + + # Find first gap in sequence, or use 0 if none exist + if not numbers: + return 'tuna0' + + # Find first gap in sequence + for i in range(max(numbers) + 2): + if i not in numbers: + return f'tuna{i}' + + +def cpuset_create(cpu_list, name, isolated): + """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 + """ + # Check cgroup v2 support + ci = cpuset.CpusetsInit() + if not ci.supported: + print("Error: cgroup v2 cpusets not supported on this system", file=sys.stderr) + sys.exit(1) + + # Auto-generate name if not provided + if name is None: + name = get_next_tuna_cpuset_name() + + # Convert CPU list to string format (e.g., [0,1,2,3] -> "0-3") + cpu_str = tuna.collapse_cpulist(cpu_list) + + try: + # Create cpuset + cs = cpuset.Cpuset(name) + + # Configure it (must set memnode before CPUs in cgroup v2) + cs.write_memnode('0') + 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}" + + (f" (isolated)" if isolated else "")) + + except (OSError, ValueError) as e: + print(f"Error creating cpuset: {e}", file=sys.stderr) + sys.exit(1) + + +def cpuset_list(pattern, verbose, skip_empty, recursive): + """Handler for 'tuna cpuset list' command. + + Args: + pattern: Glob pattern to filter cpusets (None for all) + verbose: If True, show detailed info (CPUs, tasks, partition) + skip_empty: If True, skip cpusets with no CPUs assigned + recursive: If True, search nested cpusets + """ + # Check cgroup v2 support + ci = cpuset.CpusetsInit() + if not ci.supported: + print("Error: cgroup v2 cpusets not supported on this system", file=sys.stderr) + sys.exit(1) + + # List cpusets + cpusets = cpuset.list_cpusets(pattern=pattern, recursive=recursive) + + # Filter out empty cpusets if requested + if skip_empty: + filtered_cpusets = [] + for cs_name in cpusets: + cs_path = os.path.join('/sys/fs/cgroup', cs_name) + try: + with open(os.path.join(cs_path, 'cpuset.cpus'), 'r', encoding='utf-8') as f: + cpus = f.read().strip() + if cpus: # Only include if CPUs are assigned + filtered_cpusets.append(cs_name) + except (OSError, IOError): + # If we can't read it, skip it + pass + cpusets = filtered_cpusets + + if not cpusets: + if pattern: + print(f"No cpusets found matching pattern '{pattern}'") + else: + print("No cpusets found") + return + + if verbose: + # Verbose output: NAME, CPUS, TASKS, PARTITION + print(f"{'NAME':<20} {'CPUS':<15} {'TASKS':<7} {'PARTITION':<10}") + print("-" * 55) + + for cs_name in cpusets: + cs_path = os.path.join('/sys/fs/cgroup', cs_name) + + try: + # Read CPUs + with open(os.path.join(cs_path, 'cpuset.cpus'), 'r', encoding='utf-8') as f: + cpus = f.read().strip() or "(none)" + + # Read tasks count + with open(os.path.join(cs_path, 'cgroup.procs'), 'r', encoding='utf-8') as f: + tasks = [line.strip() for line in f if line.strip()] + task_count = len(tasks) + + # Read partition type (if available) + partition_file = os.path.join(cs_path, 'cpuset.cpus.partition') + if os.path.exists(partition_file): + with open(partition_file, 'r', encoding='utf-8') as f: + partition = f.read().strip() + else: + partition = "n/a" + + print(f"{cs_name:<20} {cpus:<15} {task_count:<7} {partition:<10}") + + except (OSError, IOError) as e: + print(f"{cs_name:<20} (error reading: {e})", file=sys.stderr) + else: + # Simple output: just names + for cs_name in cpusets: + print(cs_name) + + +def cpuset_destroy(name, pattern, force, skip_empty, recursive): + """Handler for 'tuna cpuset destroy' command. + + Args: + name: Cpuset name to destroy (single cpuset, mutually exclusive with pattern) + pattern: Glob pattern for cpusets to destroy (mutually exclusive with name) + force: If True, migrate tasks to root before destroying + skip_empty: If True, skip cpusets with no CPUs assigned (only used with pattern) + recursive: If True, find and clean nested cpusets (only used with pattern) + """ + # Check cgroup v2 support + ci = cpuset.CpusetsInit() + if not ci.supported: + print("Error: cgroup v2 cpusets not supported on this system", file=sys.stderr) + sys.exit(1) + + # Validate: must have either name OR pattern, not both, not neither + if name and pattern: + print("Error: specify either NAME or --pattern, not both", file=sys.stderr) + sys.exit(2) + if not name and not pattern: + print("Error: must specify either NAME or --pattern", file=sys.stderr) + sys.exit(2) + + try: + if name: + # Single cpuset destroy (skip_empty not applicable) + cpuset.destroy_cpuset(name, force=force) + print(f"Destroyed cpuset '{name}'") + else: + # Pattern-based cleanup + if skip_empty: + # Filter out empty cpusets before destroying + all_cpusets = cpuset.list_cpusets(pattern=pattern, recursive=recursive) + cpusets_to_destroy = [] + + for cs_name in all_cpusets: + cs_path = os.path.join('/sys/fs/cgroup', cs_name) + try: + with open(os.path.join(cs_path, 'cpuset.cpus'), 'r', encoding='utf-8') as f: + cpus = f.read().strip() + if cpus: # Only destroy if CPUs are assigned + cpusets_to_destroy.append(cs_name) + except (OSError, IOError): + # If we can't read it, skip it + pass + + # Destroy each non-empty cpuset individually + if not cpusets_to_destroy: + print(f"No non-empty cpusets found matching pattern '{pattern}'") + return + + # Sort in reverse order by depth to ensure children destroyed before parents + cpusets_by_depth = sorted(cpusets_to_destroy, key=lambda x: x.count(os.sep), reverse=True) + + for cs_name in cpusets_by_depth: + try: + cpuset.destroy_cpuset(cs_name, force=force) + print(f"Destroyed cpuset '{cs_name}'") + except (OSError, ValueError) as e: + print(f"Error destroying cpuset '{cs_name}': {e}", file=sys.stderr) + else: + # Use cleanup_cpusets for normal pattern-based destroy + cpuset.cleanup_cpusets(pattern, force=force, recursive=recursive) + except (OSError, ValueError) as e: + print(f"Error destroying cpuset(s): {e}", file=sys.stderr) + sys.exit(1) + + def main(): i18n_init() parser = gen_parser() @@ -978,6 +1218,17 @@ def main(): except KeyboardInterrupt: pass + elif args.command == 'cpuset': + # Handle cpuset subcommands + if args.cpuset_command == 'create': + cpuset_create(args.cpu_list, args.name, args.isolated) + + elif args.cpuset_command == 'list': + cpuset_list(args.pattern, args.verbose, args.skip_empty, args.recursive) + + elif args.cpuset_command == 'destroy': + cpuset_destroy(args.name, args.pattern, args.force, args.skip_empty, args.recursive) + if __name__ == '__main__': main() -- 2.54.0