[llvm] [Support] Improve jobserver concurrency handling (PR #178434)
via llvm-commits
llvm-commits at lists.llvm.org
Wed Jan 28 06:27:49 PST 2026
llvmbot wrote:
<!--LLVM PR SUMMARY COMMENT-->
@llvm/pr-subscribers-llvm-support
Author: Yaxun (Sam) Liu (yxsamliu)
<details>
<summary>Changes</summary>
Problem:
The original JobserverClient::getNumJobs() tried to determine the total
job count by draining all available tokens at startup. This is unreliable
because sibling processes may already hold tokens, causing getNumJobs() to
return a value lower than the actual -jN limit. If we then spawn that many
threads, we underutilize the available parallelism.
A naive fix (parsing -jN from MAKEFLAGS) causes a different problem: if
make -j16 spawns 16 LLVM processes, and each creates 16 threads, we get
256 threads competing for 16 tokens - massive oversubscription with
significant memory overhead from idle threads.
Solution:
Instead of pre-spawning threads based on an unreliable job count, we use
a spawn-per-token approach in jobserver mode:
1. Start a single coordinator thread that waits for work
2. When work arrives, the coordinator acquires a jobserver token
3. Only after acquiring a token do we spawn an ephemeral worker thread
4. The worker drains available tasks, then releases the token and exits
This is implemented as a separate JobserverThreadPoolExecutor class,
keeping the standard ThreadPoolExecutor unchanged for non-jobserver mode.
Benefits:
- Thread count automatically matches tokens actually held (not -jN guess)
- No idle threads blocking on token acquisition
- Multiple LLVM processes naturally share tokens without oversubscription
- Memory overhead (VmSize) scales with actual parallelism, not -jN
A thread index pool ensures getThreadIndex() returns values in
[0, getThreadCount()) for compatibility with PerThreadBumpPtrAllocator.
Fixes https://github.com/llvm/llvm-project/issues/170184
---
Patch is 33.20 KiB, truncated to 20.00 KiB below, full version: https://github.com/llvm/llvm-project/pull/178434.diff
6 Files Affected:
- (modified) llvm/include/llvm/Support/Parallel.h (+4)
- (modified) llvm/lib/Support/Jobserver.cpp (+9)
- (modified) llvm/lib/Support/Parallel.cpp (+273-60)
- (modified) llvm/lib/Support/Unix/Jobserver.inc (+35-11)
- (modified) llvm/lib/Support/Windows/Jobserver.inc (+13-1)
- (modified) llvm/unittests/Support/JobserverTest.cpp (+332)
``````````diff
diff --git a/llvm/include/llvm/Support/Parallel.h b/llvm/include/llvm/Support/Parallel.h
index b0c9e8f29f970..efc2e60370f88 100644
--- a/llvm/include/llvm/Support/Parallel.h
+++ b/llvm/include/llvm/Support/Parallel.h
@@ -50,6 +50,10 @@ LLVM_ABI extern thread_local unsigned threadIndex;
inline unsigned getThreadIndex() { GET_THREAD_INDEX_IMPL; }
#endif
+// getThreadIndex() is assigned by ThreadPoolExecutor and must be in the range
+// [0, getThreadCount()). Some users (e.g. PerThreadBumpPtrAllocator) rely on
+// this bound to size per-thread storage, so getThreadCount() represents the
+// maximum possible worker threads, not the current active count.
LLVM_ABI size_t getThreadCount();
#else
inline unsigned getThreadIndex() { return 0; }
diff --git a/llvm/lib/Support/Jobserver.cpp b/llvm/lib/Support/Jobserver.cpp
index 522ab914f7eea..794fb7c6bbf9e 100644
--- a/llvm/lib/Support/Jobserver.cpp
+++ b/llvm/lib/Support/Jobserver.cpp
@@ -9,6 +9,7 @@
#include "llvm/Support/Jobserver.h"
#include "llvm/ADT/StringExtras.h"
#include "llvm/Support/Error.h"
+#include "llvm/Support/Threading.h"
#include <atomic>
#include <memory>
@@ -36,6 +37,8 @@ struct JobserverConfig {
Mode TheMode = None;
std::string Path;
FdPair PipeFDs;
+ /// The number of jobs specified by -jN in MAKEFLAGS. 0 if not specified.
+ unsigned ParsedNumJobs = 0;
};
/// A helper function that checks if `Input` starts with `Prefix`.
@@ -114,6 +117,12 @@ Expected<JobserverConfig> parseNativeMakeFlags(StringRef MakeFlags) {
return createStringError(inconvertibleErrorCode(),
"Invalid file descriptor pair in MAKEFLAGS");
}
+ } else if (getPrefixedValue(Arg, "-j", Value) ||
+ getPrefixedValue(Arg, "--jobs=", Value)) {
+ // Parse the job count from -jN or --jobs=N (GNU Make 4.4+).
+ unsigned Jobs;
+ if (!Value.getAsInteger(10, Jobs) && Jobs > 0)
+ Config.ParsedNumJobs = Jobs;
}
}
diff --git a/llvm/lib/Support/Parallel.cpp b/llvm/lib/Support/Parallel.cpp
index ea223f6eda8a7..fb05db224d549 100644
--- a/llvm/lib/Support/Parallel.cpp
+++ b/llvm/lib/Support/Parallel.cpp
@@ -18,6 +18,7 @@
#include <future>
#include <memory>
#include <mutex>
+#include <optional>
#include <thread>
#include <vector>
@@ -44,22 +45,20 @@ class Executor {
public:
virtual ~Executor() = default;
virtual void add(std::function<void()> func) = 0;
+ virtual void stop() = 0;
virtual size_t getThreadCount() const = 0;
static Executor *getDefaultExecutor();
};
/// An implementation of an Executor that runs closures on a thread pool
-/// in filo order.
+/// in filo order. This is the standard executor for non-jobserver mode.
class ThreadPoolExecutor : public Executor {
public:
explicit ThreadPoolExecutor(ThreadPoolStrategy S) {
- if (S.UseJobserver)
- TheJobserver = JobserverClient::getInstance();
-
ThreadCount = S.compute_thread_count();
- // Spawn all but one of the threads in another thread as spawning threads
- // can take a while.
+
+ // Spawn all threads up front.
Threads.reserve(ThreadCount);
Threads.resize(1);
std::lock_guard<std::mutex> Lock(Mutex);
@@ -77,8 +76,6 @@ class ThreadPoolExecutor : public Executor {
});
}
- // To make sure the thread pool executor can only be created with a parallel
- // strategy.
ThreadPoolExecutor() = delete;
void stop() {
@@ -102,10 +99,10 @@ class ThreadPoolExecutor : public Executor {
~ThreadPoolExecutor() override { stop(); }
struct Creator {
- static void *call() { return new ThreadPoolExecutor(strategy); }
+ static void *call();
};
struct Deleter {
- static void call(void *Ptr) { ((ThreadPoolExecutor *)Ptr)->stop(); }
+ static void call(void *Ptr) { ((Executor *)Ptr)->stop(); }
};
void add(std::function<void()> F) override {
@@ -122,25 +119,146 @@ class ThreadPoolExecutor : public Executor {
void work(ThreadPoolStrategy S, unsigned ThreadID) {
threadIndex = ThreadID;
S.apply_thread_strategy(ThreadID);
- // Note on jobserver deadlock avoidance:
- // GNU Make grants each invoked process one implicit job slot. Our
- // JobserverClient models this by returning an implicit JobSlot on the
- // first successful tryAcquire() in a process. This guarantees forward
- // progress without requiring a dedicated "always-on" thread here.
while (true) {
- if (TheJobserver) {
- // Jobserver-mode scheduling:
- // - Acquire one job slot (with exponential backoff to avoid busy-wait).
- // - While holding the slot, drain and run tasks from the local queue.
- // - Release the slot when the queue is empty or when shutting down.
- // Rationale: Holding a slot amortizes acquire/release overhead over
- // multiple tasks and avoids requeue/yield churn, while still enforcing
- // the jobserver’s global concurrency limit. With K available slots,
- // up to K workers run tasks in parallel; within each worker tasks run
- // sequentially until the local queue is empty.
- ExponentialBackoff Backoff(std::chrono::hours(24));
- JobSlot Slot;
+ std::unique_lock<std::mutex> Lock(Mutex);
+ Cond.wait(Lock, [&] { return Stop || !WorkStack.empty(); });
+ if (Stop)
+ break;
+ auto Task = std::move(WorkStack.back());
+ WorkStack.pop_back();
+ Lock.unlock();
+ Task();
+ }
+ }
+
+ std::atomic<bool> Stop{false};
+ std::vector<std::function<void()>> WorkStack;
+ std::mutex Mutex;
+ std::condition_variable Cond;
+ std::promise<void> ThreadsCreated;
+ std::vector<std::thread> Threads;
+ unsigned ThreadCount;
+};
+
+/// Jobserver-aware executor that uses spawn-per-token thread management.
+///
+/// Problem (GitHub Issue #170184):
+/// The original JobserverClient::getNumJobs() tried to determine the total
+/// job count by draining all available tokens at startup. This is unreliable
+/// because sibling processes may already hold tokens, causing getNumJobs() to
+/// return a value lower than the actual -jN limit. If we then spawn that many
+/// threads (like the standard ThreadPoolExecutor does), we underutilize the
+/// available parallelism.
+///
+/// A naive fix (parsing -jN from MAKEFLAGS) causes a different problem: if
+/// make -j16 spawns 16 LLVM processes, and each creates 16 threads, we get
+/// 256 threads competing for 16 tokens - massive oversubscription.
+///
+/// Solution (spawn-per-token):
+/// Instead of pre-spawning threads based on an unreliable job count, we:
+/// 1. Start a single coordinator thread that waits for work
+/// 2. When work arrives, the coordinator acquires a jobserver token
+/// 3. Only after acquiring a token do we spawn an ephemeral worker thread
+/// 4. The worker drains available tasks, then releases the token and exits
+///
+/// Benefits:
+/// - Thread count automatically matches tokens actually held (not -jN guess)
+/// - No idle threads blocking on token acquisition
+/// - Multiple LLVM processes naturally share tokens without oversubscription
+/// - Memory overhead (VmSize) scales with actual parallelism, not -jN
+///
+/// The thread index pool ensures getThreadIndex() returns values in
+/// [0, getThreadCount()) for compatibility with PerThreadBumpPtrAllocator.
+class JobserverThreadPoolExecutor : public Executor {
+public:
+ explicit JobserverThreadPoolExecutor(ThreadPoolStrategy S) : Strategy(S) {
+ TheJobserver = JobserverClient::getInstance();
+ assert(TheJobserver && "JobserverThreadPoolExecutor requires jobserver");
+
+ ThreadCount = S.compute_thread_count();
+ initThreadIndexPool();
+
+ // Start a single coordinator thread that manages token-based spawning.
+ Threads.reserve(1);
+ Threads.resize(1);
+ std::lock_guard<std::mutex> Lock(Mutex);
+ auto &Thread0 = Threads[0];
+ Thread0 = std::thread([this] { jobserverCoordinator(); });
+ ThreadsCreated.set_value();
+ }
+
+ JobserverThreadPoolExecutor() = delete;
+
+ void stop() {
+ {
+ std::lock_guard<std::mutex> Lock(Mutex);
+ if (Stop)
+ return;
+ Stop = true;
+ }
+ Cond.notify_all();
+ IndexCond.notify_all();
+ ThreadsCreated.get_future().wait();
+
+ std::thread::id CurrentThreadId = std::this_thread::get_id();
+ for (std::thread &T : Threads)
+ if (T.get_id() == CurrentThreadId)
+ T.detach();
+ else
+ T.join();
+
+ // Join any remaining ephemeral workers.
+ joinFinishedWorkers(/*JoinAll=*/true);
+ }
+
+ ~JobserverThreadPoolExecutor() override { stop(); }
+
+ void add(std::function<void()> F) override {
+ {
+ std::lock_guard<std::mutex> Lock(Mutex);
+ WorkStack.push_back(std::move(F));
+ }
+ Cond.notify_one();
+ }
+
+ size_t getThreadCount() const override { return ThreadCount; }
+
+private:
+ struct EphemeralWorkerRecord {
+ std::thread Thread;
+ std::shared_ptr<std::atomic<bool>> Done;
+ };
+
+ /// Coordinator thread: waits for work, acquires tokens, spawns workers.
+ void jobserverCoordinator() {
+ threadIndex = 0;
+ Strategy.apply_thread_strategy(0);
+
+ while (true) {
+ // Wait until there's work or we're stopping.
+ {
+ std::unique_lock<std::mutex> Lock(Mutex);
+ Cond.wait(Lock, [&] { return Stop || !WorkStack.empty(); });
+ if (Stop)
+ break;
+ }
+
+ // Periodically join finished workers to avoid unbounded growth.
+ joinFinishedWorkers(/*JoinAll=*/false);
+
+ // Acquire a thread index before attempting to acquire a token.
+ std::optional<unsigned> ThreadIndex = acquireThreadIndex();
+ if (!ThreadIndex)
+ return;
+ unsigned Index = *ThreadIndex;
+ auto ReleaseIndex = llvm::scope_exit([&] { releaseThreadIndex(Index); });
+
+ // Try to acquire a token. If successful, spawn an ephemeral worker.
+ JobSlot Slot = TheJobserver->tryAcquire();
+ if (!Slot.isValid()) {
+ // No token available right now. Use backoff and retry.
+ ExponentialBackoff Backoff(std::chrono::seconds(1));
do {
if (Stop)
return;
@@ -148,37 +266,116 @@ class ThreadPoolExecutor : public Executor {
if (Slot.isValid())
break;
} while (Backoff.waitForNextAttempt());
+ }
- llvm::scope_exit SlotReleaser(
- [&] { TheJobserver->release(std::move(Slot)); });
-
- while (true) {
- std::function<void()> Task;
- {
- std::unique_lock<std::mutex> Lock(Mutex);
- Cond.wait(Lock, [&] { return Stop || !WorkStack.empty(); });
- if (Stop && WorkStack.empty())
- return;
- if (WorkStack.empty())
- break;
- Task = std::move(WorkStack.back());
- WorkStack.pop_back();
- }
- Task();
- }
- } else {
+ if (!Slot.isValid())
+ continue; // ReleaseIndex will run, loop back and check Stop/WorkStack.
+
+ ReleaseIndex.release();
+
+ // Spawn an ephemeral worker that owns this token.
+ auto Done = std::make_shared<std::atomic<bool>>(false);
+ std::thread Worker([this, Slot = std::move(Slot), Index, Done]() mutable {
+ ephemeralWorker(std::move(Slot), Index, Done);
+ });
+
+ {
+ std::lock_guard<std::mutex> Lock(EphemeralMutex);
+ EphemeralWorkers.push_back({std::move(Worker), Done});
+ }
+ }
+ }
+
+ /// Ephemeral worker: drains tasks while holding a token, then exits.
+ void ephemeralWorker(JobSlot Slot, unsigned ThreadIdx,
+ std::shared_ptr<std::atomic<bool>> Done) {
+ threadIndex = ThreadIdx;
+ Strategy.apply_thread_strategy(ThreadIdx);
+
+ auto ReleaseIndex = llvm::scope_exit([&] {
+ releaseThreadIndex(ThreadIdx);
+ Done->store(true, std::memory_order_release);
+ });
+
+ llvm::scope_exit SlotReleaser(
+ [&] { TheJobserver->release(std::move(Slot)); });
+
+ while (true) {
+ std::function<void()> Task;
+ {
std::unique_lock<std::mutex> Lock(Mutex);
- Cond.wait(Lock, [&] { return Stop || !WorkStack.empty(); });
+ if (WorkStack.empty())
+ return;
if (Stop)
- break;
- auto Task = std::move(WorkStack.back());
+ return;
+ Task = std::move(WorkStack.back());
WorkStack.pop_back();
- Lock.unlock();
- Task();
+ }
+ Task();
+ }
+ }
+
+ /// Join finished ephemeral workers to reclaim resources.
+ void joinFinishedWorkers(bool JoinAll) {
+ std::vector<std::thread> ToJoin;
+ std::thread::id Current = std::this_thread::get_id();
+
+ {
+ std::lock_guard<std::mutex> Lock(EphemeralMutex);
+ for (auto It = EphemeralWorkers.begin(); It != EphemeralWorkers.end();) {
+ bool Finished = JoinAll || It->Done->load(std::memory_order_acquire);
+ if (!Finished) {
+ ++It;
+ continue;
+ }
+
+ if (It->Thread.get_id() == Current) {
+ // Avoid self-join in shutdown paths.
+ It->Thread.detach();
+ It = EphemeralWorkers.erase(It);
+ continue;
+ }
+
+ ToJoin.push_back(std::move(It->Thread));
+ It = EphemeralWorkers.erase(It);
}
}
+
+ for (auto &T : ToJoin)
+ if (T.joinable())
+ T.join();
}
+ void initThreadIndexPool() {
+ if (ThreadCount == 0)
+ ThreadCount = 1;
+ std::lock_guard<std::mutex> Lock(IndexMutex);
+ FreeThreadIndices.clear();
+ FreeThreadIndices.reserve(ThreadCount);
+ for (unsigned I = 0; I < ThreadCount; ++I)
+ FreeThreadIndices.push_back(I);
+ }
+
+ std::optional<unsigned> acquireThreadIndex() {
+ std::unique_lock<std::mutex> Lock(IndexMutex);
+ IndexCond.wait(Lock,
+ [&] { return Stop.load() || !FreeThreadIndices.empty(); });
+ if (Stop)
+ return std::nullopt;
+ unsigned Index = FreeThreadIndices.back();
+ FreeThreadIndices.pop_back();
+ return Index;
+ }
+
+ void releaseThreadIndex(unsigned Index) {
+ {
+ std::lock_guard<std::mutex> Lock(IndexMutex);
+ FreeThreadIndices.push_back(Index);
+ }
+ IndexCond.notify_one();
+ }
+
+ ThreadPoolStrategy Strategy;
std::atomic<bool> Stop{false};
std::vector<std::function<void()>> WorkStack;
std::mutex Mutex;
@@ -188,26 +385,42 @@ class ThreadPoolExecutor : public Executor {
unsigned ThreadCount;
JobserverClient *TheJobserver = nullptr;
+
+ // Ephemeral workers and their mutex.
+ std::mutex EphemeralMutex;
+ std::vector<EphemeralWorkerRecord> EphemeralWorkers;
+
+ // Thread index pool for getThreadIndex() semantics.
+ std::mutex IndexMutex;
+ std::condition_variable IndexCond;
+ std::vector<unsigned> FreeThreadIndices;
};
+// Factory function to create the appropriate executor.
+void *ThreadPoolExecutor::Creator::call() {
+ if (strategy.UseJobserver && JobserverClient::getInstance())
+ return new JobserverThreadPoolExecutor(strategy);
+ return new ThreadPoolExecutor(strategy);
+}
+
Executor *Executor::getDefaultExecutor() {
#ifdef _WIN32
- // The ManagedStatic enables the ThreadPoolExecutor to be stopped via
- // llvm_shutdown() on Windows. This is important to avoid various race
- // conditions at process exit that can cause crashes or deadlocks.
-
- static ManagedStatic<ThreadPoolExecutor, ThreadPoolExecutor::Creator,
+ // The ManagedStatic enables the executor to be stopped via llvm_shutdown()
+ // on Windows. This is important to avoid various race conditions at process
+ // exit that can cause crashes or deadlocks.
+ static ManagedStatic<Executor, ThreadPoolExecutor::Creator,
ThreadPoolExecutor::Deleter>
ManagedExec;
- static std::unique_ptr<ThreadPoolExecutor> Exec(&(*ManagedExec));
+ static std::unique_ptr<Executor> Exec(&(*ManagedExec));
return Exec.get();
#else
- // ManagedStatic is not desired on other platforms. When `Exec` is destroyed
- // by llvm_shutdown(), worker threads will clean up and invoke TLS
+ // ManagedStatic is not desired on other platforms. When the executor is
+ // destroyed by llvm_shutdown(), worker threads will clean up and invoke TLS
// destructors. This can lead to race conditions if other threads attempt to
// access TLS objects that have already been destroyed.
- static ThreadPoolExecutor Exec(strategy);
- return &Exec;
+ static std::unique_ptr<Executor> Exec(
+ static_cast<Executor *>(ThreadPoolExecutor::Creator::call()));
+ return Exec.get();
#endif
}
} // namespace
diff --git a/llvm/lib/Support/Unix/Jobserver.inc b/llvm/lib/Support/Unix/Jobserver.inc
index 300fb5e55045e..a5a473fc284a2 100644
--- a/llvm/lib/Support/Unix/Jobserver.inc
+++ b/llvm/lib/Support/Unix/Jobserver.inc
@@ -82,19 +82,43 @@ JobserverClientImpl::JobserverClientImpl(const JobserverConfig &Config) {
}
IsInitialized = true;
- // Determine the total number of jobs by acquiring all available slots and
- // then immediately releasing them.
- SmallVector<JobSlot, 8> Slots;
- while (true) {
- auto S = tryAcquire();
- if (!S.isValid())
- break;
- Slots.push_back(std::move(S));
+
+ // Determine NumJobs using the best available information:
+ // 1. If -jN was parsed from MAKEFLAGS (GNU Make 4.4+), use it as the base
+ // 2. Otherwise, drain available tokens to estimate (may be inaccurate if
+ // sibling processes are holding tokens)
+ // 3. Cap at hardware_concurrency to prevent thread oversubscription when
+ // multiple LLVM processes run in parallel
+ unsigned DrainedCount = 0;
+ {
+ SmallVector<JobSlot, 8> Slots;
+ while (true) {
+ auto S = tryAcquire();
+ if (!S.isValid())
+ break;
+ Slots.push_back(std::move(S));
+ }
+ DrainedCount = Slots.size();
+ for (auto &S : Slots)
+ release(std::move(S));
}
- NumJobs = Slots.size();
+
+ // Use parsed -jN if available, otherwise use drained count
+ unsigned BaseCount =
+ Config.ParsedNumJobs > 0 ? Config.ParsedNumJobs : DrainedCount;
+
+ // Ensure at least 1 (for the implicit slot)
+ if (BaseCount == 0)
+ BaseCount = 1;
+
+ // Cap at hardware_concurrency to prevent oversubscription
+ unsigned HWThreads = llvm::hardware_concurrency().compute_thread_count();
+ if (HWThreads > 0)
+ NumJobs = std::min(BaseCount, HWThreads);
+ else
+ NumJobs = BaseCount;
+
assert(NumJobs >= 1 && "Invalid number of jobs");
- for (auto &S : Slots)
- release(std::move(S));
}
/// The destructor closes any open file descriptors.
diff --git a/llvm/lib/Support/Windows/Jobserver.inc b/llvm/lib/Support/Windows/Jobserver.inc
index 79028eee4b302..7bf19bacee972 100644
--- a/llvm/lib/Support/Windows/Jobserver.inc
+++ b/llvm/lib/Support/Windows/Jobserver.inc
@@ -23,8 +23,20 @@ namespace llvm {
JobserverClientImpl::JobserverClientImpl(const JobserverConfig &Config) {
Semaphore = (void *)::OpenSemaphoreA(SEMAPHORE_MODIFY_STATE | SYNCHRONIZE,
FALSE, Config.Path.c_str());
- if (Semaphore != nullptr)
+ if (Semaphore != nullptr) {
IsInitialized = true;
+
+ // Use parsed -jN if available, otherwise default to 1 (can't drain
+ // semaphore count reliably on Windows)
+ unsigned BaseCount = Config.ParsedNumJobs > 0 ? Config.ParsedNumJobs : 1;
+
+ // Cap at hardware_concurrency to prevent oversubscription
+ unsigned HWThreads = llvm::hardware_concurrency().compute_thread_count();
+ if (HWThreads > 0)
+ NumJobs = std::min(BaseCount, HWThreads);
+ else
+ NumJobs = BaseCount;
+ }
}
/// The destructor closes the handle to the semaphore, releasing the resource.
diff --git a/llvm/unittests/Support/JobserverTest.cpp b/llvm/unittests/Support/JobserverTest.cpp
index c36769087aba7..ec7c545f290a0 100644
--- a/llvm/unittests/Support/JobserverTest.cpp
+++ b/llvm/unittests/Support/JobserverTest.cpp
@@ -16,8 +16,10 @@
#include "llvm/Support/Debug.h"
#include "llvm/Support/FileUtilities.h"
#include "llvm/Support/Parallel.h"
+#include "llvm/Support/PerThreadBumpPtrAllocator.h"
#include "llvm/Support/Program.h"
#include "llvm/Support/ThreadPool.h"
+#include "llvm/Support/Threading.h"
#include "llvm/Support/raw_ostream.h"
#include "gtest/gtest.h"
#include <future>
@@ -27,10 +29,12 @@
#if defined(LLVM_ON_UNIX)
#include "llvm/ADT/SmallString.h"
#inclu...
[truncated]
``````````
</details>
https://github.com/llvm/llvm-project/pull/178434
More information about the llvm-commits
mailing list