* [PATCH 0/7] kunit: tool: Support parallel test execution
@ 2026-10-13 15:43 Mark Brown
2026-10-13 15:43 ` [PATCH 1/7] kunit: tool: Preserve failures from kernels without test results Mark Brown
` (6 more replies)
0 siblings, 7 replies; 8+ messages in thread
From: Mark Brown @ 2026-10-13 15:43 UTC (permalink / raw)
To: Brendan Higgins, David Gow, Rae Moar, Shuah Khan, Daniel Latypov,
Jonathan Corbet, Randy Dunlap
Cc: linux-kselftest, kunit-dev, linux-kernel, linux-doc, Mark Brown
Currently the test execution part of KUnit is very single threaded,
meaning that on larger systems with many CPUs the wall clock time for
running the full suite is longer than it could be. While we don't want
to run multiple tests in parallel on the same kernel for stability and
clarity reasons all the tests are being run in a virtual environment of
some kind so we can achieve better parallelism by splitting the tests
between multiple simultaneously running virtual environments. The KUnit
runner already has support for splitting tests up and running either
suites or individual tests in isolated guests, build on this to support
parallel execution.
While developing this some existing issues became more visible, some of
these are fixed with some standalone patches at the start of the series
but I split some issues with test naming out, these were sent as:
- lib/tests: util_macros: Drop the '.' from the KUnit suite name
https://lore.kernel.org/r/20261012-kunit-util-macros-name-v1-1-810b401baeb0@kernel.org
- kunit: Add KUNIT_CASE_PARAM_UNIQUE() and KUNIT_CASE_PARAM_WITH_INIT_UNIQUE()
https://lore.kernel.org/r/20261013-kunit-param-names-v1-0-4b22ec5b85b9@kernel.org
Performance results with --alltests, using the elapsed time reported by
the KUnit runner with an already built kernel on a Raspberry Pi 5 with 4
CPUs and a Graviton 4 with 192 CPUs:
default parallel per test parallel per test
Raspberry Pi 5 67.7s 49.4s 836.4s 326.5s
Graviton 4 64.1s 30.8s 445.9s 39.7s
Even with the overhead of having to boot more kernels we are seeing
substantial speedups, even on lower end systems with limited parallelism
available.
Signed-off-by: Mark Brown <broonie@kernel.org>
---
Mark Brown (7):
kunit: tool: Preserve failures from kernels without test results
kunit: tool: Combine reports from isolated kernels
kunit: tool: Boot with a larger log buffer when listing tests
kunit: tool: Add an outfile argument to run_kernel()
kunit: tool: Allow several run_kernel() calls to be in flight at once
kunit: tool: Serialise output written from multiple threads
kunit: tool: Add --parallel to run suites in concurrent kernels
Documentation/dev-tools/kunit/run_wrapper.rst | 11 ++
tools/testing/kunit/kunit.py | 151 +++++++++++----
tools/testing/kunit/kunit_junit.py | 3 +-
tools/testing/kunit/kunit_kernel.py | 78 +++++---
tools/testing/kunit/kunit_printer.py | 9 +-
tools/testing/kunit/kunit_tool_test.py | 253 +++++++++++++++++++++++++-
6 files changed, 438 insertions(+), 67 deletions(-)
---
base-commit: 24a19b953e2a3d0ea5a8c4951a0ab8f32720c907
base-tree: git://git.kernel.org/pub/scm/linux/kernel/git/rafael/linux-pm master
change-id: 20261012-kunit-parallel-a8a7e3c19a30
Best regards,
--
Mark Brown <broonie@kernel.org>
^ permalink raw reply [flat|nested] 8+ messages in thread* [PATCH 1/7] kunit: tool: Preserve failures from kernels without test results 2026-10-13 15:43 [PATCH 0/7] kunit: tool: Support parallel test execution Mark Brown @ 2026-10-13 15:43 ` Mark Brown 2026-10-13 15:43 ` [PATCH 2/7] kunit: tool: Combine reports from isolated kernels Mark Brown ` (5 subsequent siblings) 6 siblings, 0 replies; 8+ messages in thread From: Mark Brown @ 2026-10-13 15:43 UTC (permalink / raw) To: Brendan Higgins, David Gow, Rae Moar, Shuah Khan, Daniel Latypov, Jonathan Corbet, Randy Dunlap Cc: linux-kselftest, kunit-dev, linux-kernel, linux-doc, Mark Brown When an isolated kernel produces no test results, the parser reports failure without adding failed test counts. Aggregating only the counts therefore lets a passing kernel mask the missing results and makes the command exit successfully. A kernel reporting an empty test plan has the same problem. Preserve each kernel's failure status alongside the aggregate counts. Keep treating skipped tests as successful and retain the failure when no kernels run at all. Add regression coverage for missing and empty results in either execution order and for passing and skipped kernels. Fixes: d65d07cb5b01 ("kunit: tool: improve compatibility of kunit_parser with KTAP specification") Signed-off-by: Mark Brown <broonie@kernel.org> --- tools/testing/kunit/kunit.py | 6 ++++++ tools/testing/kunit/kunit_tool_test.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/tools/testing/kunit/kunit.py b/tools/testing/kunit/kunit.py index 91d234ac3b57..218ae294ad1b 100755 --- a/tools/testing/kunit/kunit.py +++ b/tools/testing/kunit/kunit.py @@ -191,6 +191,7 @@ def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) - metadata = kunit_json.Metadata(arch=linux.arch(), build_dir=request.build_dir, def_config='kunit_defconfig') test_counts = kunit_parser.TestCounts() + test_failed = False exec_time = 0.0 for i, filter_glob in enumerate(filter_globs): stdout.print_with_timestamp('Starting KUnit Kernel ({}/{})...'.format(i+1, len(filter_globs))) @@ -212,6 +213,9 @@ def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) - exec_time += test_end - test_start test_counts.add_subtest_counts(test_result.counts) + # A kernel with no results has no failed test counts to aggregate. + if _map_to_overall_status(test_result.status) != KunitStatus.SUCCESS: + test_failed = True if len(filter_globs) == 1 and test_counts.crashed > 0: bd = request.build_dir @@ -220,6 +224,8 @@ def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) - bd, bd, kunit_kernel.get_outfile_path(bd), bd, sys.argv[0])) kunit_status = _map_to_overall_status(test_counts.get_status()) + if test_failed: + kunit_status = KunitStatus.TEST_FAILURE return KunitResult(status=kunit_status, elapsed_time=exec_time) def _map_to_overall_status(test_status: kunit_parser.TestStatus) -> KunitStatus: diff --git a/tools/testing/kunit/kunit_tool_test.py b/tools/testing/kunit/kunit_tool_test.py index 85ae21754bdf..ca2dac6651aa 100755 --- a/tools/testing/kunit/kunit_tool_test.py +++ b/tools/testing/kunit/kunit_tool_test.py @@ -1018,6 +1018,37 @@ class KUnitMainTest(unittest.TestCase): mock.call(args=None, build_dir='.kunit', filter_glob='suite2.test1', filter='', filter_action=None, timeout=300), ]) + @mock.patch.object(kunit, '_list_tests') + def test_isolated_kernel_without_results_fails(self, mock_tests): + mock_tests.return_value = ['suite1.test1', 'suite2.test1'] + for missing_output in ([], ['KTAP version 1', '1..0']): + for missing_suite in ('suite1', 'suite2'): + with self.subTest(output=missing_output, + suite=missing_suite): + def fake_run_kernel(filter_glob, **kwargs): + if filter_glob == missing_suite: + return missing_output + return ['KTAP version 1', '1..1', 'ok 1 - test1'] + self.linux_source_mock.run_kernel.side_effect = fake_run_kernel + + with self.assertRaises(SystemExit) as e: + kunit.main(['exec', '--run_isolated=suite']) + self.assertEqual(e.exception.code, 1) + + @mock.patch.object(kunit, '_list_tests') + def test_isolated_skipped_kernels_succeed(self, mock_tests): + mock_tests.return_value = ['suite1.test1', 'suite2.test1'] + for all_skipped in (False, True): + with self.subTest(all_skipped=all_skipped): + def fake_run_kernel(filter_glob, **kwargs): + result = 'ok 1 - test1' + if all_skipped or filter_glob == 'suite1': + result += ' # SKIP unavailable' + return ['KTAP version 1', '1..1', result] + self.linux_source_mock.run_kernel.side_effect = fake_run_kernel + + kunit.main(['exec', '--run_isolated=suite']) + @mock.patch.object(kunit, '_list_tests') @mock.patch.object(sys, 'stdout', new_callable=io.StringIO) def test_list_suites(self, mock_stdout, mock_tests): -- 2.47.3 ^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH 2/7] kunit: tool: Combine reports from isolated kernels 2026-10-13 15:43 [PATCH 0/7] kunit: tool: Support parallel test execution Mark Brown 2026-10-13 15:43 ` [PATCH 1/7] kunit: tool: Preserve failures from kernels without test results Mark Brown @ 2026-10-13 15:43 ` Mark Brown 2026-10-13 15:43 ` [PATCH 3/7] kunit: tool: Boot with a larger log buffer when listing tests Mark Brown ` (4 subsequent siblings) 6 siblings, 0 replies; 8+ messages in thread From: Mark Brown @ 2026-10-13 15:43 UTC (permalink / raw) To: Brendan Higgins, David Gow, Rae Moar, Shuah Khan, Daniel Latypov, Jonathan Corbet, Randy Dunlap Cc: linux-kselftest, kunit-dev, linux-kernel, linux-doc, Mark Brown With --run_isolated, parse_tests() writes the same JSON or JUnit destination after every kernel. Only the last kernel survives in a file, so an earlier failing suite can disappear from the saved results. Stdout similarly receives several independent report documents. Separate report writing from parsing. Keep printing each kernel's results as they are parsed, accumulate the top-level tests and counts, and write one report after execution finishes. Preserve the parsed root for a single kernel and continue suppressing reports with --raw_output. Test JSON and JUnit files with an early failure and suite and test isolation. Also check equivalence between single-kernel execution and parsing. Fixes: ff9e09a3762f ("kunit: tool: support running each suite/test separately") Signed-off-by: Mark Brown <broonie@kernel.org> --- tools/testing/kunit/kunit.py | 35 +++++++++++++++---- tools/testing/kunit/kunit_tool_test.py | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/tools/testing/kunit/kunit.py b/tools/testing/kunit/kunit.py index 218ae294ad1b..aea67b6a72c4 100755 --- a/tools/testing/kunit/kunit.py +++ b/tools/testing/kunit/kunit.py @@ -16,7 +16,7 @@ import time assert sys.version_info >= (3, 7), "Python version is too old" -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import Enum, auto from typing import Iterable, List, Optional, Sequence, Tuple @@ -191,6 +191,10 @@ def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) - metadata = kunit_json.Metadata(arch=linux.arch(), build_dir=request.build_dir, def_config='kunit_defconfig') test_counts = kunit_parser.TestCounts() + report = kunit_parser.Test() + report.name = 'main' + report.counts = test_counts + parse_request = replace(request, json=None, junit=None) test_failed = False exec_time = 0.0 for i, filter_glob in enumerate(filter_globs): @@ -205,7 +209,7 @@ def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) - filter_action=request.filter_action, build_dir=request.build_dir) - _, test_result = parse_tests(request, metadata, run_result) + _, test_result = parse_tests(parse_request, metadata, run_result) # run_kernel() doesn't block on the kernel exiting. # That only happens after we get the last line of output from `run_result`. # So exec_time here actually contains parsing + execution time, which is fine. @@ -216,6 +220,19 @@ def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) - # A kernel with no results has no failed test counts to aggregate. if _map_to_overall_status(test_result.status) != KunitStatus.SUCCESS: test_failed = True + if request.json or request.junit: + if len(filter_globs) == 1: + report = test_result + else: + report.subtests.extend(test_result.subtests) + report.log.extend(test_result.log) + if len(filter_globs) != 1: + report.expected_count = len(report.subtests) + report.status = test_counts.get_status() + if test_failed and report.ok_status(): + report.status = kunit_parser.TestStatus.FAILURE_TO_PARSE_TESTS + if not request.raw_output: + _write_test_results(request, metadata, report) if len(filter_globs) == 1 and test_counts.crashed > 0: bd = request.build_dir @@ -264,6 +281,15 @@ def parse_tests(request: KunitParseRequest, metadata: kunit_json.Metadata, input kunit_parser.print_test(test, request.failed, stdout) kunit_parser.print_summary_line(test, stdout) + _write_test_results(request, metadata, test) + + if test.status != kunit_parser.TestStatus.SUCCESS: + return KunitResult(KunitStatus.TEST_FAILURE, parse_time), test + + return KunitResult(KunitStatus.SUCCESS, parse_time), test + +def _write_test_results(request: KunitParseRequest, metadata: kunit_json.Metadata, + test: kunit_parser.Test) -> None: if request.json: json_str = kunit_json.get_json_result( test=test, @@ -283,11 +309,6 @@ def parse_tests(request: KunitParseRequest, metadata: kunit_json.Metadata, input kunit_junit.write_junit_result(test=test,filename=request.junit) stdout.print_with_timestamp(f"Test results stored in {os.path.abspath(request.junit)}") - if test.status != kunit_parser.TestStatus.SUCCESS: - return KunitResult(KunitStatus.TEST_FAILURE, parse_time), test - - return KunitResult(KunitStatus.SUCCESS, parse_time), test - def run_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitRequest) -> KunitResult: run_start = time.time() diff --git a/tools/testing/kunit/kunit_tool_test.py b/tools/testing/kunit/kunit_tool_test.py index ca2dac6651aa..4590108e4306 100755 --- a/tools/testing/kunit/kunit_tool_test.py +++ b/tools/testing/kunit/kunit_tool_test.py @@ -19,6 +19,7 @@ import signal import subprocess import sys from typing import Iterable +import xml.etree.ElementTree as ET import kunit_config import kunit_parser @@ -738,6 +739,7 @@ class KUnitMainTest(unittest.TestCase): self.mock_linux_init = mock.patch.object(kunit_kernel, 'LinuxSourceTree').start() self.linux_source_mock = self.mock_linux_init.return_value + self.linux_source_mock.arch.return_value = 'um' self.linux_source_mock.build_reconfig.return_value = True self.linux_source_mock.build_kernel.return_value = True self.linux_source_mock.run_kernel.return_value = all_passed_log @@ -1049,6 +1051,65 @@ class KUnitMainTest(unittest.TestCase): kunit.main(['exec', '--run_isolated=suite']) + @mock.patch.object(kunit, '_list_tests') + def test_isolated_reports_include_all_kernels(self, mock_tests): + def fake_run_kernel(filter_glob, **kwargs): + if '.' in filter_glob: + suite, case = filter_glob.split('.') + failed = case == 'test1' + else: + suite, case = filter_glob, 'test' + failed = suite == 'suite1' + status = 'not ok' if failed else 'ok' + return ['KTAP version 1', '1..1', ' # Subtest: ' + suite, + ' 1..1', f' {status} 1 - {case}', f'{status} 1 - {suite}'] + self.linux_source_mock.run_kernel.side_effect = fake_run_kernel + + for isolation in ('suite', 'test'): + with self.subTest(isolation=isolation), \ + tempfile.TemporaryDirectory() as output_dir: + if isolation == 'suite': + mock_tests.return_value = ['suite1.test', 'suite2.test'] + expected = [('suite1', 'test', 'FAIL'), ('suite2', 'test', 'PASS')] + else: + mock_tests.return_value = ['suite.test1', 'suite.test2'] + expected = [('suite', 'test1', 'FAIL'), ('suite', 'test2', 'PASS')] + json_path = os.path.join(output_dir, 'results.json') + xml_path = os.path.join(output_dir, 'results.xml') + with self.assertRaises(SystemExit) as e: + kunit.main(['exec', '--run_isolated=' + isolation, + '--json=' + json_path, '--junit=' + xml_path]) + self.assertEqual(e.exception.code, 1) + + with open(json_path) as f: + report = json.load(f) + self.assertEqual(report['misc']['tests'], 2) + self.assertEqual(report['misc']['failed'], 1) + self.assertEqual(report['arch'], 'um') + self.assertEqual([(suite['name'], case['name'], case['status']) + for suite in report['sub_groups'] for case in suite['test_cases']], expected) + root = ET.parse(xml_path).getroot() + self.assertEqual(root.get('tests'), '2') + self.assertEqual(root.get('failures'), '1') + self.assertEqual([(suite.get('name'), case.get('name'), + 'FAIL' if case.find('failure') is not None else 'PASS') + for suite in root for case in suite], expected) + + def test_single_kernel_reports_match_parse(self): + with tempfile.TemporaryDirectory() as output_dir: + json_path = os.path.join(output_dir, 'results.json') + xml_path = os.path.join(output_dir, 'results.xml') + reports = [] + for args in (['exec'], ['parse', _test_data_path('test_is_test_passed-all_passed.log')]): + kunit.main(args + ['--json=' + json_path, '--junit=' + xml_path]) + with open(json_path) as f: + report = json.load(f) + # Execution adds build metadata, which parse cannot know. + reports.append((report['misc'], + [(s['name'], s['test_cases']) for s in report['sub_groups']], + ET.tostring(ET.parse(xml_path).getroot()))) + self.assertEqual(reports[0], reports[1]) + @mock.patch.object(kunit, '_list_tests') @mock.patch.object(sys, 'stdout', new_callable=io.StringIO) def test_list_suites(self, mock_stdout, mock_tests): -- 2.47.3 ^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH 3/7] kunit: tool: Boot with a larger log buffer when listing tests 2026-10-13 15:43 [PATCH 0/7] kunit: tool: Support parallel test execution Mark Brown 2026-10-13 15:43 ` [PATCH 1/7] kunit: tool: Preserve failures from kernels without test results Mark Brown 2026-10-13 15:43 ` [PATCH 2/7] kunit: tool: Combine reports from isolated kernels Mark Brown @ 2026-10-13 15:43 ` Mark Brown 2026-10-13 15:43 ` [PATCH 4/7] kunit: tool: Add an outfile argument to run_kernel() Mark Brown ` (3 subsequent siblings) 6 siblings, 0 replies; 8+ messages in thread From: Mark Brown @ 2026-10-13 15:43 UTC (permalink / raw) To: Brendan Higgins, David Gow, Rae Moar, Shuah Khan, Daniel Latypov, Jonathan Corbet, Randy Dunlap Cc: linux-kselftest, kunit-dev, linux-kernel, linux-doc, Mark Brown kunit.action=list prints the name of every suite and test in one burst during boot, which on a large configuration such as --alltests comes to several thousand lines. That is more than the default 128KiB printk ring buffer (CONFIG_LOG_BUF_SHIFT=17, overridden by many defconfigs) can hold, and since the console drains more slowly than the executor prints the buffer wraps and lines can easily be dropped. If the KTAP header is dropped _list_tests() fails with: ValueError: LineStream: going past EOF, last line was but otherwise the loss is silent, --list_tests prints an incomplete list and --run_isolated=test will execute a timing dependent sent of tests. Pass log_buf_len=4M on the kernel command line when booting to list tests so that the whole list fits in the buffer, at least until we get substantially more tests than we currently have. Signed-off-by: Mark Brown <broonie@kernel.org> --- tools/testing/kunit/kunit.py | 11 +++++++++-- tools/testing/kunit/kunit_tool_test.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/testing/kunit/kunit.py b/tools/testing/kunit/kunit.py index aea67b6a72c4..971e70b0fb1a 100755 --- a/tools/testing/kunit/kunit.py +++ b/tools/testing/kunit/kunit.py @@ -109,8 +109,15 @@ def config_and_build_tests(linux: kunit_kernel.LinuxSourceTree, return build_tests(linux, request) +# kunit.action=list prints every suite and test name in one burst at boot, +# which can be thousands of lines on a large config. That easily overflows +# the default 128KiB printk buffer before the console has drained it, so +# lines (sometimes including the KTAP header) are dropped and the list +# comes out incomplete. Give the kernel a large enough buffer to hold it. +_LIST_KERNEL_ARGS = ['log_buf_len=4M'] + def _list_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) -> List[str]: - args = ['kunit.action=list'] + args = ['kunit.action=list'] + _LIST_KERNEL_ARGS if request.kernel_args: args.extend(request.kernel_args) @@ -129,7 +136,7 @@ def _list_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) return [l for l in lines if re.match(r'^[^\s.]+\.[^\s.]+$', l)] def _list_tests_attr(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) -> Iterable[str]: - args = ['kunit.action=list_attr'] + args = ['kunit.action=list_attr'] + _LIST_KERNEL_ARGS if request.kernel_args: args.extend(request.kernel_args) diff --git a/tools/testing/kunit/kunit_tool_test.py b/tools/testing/kunit/kunit_tool_test.py index 4590108e4306..17f926ef2820 100755 --- a/tools/testing/kunit/kunit_tool_test.py +++ b/tools/testing/kunit/kunit_tool_test.py @@ -979,7 +979,7 @@ class KUnitMainTest(unittest.TestCase): self.assertEqual(got, want) # Should respect the user's filter glob when listing tests. self.linux_source_mock.run_kernel.assert_called_once_with( - args=['kunit.action=list'], build_dir='.kunit', filter_glob='suite*', filter='', filter_action=None, timeout=300) + args=['kunit.action=list', 'log_buf_len=4M'], build_dir='.kunit', filter_glob='suite*', filter='', filter_action=None, timeout=300) def test_list_tests_with_prefix(self): want = ['suite.test1', 'suite.test2', 'suite2.test1'] -- 2.47.3 ^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH 4/7] kunit: tool: Add an outfile argument to run_kernel() 2026-10-13 15:43 [PATCH 0/7] kunit: tool: Support parallel test execution Mark Brown ` (2 preceding siblings ...) 2026-10-13 15:43 ` [PATCH 3/7] kunit: tool: Boot with a larger log buffer when listing tests Mark Brown @ 2026-10-13 15:43 ` Mark Brown 2026-10-13 15:43 ` [PATCH 5/7] kunit: tool: Allow several run_kernel() calls to be in flight at once Mark Brown ` (2 subsequent siblings) 6 siblings, 0 replies; 8+ messages in thread From: Mark Brown @ 2026-10-13 15:43 UTC (permalink / raw) To: Brendan Higgins, David Gow, Rae Moar, Shuah Khan, Daniel Latypov, Jonathan Corbet, Randy Dunlap Cc: linux-kselftest, kunit-dev, linux-kernel, linux-doc, Mark Brown run_kernel() always tees the kernel's console output to test.log in the build directory. If more than one kernel were to be run at a time they would all write to the same file, so allow the caller to choose where the log goes with a new optional outfile argument. Also teach get_outfile_path() to generate per-kernel log names of the form test.<name>.log when given a name, for callers that need to keep the logs of several kernels apart. Characters that are awkward in file names, such as the '*' in a filter glob, are replaced with '_'. Add a unit test covering the new argument. Signed-off-by: Mark Brown <broonie@kernel.org> --- tools/testing/kunit/kunit_kernel.py | 28 ++++++++++++++++++++++++---- tools/testing/kunit/kunit_tool_test.py | 17 +++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/tools/testing/kunit/kunit_kernel.py b/tools/testing/kunit/kunit_kernel.py index 58557c47d85f..2641db9eddba 100644 --- a/tools/testing/kunit/kunit_kernel.py +++ b/tools/testing/kunit/kunit_kernel.py @@ -11,6 +11,7 @@ import importlib.util import logging import subprocess import os +import re import shlex import shutil import signal @@ -193,8 +194,19 @@ def get_parsed_kunitconfig(build_dir: str, merged.merge_in_entries(partial) return merged -def get_outfile_path(build_dir: str) -> str: - return os.path.join(build_dir, OUTFILE_PATH) +def get_outfile_path(build_dir: str, name: Optional[str]=None) -> str: + """Returns the path of the kernel console log. + + When several kernels are run each needs its own log, so `name` is + used to tell them apart, giving test.<name>.log. Characters that are + awkward in file names (anything other than letters, digits, '_', '-' + and '.') are replaced with '_'. + """ + if name is None: + return os.path.join(build_dir, OUTFILE_PATH) + base, ext = os.path.splitext(OUTFILE_PATH) + name = re.sub(r'[^\w.-]', '_', name) + return os.path.join(build_dir, f'{base}.{name}{ext}') def _default_qemu_config_path(arch: str) -> str: config_path = os.path.join(QEMU_CONFIGS_DIR, arch + '.py') @@ -352,7 +364,12 @@ class LinuxSourceTree: return subprocess.call(['stty', 'sane']) - def run_kernel(self, args: Optional[List[str]]=None, build_dir: str='', filter_glob: str='', filter: str='', filter_action: Optional[str]=None, timeout: Optional[int]=None) -> Iterator[str]: + def run_kernel(self, args: Optional[List[str]]=None, build_dir: str='', filter_glob: str='', filter: str='', filter_action: Optional[str]=None, timeout: Optional[int]=None, outfile: Optional[str]=None) -> Iterator[str]: + """Boots the kernel and yields its console output line by line. + + The output is also written to `outfile`, which defaults to + test.log in the build directory. + """ # Copy to avoid mutating the caller-supplied list. exec_tests() reuses # the same args across repeated run_kernel() calls (e.g. --run_isolated), # so appending to the original would accumulate stale flags on each call. @@ -365,6 +382,9 @@ class LinuxSourceTree: args.append('kunit.filter_action=' + filter_action) args.append('kunit.enable=1') + if outfile is None: + outfile = get_outfile_path(build_dir) + self._process = self._ops.start(args, build_dir) assert self._process is not None # tell mypy it's set assert self._process.stdout is not None # tell mypy it's set @@ -382,7 +402,7 @@ class LinuxSourceTree: waiter = threading.Thread(target=_wait_proc) waiter.start() - output = open(get_outfile_path(build_dir), 'w') + output = open(outfile, 'w') try: # Tee the output to the file and to our caller in real time. for line in self._process.stdout: diff --git a/tools/testing/kunit/kunit_tool_test.py b/tools/testing/kunit/kunit_tool_test.py index 17f926ef2820..4e8068dce918 100755 --- a/tools/testing/kunit/kunit_tool_test.py +++ b/tools/testing/kunit/kunit_tool_test.py @@ -522,6 +522,23 @@ class LinuxSourceTreeTest(unittest.TestCase): with open(kunit_kernel.get_outfile_path(build_dir), 'rt') as outfile: self.assertEqual(outfile.read(), 'hi\nbye\n', msg='Missing some output') + def test_run_kernel_outfile(self): + """Verify run_kernel() logs to the given outfile instead of test.log.""" + def fake_start(unused_args, unused_build_dir): + return subprocess.Popen(['printf', 'hi\n'], text=True, stdout=subprocess.PIPE) + + with tempfile.TemporaryDirectory('') as build_dir: + tree = kunit_kernel.LinuxSourceTree(build_dir, kunitconfig_paths=[os.devnull]) + mock.patch.object(tree._ops, 'start', side_effect=fake_start).start() + + # Awkward characters in the name are sanitised. + outfile = kunit_kernel.get_outfile_path(build_dir, 'my suite.test*') + self.assertEqual(outfile, os.path.join(build_dir, 'test.my_suite.test_.log')) + self.assertEqual(list(tree.run_kernel(build_dir=build_dir, outfile=outfile)), ['hi\n']) + with open(outfile, 'rt') as f: + self.assertEqual(f.read(), 'hi\n') + self.assertFalse(os.path.exists(kunit_kernel.get_outfile_path(build_dir))) + def test_run_kernel_args_not_mutated(self): """Verify run_kernel() copies args so callers can reuse them.""" start_calls = [] -- 2.47.3 ^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH 5/7] kunit: tool: Allow several run_kernel() calls to be in flight at once 2026-10-13 15:43 [PATCH 0/7] kunit: tool: Support parallel test execution Mark Brown ` (3 preceding siblings ...) 2026-10-13 15:43 ` [PATCH 4/7] kunit: tool: Add an outfile argument to run_kernel() Mark Brown @ 2026-10-13 15:43 ` Mark Brown 2026-10-13 15:43 ` [PATCH 6/7] kunit: tool: Serialise output written from multiple threads Mark Brown 2026-10-13 15:43 ` [PATCH 7/7] kunit: tool: Add --parallel to run suites in concurrent kernels Mark Brown 6 siblings, 0 replies; 8+ messages in thread From: Mark Brown @ 2026-10-13 15:43 UTC (permalink / raw) To: Brendan Higgins, David Gow, Rae Moar, Shuah Khan, Daniel Latypov, Jonathan Corbet, Randy Dunlap Cc: linux-kselftest, kunit-dev, linux-kernel, linux-doc, Mark Brown run_kernel() records the kernel it starts in a single self._process attribute, which the timeout thread, the cleanup at the end of the output and the SIGINT handler all read back. That only works while one kernel is running at a time, which rules out running suites in parallel guests from multiple threads. Track running kernels in a list protected by a lock instead, with each run_kernel() invocation only adding and removing its own entry. The timeout thread and the cleanup code work on a local reference to their own process rather than going via self, so one kernel timing out can no longer terminate a different one. The SIGINT handler terminates every kernel that is still running. No functional change when a single kernel is run at a time. Signed-off-by: Mark Brown <broonie@kernel.org> --- tools/testing/kunit/kunit_kernel.py | 45 ++++++++++++++++++++-------------- tools/testing/kunit/kunit_tool_test.py | 27 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/tools/testing/kunit/kunit_kernel.py b/tools/testing/kunit/kunit_kernel.py index 2641db9eddba..1d3226f5a136 100644 --- a/tools/testing/kunit/kunit_kernel.py +++ b/tools/testing/kunit/kunit_kernel.py @@ -277,7 +277,11 @@ class LinuxSourceTree: if kconfig_add: kconfig = kunit_config.parse_from_string('\n'.join(kconfig_add)) self._kconfig.merge_in_entries(kconfig) - self._process : Optional[subprocess.Popen[Any]] = None + # Kernels currently running. There can be several when + # run_kernel() is called concurrently from multiple threads. + self._processes : List[subprocess.Popen[Any]] = [] + # SIGINT can interrupt the main thread while it holds this lock. + self._processes_lock = threading.RLock() def arch(self) -> str: return self._arch @@ -368,7 +372,9 @@ class LinuxSourceTree: """Boots the kernel and yields its console output line by line. The output is also written to `outfile`, which defaults to - test.log in the build directory. + test.log in the build directory. This may be called from + several threads at once to run kernels concurrently, in which + case each caller must supply a distinct `outfile`. """ # Copy to avoid mutating the caller-supplied list. exec_tests() reuses # the same args across repeated run_kernel() calls (e.g. --run_isolated), @@ -385,37 +391,36 @@ class LinuxSourceTree: if outfile is None: outfile = get_outfile_path(build_dir) - self._process = self._ops.start(args, build_dir) - assert self._process is not None # tell mypy it's set - assert self._process.stdout is not None # tell mypy it's set + process = self._ops.start(args, build_dir) + assert process is not None # tell mypy it's set + assert process.stdout is not None # tell mypy it's set + with self._processes_lock: + self._processes.append(process) # Enforce the timeout in a background thread. def _wait_proc() -> None: try: - if self._process: - self._process.wait(timeout=timeout) + process.wait(timeout=timeout) except Exception as e: print(e) - if self._process: - self._process.terminate() - self._process.wait() + process.terminate() + process.wait() waiter = threading.Thread(target=_wait_proc) waiter.start() output = open(outfile, 'w') try: # Tee the output to the file and to our caller in real time. - for line in self._process.stdout: + for line in process.stdout: output.write(line) yield line # This runs even if our caller doesn't consume every line. finally: # Flush any leftover output to the file - if self._process: - if self._process.stdout: - output.write(self._process.stdout.read()) - self._process.stdout.close() - self._process = None + output.write(process.stdout.read()) + process.stdout.close() + with self._processes_lock: + self._processes.remove(process) output.close() waiter.join() @@ -423,7 +428,9 @@ class LinuxSourceTree: def signal_handler(self, unused_sig: int, unused_frame: Optional[FrameType]) -> None: logging.error('Build interruption occurred. Cleaning console.') - if self._process: - self._process.terminate() - self._process.wait() + with self._processes_lock: + processes = list(self._processes) + for process in processes: + process.terminate() + process.wait() self._restore_terminal_if_tty() diff --git a/tools/testing/kunit/kunit_tool_test.py b/tools/testing/kunit/kunit_tool_test.py index 4e8068dce918..c4999273e491 100755 --- a/tools/testing/kunit/kunit_tool_test.py +++ b/tools/testing/kunit/kunit_tool_test.py @@ -18,6 +18,7 @@ import os import signal import subprocess import sys +import textwrap from typing import Iterable import xml.etree.ElementTree as ET @@ -538,6 +539,8 @@ class LinuxSourceTreeTest(unittest.TestCase): with open(outfile, 'rt') as f: self.assertEqual(f.read(), 'hi\n') self.assertFalse(os.path.exists(kunit_kernel.get_outfile_path(build_dir))) + # The kernel has exited so should no longer be tracked. + self.assertEqual(tree._processes, []) def test_run_kernel_args_not_mutated(self): """Verify run_kernel() copies args so callers can reuse them.""" @@ -607,6 +610,30 @@ class LinuxSourceTreeTest(unittest.TestCase): mock_error.assert_called_once() mock_call.assert_called_once_with(['stty', 'sane']) + def test_signal_handler_during_process_update(self): + # Run in a subprocess so a deadlock fails with a timeout rather + # than hanging the test runner. Deliver a real signal while the + # main thread holds the lock used to update the process list. + code = textwrap.dedent('''\ + import os + import signal + from unittest import mock + import kunit_kernel + + tree = kunit_kernel.LinuxSourceTree('', kunitconfig_paths=[os.devnull]) + process = mock.Mock() + with tree._processes_lock: + tree._processes.append(process) + os.kill(os.getpid(), signal.SIGINT) + process.terminate.assert_called_once_with() + process.wait.assert_called_once_with() + ''') + result = subprocess.run([sys.executable, '-c', code], + cwd=os.path.dirname(os.path.abspath(__file__)), + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=5) + self.assertEqual(result.returncode, 0, msg=result.stdout) + def test_build_reconfig_no_config(self): with tempfile.TemporaryDirectory('') as build_dir: with open(kunit_kernel.get_kunitconfig_path(build_dir), 'w') as f: -- 2.47.3 ^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH 6/7] kunit: tool: Serialise output written from multiple threads 2026-10-13 15:43 [PATCH 0/7] kunit: tool: Support parallel test execution Mark Brown ` (4 preceding siblings ...) 2026-10-13 15:43 ` [PATCH 5/7] kunit: tool: Allow several run_kernel() calls to be in flight at once Mark Brown @ 2026-10-13 15:43 ` Mark Brown 2026-10-13 15:43 ` [PATCH 7/7] kunit: tool: Add --parallel to run suites in concurrent kernels Mark Brown 6 siblings, 0 replies; 8+ messages in thread From: Mark Brown @ 2026-10-13 15:43 UTC (permalink / raw) To: Brendan Higgins, David Gow, Rae Moar, Shuah Khan, Daniel Latypov, Jonathan Corbet, Randy Dunlap Cc: linux-kselftest, kunit-dev, linux-kernel, linux-doc, Mark Brown Python's text streams are not thread safe. If two threads write to sys.stdout at the same time the stream's internal buffer can be corrupted, resulting in lost output and runs of NUL bytes or heap garbage being written in its place. This isn't an issue currently, but will be when we implement support for parallel test execution. Add a lock to kunit_printer which Printer.print() takes around each write, and route the other prints that can run concurrently with the test results through the printer: the kernel start banners and timeout message in kunit_kernel, and the raw, JSON and JUnit output to stdout. Prints that only happen before any kernel is started, such as during configuration and build, are left alone. No change in output when a single kernel is run at a time. Signed-off-by: Mark Brown <broonie@kernel.org> --- tools/testing/kunit/kunit.py | 4 ++-- tools/testing/kunit/kunit_junit.py | 3 ++- tools/testing/kunit/kunit_kernel.py | 7 ++++--- tools/testing/kunit/kunit_printer.py | 9 ++++++++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tools/testing/kunit/kunit.py b/tools/testing/kunit/kunit.py index 971e70b0fb1a..0132687cdd43 100755 --- a/tools/testing/kunit/kunit.py +++ b/tools/testing/kunit/kunit.py @@ -272,7 +272,7 @@ def parse_tests(request: KunitParseRequest, metadata: kunit_json.Metadata, input elif request.raw_output == 'kunit': output = kunit_parser.extract_tap_lines(output) for line in output: - print(line.rstrip()) + stdout.print(line.rstrip()) parse_time = time.time() - parse_start return KunitResult(KunitStatus.SUCCESS, parse_time), fake_test @@ -302,7 +302,7 @@ def _write_test_results(request: KunitParseRequest, metadata: kunit_json.Metadat test=test, metadata=metadata) if request.json == 'stdout': - print(json_str) + stdout.print(json_str) else: with open(request.json, 'w') as f: f.write(json_str) diff --git a/tools/testing/kunit/kunit_junit.py b/tools/testing/kunit/kunit_junit.py index 3622070358e7..a15c6061003f 100644 --- a/tools/testing/kunit/kunit_junit.py +++ b/tools/testing/kunit/kunit_junit.py @@ -6,6 +6,7 @@ from xml.sax.saxutils import quoteattr, XMLGenerator import xml.etree.ElementTree as ET +import kunit_printer from kunit_parser import Test, TestStatus from typing import Optional @@ -51,7 +52,7 @@ def get_junit_result(test: Test) -> str: def print_junit_result(test: Test) -> None: root_element = get_test_suite(test, None) ET.indent(root_element) - ET.dump(root_element) + kunit_printer.stdout.print(ET.tostring(root_element, encoding='unicode')) # Write an entire XML file for the test structure starting at test def write_junit_result(test: Test, filename: str) -> None: diff --git a/tools/testing/kunit/kunit_kernel.py b/tools/testing/kunit/kunit_kernel.py index 1d3226f5a136..508bd62eaa40 100644 --- a/tools/testing/kunit/kunit_kernel.py +++ b/tools/testing/kunit/kunit_kernel.py @@ -21,6 +21,7 @@ from typing import Iterator, List, Optional, Tuple, Any from types import FrameType import kunit_config +import kunit_printer import qemu_config KCONFIG_PATH = '.config' @@ -132,7 +133,7 @@ class LinuxSourceTreeOperationsQemu(LinuxSourceTreeOperations): '-accel', 'tcg', '-serial', self._serial] + self._extra_qemu_params # Note: shlex.join() does what we want, but requires python 3.8+. - print('Running tests with:\n$', ' '.join(shlex.quote(arg) for arg in qemu_command)) + kunit_printer.stdout.print('Running tests with:\n$ ' + ' '.join(shlex.quote(arg) for arg in qemu_command)) return subprocess.Popen(qemu_command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -154,7 +155,7 @@ class LinuxSourceTreeOperationsUml(LinuxSourceTreeOperations): """Runs the Linux UML binary. Must be named 'linux'.""" linux_bin = os.path.join(build_dir, 'linux') params.extend(['mem=1G', 'console=tty', 'kunit_shutdown=halt']) - print('Running tests with:\n$', linux_bin, ' '.join(shlex.quote(arg) for arg in params)) + kunit_printer.stdout.print('Running tests with:\n$ ' + linux_bin + ' ' + ' '.join(shlex.quote(arg) for arg in params)) return subprocess.Popen([linux_bin] + params, stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -402,7 +403,7 @@ class LinuxSourceTree: try: process.wait(timeout=timeout) except Exception as e: - print(e) + kunit_printer.stdout.print(str(e)) process.terminate() process.wait() waiter = threading.Thread(target=_wait_proc) diff --git a/tools/testing/kunit/kunit_printer.py b/tools/testing/kunit/kunit_printer.py index ca119f61fe79..440aa7667ece 100644 --- a/tools/testing/kunit/kunit_printer.py +++ b/tools/testing/kunit/kunit_printer.py @@ -8,10 +8,16 @@ import datetime import sys +import threading import typing _RESET = '\033[0;0m' +# Python's text streams aren't thread safe: concurrent writes to +# sys.stdout from several threads can lose output or write garbage. All +# output from the tool should go through this lock. +print_lock = threading.Lock() + class Printer: """Wraps a file object, providing utilities for coloring output, etc.""" @@ -25,7 +31,8 @@ class Printer: def print(self, message: str) -> None: if self._print: - print(message, file=self._output) + with print_lock: + print(message, file=self._output) def print_with_timestamp(self, message: str) -> None: ts = datetime.datetime.now().strftime('%H:%M:%S') -- 2.47.3 ^ permalink raw reply related [flat|nested] 8+ messages in thread
* [PATCH 7/7] kunit: tool: Add --parallel to run suites in concurrent kernels 2026-10-13 15:43 [PATCH 0/7] kunit: tool: Support parallel test execution Mark Brown ` (5 preceding siblings ...) 2026-10-13 15:43 ` [PATCH 6/7] kunit: tool: Serialise output written from multiple threads Mark Brown @ 2026-10-13 15:43 ` Mark Brown 6 siblings, 0 replies; 8+ messages in thread From: Mark Brown @ 2026-10-13 15:43 UTC (permalink / raw) To: Brendan Higgins, David Gow, Rae Moar, Shuah Khan, Daniel Latypov, Jonathan Corbet, Randy Dunlap Cc: linux-kselftest, kunit-dev, linux-kernel, linux-doc, Mark Brown kunit.py boots a single kernel to run all the selected tests, one after another on a single CPU. This is good for robustness but means that the overall time taken to complete testing could be improved. Since we are already able to split up the set of tests run into multiple kernel boots with the --run-isolated option let's take advantage of that and allow execution in parallel. Add a --parallel N option to the run and exec subcommands. When N is greater than one the tests are enumerated with kunit.action=list and split up by top level suite, as --run_isolated=suite does, or by individual test if --run_isolated=test is also given. The resulting kernels are run from a thread pool N wide, each logging its console to test.<suite/test>.log in the build directory. Each kernel's output is buffered by its worker and handed to the parser in the order the suites were enumerated in, which is the order the kernel would have run them, so the results are reported identically regardless of which kernel happens to finish first. While developing this I noticed some issues with the existing --run_isolated support due to test naming, with those fixes in place results were identical with and without parallelism. Performance results with --alltests, using the elapsed time reported by the KUnit runner with an already built kernel on a Raspberry Pi 5 with 4 CPUs and a Graviton 4 with 192 CPUs: default parallel per test parallel per test Raspberry Pi 5 67.7s 49.4s 836.4s 326.5s Graviton 4 64.1s 30.8s 445.9s 39.7s Even with the overhead of having to boot more kernels we are seeing substantial speedups, for obvious reasons these are much better with more CPUs available. Signed-off-by: Mark Brown <broonie@kernel.org> --- Documentation/dev-tools/kunit/run_wrapper.rst | 11 ++ tools/testing/kunit/kunit.py | 95 +++++++++++---- tools/testing/kunit/kunit_tool_test.py | 165 +++++++++++++++++++++----- 3 files changed, 218 insertions(+), 53 deletions(-) diff --git a/Documentation/dev-tools/kunit/run_wrapper.rst b/Documentation/dev-tools/kunit/run_wrapper.rst index cecc110a3399..af5176463966 100644 --- a/Documentation/dev-tools/kunit/run_wrapper.rst +++ b/Documentation/dev-tools/kunit/run_wrapper.rst @@ -315,6 +315,17 @@ command line arguments: This is useful for debugging a non-hermetic test, one that might pass/fail based on what ran before it. +- ``--parallel``: Specifies the number of kernels to run at the same time. + The tests are split up by suite (or by test, with ``--run_isolated=test``) + and each is run in its own kernel, up to ``N`` at a time. The results are + reported in the same order as a sequential run, so the output is stable + regardless of which kernel finishes first. Each kernel's console output is + saved to its own log in the build directory, named after the suite or test + it ran, e.g. ``test.kunit_executor_test.log``. Note that each + kernel needs its own memory (1GiB for QEMU), so this is best combined with + ``--filter`` or a filter glob to avoid running more kernels than the host + can comfortably accommodate. + - ``--raw_output``: If set, generates unformatted output from kernel. Possible options are: - ``all``: To view the full kernel output, use ``--raw_output=all``. diff --git a/tools/testing/kunit/kunit.py b/tools/testing/kunit/kunit.py index 0132687cdd43..4c320ef207df 100755 --- a/tools/testing/kunit/kunit.py +++ b/tools/testing/kunit/kunit.py @@ -8,6 +8,7 @@ # Author: Brendan Higgins <brendanhiggins@google.com> import argparse +import concurrent.futures import os import re import shlex @@ -18,7 +19,7 @@ assert sys.version_info >= (3, 7), "Python version is too old" from dataclasses import dataclass, replace from enum import Enum, auto -from typing import Iterable, List, Optional, Sequence, Tuple +from typing import Iterable, Iterator, List, Optional, Sequence, Tuple import kunit_json import kunit_junit @@ -66,6 +67,7 @@ class KunitExecRequest(KunitParseRequest): list_tests: bool list_tests_attr: bool list_suites: bool + parallel: int @dataclass class KunitRequest(KunitExecRequest, KunitBuildRequest): @@ -166,6 +168,52 @@ def _suites_from_test_list(tests: List[str]) -> List[str]: suites.append(suite) return suites +def _run_kernels(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest, + filter_globs: List[str]) -> Iterator[Iterable[str]]: + """Boots a kernel for each filter glob and yields each kernel's output. + + Outputs are always yielded in the order of filter_globs, so the + results are reported in the same order whether the kernels are run + one after another or (with --parallel) several at a time. + """ + num_kernels = len(filter_globs) + workers = min(request.parallel, num_kernels) + if workers <= 1: + for i, filter_glob in enumerate(filter_globs): + stdout.print_with_timestamp('Starting KUnit Kernel ({}/{})...'.format(i+1, num_kernels)) + yield linux.run_kernel( + args=request.kernel_args, + timeout=request.timeout, + filter_glob=filter_glob, + filter=request.filter, + filter_action=request.filter_action, + build_dir=request.build_dir) + return + + # Each kernel's output is buffered by a worker thread and only + # handed back once all the kernels before it in filter_globs have + # been reported, so which kernel finishes first doesn't matter. + # The kernels write to separate log files named after the suite or + # test they ran, e.g. test.<suite>.log. + def run(filter_glob: str) -> List[str]: + return list(linux.run_kernel( + args=request.kernel_args, + timeout=request.timeout, + filter_glob=filter_glob, + filter=request.filter, + filter_action=request.filter_action, + build_dir=request.build_dir, + outfile=kunit_kernel.get_outfile_path(request.build_dir, filter_glob))) + + stdout.print_with_timestamp('Starting {} KUnit Kernels, {} at a time...'.format(num_kernels, workers)) + with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(run, filter_glob) for filter_glob in filter_globs] + for i, (filter_glob, future) in enumerate(zip(filter_globs, futures)): + output = future.result() + stdout.print_with_timestamp('Results from KUnit Kernel ({}/{}) for {}, console log in {}:'.format( + i+1, num_kernels, filter_glob, kunit_kernel.get_outfile_path(request.build_dir, filter_glob))) + yield output + def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) -> KunitResult: filter_globs = [request.filter_glob] if request.list_tests: @@ -184,11 +232,14 @@ def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) - for line in output: print(line.rstrip()) return KunitResult(status=KunitStatus.SUCCESS, elapsed_time=0.0) - if request.run_isolated: + if request.run_isolated or request.parallel > 1: tests = _list_tests(linux, request) if request.run_isolated == 'test': filter_globs = tests - elif request.run_isolated == 'suite': + else: + # One kernel per suite: either --run_isolated=suite, or + # --parallel, which splits the tests up by suite so that + # each suite can be run in a separate kernel. filter_globs = _suites_from_test_list(tests) # Apply the test-part of the user's glob, if present. if '.' in request.filter_glob: @@ -203,26 +254,9 @@ def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) - report.counts = test_counts parse_request = replace(request, json=None, junit=None) test_failed = False - exec_time = 0.0 - for i, filter_glob in enumerate(filter_globs): - stdout.print_with_timestamp('Starting KUnit Kernel ({}/{})...'.format(i+1, len(filter_globs))) - - test_start = time.time() - run_result = linux.run_kernel( - args=request.kernel_args, - timeout=request.timeout, - filter_glob=filter_glob, - filter=request.filter, - filter_action=request.filter_action, - build_dir=request.build_dir) - + exec_start = time.time() + for run_result in _run_kernels(linux, request, filter_globs): _, test_result = parse_tests(parse_request, metadata, run_result) - # run_kernel() doesn't block on the kernel exiting. - # That only happens after we get the last line of output from `run_result`. - # So exec_time here actually contains parsing + execution time, which is fine. - test_end = time.time() - exec_time += test_end - test_start - test_counts.add_subtest_counts(test_result.counts) # A kernel with no results has no failed test counts to aggregate. if _map_to_overall_status(test_result.status) != KunitStatus.SUCCESS: @@ -240,6 +274,10 @@ def exec_tests(linux: kunit_kernel.LinuxSourceTree, request: KunitExecRequest) - report.status = kunit_parser.TestStatus.FAILURE_TO_PARSE_TESTS if not request.raw_output: _write_test_results(request, metadata, report) + # run_kernel() doesn't block on the kernel exiting. + # That only happens after we get the last line of output from `run_result`. + # So exec_time here actually contains parsing + execution time, which is fine. + exec_time = time.time() - exec_start if len(filter_globs) == 1 and test_counts.crashed > 0: bd = request.build_dir @@ -492,6 +530,13 @@ def add_exec_opts(parser: argparse.ArgumentParser) -> None: parser.add_argument('--list_suites', help='If set, list all suites that will be ' 'run.', action='store_true') + parser.add_argument('--parallel', + help='Number of kernels to run at the same time. The tests ' + 'are split up by suite (or by test with --run_isolated=test) ' + 'and each suite is run in its own kernel, with the results ' + 'reported in the same order as a sequential run. Defaults ' + 'to 1, i.e. a single kernel running all the tests.', + type=int, default=1, metavar='N') def add_parse_opts(parser: argparse.ArgumentParser) -> None: parser.add_argument('--raw_output', help='If set don\'t parse output from kernel. ' @@ -562,7 +607,8 @@ def run_handler(cli_args: argparse.Namespace) -> None: run_isolated=cli_args.run_isolated, list_tests=cli_args.list_tests, list_tests_attr=cli_args.list_tests_attr, - list_suites=cli_args.list_suites) + list_suites=cli_args.list_suites, + parallel=cli_args.parallel) result = run_tests(linux, request) if result.status != KunitStatus.SUCCESS: sys.exit(1) @@ -613,7 +659,8 @@ def exec_handler(cli_args: argparse.Namespace) -> None: run_isolated=cli_args.run_isolated, list_tests=cli_args.list_tests, list_tests_attr=cli_args.list_tests_attr, - list_suites=cli_args.list_suites) + list_suites=cli_args.list_suites, + parallel=cli_args.parallel) result = exec_tests(linux, exec_request) stdout.print_with_timestamp(( 'Elapsed time: %.3fs\n') % (result.elapsed_time)) diff --git a/tools/testing/kunit/kunit_tool_test.py b/tools/testing/kunit/kunit_tool_test.py index c4999273e491..beb80c728004 100755 --- a/tools/testing/kunit/kunit_tool_test.py +++ b/tools/testing/kunit/kunit_tool_test.py @@ -19,6 +19,7 @@ import signal import subprocess import sys import textwrap +import time from typing import Iterable import xml.etree.ElementTree as ET @@ -1019,7 +1020,7 @@ class KUnitMainTest(unittest.TestCase): self.linux_source_mock.run_kernel.return_value = ['TAP version 14', 'init: random output'] + want got = kunit._list_tests(self.linux_source_mock, - kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'suite', False, False, False)) + kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'suite', False, False, False, 1)) self.assertEqual(got, want) # Should respect the user's filter glob when listing tests. self.linux_source_mock.run_kernel.assert_called_once_with( @@ -1034,7 +1035,7 @@ class KUnitMainTest(unittest.TestCase): '[ 0.300000] suite2.test1'] got = kunit._list_tests(self.linux_source_mock, - kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'suite', False, False, False)) + kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'suite', False, False, False, 1)) self.assertEqual(got, want) @mock.patch.object(kunit, '_list_tests') @@ -1044,7 +1045,7 @@ class KUnitMainTest(unittest.TestCase): # Should respect the user's filter glob when listing tests. mock_tests.assert_called_once_with(mock.ANY, - kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*.test*', '', None, None, 'suite', False, False, False)) + kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*.test*', '', None, None, 'suite', False, False, False, 1)) self.linux_source_mock.run_kernel.assert_has_calls([ mock.call(args=None, build_dir='.kunit', filter_glob='suite.test*', filter='', filter_action=None, timeout=300), mock.call(args=None, build_dir='.kunit', filter_glob='suite2.test*', filter='', filter_action=None, timeout=300), @@ -1057,7 +1058,7 @@ class KUnitMainTest(unittest.TestCase): # Should respect the user's filter glob when listing tests. mock_tests.assert_called_once_with(mock.ANY, - kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'test', False, False, False)) + kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'test', False, False, False, 1)) self.linux_source_mock.run_kernel.assert_has_calls([ mock.call(args=None, build_dir='.kunit', filter_glob='suite.test1', filter='', filter_action=None, timeout=300), mock.call(args=None, build_dir='.kunit', filter_glob='suite.test2', filter='', filter_action=None, timeout=300), @@ -1065,35 +1066,115 @@ class KUnitMainTest(unittest.TestCase): ]) @mock.patch.object(kunit, '_list_tests') - def test_isolated_kernel_without_results_fails(self, mock_tests): + def test_parallel_by_suite(self, mock_tests): + mock_tests.return_value = ['suite.test1', 'suite.test2', 'suite2.test1'] + kunit.main(['exec', '--parallel=2', 'suite*.test*']) + + # Should split by suite, respecting the user's filter glob. + mock_tests.assert_called_once_with(mock.ANY, + kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*.test*', '', None, None, None, False, False, False, 2)) + # Each kernel gets its own log file, named after its suite. + # The kernels run concurrently, so don't depend on the order + # they start in. + self.assertEqual(self.linux_source_mock.run_kernel.call_count, 2) + self.linux_source_mock.run_kernel.assert_has_calls([ + mock.call(args=None, build_dir='.kunit', filter_glob='suite.test*', filter='', filter_action=None, timeout=300, outfile='.kunit/test.suite.test_.log'), + mock.call(args=None, build_dir='.kunit', filter_glob='suite2.test*', filter='', filter_action=None, timeout=300, outfile='.kunit/test.suite2.test_.log'), + ], any_order=True) + self.print_mock.assert_any_call(StrContains('Starting 2 KUnit Kernels, 2 at a time...')) + self.print_mock.assert_any_call(StrContains('Results from KUnit Kernel (1/2) for suite.test*, console log in .kunit/test.suite.test_.log:')) + self.print_mock.assert_any_call(StrContains('Results from KUnit Kernel (2/2) for suite2.test*, console log in .kunit/test.suite2.test_.log:')) + + @mock.patch.object(kunit, '_list_tests') + def test_parallel_by_test(self, mock_tests): + mock_tests.return_value = ['suite.test1', 'suite.test2', 'suite2.test1'] + kunit.main(['exec', '--parallel=2', '--run_isolated=test', 'suite*']) + + self.assertEqual(self.linux_source_mock.run_kernel.call_count, 3) + self.linux_source_mock.run_kernel.assert_has_calls([ + mock.call(args=None, build_dir='.kunit', filter_glob='suite.test1', filter='', filter_action=None, timeout=300, outfile='.kunit/test.suite.test1.log'), + mock.call(args=None, build_dir='.kunit', filter_glob='suite.test2', filter='', filter_action=None, timeout=300, outfile='.kunit/test.suite.test2.log'), + mock.call(args=None, build_dir='.kunit', filter_glob='suite2.test1', filter='', filter_action=None, timeout=300, outfile='.kunit/test.suite2.test1.log'), + ], any_order=True) + + @mock.patch.object(kunit, '_list_tests') + def test_parallel_single_suite_runs_one_kernel(self, mock_tests): + # With only one suite there's nothing to parallelise, so this + # should behave exactly like a sequential run. + mock_tests.return_value = ['suite.test1', 'suite.test2'] + kunit.main(['exec', '--parallel=4', 'suite']) + + self.linux_source_mock.run_kernel.assert_called_once_with( + args=None, build_dir='.kunit', filter_glob='suite', filter='', filter_action=None, timeout=300) + self.print_mock.assert_any_call(StrContains('Starting KUnit Kernel (1/1)...')) + + @mock.patch.object(kunit, '_list_tests') + def test_parallel_stable_order(self, mock_tests): + mock_tests.return_value = ['suite1.test1', 'suite2.test1', 'suite3.test1'] + + # Make the kernels finish in the reverse of the order they're + # listed in: results must still come out in the listed order. + def fake_run_kernel(filter_glob, **kwargs): + delay = {'suite1': 0.2, 'suite2': 0.1, 'suite3': 0.0}[filter_glob] + time.sleep(delay) + return ['TAP version 14', '1..1', 'ok 1 - ' + filter_glob] + self.linux_source_mock.run_kernel.side_effect = fake_run_kernel + + kunit.main(['exec', '--parallel=3', '--raw_output=kunit']) + + output = [call.args[0] for call in self.print_mock.call_args_list] + want = ['ok 1 - suite1', 'ok 1 - suite2', 'ok 1 - suite3'] + self.assertEqual([l for l in output if l.startswith('ok')], want) + + @mock.patch.object(kunit, '_list_tests') + def test_parallel_counts_all_kernels(self, mock_tests): mock_tests.return_value = ['suite1.test1', 'suite2.test1'] - for missing_output in ([], ['KTAP version 1', '1..0']): - for missing_suite in ('suite1', 'suite2'): - with self.subTest(output=missing_output, - suite=missing_suite): - def fake_run_kernel(filter_glob, **kwargs): - if filter_glob == missing_suite: - return missing_output - return ['KTAP version 1', '1..1', 'ok 1 - test1'] - self.linux_source_mock.run_kernel.side_effect = fake_run_kernel - with self.assertRaises(SystemExit) as e: - kunit.main(['exec', '--run_isolated=suite']) - self.assertEqual(e.exception.code, 1) + def fake_run_kernel(filter_glob, **kwargs): + if filter_glob == 'suite2': + return ['TAP version 14', '1..1', 'not ok 1 - suite2'] + return ['TAP version 14', '1..1', 'ok 1 - suite1'] + self.linux_source_mock.run_kernel.side_effect = fake_run_kernel + + # A failure in any of the kernels must fail the overall run. + with self.assertRaises(SystemExit) as e: + kunit.main(['exec', '--parallel=2']) + self.assertEqual(e.exception.code, 1) + + @mock.patch.object(kunit, '_list_tests') + def test_isolated_kernel_without_results_fails(self, mock_tests): + mock_tests.return_value = ['suite1.test1', 'suite2.test1'] + for parallel in (1, 2): + for missing_output in ([], ['KTAP version 1', '1..0']): + for missing_suite in ('suite1', 'suite2'): + with self.subTest(parallel=parallel, output=missing_output, + suite=missing_suite): + def fake_run_kernel(filter_glob, **kwargs): + if filter_glob == missing_suite: + return missing_output + return ['KTAP version 1', '1..1', 'ok 1 - test1'] + self.linux_source_mock.run_kernel.side_effect = fake_run_kernel + + with self.assertRaises(SystemExit) as e: + kunit.main(['exec', '--run_isolated=suite', + '--parallel=' + str(parallel)]) + self.assertEqual(e.exception.code, 1) @mock.patch.object(kunit, '_list_tests') def test_isolated_skipped_kernels_succeed(self, mock_tests): mock_tests.return_value = ['suite1.test1', 'suite2.test1'] - for all_skipped in (False, True): - with self.subTest(all_skipped=all_skipped): - def fake_run_kernel(filter_glob, **kwargs): - result = 'ok 1 - test1' - if all_skipped or filter_glob == 'suite1': - result += ' # SKIP unavailable' - return ['KTAP version 1', '1..1', result] - self.linux_source_mock.run_kernel.side_effect = fake_run_kernel + for parallel in (1, 2): + for all_skipped in (False, True): + with self.subTest(parallel=parallel, all_skipped=all_skipped): + def fake_run_kernel(filter_glob, **kwargs): + result = 'ok 1 - test1' + if all_skipped or filter_glob == 'suite1': + result += ' # SKIP unavailable' + return ['KTAP version 1', '1..1', result] + self.linux_source_mock.run_kernel.side_effect = fake_run_kernel - kunit.main(['exec', '--run_isolated=suite']) + kunit.main(['exec', '--run_isolated=suite', + '--parallel=' + str(parallel)]) @mock.patch.object(kunit, '_list_tests') def test_isolated_reports_include_all_kernels(self, mock_tests): @@ -1109,8 +1190,8 @@ class KUnitMainTest(unittest.TestCase): ' 1..1', f' {status} 1 - {case}', f'{status} 1 - {suite}'] self.linux_source_mock.run_kernel.side_effect = fake_run_kernel - for isolation in ('suite', 'test'): - with self.subTest(isolation=isolation), \ + for parallel, isolation in itertools.product((1, 2), ('suite', 'test')): + with self.subTest(parallel=parallel, isolation=isolation), \ tempfile.TemporaryDirectory() as output_dir: if isolation == 'suite': mock_tests.return_value = ['suite1.test', 'suite2.test'] @@ -1121,7 +1202,8 @@ class KUnitMainTest(unittest.TestCase): json_path = os.path.join(output_dir, 'results.json') xml_path = os.path.join(output_dir, 'results.xml') with self.assertRaises(SystemExit) as e: - kunit.main(['exec', '--run_isolated=' + isolation, + kunit.main(['exec', '--parallel=' + str(parallel), + '--run_isolated=' + isolation, '--json=' + json_path, '--junit=' + xml_path]) self.assertEqual(e.exception.code, 1) @@ -1139,6 +1221,31 @@ class KUnitMainTest(unittest.TestCase): 'FAIL' if case.find('failure') is not None else 'PASS') for suite in root for case in suite], expected) + @mock.patch.object(kunit, '_list_tests') + def test_parallel_reports_to_stdout(self, mock_tests): + mock_tests.return_value = ['suite1.test', 'suite2.test'] + self.linux_source_mock.run_kernel.side_effect = lambda filter_glob, **kwargs: [ + 'KTAP version 1', '1..1', 'ok 1 - ' + filter_glob] + kunit.main(['exec', '--parallel=2', '--json', '--junit']) + output = [call.args[0] for call in self.print_mock.call_args_list] + json_reports = [json.loads(line) for line in output if line.startswith('{')] + xml_reports = [ET.fromstring(line) for line in output if line.startswith('<testsuite')] + self.assertEqual(len(json_reports), 1) + self.assertEqual(json_reports[0]['misc']['tests'], 2) + self.assertEqual(len(xml_reports), 1) + self.assertEqual(xml_reports[0].get('tests'), '2') + + @mock.patch.object(kunit, '_list_tests') + def test_parallel_raw_output_does_not_write_reports(self, mock_tests): + mock_tests.return_value = ['suite1.test', 'suite2.test'] + with tempfile.TemporaryDirectory() as output_dir: + json_path = os.path.join(output_dir, 'results.json') + xml_path = os.path.join(output_dir, 'results.xml') + kunit.main(['exec', '--parallel=2', '--raw_output=all', + '--json=' + json_path, '--junit=' + xml_path]) + self.assertFalse(os.path.exists(json_path)) + self.assertFalse(os.path.exists(xml_path)) + def test_single_kernel_reports_match_parse(self): with tempfile.TemporaryDirectory() as output_dir: json_path = os.path.join(output_dir, 'results.json') -- 2.47.3 ^ permalink raw reply related [flat|nested] 8+ messages in thread
end of thread, other threads:[~2026-10-13 15:52 UTC | newest] Thread overview: 8+ messages (download: mbox.gz follow: Atom feed -- links below jump to the message on this page -- 2026-10-13 15:43 [PATCH 0/7] kunit: tool: Support parallel test execution Mark Brown 2026-10-13 15:43 ` [PATCH 1/7] kunit: tool: Preserve failures from kernels without test results Mark Brown 2026-10-13 15:43 ` [PATCH 2/7] kunit: tool: Combine reports from isolated kernels Mark Brown 2026-10-13 15:43 ` [PATCH 3/7] kunit: tool: Boot with a larger log buffer when listing tests Mark Brown 2026-10-13 15:43 ` [PATCH 4/7] kunit: tool: Add an outfile argument to run_kernel() Mark Brown 2026-10-13 15:43 ` [PATCH 5/7] kunit: tool: Allow several run_kernel() calls to be in flight at once Mark Brown 2026-10-13 15:43 ` [PATCH 6/7] kunit: tool: Serialise output written from multiple threads Mark Brown 2026-10-13 15:43 ` [PATCH 7/7] kunit: tool: Add --parallel to run suites in concurrent kernels Mark Brown
This is a public inbox, see mirroring instructions for how to clone and mirror all data and code used for this inbox