Files
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

1087 lines
37 KiB
C++

/*************************************************************************
* Copyright (c) 2025 Advanced Micro Devices, Inc. All rights reserved.
*
* See LICENSE.txt for license information
************************************************************************/
#include "ProcessIsolatedTestRunner.hpp"
#include <errno.h>
#include <fcntl.h>
#include <gtest/gtest.h>
#include <poll.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <future>
#include <iostream>
#include <mutex>
#include <numeric>
#include <sstream>
#include <thread>
#include "ErrCode.hpp"
#if defined(RCCL_TEST_CODE_COVERAGE)
#include <dlfcn.h>
extern "C" int __llvm_profile_write_file(void);
#endif
namespace RcclUnitTesting
{
// Exit codes for test process results
enum RcclTestCode
{
RCCL_TEST_INVALID = -1,
RCCL_TEST_SUCCESS = 0,
RCCL_TEST_FAILURE = 1,
RCCL_TEST_UNKNOWN_EXCEPTION = 2,
RCCL_TEST_TIMEOUT = 3,
RCCL_TEST_SKIPPED = 4
};
// Grace period given to a timed-out child after SIGTERM before SIGKILL.
static constexpr int kSigtermGraceSeconds = 2;
// Maximum poll(2) slice when draining child output with an active deadline.
// Caps the wait so the deadline is re-checked regularly even if output is slow.
static constexpr long long kPollSliceMs = 5000LL;
// Number of pipe file descriptors polled per child process (stdout + stderr).
static constexpr int kNumPipeFds = 2;
// POSIX waitpid(2): exit code is stored in bits [15:8] of the status word.
static constexpr int kWaitStatusExitShift = 8;
// Read buffer size for draining child stdout/stderr pipes.
static constexpr int kPipeReadBufferSize = 4096;
// poll(2) timeout sentinel: block indefinitely until an fd becomes ready.
static constexpr int kPollBlockIndefinitely = -1;
// pollfd.fd sentinel: poll(2) ignores entries with a negative fd.
static constexpr int kDisabledFd = -1;
// Default test timeout (seconds) if none is specified in TestConfig.
// Raised from 30s to 300s to accommodate slow-running collective tests in
// DPX+NPS2 mode:
// - gfx1250 (DPX+NPS2): some tests currently take ~133s
// - gfx950 (DPX+NPS2): some tests currently take ~68s
// ~2x headroom over the observed worst case is intentional to avoid
// flaky false-positive timeouts
//
// TODO: Reduce this (toward ~70s) once gfx1250's runtime comes down
static constexpr int kDefaultTestTimeoutSeconds = 300;
// Unset process ID — used before a child is forked or when fork fails.
static constexpr pid_t kInvalidPid = -1;
// maxParallelJobs value that selects single-child sequential execution.
static constexpr size_t kSequentialExecution = 1;
// Define static members
std::mutex ProcessIsolatedTestRunner::testConfigsMutex_;
std::vector<ProcessIsolatedTestRunner::TestConfig> ProcessIsolatedTestRunner::testConfigs_;
std::mutex ProcessIsolatedTestRunner::resultsMutex_;
std::vector<ProcessIsolatedTestRunner::TestResult> ProcessIsolatedTestRunner::testResults_;
// TestResult implementation
ProcessIsolatedTestRunner::TestResult::TestResult()
: passed(false), skipped(false), exitCode(RCCL_TEST_INVALID), processId(kInvalidPid), duration(0)
{}
// TestConfig implementation
ProcessIsolatedTestRunner::TestConfig::TestConfig(
const std::string& testName, std::function<void()> logic
)
: name(testName), testLogic(logic), timeout(kDefaultTestTimeoutSeconds), inheritParentEnv(true), numGpus(0)
{}
ProcessIsolatedTestRunner::TestConfig& ProcessIsolatedTestRunner::TestConfig::withEnvironment(
const std::unordered_map<std::string, std::string>& env
)
{
environmentVariables = env;
return *this;
}
ProcessIsolatedTestRunner::TestConfig&
ProcessIsolatedTestRunner::TestConfig::withTimeout(std::chrono::seconds timeoutSeconds)
{
timeout = timeoutSeconds;
return *this;
}
ProcessIsolatedTestRunner::TestConfig&
ProcessIsolatedTestRunner::TestConfig::withCleanEnvironment(bool cleanEnv)
{
inheritParentEnv = !cleanEnv;
return *this;
}
ProcessIsolatedTestRunner::TestConfig&
ProcessIsolatedTestRunner::TestConfig::clearVariable(const std::string& varName)
{
clearEnvVars.push_back(varName);
return *this;
}
ProcessIsolatedTestRunner::TestConfig& ProcessIsolatedTestRunner::TestConfig::setVariable(
const std::string& name, const std::string& value
)
{
environmentVariables[name] = value;
return *this;
}
ProcessIsolatedTestRunner::TestConfig& ProcessIsolatedTestRunner::TestConfig::withNumGpus(size_t n)
{
numGpus = n;
return *this;
}
// Returns GPU indices from HIP_VISIBLE_DEVICES, or empty if unset/invalid.
static std::vector<int> gpuPoolFromEnv()
{
const char* val = std::getenv("HIP_VISIBLE_DEVICES");
if(!val || !*val)
return {};
std::vector<int> pool;
std::istringstream ss(val);
std::string token;
while(std::getline(ss, token, ','))
{
try
{
pool.push_back(std::stoi(token));
}
catch(...)
{
}
}
return pool;
}
// Returns GPU indices from KFD topology sysfs, or empty if unavailable.
// KFD topology nodes with gpu_id == 0 represent CPU or bridge nodes and are
// skipped; only nodes with a non-zero gpu_id correspond to physical GPUs.
// Uses sysfs rather than opening a KFD device file to avoid inheriting GPU
// file descriptors in child processes.
static std::vector<int> gpuPoolFromKfd()
{
namespace fs = std::filesystem;
const fs::path nodesDir{"/sys/class/kfd/kfd/topology/nodes"};
int kfdCount = 0;
std::error_code ec;
for(const auto& entry : fs::directory_iterator(nodesDir, ec))
{
if(std::ifstream f{entry.path() / "gpu_id"})
{
unsigned gpuId = 0;
if(f >> gpuId && gpuId != 0)
++kfdCount;
}
}
if(kfdCount == 0)
return {};
std::vector<int> pool(static_cast<size_t>(kfdCount));
std::iota(pool.begin(), pool.end(), 0);
return pool;
}
// Detects available GPU indices: first from HIP_VISIBLE_DEVICES, then from
// KFD sysfs. Returns empty if neither source yields a pool.
static std::vector<int> detectGpuPool()
{
auto pool = gpuPoolFromEnv();
if(pool.empty())
pool = gpuPoolFromKfd();
return pool;
}
// Tracks which physical GPU device indices from a fixed pool are in use.
// acquire() and release() must be called with the external mutex (cvMtx) held.
class GpuSlotManager
{
public:
explicit GpuSlotManager(std::vector<int> pool)
: pool_(std::move(pool)), inUse_(pool_.size(), false)
{}
bool empty() const { return pool_.empty(); }
size_t size() const { return pool_.size(); }
const std::vector<int>& pool() const { return pool_; }
// Number of currently free slots. Must be called with the external mutex held.
size_t freeSlots() const
{
return static_cast<size_t>(
std::count(inUse_.begin(), inUse_.end(), false));
}
// Assigns `n` free slots and returns their physical device IDs.
// Precondition: n <= size(); must be called with the external mutex held.
std::vector<int> acquire(size_t n)
{
std::vector<int> assigned;
assigned.reserve(n);
for(size_t i = 0; i < pool_.size() && assigned.size() < n; ++i)
{
if(!inUse_[i])
{
inUse_[i] = true;
assigned.push_back(pool_[i]);
}
}
return assigned;
}
// Return previously acquired slots back to the pool.
// Must be called with the external mutex held.
void release(const std::vector<int>& physIds)
{
for(int id : physIds)
for(size_t i = 0; i < pool_.size(); ++i)
if(pool_[i] == id)
{
inUse_[i] = false;
break;
}
}
// Format a list of device indices as a comma-separated string.
static std::string formatList(const std::vector<int>& ids)
{
std::string s;
for(size_t i = 0; i < ids.size(); ++i)
{
if(i) s += ',';
s += std::to_string(ids[i]);
}
return s;
}
private:
std::vector<int> pool_;
std::vector<bool> inUse_;
};
// ExecutionOptions implementation
ProcessIsolatedTestRunner::ExecutionOptions::ExecutionOptions()
: stopOnFirstFailure(false), verboseLogging(true), maxParallelJobs(kSequentialExecution)
{}
// Apply environment variables to current process
void ProcessIsolatedTestRunner::applyEnvironmentVariables(const TestConfig& config)
{
for(const auto& varName : config.clearEnvVars)
unsetenv(varName.c_str());
if(!config.inheritParentEnv)
if(clearenv() != 0)
std::cerr << "Warning: Failed to clear environment variables\n";
for(const auto& [name, value] : config.environmentVariables)
setenv(name.c_str(), value.c_str(), 1);
}
int ProcessIsolatedTestRunner::runTestInProcess(const TestConfig& config)
{
pid_t processId = getpid();
if(config.name.empty())
{
std::cerr << "Error: Test name is empty for process " << processId << std::endl;
return RCCL_TEST_FAILURE;
}
try
{
const ::testing::UnitTest* unitTest = ::testing::UnitTest::GetInstance();
const size_t initialFailureCount = unitTest->failed_test_count();
const size_t initialSkippedCount = unitTest->skipped_test_count();
// Package the test as a future so wait_for() can enforce the deadline
// without a busy-wait loop.
std::packaged_task<int()> task(
[&]() -> int
{
config.testLogic();
const bool passed = (unitTest->failed_test_count() == initialFailureCount);
const bool skipped = (unitTest->skipped_test_count() > initialSkippedCount);
return skipped ? RCCL_TEST_SKIPPED
: passed ? RCCL_TEST_SUCCESS
: RCCL_TEST_FAILURE;
}
);
auto fut = task.get_future();
std::thread testThread(std::move(task));
if(fut.wait_for(config.timeout) == std::future_status::timeout)
{
TEST_INFO(
"Test '%s' TIMED OUT after %ld seconds",
config.name.c_str(),
config.timeout.count()
);
fflush(NULL);
testThread.detach();
return RCCL_TEST_TIMEOUT;
}
testThread.join();
fflush(NULL);
return fut.get(); // re-throws if the task threw
}
catch(const std::exception& e)
{
TEST_INFO("Test '%s' FAILED with exception: %s", config.name.c_str(), e.what());
std::cerr << "Exception in test '" << config.name << "': " << e.what() << std::endl;
fflush(NULL);
return RCCL_TEST_FAILURE;
}
catch(...)
{
TEST_INFO("Test '%s' FAILED with unknown exception", config.name.c_str());
std::cerr << "Unknown exception in test '" << config.name << "'" << std::endl;
fflush(NULL);
return RCCL_TEST_UNKNOWN_EXCEPTION;
}
}
// Register a test configuration
void ProcessIsolatedTestRunner::registerTest(const TestConfig& config)
{
// Null bytes are silently truncated by setenv()/getenv(), which would
// cause the re-exec child to fail to find its target test.
if(config.name.find('\0') != std::string::npos)
{
std::cerr << "ProcessIsolatedTestRunner: test name contains a null "
"byte and cannot be registered: '" << config.name
<< "'\n";
return;
}
std::lock_guard<std::mutex> lock(testConfigsMutex_);
// Duplicate names cause the re-exec child to always match the first
// registration and silently skip the second.
for(const auto& existing : testConfigs_)
{
if(existing.name == config.name)
{
std::cerr << "ProcessIsolatedTestRunner: duplicate test name '"
<< config.name << "' — second registration ignored.\n";
return;
}
}
testConfigs_.push_back(config);
}
// Register a simple test with just name and logic
void ProcessIsolatedTestRunner::registerTest(
const std::string& name, std::function<void()> testLogic
)
{
registerTest(TestConfig(name, testLogic));
}
// Register a test with environment variables
void ProcessIsolatedTestRunner::registerTest(
const std::string& name,
std::function<void()> testLogic,
const std::unordered_map<std::string, std::string>& env
)
{
registerTest(TestConfig(name, testLogic).withEnvironment(env));
}
// Record test result (thread-safe)
void ProcessIsolatedTestRunner::recordTestResult(const TestResult& result)
{
std::lock_guard<std::mutex> lock(resultsMutex_);
testResults_.push_back(result);
}
// Helper method: Create pipes for capturing process output
bool ProcessIsolatedTestRunner::createOutputPipes(int stdoutPipe[2], int stderrPipe[2])
{
// Create pipes for stdout and stderr
// stdoutPipe[0] = read end, stdoutPipe[1] = write end
if(pipe(stdoutPipe) == -1)
{
std::cerr << "Failed to create stdout pipe: " << strerror(errno) << std::endl;
return false;
}
if(pipe(stderrPipe) == -1)
{
std::cerr << "Failed to create stderr pipe: " << strerror(errno) << std::endl;
close(stdoutPipe[0]);
close(stdoutPipe[1]);
return false;
}
return true;
}
// Helper method: Redirect child process output to pipes
void ProcessIsolatedTestRunner::redirectOutputToPipes(int stdoutPipe[2], int stderrPipe[2])
{
// Close read ends of pipes in child process (not needed)
close(stdoutPipe[0]);
close(stderrPipe[0]);
// Redirect stdout and stderr to write ends of pipes
dup2(stdoutPipe[1], STDOUT_FILENO);
dup2(stderrPipe[1], STDERR_FILENO);
// Close the original write end file descriptors after duplication
// The duplicated descriptors (STDOUT_FILENO, STDERR_FILENO) will be closed by _exit()
close(stdoutPipe[1]);
close(stderrPipe[1]);
}
// Reads stdout/stderr pipes until the child exits or the deadline is reached.
// On timeout: SIGTERM, then SIGKILL after 2 s; *status set to RCCL_TEST_TIMEOUT.
ProcessIsolatedTestRunner::CapturedOutput ProcessIsolatedTestRunner::captureProcessOutput(
int stdoutPipe[2], int stderrPipe[2], pid_t pid, int* status,
std::chrono::seconds timeout
)
{
// Close write ends of pipes in parent process (not needed)
close(stdoutPipe[1]);
close(stderrPipe[1]);
CapturedOutput output;
char buffer[kPipeReadBufferSize];
ssize_t nbytes;
using Clock = std::chrono::steady_clock;
using TimePoint = Clock::time_point;
const bool hasTimeout = (timeout.count() > 0);
const TimePoint deadline = hasTimeout ? Clock::now() + timeout
: TimePoint::max();
// Drain stdout and stderr interleaved via poll() to avoid deadlock when
// the child fills one pipe buffer (~64 KB) while the parent reads the other.
struct pollfd pfds[kNumPipeFds];
pfds[0] = {stdoutPipe[0], POLLIN, 0};
pfds[1] = {stderrPipe[0], POLLIN, 0};
int openFds = kNumPipeFds;
bool timedOut = false;
while(openFds > 0)
{
int pollMs = kPollBlockIndefinitely;
if(hasTimeout)
{
auto remaining = std::chrono::duration_cast<std::chrono::milliseconds>(
deadline - Clock::now()
);
if(remaining.count() <= 0)
{
timedOut = true;
break;
}
// Cap to kPollSliceMs so we re-check the deadline regularly.
auto capMs = std::min<long long>(remaining.count(), kPollSliceMs);
pollMs = static_cast<int>(capMs);
}
int ready = poll(pfds, kNumPipeFds, pollMs);
if(ready < 0)
{
if(errno == EINTR) continue;
break; // unexpected error -- fall through to waitpid
}
if(ready == 0)
{
// poll timed out — re-check deadline next iteration
continue;
}
for(int i = 0; i < kNumPipeFds; ++i)
{
if(pfds[i].fd < 0) continue;
if(pfds[i].revents & (POLLIN | POLLHUP | POLLERR))
{
nbytes = read(pfds[i].fd, buffer, sizeof(buffer) - 1);
if(nbytes > 0)
{
buffer[nbytes] = '\0';
(i == 0 ? output.stdoutContent : output.stderrContent)
+= buffer;
}
else
{
// EOF or error -- no more data from this fd.
close(pfds[i].fd);
pfds[i].fd = kDisabledFd;
--openFds;
}
}
}
}
if(timedOut)
{
// Give the child a chance to exit cleanly, then force-kill it.
kill(pid, SIGTERM);
std::this_thread::sleep_for(std::chrono::seconds(kSigtermGraceSeconds));
kill(pid, SIGKILL);
// Drain any last output flushed before death.
for(int i = 0; i < kNumPipeFds; ++i)
{
if(pfds[i].fd < 0) continue;
// Non-blocking drain
fcntl(pfds[i].fd, F_SETFL, O_NONBLOCK);
while((nbytes = read(pfds[i].fd, buffer, sizeof(buffer) - 1)) > 0)
{
buffer[nbytes] = '\0';
(i == 0 ? output.stdoutContent : output.stderrContent) += buffer;
}
close(pfds[i].fd);
}
waitpid(pid, status, 0);
// Synthesise a waitpid(2) status as if the child called exit(RCCL_TEST_TIMEOUT).
// POSIX encodes the exit code in bits [15:8] of the status word, so
// WIFEXITED(*status) is true and WEXITSTATUS(*status) == RCCL_TEST_TIMEOUT.
*status = RCCL_TEST_TIMEOUT << kWaitStatusExitShift;
return output;
}
// Normal path: wait for child to exit.
waitpid(pid, status, 0);
return output;
}
// Helper method: Display captured output
void ProcessIsolatedTestRunner::displayCapturedOutput(
const CapturedOutput& output, const std::string& /*testName*/
)
{
auto flush = [](std::ostream& os, const std::string& s) {
if(s.empty()) return;
os << s;
if(s.back() != '\n') os << '\n';
};
flush(std::cout, output.stdoutContent);
flush(std::cerr, output.stderrContent);
}
// Re-exec child entrypoint: if kReexecMarkerEnvVar is set, runs the named test
// and _exit()s with the result. Returns normally only in the original parent.
void ProcessIsolatedTestRunner::handleReexecEntrypoint(
const std::vector<TestConfig>& tests)
{
const char* target = std::getenv(kReexecMarkerEnvVar);
if(!target)
return;
// Unset so any nested RUN_ISOLATED_TESTS in the lambda doesn't recurse.
unsetenv(kReexecMarkerEnvVar);
for(const auto& testConfig : tests)
{
if(testConfig.name == target)
{
int result = runTestInProcess(testConfig);
fflush(NULL);
#if defined(RCCL_TEST_CODE_COVERAGE)
__llvm_profile_write_file();
using WriteFn = int (*)(void);
auto libWrite = reinterpret_cast<WriteFn>(
dlsym(RTLD_DEFAULT, "rcclCoverageWriteFile"));
if(libWrite) libWrite();
#endif
_exit(result);
}
}
std::cerr << "ProcessIsolatedTestRunner: re-exec target '" << target
<< "' not found in registered tests" << std::endl;
fflush(NULL);
_exit(RCCL_TEST_INVALID);
}
void ProcessIsolatedTestRunner::runSequential(
const std::vector<TestConfig>& tests,
const ExecutionOptions& opts,
const SpawnFn& spawnFn)
{
for(const auto& testConfig : tests)
{
auto outcome = spawnFn(testConfig, {});
displayCapturedOutput(outcome.output, testConfig.name);
recordTestResult(outcome.result);
if(opts.stopOnFirstFailure && !outcome.result.passed && !outcome.result.skipped)
break;
}
}
void ProcessIsolatedTestRunner::runParallel(
const std::vector<TestConfig>& tests,
const ExecutionOptions& opts,
size_t parallelism,
const std::vector<int>& gpuPool,
const SpawnFn& spawnFn)
{
// Bounded sliding window: up to `parallelism` children run at once.
// GPU slot availability and active count are checked atomically.
GpuSlotManager slots(gpuPool);
if(!slots.empty())
TEST_INFO(
"GPU slot manager: pool = [%s] (%zu device(s))",
GpuSlotManager::formatList(slots.pool()).c_str(), slots.size()
);
std::mutex cvMtx;
std::condition_variable cv;
size_t active = 0;
std::atomic<bool> anyFailed{false};
const bool stopOnFirst = opts.stopOnFirstFailure;
using OutcomePair = std::pair<TestResult, CapturedOutput>;
std::vector<std::future<OutcomePair>> futures;
futures.reserve(tests.size());
for(const auto& testConfig : tests)
{
// 0 = CPU-only (no slot); > 0 = GPU slots needed.
const size_t need = (!slots.empty() && testConfig.numGpus > 0)
? testConfig.numGpus
: 0;
// Clamp to pool size: over-requesting runs exclusively so no
// sibling can hold a subset and cause resource contention.
const size_t effectiveNeed = std::min(need, slots.size());
if(need > 0 && need > slots.size())
{
TEST_INFO(
"WARNING: test '%s' requests %zu GPU(s) but pool has only %zu — "
"assigning the entire pool and running exclusively",
testConfig.name.c_str(), need, slots.size()
);
}
std::vector<int> assignedGpus;
{
std::unique_lock<std::mutex> lk(cvMtx);
cv.wait(lk, [&]
{
if(stopOnFirst && anyFailed.load()) return true;
if(active >= parallelism) return false;
if(effectiveNeed > 0 && slots.freeSlots() < effectiveNeed)
return false;
return true;
});
if(stopOnFirst && anyFailed.load())
break;
++active;
if(effectiveNeed > 0)
assignedGpus = slots.acquire(effectiveNeed);
}
futures.push_back(std::async(
std::launch::async,
[testConfig, assignedGpus, stopOnFirst, &spawnFn, &active, &cv, &cvMtx,
&slots, &anyFailed]() -> OutcomePair
{
auto outcome = spawnFn(testConfig, assignedGpus);
{
std::lock_guard<std::mutex> lk(cvMtx);
--active;
slots.release(assignedGpus);
if(stopOnFirst && !outcome.result.passed && !outcome.result.skipped)
anyFailed.store(true);
}
cv.notify_all();
return {std::move(outcome.result), std::move(outcome.output)};
}
));
}
// Drain futures in registration order (including in-flight ones after a break).
for(auto& future : futures)
{
auto [result, output] = future.get();
displayCapturedOutput(output, result.testName);
recordTestResult(result);
}
}
// Execute all registered tests (simplified sequential execution only)
bool ProcessIsolatedTestRunner::executeAllTests(const ExecutionOptions& options)
{
// Get test configurations to run
std::vector<TestConfig> testsToRun;
{
std::lock_guard<std::mutex> lock(testConfigsMutex_);
testsToRun = testConfigs_;
}
// Clear previous results
{
std::lock_guard<std::mutex> lock(resultsMutex_);
testResults_.clear();
}
// If this process is a re-exec child, handleReexecEntrypoint() runs the
// target test and calls _exit() — it returns only in the parent.
handleReexecEntrypoint(testsToRun);
// Capture gtest test-info once in the parent; current_test_info() is a
// single global valid for the lifetime of this TEST() body.
const ::testing::TestInfo* gtestInfo
= ::testing::UnitTest::GetInstance()->current_test_info();
if(!gtestInfo)
{
std::cerr << "ProcessIsolatedTestRunner: executeAllTests() must be "
"called from within a gtest TEST() body; "
"current_test_info() is null." << std::endl;
return false;
}
// spawnOne: fork+execv a fresh binary copy to run one isolated test.
// Thread-safe: env changes happen only in the fork child; parents own their pipe fds.
auto spawnOne = [&](const TestConfig& cfg, const std::vector<int>& assignedGpus)
-> SpawnOutcome
{
auto makeError = [&](const char* msg) -> SpawnOutcome {
TestResult r;
r.testName = cfg.name;
r.passed = false;
r.exitCode = RCCL_TEST_INVALID;
r.errorMessage = msg;
return {r, {}};
};
auto startTime = std::chrono::steady_clock::now();
int stdout_fd[2], stderr_fd[2];
if(!createOutputPipes(stdout_fd, stderr_fd))
return makeError("Failed to create output pipes");
// Flush all output before fork to prevent child from inheriting
// unflushed stdio buffers.
fflush(NULL);
pid_t pid = fork();
if(pid == 0)
{
// Always re-exec to get a fresh binary image; execv() discards
// all counters before coverage data is touched.
redirectOutputToPipes(stdout_fd, stderr_fd);
applyEnvironmentVariables(cfg);
// Restrict GPU visibility to the assigned subset so this child
// cannot accidentally share a GPU with a sibling test.
if(!assignedGpus.empty())
{
std::string ids = GpuSlotManager::formatList(assignedGpus);
// Warn when the slot manager overrides a test-specified value;
// the override is intentional for isolation.
const char* existingHVD = std::getenv("HIP_VISIBLE_DEVICES");
if(existingHVD && *existingHVD)
std::cerr
<< "ProcessIsolatedTestRunner: GPU slot manager overrides "
"test-specified HIP_VISIBLE_DEVICES='" << existingHVD
<< "' with '" << ids
<< "' for parallel isolation\n";
setenv("HIP_VISIBLE_DEVICES", ids.c_str(), 1 /*overwrite*/);
}
// Give each child its own profraw file (%p=PID, %m=module hash).
// overwrite=0 preserves any LLVM_PROFILE_FILE already set by CI.
#if defined(RCCL_TEST_CODE_COVERAGE)
setenv("LLVM_PROFILE_FILE", "rccl_tests_%p_%m.profraw", 0 /*no overwrite*/);
#endif
setenv(kReexecMarkerEnvVar, cfg.name.c_str(), 1);
// gtestInfo was validated non-null in the parent before any
// fork; no need to re-check here.
std::string filterArg = std::string("--gtest_filter=")
+ gtestInfo->test_suite_name() + "." + gtestInfo->name();
// Disable ANSI color in the child: its output is captured via pipe
// and replayed by the parent, so escape sequences are unwanted.
char argv0[] = "/proc/self/exe";
char colorArg[] = "--gtest_color=no";
char* argv[] = {argv0, filterArg.data(), colorArg, nullptr};
execv("/proc/self/exe", argv);
std::cerr << "ProcessIsolatedTestRunner: execv(/proc/self/exe) failed: "
<< strerror(errno) << std::endl;
fflush(NULL);
_exit(RCCL_TEST_INVALID);
}
else if(pid < 0)
{
close(stdout_fd[0]); close(stdout_fd[1]);
close(stderr_fd[0]); close(stderr_fd[1]);
TEST_INFO("Failed to fork process for test '%s'", cfg.name.c_str());
return makeError("Failed to fork process");
}
// Parent: log launch, drain pipes, wait for child.
{
std::string extras;
if(!assignedGpus.empty())
extras += " GPUs: " + GpuSlotManager::formatList(assignedGpus);
for(const auto& [name, value] : cfg.environmentVariables)
extras += (extras.empty() ? " env: " : ", ") + name + "=" + value;
if(!extras.empty())
TEST_INFO("Running isolated test '%s' (PID: %d) with%s",
cfg.name.c_str(), pid, extras.c_str());
else
TEST_INFO("Running isolated test '%s' (PID: %d)", cfg.name.c_str(), pid);
}
int status;
CapturedOutput output = captureProcessOutput(stdout_fd, stderr_fd, pid, &status,
cfg.timeout);
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - startTime
);
TestResult result;
result.testName = cfg.name;
result.processId = pid;
result.duration = duration;
if(WIFEXITED(status))
{
int exitCode = WEXITSTATUS(status);
result.exitCode = exitCode;
result.passed = (exitCode == RCCL_TEST_SUCCESS);
result.skipped = (exitCode == RCCL_TEST_SKIPPED);
if(exitCode == RCCL_TEST_SUCCESS)
{
TEST_INFO("Test '%s' PASSED (%ld ms)", cfg.name.c_str(), duration.count());
}
else if(exitCode == RCCL_TEST_TIMEOUT)
{
TEST_INFO(
"Test '%s' (PID: %d) TIMED OUT after %ld ms",
cfg.name.c_str(), pid, duration.count()
);
result.errorMessage = "Test timed out";
}
else if(exitCode == RCCL_TEST_SKIPPED)
{
TEST_INFO(
"Test '%s' (PID: %d) SKIPPED in %ld ms",
cfg.name.c_str(), pid, duration.count()
);
result.errorMessage = "Test skipped";
}
else
{
TEST_INFO(
"Test '%s' (PID: %d) FAILED with exit code %d after %ld ms",
cfg.name.c_str(), pid, exitCode, duration.count()
);
result.errorMessage
= "Test failed with exit code " + std::to_string(exitCode);
}
}
else if(WIFSIGNALED(status))
{
// With re-exec the child always _exit()s after completion, so a
// signal indicates a genuine crash or external kill (OOM, SIGSEGV).
int signal = WTERMSIG(status);
result.passed = false;
result.exitCode = -signal;
result.errorMessage = "Terminated by signal " + std::to_string(signal);
TEST_INFO(
"Test '%s' (PID: %d) terminated by signal %d after %ld ms",
cfg.name.c_str(), pid, signal, duration.count()
);
}
else
{
result.passed = false;
result.exitCode = RCCL_TEST_INVALID;
result.errorMessage = "Failed to wait for process";
}
return {result, output};
};
// Detect the GPU pool once so the parallelism calculation and slot
// manager always operate on the same snapshot.
const std::vector<int> detectedPool
= options.gpuPool.empty() ? detectGpuPool() : options.gpuPool;
// When maxParallelJobs is kAutoParallelism, default to pool size (or
// hardware_concurrency() for CPU-only suites) to avoid threads piling up
// waiting for GPU slots.
const size_t parallelism
= (options.maxParallelJobs == ExecutionOptions::kAutoParallelism)
? (detectedPool.empty()
? static_cast<size_t>(std::thread::hardware_concurrency())
: detectedPool.size())
: options.maxParallelJobs;
// A GPU pool is only required when running in parallel with tests that
// actually request GPU slots. Sequential runs and CPU-only suites proceed
// without one; kAutoParallelism falls back to hardware_concurrency().
const bool anyTestNeedsGpu = std::any_of(
testsToRun.begin(), testsToRun.end(),
[](const TestConfig& c) { return c.numGpus > TestConfig::kCpuOnly; }
);
if(detectedPool.empty() && parallelism > kSequentialExecution && anyTestNeedsGpu)
{
TEST_INFO("Could not determine GPU pool for parallel GPU tests.");
TEST_INFO(" Tried: HIP_VISIBLE_DEVICES (not set or empty),");
TEST_INFO(" /sys/class/kfd/kfd/topology/nodes (not found or no GPU nodes).");
TEST_INFO(" Fix: set HIP_VISIBLE_DEVICES=0,1,... or ensure the KFD sysfs is mounted.");
ADD_FAILURE() << "Set HIP_VISIBLE_DEVICES or ensure /sys/class/kfd is mounted.";
return false;
}
SpawnFn spawn = spawnOne;
if(parallelism <= kSequentialExecution)
runSequential(testsToRun, options, spawn);
else
runParallel(testsToRun, options, parallelism, detectedPool, spawn);
bool result = generateReport(options, testsToRun.size());
{
std::lock_guard<std::mutex> lock(testConfigsMutex_);
testConfigs_.clear();
}
{
std::lock_guard<std::mutex> lock(resultsMutex_);
testResults_.clear();
}
return result;
}
// Generate and display test report
bool ProcessIsolatedTestRunner::generateReport(
const ExecutionOptions& options, size_t totalRegistered
)
{
int totalTests = 0;
int passedTests = 0;
int failedTests = 0;
int skippedTests = 0;
std::chrono::milliseconds totalDuration{0};
// Collect failure details inside the lock so we don't need a second pass.
std::vector<std::pair<std::string, std::string>> failureDetails;
{
std::lock_guard<std::mutex> lock(resultsMutex_);
totalTests = static_cast<int>(testResults_.size());
for(const auto& result : testResults_)
{
if(result.skipped) ++skippedTests;
else if(result.passed) ++passedTests;
else
{
++failedTests;
failureDetails.emplace_back(result.testName, result.errorMessage);
}
totalDuration += result.duration;
}
}
const int notRunTests = static_cast<int>(totalRegistered) - totalTests;
if(failedTests > 0 || totalTests > 1 || notRunTests > 0)
{
if(notRunTests > 0)
TEST_INFO(
"Process-Isolated Tests: %d passed, %d failed, %d skipped, "
"%d not run (stopped on first failure) (%ld ms total)",
passedTests, failedTests, skippedTests, notRunTests, totalDuration.count()
);
else
TEST_INFO(
"Process-Isolated Tests: %d passed, %d failed, %d skipped (%ld ms total)",
passedTests, failedTests, skippedTests, totalDuration.count()
);
for(const auto& [name, msg] : failureDetails)
TEST_INFO(" Failed: %s - %s", name.c_str(), msg.c_str());
}
// notRunTests is shown in the summary but is not itself a failure;
// the actual failing test drives the false return.
return failedTests == 0;
}
// Get detailed test results (thread-safe)
std::vector<ProcessIsolatedTestRunner::TestResult> ProcessIsolatedTestRunner::getTestResults()
{
std::lock_guard<std::mutex> lock(resultsMutex_);
return testResults_;
}
// Clear test registry and results (thread-safe)
void ProcessIsolatedTestRunner::clear()
{
size_t registeredCount = 0;
size_t executedCount = 0;
{
std::lock_guard<std::mutex> lock(testConfigsMutex_);
registeredCount = testConfigs_.size();
testConfigs_.clear();
}
{
std::lock_guard<std::mutex> lock(resultsMutex_);
executedCount = testResults_.size();
testResults_.clear();
}
if(registeredCount > 0 && executedCount < registeredCount)
{
std::cerr << "\n WARNING: ProcessIsolatedTestRunner::clear() called with "
<< (registeredCount - executedCount) << " unexecuted test(s)!\n"
<< " Registered: " << registeredCount << " test(s)\n"
<< " Executed: " << executedCount << " test(s)\n"
<< " Did you forget to call executeAllTests()?\n"
<< std::endl;
}
}
// Get number of registered tests
size_t ProcessIsolatedTestRunner::getTestCount()
{
std::lock_guard<std::mutex> lock(testConfigsMutex_);
return testConfigs_.size();
}
} // namespace RcclUnitTesting