[llvm] [lit] Add --per-test-output-subdir to isolate test_exec_root across concurrent runs (PR #206826)

David Young via llvm-commits llvm-commits at lists.llvm.org
Tue Jul 14 11:52:16 PDT 2026


https://github.com/youngd007 updated https://github.com/llvm/llvm-project/pull/206826

>From dfb82b2bd2045ba9ded2316bc67053682f4094b9 Mon Sep 17 00:00:00 2001
From: David Young <davidayoung at meta.com>
Date: Tue, 30 Jun 2026 13:18:15 -0700
Subject: [PATCH 1/5] [lit] Add --per-process-output-dir to isolate
 test_exec_root across concurrent runs

---
 llvm/docs/CommandGuide/lit.rst                |  9 ++++++++
 llvm/utils/lit/lit/LitConfig.py               |  2 ++
 llvm/utils/lit/lit/TestingConfig.py           | 13 +++++++++++
 llvm/utils/lit/lit/cl_arguments.py            | 10 +++++++++
 llvm/utils/lit/lit/main.py                    |  1 +
 .../Inputs/per-process-output-dir/lit.cfg     |  9 ++++++++
 .../Inputs/per-process-output-dir/test.txt    |  2 ++
 .../utils/lit/tests/per-process-output-dir.py | 22 +++++++++++++++++++
 8 files changed, 68 insertions(+)
 create mode 100644 llvm/utils/lit/tests/Inputs/per-process-output-dir/lit.cfg
 create mode 100644 llvm/utils/lit/tests/Inputs/per-process-output-dir/test.txt
 create mode 100644 llvm/utils/lit/tests/per-process-output-dir.py

diff --git a/llvm/docs/CommandGuide/lit.rst b/llvm/docs/CommandGuide/lit.rst
index 8b58011f8ec37..6a0e931199629 100644
--- a/llvm/docs/CommandGuide/lit.rst
+++ b/llvm/docs/CommandGuide/lit.rst
@@ -246,6 +246,15 @@ EXECUTION OPTIONS
  setting a unique value to LLVM_PROFILE_FILE for each RUN). The coverage
  data files will be emitted in the directory specified by ``config.test_exec_root``.
 
+.. option:: --per-process-output-dir
+
+ Isolate each lit process's writable output by appending a unique per-process
+ subdirectory (``pid-<pid>``) to each test suite's ``config.test_exec_root``.
+ This causes ``%t``, ``%T`` and the per-test ``Output`` directory to resolve
+ under a per-process path, so multiple lit processes can run the same tests
+ concurrently against a single build tree (for example, test stress runs)
+ without clobbering each other's temporary files. Off by default.
+
 .. option:: --ignore-fail
 
  Exit with status zero even if some tests fail.
diff --git a/llvm/utils/lit/lit/LitConfig.py b/llvm/utils/lit/lit/LitConfig.py
index 331320cf8ebc8..5622ee9ee29ab 100644
--- a/llvm/utils/lit/lit/LitConfig.py
+++ b/llvm/utils/lit/lit/LitConfig.py
@@ -41,6 +41,7 @@ def __init__(
         per_test_coverage=False,
         gtest_sharding=True,
         update_tests=False,
+        per_process_output_dir=False,
     ):
         # The name of the test runner.
         self.progname = progname
@@ -94,6 +95,7 @@ def __init__(
         self.per_test_coverage = per_test_coverage
         self.gtest_sharding = bool(gtest_sharding)
         self.update_tests = update_tests
+        self.per_process_output_dir = bool(per_process_output_dir)
         self.test_updaters = [diff_test_updater]
 
     @property
diff --git a/llvm/utils/lit/lit/TestingConfig.py b/llvm/utils/lit/lit/TestingConfig.py
index 1508b1d71caf2..f3e85e62eade4 100644
--- a/llvm/utils/lit/lit/TestingConfig.py
+++ b/llvm/utils/lit/lit/TestingConfig.py
@@ -239,6 +239,19 @@ def finish(self, litConfig):
             # FIXME: This should really only be suite in test suite config
             # files. Should we distinguish them?
             self.test_exec_root = str(self.test_exec_root)
+            # Optionally isolate this lit process's writable output tree so that
+            # multiple lit processes can run the same tests concurrently against
+            # one build tree without clobbering each other's %t/%T/Output files.
+            # The guard makes this idempotent: local configs are deep-copied from
+            # their parent (which is already isolated) and then finish()ed again,
+            # which must not append a second component.
+            if litConfig.per_process_output_dir and not getattr(
+                self, "_per_process_output_dir_applied", False
+            ):
+                self.test_exec_root = os.path.join(
+                    self.test_exec_root, "pid-%d" % os.getpid()
+                )
+                self._per_process_output_dir_applied = True
         if self.test_source_root is not None:
             # FIXME: This should really only be suite in test suite config
             # files. Should we distinguish them?
diff --git a/llvm/utils/lit/lit/cl_arguments.py b/llvm/utils/lit/lit/cl_arguments.py
index 6225ac57abfd0..3a36aef53e5f0 100644
--- a/llvm/utils/lit/lit/cl_arguments.py
+++ b/llvm/utils/lit/lit/cl_arguments.py
@@ -363,6 +363,16 @@ def parse_args():
         action="store_true",
         help="Enable individual test case coverage",
     )
+    execution_group.add_argument(
+        "--per-process-output-dir",
+        dest="per_process_output_dir",
+        action="store_true",
+        help="Isolate each lit process's writable output by appending a unique "
+        "per-process subdirectory (pid-<pid>) to each test suite's "
+        "test_exec_root. This lets multiple lit processes run the same tests "
+        "concurrently against a single build tree (e.g. test stress runs) "
+        "without clobbering each other's %%t/%%T/Output files. [Default: Off]",
+    )
     execution_group.add_argument(
         "--ignore-fail",
         dest="ignoreFail",
diff --git a/llvm/utils/lit/lit/main.py b/llvm/utils/lit/lit/main.py
index a3bd153040a69..74b5609655772 100755
--- a/llvm/utils/lit/lit/main.py
+++ b/llvm/utils/lit/lit/main.py
@@ -41,6 +41,7 @@ def main(builtin_params={}):
         params=params,
         config_prefix=opts.configPrefix,
         per_test_coverage=opts.per_test_coverage,
+        per_process_output_dir=opts.per_process_output_dir,
         gtest_sharding=opts.gtest_sharding,
         maxRetriesPerTest=opts.maxRetriesPerTest,
         update_tests=opts.update_tests,
diff --git a/llvm/utils/lit/tests/Inputs/per-process-output-dir/lit.cfg b/llvm/utils/lit/tests/Inputs/per-process-output-dir/lit.cfg
new file mode 100644
index 0000000000000..43fa67116e27b
--- /dev/null
+++ b/llvm/utils/lit/tests/Inputs/per-process-output-dir/lit.cfg
@@ -0,0 +1,9 @@
+import lit.formats
+
+config.name = "per-process-output-dir"
+config.suffixes = [".txt"]
+config.test_format = lit.formats.ShTest()
+config.test_source_root = os.path.dirname(__file__)
+# The outer test passes a writable exec root so that --per-process-output-dir
+# has a non-None test_exec_root to splice the per-process component into.
+config.test_exec_root = lit_config.params["exec_root"]
diff --git a/llvm/utils/lit/tests/Inputs/per-process-output-dir/test.txt b/llvm/utils/lit/tests/Inputs/per-process-output-dir/test.txt
new file mode 100644
index 0000000000000..d2b85b1fbf71e
--- /dev/null
+++ b/llvm/utils/lit/tests/Inputs/per-process-output-dir/test.txt
@@ -0,0 +1,2 @@
+# Print the resolved per-test temp path so the outer test can inspect it.
+# RUN: echo "TEMP_PATH=%t"
diff --git a/llvm/utils/lit/tests/per-process-output-dir.py b/llvm/utils/lit/tests/per-process-output-dir.py
new file mode 100644
index 0000000000000..3c6f27f9f93e3
--- /dev/null
+++ b/llvm/utils/lit/tests/per-process-output-dir.py
@@ -0,0 +1,22 @@
+# Check that --per-process-output-dir isolates each lit process's writable
+# output tree by splicing a unique "pid-<pid>" component into test_exec_root,
+# so that %t/%T/Output resolve under a per-process directory. This lets multiple
+# lit processes run the same tests concurrently against one build tree without
+# clobbering each other.
+
+# With the flag, the temp path is nested under a pid-<pid> directory.
+# RUN: rm -rf %t && mkdir -p %t
+# RUN: %{lit} -a --per-process-output-dir -Dexec_root=%t \
+# RUN:     %{inputs}/per-process-output-dir | \
+# RUN:   FileCheck --check-prefix=ISOLATED %s
+
+# Without the flag, the temp path is not nested under a pid- directory.
+# RUN: rm -rf %t && mkdir -p %t
+# RUN: %{lit} -a -Dexec_root=%t \
+# RUN:     %{inputs}/per-process-output-dir | \
+# RUN:   FileCheck --check-prefix=SHARED %s
+
+# ISOLATED: TEMP_PATH={{.*}}/pid-{{[0-9]+}}/{{.*}}Output
+
+# SHARED: TEMP_PATH=
+# SHARED-NOT: /pid-

>From ceea9b13427bebab352c1550d987834d43b6498c Mon Sep 17 00:00:00 2001
From: David Young <davidayoung at meta.com>
Date: Wed, 1 Jul 2026 04:40:46 -0700
Subject: [PATCH 2/5] [lit] Rework isolation flag into
 --per-test-output-subdir=ID

Address PR feedback: rather than a boolean --per-process-output-dir that
derives the isolation directory from the PID, take a caller-supplied ID
and append it as a subdirectory to each suite's config.test_exec_root
(%t/%T/Output resolve under test_exec_root/<ID>).

Making the ID caller-chosen decouples it from the process, so it can be a
PID, a CI job id, etc. Reusing an ID reuses that output tree, preserving
artifacts such as .lit_test_times.txt across runs (retaining test-ordering
benefits when a build tree is reused). Passing --per-test-output-subdir=pid-$$
recovers the original per-process behavior.
---
 llvm/docs/CommandGuide/lit.rst                | 18 ++++++++-------
 llvm/utils/lit/lit/LitConfig.py               |  4 ++--
 llvm/utils/lit/lit/TestingConfig.py           | 17 ++++++++------
 llvm/utils/lit/lit/cl_arguments.py            | 20 ++++++++++-------
 llvm/utils/lit/lit/main.py                    |  2 +-
 .../lit.cfg                                   |  6 ++---
 .../test.txt                                  |  0
 .../utils/lit/tests/per-process-output-dir.py | 22 -------------------
 .../utils/lit/tests/per-test-output-subdir.py | 22 +++++++++++++++++++
 9 files changed, 60 insertions(+), 51 deletions(-)
 rename llvm/utils/lit/tests/Inputs/{per-process-output-dir => per-test-output-subdir}/lit.cfg (50%)
 rename llvm/utils/lit/tests/Inputs/{per-process-output-dir => per-test-output-subdir}/test.txt (100%)
 delete mode 100644 llvm/utils/lit/tests/per-process-output-dir.py
 create mode 100644 llvm/utils/lit/tests/per-test-output-subdir.py

diff --git a/llvm/docs/CommandGuide/lit.rst b/llvm/docs/CommandGuide/lit.rst
index 6a0e931199629..57ce95730f00c 100644
--- a/llvm/docs/CommandGuide/lit.rst
+++ b/llvm/docs/CommandGuide/lit.rst
@@ -246,14 +246,16 @@ EXECUTION OPTIONS
  setting a unique value to LLVM_PROFILE_FILE for each RUN). The coverage
  data files will be emitted in the directory specified by ``config.test_exec_root``.
 
-.. option:: --per-process-output-dir
-
- Isolate each lit process's writable output by appending a unique per-process
- subdirectory (``pid-<pid>``) to each test suite's ``config.test_exec_root``.
- This causes ``%t``, ``%T`` and the per-test ``Output`` directory to resolve
- under a per-process path, so multiple lit processes can run the same tests
- concurrently against a single build tree (for example, test stress runs)
- without clobbering each other's temporary files. Off by default.
+.. option:: --per-test-output-subdir ID
+
+ Isolate this run's writable output by appending ``ID`` as a subdirectory to
+ each test suite's ``config.test_exec_root``. This causes ``%t``, ``%T`` and the
+ per-test ``Output`` directory to resolve under ``test_exec_root/<ID>``, so
+ multiple lit runs can use the same tests concurrently against a single build
+ tree (for example, test stress runs) without clobbering each other's temporary
+ files. Give each concurrent run a distinct ``ID`` (a process id, CI job id,
+ etc.); reusing an ``ID`` reuses that output tree, preserving artifacts such as
+ ``.lit_test_times.txt`` across runs. Disabled by default.
 
 .. option:: --ignore-fail
 
diff --git a/llvm/utils/lit/lit/LitConfig.py b/llvm/utils/lit/lit/LitConfig.py
index 5622ee9ee29ab..e9cebd85a1719 100644
--- a/llvm/utils/lit/lit/LitConfig.py
+++ b/llvm/utils/lit/lit/LitConfig.py
@@ -41,7 +41,7 @@ def __init__(
         per_test_coverage=False,
         gtest_sharding=True,
         update_tests=False,
-        per_process_output_dir=False,
+        per_test_output_subdir=None,
     ):
         # The name of the test runner.
         self.progname = progname
@@ -95,7 +95,7 @@ def __init__(
         self.per_test_coverage = per_test_coverage
         self.gtest_sharding = bool(gtest_sharding)
         self.update_tests = update_tests
-        self.per_process_output_dir = bool(per_process_output_dir)
+        self.per_test_output_subdir = per_test_output_subdir
         self.test_updaters = [diff_test_updater]
 
     @property
diff --git a/llvm/utils/lit/lit/TestingConfig.py b/llvm/utils/lit/lit/TestingConfig.py
index f3e85e62eade4..e90b50452e908 100644
--- a/llvm/utils/lit/lit/TestingConfig.py
+++ b/llvm/utils/lit/lit/TestingConfig.py
@@ -239,19 +239,22 @@ def finish(self, litConfig):
             # FIXME: This should really only be suite in test suite config
             # files. Should we distinguish them?
             self.test_exec_root = str(self.test_exec_root)
-            # Optionally isolate this lit process's writable output tree so that
-            # multiple lit processes can run the same tests concurrently against
-            # one build tree without clobbering each other's %t/%T/Output files.
+            # Optionally isolate this run's writable output tree so that
+            # multiple lit runs can use the same tests concurrently against one
+            # build tree without clobbering each other's %t/%T/Output files.
+            # The caller-supplied ID names the subdirectory, so it need not be
+            # tied to the process (a PID, CI job id, etc.); reusing an ID reuses
+            # the tree, preserving artifacts like .lit_test_times.txt.
             # The guard makes this idempotent: local configs are deep-copied from
             # their parent (which is already isolated) and then finish()ed again,
             # which must not append a second component.
-            if litConfig.per_process_output_dir and not getattr(
-                self, "_per_process_output_dir_applied", False
+            if litConfig.per_test_output_subdir and not getattr(
+                self, "_per_test_output_subdir_applied", False
             ):
                 self.test_exec_root = os.path.join(
-                    self.test_exec_root, "pid-%d" % os.getpid()
+                    self.test_exec_root, litConfig.per_test_output_subdir
                 )
-                self._per_process_output_dir_applied = True
+                self._per_test_output_subdir_applied = True
         if self.test_source_root is not None:
             # FIXME: This should really only be suite in test suite config
             # files. Should we distinguish them?
diff --git a/llvm/utils/lit/lit/cl_arguments.py b/llvm/utils/lit/lit/cl_arguments.py
index 3a36aef53e5f0..e309df96f209e 100644
--- a/llvm/utils/lit/lit/cl_arguments.py
+++ b/llvm/utils/lit/lit/cl_arguments.py
@@ -364,14 +364,18 @@ def parse_args():
         help="Enable individual test case coverage",
     )
     execution_group.add_argument(
-        "--per-process-output-dir",
-        dest="per_process_output_dir",
-        action="store_true",
-        help="Isolate each lit process's writable output by appending a unique "
-        "per-process subdirectory (pid-<pid>) to each test suite's "
-        "test_exec_root. This lets multiple lit processes run the same tests "
-        "concurrently against a single build tree (e.g. test stress runs) "
-        "without clobbering each other's %%t/%%T/Output files. [Default: Off]",
+        "--per-test-output-subdir",
+        dest="per_test_output_subdir",
+        metavar="ID",
+        default=None,
+        help="Isolate this run's writable output by appending the given ID as a "
+        "subdirectory to each test suite's test_exec_root, so %%t/%%T/Output "
+        "resolve under test_exec_root/<ID>. This lets multiple lit runs use the "
+        "same tests concurrently against a single build tree (e.g. test stress "
+        "runs) without clobbering each other's output; give each run a distinct "
+        "ID (a PID, CI job id, etc.). Reusing an ID reuses that output tree, "
+        "preserving artifacts like .lit_test_times.txt across runs. [Default: "
+        "disabled]",
     )
     execution_group.add_argument(
         "--ignore-fail",
diff --git a/llvm/utils/lit/lit/main.py b/llvm/utils/lit/lit/main.py
index 74b5609655772..8d2dc0219d539 100755
--- a/llvm/utils/lit/lit/main.py
+++ b/llvm/utils/lit/lit/main.py
@@ -41,7 +41,7 @@ def main(builtin_params={}):
         params=params,
         config_prefix=opts.configPrefix,
         per_test_coverage=opts.per_test_coverage,
-        per_process_output_dir=opts.per_process_output_dir,
+        per_test_output_subdir=opts.per_test_output_subdir,
         gtest_sharding=opts.gtest_sharding,
         maxRetriesPerTest=opts.maxRetriesPerTest,
         update_tests=opts.update_tests,
diff --git a/llvm/utils/lit/tests/Inputs/per-process-output-dir/lit.cfg b/llvm/utils/lit/tests/Inputs/per-test-output-subdir/lit.cfg
similarity index 50%
rename from llvm/utils/lit/tests/Inputs/per-process-output-dir/lit.cfg
rename to llvm/utils/lit/tests/Inputs/per-test-output-subdir/lit.cfg
index 43fa67116e27b..cbe2ffb1c973c 100644
--- a/llvm/utils/lit/tests/Inputs/per-process-output-dir/lit.cfg
+++ b/llvm/utils/lit/tests/Inputs/per-test-output-subdir/lit.cfg
@@ -1,9 +1,9 @@
 import lit.formats
 
-config.name = "per-process-output-dir"
+config.name = "per-test-output-subdir"
 config.suffixes = [".txt"]
 config.test_format = lit.formats.ShTest()
 config.test_source_root = os.path.dirname(__file__)
-# The outer test passes a writable exec root so that --per-process-output-dir
-# has a non-None test_exec_root to splice the per-process component into.
+# The outer test passes a writable exec root so that --per-test-output-subdir
+# has a non-None test_exec_root to splice the per-run subdirectory into.
 config.test_exec_root = lit_config.params["exec_root"]
diff --git a/llvm/utils/lit/tests/Inputs/per-process-output-dir/test.txt b/llvm/utils/lit/tests/Inputs/per-test-output-subdir/test.txt
similarity index 100%
rename from llvm/utils/lit/tests/Inputs/per-process-output-dir/test.txt
rename to llvm/utils/lit/tests/Inputs/per-test-output-subdir/test.txt
diff --git a/llvm/utils/lit/tests/per-process-output-dir.py b/llvm/utils/lit/tests/per-process-output-dir.py
deleted file mode 100644
index 3c6f27f9f93e3..0000000000000
--- a/llvm/utils/lit/tests/per-process-output-dir.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Check that --per-process-output-dir isolates each lit process's writable
-# output tree by splicing a unique "pid-<pid>" component into test_exec_root,
-# so that %t/%T/Output resolve under a per-process directory. This lets multiple
-# lit processes run the same tests concurrently against one build tree without
-# clobbering each other.
-
-# With the flag, the temp path is nested under a pid-<pid> directory.
-# RUN: rm -rf %t && mkdir -p %t
-# RUN: %{lit} -a --per-process-output-dir -Dexec_root=%t \
-# RUN:     %{inputs}/per-process-output-dir | \
-# RUN:   FileCheck --check-prefix=ISOLATED %s
-
-# Without the flag, the temp path is not nested under a pid- directory.
-# RUN: rm -rf %t && mkdir -p %t
-# RUN: %{lit} -a -Dexec_root=%t \
-# RUN:     %{inputs}/per-process-output-dir | \
-# RUN:   FileCheck --check-prefix=SHARED %s
-
-# ISOLATED: TEMP_PATH={{.*}}/pid-{{[0-9]+}}/{{.*}}Output
-
-# SHARED: TEMP_PATH=
-# SHARED-NOT: /pid-
diff --git a/llvm/utils/lit/tests/per-test-output-subdir.py b/llvm/utils/lit/tests/per-test-output-subdir.py
new file mode 100644
index 0000000000000..cd0dde42c2992
--- /dev/null
+++ b/llvm/utils/lit/tests/per-test-output-subdir.py
@@ -0,0 +1,22 @@
+# Check that --per-test-output-subdir isolates a run's writable output tree by
+# splicing the caller-supplied ID as a component of test_exec_root, so that
+# %t/%T/Output resolve under test_exec_root/<ID>. This lets multiple lit runs
+# use the same tests concurrently against one build tree without clobbering each
+# other, while reusing an ID reuses the tree.
+
+# With the option, the temp path is nested under the given ID directory.
+# RUN: rm -rf %t && mkdir -p %t
+# RUN: %{lit} -a --per-test-output-subdir run-abc123 -Dexec_root=%t \
+# RUN:     %{inputs}/per-test-output-subdir | \
+# RUN:   FileCheck --check-prefix=ISOLATED %s
+
+# Without the option, the temp path is not nested under the ID directory.
+# RUN: rm -rf %t && mkdir -p %t
+# RUN: %{lit} -a -Dexec_root=%t \
+# RUN:     %{inputs}/per-test-output-subdir | \
+# RUN:   FileCheck --check-prefix=SHARED %s
+
+# ISOLATED: TEMP_PATH={{.*}}/run-abc123/{{.*}}Output
+
+# SHARED: TEMP_PATH=
+# SHARED-NOT: /run-abc123

>From 2ef328ba7e7f231f18f4d471c15f32fc5be9ef0a Mon Sep 17 00:00:00 2001
From: David Young <davidayoung at meta.com>
Date: Wed, 1 Jul 2026 08:01:40 -0700
Subject: [PATCH 3/5] [lit] Replace --per-test-output-subdir with full-path
 --test-output-root

Per PR feedback, generalize the output-isolation option: instead of
--per-test-output-subdir=ID (which appended an ID subdirectory to each
suite's existing build-tree test_exec_root), take --test-output-root=DIR,
a full path to the root of the test output tree.

Each suite's exec root becomes DIR/<suite-name> (the suite name is
sanitized to a single path component so multiple suites in one run don't
collide), so %t/%T/Output and .lit_test_times.txt resolve under DIR. This:

- lets test output live entirely outside the build tree (DIR can be
  anywhere), and
- lets multiple lit runs share one build tree without clobbering each
  other -- give each concurrent run a distinct DIR (from a PID, CI job id,
  per-run temp dir, etc.); reusing a DIR reuses the tree, preserving
  test-run ordering (.lit_test_times.txt) across runs.

A full path is also easier to wire from build systems (buck2/bazel/CI),
which already hand each invocation a unique output/temp directory, than a
PID that must be synthesized and shell-expanded.

Applied even when a suite sets no test_exec_root, since the intent is to
force the output location. Rename the lit test accordingly.
---
 llvm/docs/CommandGuide/lit.rst                | 23 ++++++-----
 llvm/utils/lit/lit/LitConfig.py               |  4 +-
 llvm/utils/lit/lit/TestingConfig.py           | 38 +++++++++++--------
 llvm/utils/lit/lit/cl_arguments.py            | 24 ++++++------
 llvm/utils/lit/lit/main.py                    |  2 +-
 .../lit.cfg                                   |  6 +--
 .../test.txt                                  |  0
 .../utils/lit/tests/per-test-output-subdir.py | 22 -----------
 llvm/utils/lit/tests/test-output-root.py      | 23 +++++++++++
 9 files changed, 77 insertions(+), 65 deletions(-)
 rename llvm/utils/lit/tests/Inputs/{per-test-output-subdir => test-output-root}/lit.cfg (50%)
 rename llvm/utils/lit/tests/Inputs/{per-test-output-subdir => test-output-root}/test.txt (100%)
 delete mode 100644 llvm/utils/lit/tests/per-test-output-subdir.py
 create mode 100644 llvm/utils/lit/tests/test-output-root.py

diff --git a/llvm/docs/CommandGuide/lit.rst b/llvm/docs/CommandGuide/lit.rst
index 57ce95730f00c..12d035dac874f 100644
--- a/llvm/docs/CommandGuide/lit.rst
+++ b/llvm/docs/CommandGuide/lit.rst
@@ -246,16 +246,19 @@ EXECUTION OPTIONS
  setting a unique value to LLVM_PROFILE_FILE for each RUN). The coverage
  data files will be emitted in the directory specified by ``config.test_exec_root``.
 
-.. option:: --per-test-output-subdir ID
-
- Isolate this run's writable output by appending ``ID`` as a subdirectory to
- each test suite's ``config.test_exec_root``. This causes ``%t``, ``%T`` and the
- per-test ``Output`` directory to resolve under ``test_exec_root/<ID>``, so
- multiple lit runs can use the same tests concurrently against a single build
- tree (for example, test stress runs) without clobbering each other's temporary
- files. Give each concurrent run a distinct ``ID`` (a process id, CI job id,
- etc.); reusing an ``ID`` reuses that output tree, preserving artifacts such as
- ``.lit_test_times.txt`` across runs. Disabled by default.
+.. option:: --test-output-root DIR
+
+ Write all test output under ``DIR`` instead of each test suite's default
+ ``config.test_exec_root``. Each suite's exec root becomes ``DIR/<suite-name>``,
+ so ``%t``, ``%T``, the per-test ``Output`` directory and ``.lit_test_times.txt``
+ resolve under ``DIR`` (a relative ``DIR`` is resolved against the current
+ directory). This lets test output live outside the build tree, and lets
+ multiple lit runs use the same tests concurrently against a single build tree
+ (for example, test stress runs) without clobbering each other's temporary
+ files. Give each concurrent run a distinct ``DIR`` (derived from a process id,
+ CI job id, per-run temp dir, etc.); reusing a ``DIR`` reuses that output tree,
+ preserving artifacts such as ``.lit_test_times.txt`` across runs. Disabled by
+ default.
 
 .. option:: --ignore-fail
 
diff --git a/llvm/utils/lit/lit/LitConfig.py b/llvm/utils/lit/lit/LitConfig.py
index e9cebd85a1719..ab6216f778dbc 100644
--- a/llvm/utils/lit/lit/LitConfig.py
+++ b/llvm/utils/lit/lit/LitConfig.py
@@ -41,7 +41,7 @@ def __init__(
         per_test_coverage=False,
         gtest_sharding=True,
         update_tests=False,
-        per_test_output_subdir=None,
+        test_output_root=None,
     ):
         # The name of the test runner.
         self.progname = progname
@@ -95,7 +95,7 @@ def __init__(
         self.per_test_coverage = per_test_coverage
         self.gtest_sharding = bool(gtest_sharding)
         self.update_tests = update_tests
-        self.per_test_output_subdir = per_test_output_subdir
+        self.test_output_root = test_output_root
         self.test_updaters = [diff_test_updater]
 
     @property
diff --git a/llvm/utils/lit/lit/TestingConfig.py b/llvm/utils/lit/lit/TestingConfig.py
index e90b50452e908..a2728e3ea44be 100644
--- a/llvm/utils/lit/lit/TestingConfig.py
+++ b/llvm/utils/lit/lit/TestingConfig.py
@@ -1,4 +1,5 @@
 import os
+import re
 import sys
 
 
@@ -239,22 +240,27 @@ def finish(self, litConfig):
             # FIXME: This should really only be suite in test suite config
             # files. Should we distinguish them?
             self.test_exec_root = str(self.test_exec_root)
-            # Optionally isolate this run's writable output tree so that
-            # multiple lit runs can use the same tests concurrently against one
-            # build tree without clobbering each other's %t/%T/Output files.
-            # The caller-supplied ID names the subdirectory, so it need not be
-            # tied to the process (a PID, CI job id, etc.); reusing an ID reuses
-            # the tree, preserving artifacts like .lit_test_times.txt.
-            # The guard makes this idempotent: local configs are deep-copied from
-            # their parent (which is already isolated) and then finish()ed again,
-            # which must not append a second component.
-            if litConfig.per_test_output_subdir and not getattr(
-                self, "_per_test_output_subdir_applied", False
-            ):
-                self.test_exec_root = os.path.join(
-                    self.test_exec_root, litConfig.per_test_output_subdir
-                )
-                self._per_test_output_subdir_applied = True
+        # Optionally relocate this suite's writable output tree under a
+        # caller-specified root, so %t/%T/Output and .lit_test_times.txt resolve
+        # under <root>/<suite-name> instead of the build tree. This lets output
+        # live outside the build dir, and lets multiple lit runs use the same
+        # tests concurrently against one build tree without clobbering each
+        # other; reusing a root reuses the tree (preserving .lit_test_times.txt).
+        # Applied even when test_exec_root is None, since the point is to force
+        # the output location. The per-suite <suite-name> component keeps
+        # multiple suites in one run from colliding under a shared root. The
+        # guard makes this idempotent: local configs are deep-copied from their
+        # parent (already relocated) and finish()ed again, which must not relocate
+        # a second time.
+        if litConfig.test_output_root and not getattr(
+            self, "_test_output_root_applied", False
+        ):
+            # Sanitize the suite name into a single safe path component.
+            suite_dir = re.sub(r"[^\w.-]", "_", self.name)
+            self.test_exec_root = os.path.join(
+                os.path.abspath(litConfig.test_output_root), suite_dir
+            )
+            self._test_output_root_applied = True
         if self.test_source_root is not None:
             # FIXME: This should really only be suite in test suite config
             # files. Should we distinguish them?
diff --git a/llvm/utils/lit/lit/cl_arguments.py b/llvm/utils/lit/lit/cl_arguments.py
index e309df96f209e..ea6272660347a 100644
--- a/llvm/utils/lit/lit/cl_arguments.py
+++ b/llvm/utils/lit/lit/cl_arguments.py
@@ -364,18 +364,20 @@ def parse_args():
         help="Enable individual test case coverage",
     )
     execution_group.add_argument(
-        "--per-test-output-subdir",
-        dest="per_test_output_subdir",
-        metavar="ID",
+        "--test-output-root",
+        dest="test_output_root",
+        metavar="DIR",
         default=None,
-        help="Isolate this run's writable output by appending the given ID as a "
-        "subdirectory to each test suite's test_exec_root, so %%t/%%T/Output "
-        "resolve under test_exec_root/<ID>. This lets multiple lit runs use the "
-        "same tests concurrently against a single build tree (e.g. test stress "
-        "runs) without clobbering each other's output; give each run a distinct "
-        "ID (a PID, CI job id, etc.). Reusing an ID reuses that output tree, "
-        "preserving artifacts like .lit_test_times.txt across runs. [Default: "
-        "disabled]",
+        help="Write all test output under DIR instead of each test suite's "
+        "default test_exec_root. Each suite's exec root becomes DIR/<suite-name>, "
+        "so %%t/%%T/Output and .lit_test_times.txt resolve under DIR (a relative "
+        "DIR is resolved against the current directory). This lets test output "
+        "live outside the build tree, and lets multiple lit runs use the same "
+        "tests concurrently against a single build tree (e.g. test stress runs) "
+        "without clobbering each other -- give each concurrent run a distinct DIR "
+        "(from a PID, CI job id, per-run temp dir, etc.). Reusing a DIR reuses "
+        "that output tree, preserving artifacts like .lit_test_times.txt across "
+        "runs. [Default: disabled]",
     )
     execution_group.add_argument(
         "--ignore-fail",
diff --git a/llvm/utils/lit/lit/main.py b/llvm/utils/lit/lit/main.py
index 8d2dc0219d539..936c3dfa23308 100755
--- a/llvm/utils/lit/lit/main.py
+++ b/llvm/utils/lit/lit/main.py
@@ -41,7 +41,7 @@ def main(builtin_params={}):
         params=params,
         config_prefix=opts.configPrefix,
         per_test_coverage=opts.per_test_coverage,
-        per_test_output_subdir=opts.per_test_output_subdir,
+        test_output_root=opts.test_output_root,
         gtest_sharding=opts.gtest_sharding,
         maxRetriesPerTest=opts.maxRetriesPerTest,
         update_tests=opts.update_tests,
diff --git a/llvm/utils/lit/tests/Inputs/per-test-output-subdir/lit.cfg b/llvm/utils/lit/tests/Inputs/test-output-root/lit.cfg
similarity index 50%
rename from llvm/utils/lit/tests/Inputs/per-test-output-subdir/lit.cfg
rename to llvm/utils/lit/tests/Inputs/test-output-root/lit.cfg
index cbe2ffb1c973c..85021508f7efe 100644
--- a/llvm/utils/lit/tests/Inputs/per-test-output-subdir/lit.cfg
+++ b/llvm/utils/lit/tests/Inputs/test-output-root/lit.cfg
@@ -1,9 +1,9 @@
 import lit.formats
 
-config.name = "per-test-output-subdir"
+config.name = "output-root-suite"
 config.suffixes = [".txt"]
 config.test_format = lit.formats.ShTest()
 config.test_source_root = os.path.dirname(__file__)
-# The outer test passes a writable exec root so that --per-test-output-subdir
-# has a non-None test_exec_root to splice the per-run subdirectory into.
+# The outer test passes a build exec root here; --test-output-root should
+# override it and relocate output under <root>/<config.name>.
 config.test_exec_root = lit_config.params["exec_root"]
diff --git a/llvm/utils/lit/tests/Inputs/per-test-output-subdir/test.txt b/llvm/utils/lit/tests/Inputs/test-output-root/test.txt
similarity index 100%
rename from llvm/utils/lit/tests/Inputs/per-test-output-subdir/test.txt
rename to llvm/utils/lit/tests/Inputs/test-output-root/test.txt
diff --git a/llvm/utils/lit/tests/per-test-output-subdir.py b/llvm/utils/lit/tests/per-test-output-subdir.py
deleted file mode 100644
index cd0dde42c2992..0000000000000
--- a/llvm/utils/lit/tests/per-test-output-subdir.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Check that --per-test-output-subdir isolates a run's writable output tree by
-# splicing the caller-supplied ID as a component of test_exec_root, so that
-# %t/%T/Output resolve under test_exec_root/<ID>. This lets multiple lit runs
-# use the same tests concurrently against one build tree without clobbering each
-# other, while reusing an ID reuses the tree.
-
-# With the option, the temp path is nested under the given ID directory.
-# RUN: rm -rf %t && mkdir -p %t
-# RUN: %{lit} -a --per-test-output-subdir run-abc123 -Dexec_root=%t \
-# RUN:     %{inputs}/per-test-output-subdir | \
-# RUN:   FileCheck --check-prefix=ISOLATED %s
-
-# Without the option, the temp path is not nested under the ID directory.
-# RUN: rm -rf %t && mkdir -p %t
-# RUN: %{lit} -a -Dexec_root=%t \
-# RUN:     %{inputs}/per-test-output-subdir | \
-# RUN:   FileCheck --check-prefix=SHARED %s
-
-# ISOLATED: TEMP_PATH={{.*}}/run-abc123/{{.*}}Output
-
-# SHARED: TEMP_PATH=
-# SHARED-NOT: /run-abc123
diff --git a/llvm/utils/lit/tests/test-output-root.py b/llvm/utils/lit/tests/test-output-root.py
new file mode 100644
index 0000000000000..5c2fb41f8ea1b
--- /dev/null
+++ b/llvm/utils/lit/tests/test-output-root.py
@@ -0,0 +1,23 @@
+# Check that --test-output-root relocates each suite's writable output tree to
+# <root>/<suite-name>, so %t/%T/Output resolve under the given root directory
+# instead of the suite's build test_exec_root. This lets test output live
+# outside the build tree and lets multiple lit runs share one build tree without
+# clobbering each other; reusing a root reuses the tree.
+
+# With the option, the temp path is under <root>/<suite-name>, not the build dir.
+# RUN: rm -rf %t && mkdir -p %t/build %t/out
+# RUN: %{lit} -a --test-output-root %t/out -Dexec_root=%t/build \
+# RUN:     %{inputs}/test-output-root | \
+# RUN:   FileCheck --check-prefix=ROOTED %s
+
+# Without the option, the temp path stays under the build test_exec_root.
+# RUN: rm -rf %t && mkdir -p %t/build
+# RUN: %{lit} -a -Dexec_root=%t/build \
+# RUN:     %{inputs}/test-output-root | \
+# RUN:   FileCheck --check-prefix=DEFAULT %s
+
+# ROOTED: TEMP_PATH={{.*}}/out/output-root-suite/{{.*}}Output
+# ROOTED-NOT: TEMP_PATH={{.*}}/build/
+
+# DEFAULT: TEMP_PATH={{.*}}/build/{{.*}}Output
+# DEFAULT-NOT: output-root-suite

>From 3348f3d32ea53cb21722f1e260f16ae2e714f076 Mon Sep 17 00:00:00 2001
From: David Young <davidayoung at meta.com>
Date: Mon, 6 Jul 2026 18:12:29 -0700
Subject: [PATCH 4/5] [lit] Fix test-output-root.py ROOTED-NOT false positive
 when build tree contains /build/

The ROOTED-NOT check asserted the temp path is not placed under the
passed exec root by matching TEMP_PATH={{.*}}/build/. But when the lit
test tree itself lives under a build directory (e.g. CI runs from
llvm-project/build/utils/lit/tests/...), %t already contains a /build/
segment, so the pattern matched the ambient path and failed spuriously.

Use a distinctive 'execroot' directory name for the passed exec root so
the negative check can only match the exec root, not an ambient /build/
segment in %t.
---
 llvm/utils/lit/tests/test-output-root.py | 20 ++++++++++++--------
 1 file changed, 12 insertions(+), 8 deletions(-)

diff --git a/llvm/utils/lit/tests/test-output-root.py b/llvm/utils/lit/tests/test-output-root.py
index 5c2fb41f8ea1b..cc81c40be098d 100644
--- a/llvm/utils/lit/tests/test-output-root.py
+++ b/llvm/utils/lit/tests/test-output-root.py
@@ -3,21 +3,25 @@
 # instead of the suite's build test_exec_root. This lets test output live
 # outside the build tree and lets multiple lit runs share one build tree without
 # clobbering each other; reusing a root reuses the tree.
+#
+# The passed exec root uses a distinctive "execroot" directory name (rather than
+# "build") so the ROOTED-NOT check below can't be fooled by an ambient "/build/"
+# segment in %t (e.g. when the lit test tree itself lives under a build dir).
 
-# With the option, the temp path is under <root>/<suite-name>, not the build dir.
-# RUN: rm -rf %t && mkdir -p %t/build %t/out
-# RUN: %{lit} -a --test-output-root %t/out -Dexec_root=%t/build \
+# With the option, the temp path is under <root>/<suite-name>, not the exec root.
+# RUN: rm -rf %t && mkdir -p %t/execroot %t/out
+# RUN: %{lit} -a --test-output-root %t/out -Dexec_root=%t/execroot \
 # RUN:     %{inputs}/test-output-root | \
 # RUN:   FileCheck --check-prefix=ROOTED %s
 
-# Without the option, the temp path stays under the build test_exec_root.
-# RUN: rm -rf %t && mkdir -p %t/build
-# RUN: %{lit} -a -Dexec_root=%t/build \
+# Without the option, the temp path stays under the exec root.
+# RUN: rm -rf %t && mkdir -p %t/execroot
+# RUN: %{lit} -a -Dexec_root=%t/execroot \
 # RUN:     %{inputs}/test-output-root | \
 # RUN:   FileCheck --check-prefix=DEFAULT %s
 
 # ROOTED: TEMP_PATH={{.*}}/out/output-root-suite/{{.*}}Output
-# ROOTED-NOT: TEMP_PATH={{.*}}/build/
+# ROOTED-NOT: TEMP_PATH={{.*}}/execroot/
 
-# DEFAULT: TEMP_PATH={{.*}}/build/{{.*}}Output
+# DEFAULT: TEMP_PATH={{.*}}/execroot/{{.*}}Output
 # DEFAULT-NOT: output-root-suite

>From 2f0cb154ba36ba7b0efa69cef441de53c87157be Mon Sep 17 00:00:00 2001
From: David Young <davidayoung at meta.com>
Date: Tue, 14 Jul 2026 11:52:02 -0700
Subject: [PATCH 5/5] Account for feedback to shorten comments, harden test,
 and remove reference to deprecated %T

---
 llvm/docs/CommandGuide/lit.rst           | 12 ++++--------
 llvm/utils/lit/lit/TestingConfig.py      | 13 ++-----------
 llvm/utils/lit/lit/cl_arguments.py       | 10 +---------
 llvm/utils/lit/tests/test-output-root.py | 21 ++++++++-------------
 4 files changed, 15 insertions(+), 41 deletions(-)

diff --git a/llvm/docs/CommandGuide/lit.rst b/llvm/docs/CommandGuide/lit.rst
index d4e6e7cf84c42..cbb427f5979b1 100644
--- a/llvm/docs/CommandGuide/lit.rst
+++ b/llvm/docs/CommandGuide/lit.rst
@@ -250,15 +250,11 @@ EXECUTION OPTIONS
 
  Write all test output under ``DIR`` instead of each test suite's default
  ``config.test_exec_root``. Each suite's exec root becomes ``DIR/<suite-name>``,
- so ``%t``, ``%T``, the per-test ``Output`` directory and ``.lit_test_times.txt``
- resolve under ``DIR`` (a relative ``DIR`` is resolved against the current
- directory). This lets test output live outside the build tree, and lets
- multiple lit runs use the same tests concurrently against a single build tree
- (for example, test stress runs) without clobbering each other's temporary
- files. Give each concurrent run a distinct ``DIR`` (derived from a process id,
+ so ``%t``, the per-test ``Output`` directory, ``.lit_test_times.txt``, etc.
+ resolve under ``DIR``. This lets test output live outside the build tree.
+ Parallel concurrent runs can have a distinct ``DIR`` (derived from a process id,
  CI job id, per-run temp dir, etc.); reusing a ``DIR`` reuses that output tree,
- preserving artifacts such as ``.lit_test_times.txt`` across runs. Disabled by
- default.
+ preserving artifacts such as ``.lit_test_times.txt`` across runs.
 
 .. option:: --ignore-fail
 
diff --git a/llvm/utils/lit/lit/TestingConfig.py b/llvm/utils/lit/lit/TestingConfig.py
index a2728e3ea44be..9b2a752ada47f 100644
--- a/llvm/utils/lit/lit/TestingConfig.py
+++ b/llvm/utils/lit/lit/TestingConfig.py
@@ -241,17 +241,8 @@ def finish(self, litConfig):
             # files. Should we distinguish them?
             self.test_exec_root = str(self.test_exec_root)
         # Optionally relocate this suite's writable output tree under a
-        # caller-specified root, so %t/%T/Output and .lit_test_times.txt resolve
-        # under <root>/<suite-name> instead of the build tree. This lets output
-        # live outside the build dir, and lets multiple lit runs use the same
-        # tests concurrently against one build tree without clobbering each
-        # other; reusing a root reuses the tree (preserving .lit_test_times.txt).
-        # Applied even when test_exec_root is None, since the point is to force
-        # the output location. The per-suite <suite-name> component keeps
-        # multiple suites in one run from colliding under a shared root. The
-        # guard makes this idempotent: local configs are deep-copied from their
-        # parent (already relocated) and finish()ed again, which must not relocate
-        # a second time.
+        # caller-specified root, so %t/Output/.lit_test_times.txt/etc. resolve
+        # under <root>/<suite-name> instead of the build tree.
         if litConfig.test_output_root and not getattr(
             self, "_test_output_root_applied", False
         ):
diff --git a/llvm/utils/lit/lit/cl_arguments.py b/llvm/utils/lit/lit/cl_arguments.py
index fd4acc15dd086..11672e3d9eb39 100644
--- a/llvm/utils/lit/lit/cl_arguments.py
+++ b/llvm/utils/lit/lit/cl_arguments.py
@@ -373,15 +373,7 @@ def parse_args():
         metavar="DIR",
         default=None,
         help="Write all test output under DIR instead of each test suite's "
-        "default test_exec_root. Each suite's exec root becomes DIR/<suite-name>, "
-        "so %%t/%%T/Output and .lit_test_times.txt resolve under DIR (a relative "
-        "DIR is resolved against the current directory). This lets test output "
-        "live outside the build tree, and lets multiple lit runs use the same "
-        "tests concurrently against a single build tree (e.g. test stress runs) "
-        "without clobbering each other -- give each concurrent run a distinct DIR "
-        "(from a PID, CI job id, per-run temp dir, etc.). Reusing a DIR reuses "
-        "that output tree, preserving artifacts like .lit_test_times.txt across "
-        "runs. [Default: disabled]",
+        "default test_exec_root.",
     )
     execution_group.add_argument(
         "--ignore-fail",
diff --git a/llvm/utils/lit/tests/test-output-root.py b/llvm/utils/lit/tests/test-output-root.py
index cc81c40be098d..dff63f9b1bf0d 100644
--- a/llvm/utils/lit/tests/test-output-root.py
+++ b/llvm/utils/lit/tests/test-output-root.py
@@ -1,27 +1,22 @@
 # Check that --test-output-root relocates each suite's writable output tree to
-# <root>/<suite-name>, so %t/%T/Output resolve under the given root directory
-# instead of the suite's build test_exec_root. This lets test output live
-# outside the build tree and lets multiple lit runs share one build tree without
-# clobbering each other; reusing a root reuses the tree.
+# <root>/<suite-name>, so %t/Output/etc. resolve under the given root directory
+# instead of the suite's build test_exec_root.
 #
-# The passed exec root uses a distinctive "execroot" directory name (rather than
-# "build") so the ROOTED-NOT check below can't be fooled by an ambient "/build/"
-# segment in %t (e.g. when the lit test tree itself lives under a build dir).
+# Both cases capture the outer lit's %t as TESTDIR and match the inner temp path
+# against it exactly, so each check fully pins where the output landed.
 
 # With the option, the temp path is under <root>/<suite-name>, not the exec root.
 # RUN: rm -rf %t && mkdir -p %t/execroot %t/out
 # RUN: %{lit} -a --test-output-root %t/out -Dexec_root=%t/execroot \
 # RUN:     %{inputs}/test-output-root | \
-# RUN:   FileCheck --check-prefix=ROOTED %s
+# RUN:   FileCheck --check-prefix=ROOTED %s -DTESTDIR=%t
 
 # Without the option, the temp path stays under the exec root.
 # RUN: rm -rf %t && mkdir -p %t/execroot
 # RUN: %{lit} -a -Dexec_root=%t/execroot \
 # RUN:     %{inputs}/test-output-root | \
-# RUN:   FileCheck --check-prefix=DEFAULT %s
+# RUN:   FileCheck --check-prefix=DEFAULT %s -DTESTDIR=%t
 
-# ROOTED: TEMP_PATH={{.*}}/out/output-root-suite/{{.*}}Output
-# ROOTED-NOT: TEMP_PATH={{.*}}/execroot/
+# ROOTED: TEMP_PATH=[[TESTDIR]]/out/output-root-suite/{{.*}}Output
 
-# DEFAULT: TEMP_PATH={{.*}}/execroot/{{.*}}Output
-# DEFAULT-NOT: output-root-suite
+# DEFAULT: TEMP_PATH=[[TESTDIR]]/execroot/{{.*}}Output



More information about the llvm-commits mailing list