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
1995 lines
85 KiB
C++
1995 lines
85 KiB
C++
/*************************************************************************
|
|
* Copyright (c) 2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
*
|
|
* See LICENSE.txt for license information
|
|
************************************************************************/
|
|
|
|
/**
|
|
* @file SuspendResumeMPITests.cpp
|
|
* @brief Multi-process tests for the suspend/resume feature -- both the
|
|
* internal ncclCommMemSuspend/Resume/Stats path and the public
|
|
* ncclCommSuspend/Resume/MemStats API.
|
|
*
|
|
* The single-process unit-test suite in `test/mem_manager/MemManagerTests.cpp`
|
|
* covers all early-return error paths, `ncclCommMemStats`, and the manager
|
|
* state machine via fake / real-VMM entries that don't need a bootstrap.
|
|
*
|
|
* This file extends coverage to the bootstrap-coupled paths in
|
|
* `ncclCommMemSuspend` / `ncclCommMemResume`:
|
|
* - Cross-rank `bootstrapBarrier(0xBEEF)` on entry to Suspend.
|
|
* - Cross-rank `bootstrapBarrier(0xBEEF)` after local restore in Resume.
|
|
* - `bootstrapAllGather` of per-rank export counts in Resume Step 3.
|
|
* - `bootstrapSend`/`bootstrapRecv(0xFEED)` handle-info exchange.
|
|
* - Final `bootstrapBarrier(0xCAFE)` after peer re-import.
|
|
* - The peer-import matching loop in Step 4 (both positive and negative).
|
|
* - `ncclProxyClientGetFdBlocking` round-trip used by Step 4 to convert
|
|
* a peer's CUmemGenericAllocationHandle into a POSIX FD via UDS.
|
|
* - End-to-end data preservation: rank 0 fills an `ncclMemOffload` buffer
|
|
* with a canary, both ranks Suspend (CPU backup) + Resume (restore from
|
|
* backup, re-export, peer re-import), rank 1 reads the canary back
|
|
* through its imported VA.
|
|
*
|
|
* The tests allocate VMM directly via raw HIP (hipMemCreate / Reserve / Map /
|
|
* SetAccess) instead of going through ncclCuMemAlloc - the wrapper has its
|
|
* own issues on ROCm 7.0/7.2 and the goal is to exercise mem_manager logic,
|
|
* not the allocator. If the runtime really lacks raw VMM, hipMemCreate fails
|
|
* with an explicit HIP error code instead of a misleading version skip.
|
|
*
|
|
* Suites:
|
|
* - MemManagerAnyRanks - each rank runs the same local Suspend/Resume
|
|
* scenario in lockstep. The cross-rank bootstrapBarrier inside
|
|
* ncclCommMemSuspend / ncclCommMemResume keeps all ranks in sync, so
|
|
* every test in this suite passes on any np >= 1.
|
|
* - MemManagerTwoRank - peer-coupled scenarios that need exactly 2 ranks
|
|
* (rank 0 exporter, rank 1 importer).
|
|
* - SuspendResumeBasic / MemStats / CollectiveIntegrity / Lifecycle / Group
|
|
* / ArgValidation / MemStatsValidation - public API tests for
|
|
* ncclCommSuspend / ncclCommResume / ncclCommMemStats. Need np >= 2.
|
|
*
|
|
* Run (a single mpirun -np 2 invocation runs the entire file):
|
|
* mpirun -np 2 ./rccl-UnitTestsMPI --gtest_filter='MemManager*:SuspendResume*'
|
|
*
|
|
* Or, if you want to focus on one group of suites:
|
|
* mpirun -np 1 ./rccl-UnitTestsMPI --gtest_filter='MemManagerAnyRanks.*'
|
|
* mpirun -np 2 ./rccl-UnitTestsMPI --gtest_filter='MemManagerTwoRank.*'
|
|
* mpirun -np 2 ./rccl-UnitTestsMPI --gtest_filter='SuspendResume*'
|
|
*/
|
|
|
|
#include "DeviceBufferHelpers.hpp"
|
|
#include "MPITestBase.hpp"
|
|
#include "ResourceGuards.hpp"
|
|
#include "TestChecks.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <iomanip>
|
|
#include <iostream>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <unistd.h>
|
|
#include <vector>
|
|
|
|
#ifdef MPI_TESTS_ENABLED
|
|
|
|
#include "bootstrap.h"
|
|
#include "comm.h"
|
|
#include "mem_manager.h"
|
|
#include "proxy.h"
|
|
|
|
using namespace MPITestConstants;
|
|
using namespace RCCLTestGuards;
|
|
using namespace RCCLTestHelpers;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// VMM helpers
|
|
//
|
|
// We talk to the HIP driver directly (hipMemCreate / Reserve / Map / SetAccess)
|
|
// instead of going through ncclCuMemAlloc. The wrapper has rollback paths and
|
|
// a ROCm-2550 zero-fill memset that hide the actual VMM call site, and it
|
|
// segfaults inside cuMemCreate on ROCm 7.0/7.2 even when raw VMM works.
|
|
// Same approach the standalone unit test takes (Track_RealVmm_PosixFd).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
namespace
|
|
{
|
|
struct VmmAlloc
|
|
{
|
|
void* ptr = nullptr;
|
|
hipDeviceptr_t pdev = 0;
|
|
size_t size = 0;
|
|
hipMemGenericAllocationHandle_t handle = 0;
|
|
};
|
|
|
|
void allocateVmmPosixFd(int dev, size_t requestedSize, VmmAlloc* out)
|
|
{
|
|
ASSERT_NE(out, nullptr);
|
|
|
|
hipMemAllocationProp prop = {};
|
|
prop.type = hipMemAllocationTypePinned;
|
|
prop.location.type = hipMemLocationTypeDevice;
|
|
prop.location.id = dev;
|
|
prop.requestedHandleType = hipMemHandleTypePosixFileDescriptor;
|
|
prop.allocFlags.gpuDirectRDMACapable = 1;
|
|
|
|
size_t granularity = 0;
|
|
ASSERT_EQ(hipMemGetAllocationGranularity(&granularity, &prop,
|
|
hipMemAllocationGranularityMinimum),
|
|
hipSuccess);
|
|
ASSERT_GT(granularity, 0u);
|
|
size_t size = ((requestedSize + granularity - 1) / granularity) * granularity;
|
|
|
|
hipMemGenericAllocationHandle_t handle = 0;
|
|
ASSERT_EQ(hipMemCreate(&handle, size, &prop, 0), hipSuccess);
|
|
|
|
hipDeviceptr_t pdev = 0;
|
|
ASSERT_EQ(hipMemAddressReserve(&pdev, size, granularity, 0, 0), hipSuccess);
|
|
ASSERT_EQ(hipMemMap(pdev, size, 0, handle, 0), hipSuccess);
|
|
|
|
hipMemAccessDesc accessDesc = {};
|
|
accessDesc.location.type = hipMemLocationTypeDevice;
|
|
accessDesc.location.id = dev;
|
|
accessDesc.flags = hipMemAccessFlagsProtReadWrite;
|
|
ASSERT_EQ(hipMemSetAccess(pdev, size, &accessDesc, 1), hipSuccess);
|
|
|
|
out->ptr = reinterpret_cast<void*>(pdev);
|
|
out->pdev = pdev;
|
|
out->size = size;
|
|
out->handle = handle;
|
|
}
|
|
|
|
// Reserve only (no physical handle / no map). Used to set up a "released" VA
|
|
// region as if Suspend had already torn the physical backing down. Lets a test
|
|
// drive Resume's restore path without needing a Suspend/Resume roundtrip.
|
|
void reserveVaOnly(int dev, size_t requestedSize, VmmAlloc* out)
|
|
{
|
|
ASSERT_NE(out, nullptr);
|
|
|
|
hipMemAllocationProp prop = {};
|
|
prop.type = hipMemAllocationTypePinned;
|
|
prop.location.type = hipMemLocationTypeDevice;
|
|
prop.location.id = dev;
|
|
prop.requestedHandleType = hipMemHandleTypePosixFileDescriptor;
|
|
prop.allocFlags.gpuDirectRDMACapable = 1;
|
|
|
|
size_t granularity = 0;
|
|
ASSERT_EQ(hipMemGetAllocationGranularity(&granularity, &prop,
|
|
hipMemAllocationGranularityMinimum),
|
|
hipSuccess);
|
|
size_t size = ((requestedSize + granularity - 1) / granularity) * granularity;
|
|
|
|
hipDeviceptr_t pdev = 0;
|
|
ASSERT_EQ(hipMemAddressReserve(&pdev, size, granularity, 0, 0), hipSuccess);
|
|
|
|
out->ptr = reinterpret_cast<void*>(pdev);
|
|
out->pdev = pdev;
|
|
out->size = size;
|
|
out->handle = 0;
|
|
}
|
|
|
|
// Mirrors ncclCuMemFree: recover the live handle from the VA (cached
|
|
// a->handle is stale after Suspend/Resume). Retain bumps refcount by 1, so
|
|
// release twice. Reserved-but-unmapped VA falls through to AddressFree.
|
|
void releaseVmm(VmmAlloc* a)
|
|
{
|
|
if (a == nullptr || a->pdev == 0) return;
|
|
|
|
hipMemGenericAllocationHandle_t live = 0;
|
|
if (hipMemRetainAllocationHandle(&live, a->ptr) == hipSuccess) {
|
|
(void)hipMemRelease(live);
|
|
(void)hipMemUnmap(a->pdev, a->size);
|
|
(void)hipMemRelease(live);
|
|
} else {
|
|
(void)hipMemUnmap(a->pdev, a->size);
|
|
}
|
|
(void)hipMemAddressFree(a->pdev, a->size);
|
|
a->pdev = 0;
|
|
a->handle = 0;
|
|
a->ptr = nullptr;
|
|
a->size = 0;
|
|
}
|
|
|
|
// Grant local-device RW access to a previously reserved + mapped VA range.
|
|
// Used after hipMemMap on an imported handle to make the VA usable.
|
|
void applyDeviceRwAccess(int dev, hipDeviceptr_t pdev, size_t size)
|
|
{
|
|
hipMemAccessDesc accessDesc = {};
|
|
accessDesc.location.type = hipMemLocationTypeDevice;
|
|
accessDesc.location.id = dev;
|
|
accessDesc.flags = hipMemAccessFlagsProtReadWrite;
|
|
ASSERT_EQ(hipMemSetAccess(pdev, size, &accessDesc, 1), hipSuccess);
|
|
}
|
|
|
|
// Pour `pattern` over the entire VA on the device, then DToH + verify every
|
|
// byte equals `pattern`. Uses bulk std::all_of so failures don't spam gtest
|
|
// with millions of EXPECT_EQ lines on big buffers. `what` is taken by const-
|
|
// reference so callers can pass either a string literal or a `std::to_string`-
|
|
// composed message without forcing a `.c_str()` everywhere.
|
|
void verifyCanaryReadback(void* ptr, size_t size, uint8_t pattern, const std::string& what)
|
|
{
|
|
std::vector<uint8_t> host(size, static_cast<uint8_t>(~pattern));
|
|
ASSERT_EQ(hipMemcpy(host.data(), ptr, size, hipMemcpyDeviceToHost), hipSuccess) << what;
|
|
bool ok = std::all_of(host.begin(), host.end(),
|
|
[pattern](uint8_t b) { return b == pattern; });
|
|
EXPECT_TRUE(ok) << what << ": canary 0x" << std::hex << static_cast<int>(pattern)
|
|
<< " not preserved";
|
|
}
|
|
|
|
// ncclMemUntrack + releaseVmm. Mirrors the ncclCudaFree pattern.
|
|
void untrackAndRelease(struct ncclComm* comm, void* ptr, VmmAlloc* a)
|
|
{
|
|
ASSERT_NE(comm, nullptr);
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
ASSERT_EQ(ncclMemUntrack(comm->memManager, ptr, a->size), ncclSuccess);
|
|
releaseVmm(a);
|
|
}
|
|
|
|
// Snapshot of manager state taken before the test adds its own entries.
|
|
// With NCCL_CUMEM_ENABLE=1 a freshly created comm already tracks internal
|
|
// RCCL allocations (proxy P2P buffers, channel buffers etc.), so absolute
|
|
// counters/sizes can't be asserted directly - tests must check deltas.
|
|
struct MgrBaseline
|
|
{
|
|
int numEntries = 0;
|
|
uint64_t cpuBackupUsage = 0;
|
|
uint64_t total = 0;
|
|
uint64_t persist = 0;
|
|
uint64_t suspendBytes = 0;
|
|
};
|
|
|
|
MgrBaseline captureBaseline(struct ncclComm* comm)
|
|
{
|
|
MgrBaseline b{};
|
|
EXPECT_NE(comm, nullptr);
|
|
EXPECT_NE(comm->memManager, nullptr);
|
|
b.numEntries = comm->memManager->numEntries;
|
|
b.cpuBackupUsage = comm->memManager->cpuBackupUsage;
|
|
EXPECT_EQ(ncclCommMemStats(comm, ncclStatGpuMemTotal, &b.total), ncclSuccess);
|
|
EXPECT_EQ(ncclCommMemStats(comm, ncclStatGpuMemPersist, &b.persist), ncclSuccess);
|
|
EXPECT_EQ(ncclCommMemStats(comm, ncclStatGpuMemSuspend, &b.suspendBytes), ncclSuccess);
|
|
return b;
|
|
}
|
|
|
|
// Run one Suspend/Resume cycle to learn how much CPU backup the comm's
|
|
// internal Offload entries consume - tests with their own Offload Track must
|
|
// add their size on top of this baseline when checking cpuBackupUsage.
|
|
uint64_t captureInternalOffloadCpuBackup(struct ncclComm* comm)
|
|
{
|
|
EXPECT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
uint64_t v = comm->memManager->cpuBackupUsage;
|
|
EXPECT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
return v;
|
|
}
|
|
|
|
// Find the entry tracking `ptr`. With internals tracked it's no longer
|
|
// guaranteed to be at manager->entries[0].
|
|
ncclDynMemEntry* findEntryByPtr(struct ncclComm* comm, void* ptr)
|
|
{
|
|
for (auto* e = comm->memManager->entries; e != nullptr; e = e->next) {
|
|
if (e->ptr == ptr) return e;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
// Snapshot of GPU + CPU memory usage at one point in a Suspend/Resume cycle.
|
|
// Combines what the manager tracks (ncclCommMemStats + cpuBackupUsage) with
|
|
// what the OS reports for the process (hipMemGetInfo) so the caller can see
|
|
// both bookkeeping and actual physical impact of Suspend/Resume.
|
|
struct MemFootprint
|
|
{
|
|
uint64_t mgrTotal = 0; // ncclStatGpuMemTotal (bytes)
|
|
uint64_t mgrPersist = 0; // ncclStatGpuMemPersist (bytes)
|
|
uint64_t mgrSuspendable = 0; // ncclStatGpuMemSuspend (bytes)
|
|
uint64_t mgrSuspended = 0; // ncclStatGpuMemSuspended (0 / 1 flag)
|
|
uint64_t cpuBackupUsage = 0; // manager->cpuBackupUsage (bytes)
|
|
size_t gpuFree = 0; // hipMemGetInfo free (bytes)
|
|
size_t gpuTotal = 0; // hipMemGetInfo total (bytes)
|
|
size_t gpuUsedByProc = 0; // gpuTotal - gpuFree (bytes)
|
|
};
|
|
|
|
MemFootprint captureMemFootprint(struct ncclComm* comm)
|
|
{
|
|
MemFootprint f{};
|
|
EXPECT_NE(comm, nullptr);
|
|
EXPECT_NE(comm->memManager, nullptr);
|
|
EXPECT_EQ(ncclCommMemStats(comm, ncclStatGpuMemTotal, &f.mgrTotal), ncclSuccess);
|
|
EXPECT_EQ(ncclCommMemStats(comm, ncclStatGpuMemPersist, &f.mgrPersist), ncclSuccess);
|
|
EXPECT_EQ(ncclCommMemStats(comm, ncclStatGpuMemSuspend, &f.mgrSuspendable), ncclSuccess);
|
|
EXPECT_EQ(ncclCommMemStats(comm, ncclStatGpuMemSuspended, &f.mgrSuspended), ncclSuccess);
|
|
f.cpuBackupUsage = comm->memManager->cpuBackupUsage;
|
|
EXPECT_EQ(hipMemGetInfo(&f.gpuFree, &f.gpuTotal), hipSuccess);
|
|
f.gpuUsedByProc = (f.gpuTotal > f.gpuFree) ? (f.gpuTotal - f.gpuFree) : 0;
|
|
return f;
|
|
}
|
|
|
|
// Pretty-print one footprint line. Kept on std::cout so the host test wrapper
|
|
// captures it the same way as gtest's own output. Drop these prints (and the
|
|
// helpers above) once we no longer need to eyeball the numbers.
|
|
void printMemFootprint(int rank, const char* tag, const MemFootprint& f)
|
|
{
|
|
constexpr double MiB = 1024.0 * 1024.0;
|
|
std::cout << "[MemFootprint rank=" << rank << "] " << tag << '\n'
|
|
<< " mgr.Total = " << std::fixed << std::setprecision(2)
|
|
<< (f.mgrTotal / MiB) << " MiB\n"
|
|
<< " mgr.Persist = " << (f.mgrPersist / MiB) << " MiB\n"
|
|
<< " mgr.Suspendable = " << (f.mgrSuspendable / MiB) << " MiB\n"
|
|
<< " mgr.Suspended = " << f.mgrSuspended << '\n'
|
|
<< " cpuBackupUsage = " << (f.cpuBackupUsage / MiB) << " MiB\n"
|
|
<< " gpu.UsedByProc = " << (f.gpuUsedByProc / MiB) << " MiB"
|
|
<< " (free " << (f.gpuFree / MiB)
|
|
<< " / total " << (f.gpuTotal / MiB) << " MiB)" << std::endl;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Peer export/import helpers - shared by the multi-rank tests.
|
|
//
|
|
// PeerMeta is what the owner ships over bootstrapSend so the importer can
|
|
// reconstruct the matching ncclMemTrackImportFromPeer call: handle value
|
|
// (round-tripped through the proxy GetFd UDS for FD conversion), VA address
|
|
// (matched verbatim by Resume Step 4's matching loop), aligned size, and the
|
|
// owner's cudaDev.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct PeerMeta
|
|
{
|
|
uint64_t handleVal; // owner's hipMemGenericAllocationHandle_t value
|
|
uint64_t ownerPtr; // owner's VA (uintptr_t) - matched verbatim in Step 4
|
|
uint64_t size; // owner's aligned size - matched verbatim
|
|
int ownerDev; // owner's cudaDev - stored in entry.desc.imported
|
|
};
|
|
|
|
// Owner side: Track the buffer, MarkExportToPeer, and ship the metadata over
|
|
// bootstrapSend. The owner keeps `vmm` alive through Suspend/Resume.
|
|
// Each (peerRank, tag) pair must be unique across concurrent calls.
|
|
void exportAndShipMeta(struct ncclComm* comm, const VmmAlloc& vmm, int peerRank,
|
|
ncclMemType_t memType, int tag)
|
|
{
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, vmm.ptr, vmm.size, vmm.handle,
|
|
hipMemHandleTypePosixFileDescriptor, memType),
|
|
ncclSuccess);
|
|
ASSERT_EQ(ncclDynMemMarkExportToPeer(comm->memManager, vmm.ptr, peerRank), ncclSuccess);
|
|
|
|
PeerMeta meta{};
|
|
meta.handleVal = (uint64_t)(uintptr_t)vmm.handle;
|
|
meta.ownerPtr = reinterpret_cast<uintptr_t>(vmm.ptr);
|
|
meta.size = static_cast<uint64_t>(vmm.size);
|
|
meta.ownerDev = comm->cudaDev;
|
|
ASSERT_EQ(bootstrapSend(comm->bootstrap, peerRank, tag, &meta, sizeof(meta)),
|
|
ncclSuccess);
|
|
}
|
|
|
|
// Importer side: bootstrapRecv the metadata, ask owner's proxy for a POSIX
|
|
// FD, import + map + setAccess into a fresh local VA, then Track as imported.
|
|
// Fills `out` with VA + size + the imported handle so untrackAndRelease() can
|
|
// free everything later.
|
|
void recvAndImportPeerBuffer(struct ncclComm* comm, int ownerRank, ncclMemType_t memType,
|
|
int tag, VmmAlloc* out)
|
|
{
|
|
PeerMeta meta{};
|
|
ASSERT_EQ(bootstrapRecv(comm->bootstrap, ownerRank, tag, &meta, sizeof(meta)),
|
|
ncclSuccess);
|
|
ASSERT_GT(meta.size, 0u);
|
|
ASSERT_NE(meta.ownerPtr, 0u);
|
|
|
|
int fd = -1;
|
|
hipMemGenericAllocationHandle_t peerHval =
|
|
(hipMemGenericAllocationHandle_t)(uintptr_t)meta.handleVal;
|
|
ASSERT_EQ(ncclProxyClientGetFdBlocking(comm, ownerRank, &peerHval, &fd), ncclSuccess);
|
|
ASSERT_GE(fd, 0) << "proxy GetFd from rank " << ownerRank << " returned invalid FD";
|
|
|
|
hipMemGenericAllocationHandle_t imported = 0;
|
|
ASSERT_EQ(hipMemImportFromShareableHandle(&imported, (void*)(uintptr_t)fd,
|
|
hipMemHandleTypePosixFileDescriptor),
|
|
hipSuccess);
|
|
close(fd);
|
|
|
|
reserveVaOnly(comm->cudaDev, static_cast<size_t>(meta.size), out);
|
|
ASSERT_EQ(hipMemMap(out->pdev, out->size, 0, imported, 0), hipSuccess);
|
|
applyDeviceRwAccess(comm->cudaDev, out->pdev, out->size);
|
|
out->handle = imported;
|
|
|
|
ASSERT_EQ(ncclMemTrackImportFromPeer(comm->memManager, out->ptr, out->size, imported,
|
|
hipMemHandleTypePosixFileDescriptor, memType,
|
|
ownerRank, meta.ownerDev,
|
|
reinterpret_cast<void*>(meta.ownerPtr)),
|
|
ncclSuccess);
|
|
}
|
|
} // namespace
|
|
|
|
// ===========================================================================
|
|
// AnyRanks suite (np >= 1) - local-only Suspend/Resume scenarios.
|
|
//
|
|
// Every test here only touches its own rank's manager (no cross-rank exports).
|
|
// The bootstrapBarrier(0xBEEF/0xCAFE) inside ncclCommMemSuspend/Resume keeps
|
|
// all ranks in sync, AllGather/Send-Recv in Resume Step 3 short-circuits when
|
|
// nobody has exports, so the same body works identically on np=1, np=2, np=N.
|
|
// ===========================================================================
|
|
|
|
class MemManagerAnyRanks : public MPITestBase
|
|
{
|
|
protected:
|
|
void SetUp() override
|
|
{
|
|
MPITestBase::SetUp();
|
|
if (!validateTestPrerequisites(/*min_processes=*/1, kNoProcessLimit)) {
|
|
GTEST_SKIP() << "AnyRanks suite needs at least 1 rank";
|
|
}
|
|
ASSERT_EQ(createTestCommunicator(), ncclSuccess);
|
|
}
|
|
};
|
|
|
|
TEST_F(MemManagerAnyRanks, SuspendResume_LocalScratch)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm, nullptr);
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
VmmAlloc a{};
|
|
allocateVmmPosixFd(comm->cudaDev, 1 << 20, &a);
|
|
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, a.ptr, a.size, a.handle,
|
|
hipMemHandleTypePosixFileDescriptor, ncclMemScratch),
|
|
ncclSuccess);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 1);
|
|
|
|
// The single tracked entry is the one we just added.
|
|
ncclDynMemEntry* entry = comm->memManager->entries;
|
|
ASSERT_NE(entry, nullptr);
|
|
EXPECT_EQ(entry->state, ncclDynMemStateReleased);
|
|
EXPECT_EQ(static_cast<void*>(entry->handle), nullptr);
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 0);
|
|
EXPECT_EQ(entry->state, ncclDynMemStateActive);
|
|
EXPECT_NE(static_cast<void*>(entry->handle), nullptr);
|
|
|
|
// Verify the VA is mapped again - a roundtrip memset/D2H must succeed.
|
|
ASSERT_EQ(hipMemset(a.ptr, 0xAA, a.size), hipSuccess);
|
|
std::vector<uint8_t> host(a.size, 0);
|
|
ASSERT_EQ(hipMemcpy(host.data(), a.ptr, a.size, hipMemcpyDeviceToHost), hipSuccess);
|
|
EXPECT_EQ(host[0], 0xAA);
|
|
EXPECT_EQ(host[a.size - 1], 0xAA);
|
|
|
|
ASSERT_EQ(ncclMemUntrack(comm->memManager, a.ptr, a.size), ncclSuccess);
|
|
releaseVmm(&a);
|
|
}
|
|
|
|
TEST_F(MemManagerAnyRanks, SuspendResume_LocalOffload_DataPreserved)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
const uint64_t internalOffload = captureInternalOffloadCpuBackup(comm);
|
|
|
|
VmmAlloc a{};
|
|
allocateVmmPosixFd(comm->cudaDev, 64 * 1024, &a);
|
|
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, a.ptr, a.size, a.handle,
|
|
hipMemHandleTypePosixFileDescriptor, ncclMemOffload),
|
|
ncclSuccess);
|
|
|
|
std::vector<uint8_t> pattern(a.size);
|
|
for (size_t i = 0; i < a.size; i++) pattern[i] = static_cast<uint8_t>(i * 31u);
|
|
ASSERT_EQ(hipMemcpy(a.ptr, pattern.data(), a.size, hipMemcpyHostToDevice), hipSuccess);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
|
|
ncclDynMemEntry* entry = findEntryByPtr(comm, a.ptr);
|
|
ASSERT_NE(entry, nullptr);
|
|
EXPECT_EQ(entry->state, ncclDynMemStateReleased);
|
|
EXPECT_NE(entry->cpuBackup, nullptr);
|
|
EXPECT_EQ(comm->memManager->cpuBackupUsage, internalOffload + a.size);
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(entry->state, ncclDynMemStateActive);
|
|
EXPECT_EQ(entry->cpuBackup, nullptr);
|
|
EXPECT_EQ(comm->memManager->cpuBackupUsage, 0u);
|
|
|
|
std::vector<uint8_t> roundtrip(a.size, 0);
|
|
ASSERT_EQ(hipMemcpy(roundtrip.data(), a.ptr, a.size, hipMemcpyDeviceToHost), hipSuccess);
|
|
EXPECT_EQ(std::memcmp(roundtrip.data(), pattern.data(), a.size), 0);
|
|
|
|
untrackAndRelease(comm, a.ptr, &a);
|
|
}
|
|
|
|
TEST_F(MemManagerAnyRanks, SuspendResume_PersistUntouched)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
const MgrBaseline base = captureBaseline(comm);
|
|
|
|
constexpr size_t persistSize = 8192;
|
|
void* persistPtr = reinterpret_cast<void*>(0xDEADBEEF00ULL);
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, persistPtr, persistSize,
|
|
/*handle=*/0, hipMemHandleTypePosixFileDescriptor,
|
|
ncclMemPersist),
|
|
ncclSuccess);
|
|
|
|
uint64_t persistBefore = 0;
|
|
ASSERT_EQ(ncclCommMemStats(comm, ncclStatGpuMemPersist, &persistBefore), ncclSuccess);
|
|
EXPECT_EQ(persistBefore - base.persist, persistSize);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
|
|
uint64_t persistAfter = 0;
|
|
ASSERT_EQ(ncclCommMemStats(comm, ncclStatGpuMemPersist, &persistAfter), ncclSuccess);
|
|
EXPECT_EQ(persistAfter - base.persist, persistSize);
|
|
|
|
uint64_t suspended = 0;
|
|
ASSERT_EQ(ncclCommMemStats(comm, ncclStatGpuMemSuspended, &suspended), ncclSuccess);
|
|
EXPECT_EQ(suspended, 1u);
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
|
|
ASSERT_EQ(ncclMemUntrack(comm->memManager, persistPtr, persistSize), ncclSuccess);
|
|
}
|
|
|
|
TEST_F(MemManagerAnyRanks, SuspendResume_DoubleCalls_Rejected)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
EXPECT_EQ(ncclCommMemSuspend(comm), ncclInvalidUsage);
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(ncclCommMemResume(comm), ncclInvalidUsage);
|
|
}
|
|
|
|
TEST_F(MemManagerAnyRanks, MemStats_FlipsAcrossSuspendResume)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
uint64_t suspended = ~0ULL;
|
|
ASSERT_EQ(ncclCommMemStats(comm, ncclStatGpuMemSuspended, &suspended), ncclSuccess);
|
|
EXPECT_EQ(suspended, 0u);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
ASSERT_EQ(ncclCommMemStats(comm, ncclStatGpuMemSuspended, &suspended), ncclSuccess);
|
|
EXPECT_EQ(suspended, 1u);
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
ASSERT_EQ(ncclCommMemStats(comm, ncclStatGpuMemSuspended, &suspended), ncclSuccess);
|
|
EXPECT_EQ(suspended, 0u);
|
|
}
|
|
|
|
// Capture and print the memory footprint at three checkpoints: before Suspend,
|
|
// after Suspend, after Resume. Allocates a handful of real VMM buffers as
|
|
// ncclMemOffload so Suspend actually moves bytes to CPU and frees the GPU
|
|
// backing - otherwise the gpu.UsedByProc delta would be invisible.
|
|
//
|
|
// The test is non-strict on absolute numbers (other ranks share the GPU on
|
|
// shared boxes, internal RCCL allocations vary per build) and instead checks
|
|
// the invariants we care about across the cycle:
|
|
// - mgr.Suspended flips 0 -> 1 -> 0.
|
|
// - cpuBackupUsage rises during Suspend, drops to baseline on Resume.
|
|
// - gpu.UsedByProc is lower after Suspend than before, and recovers on
|
|
// Resume to within a small slack of the pre-Suspend value.
|
|
TEST_F(MemManagerAnyRanks, MemoryFootprint_BeforeSuspendAfterSuspendAfterResume)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
// Allocate enough Offload buffers to make the GPU-memory drop on Suspend
|
|
// visible against general device noise (other processes, driver pools).
|
|
constexpr int kNumBuffers = 4;
|
|
constexpr size_t kBufBytes = 4u * 1024u * 1024u; // 4 MiB each, 16 MiB total
|
|
constexpr uint8_t kPattern = 0xC7;
|
|
|
|
std::vector<VmmAlloc> bufs(kNumBuffers);
|
|
for (int i = 0; i < kNumBuffers; ++i) {
|
|
allocateVmmPosixFd(comm->cudaDev, kBufBytes, &bufs[i]);
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, bufs[i].ptr, bufs[i].size, bufs[i].handle,
|
|
hipMemHandleTypePosixFileDescriptor, ncclMemOffload),
|
|
ncclSuccess);
|
|
ASSERT_EQ(hipMemset(bufs[i].ptr, kPattern, bufs[i].size), hipSuccess);
|
|
}
|
|
ASSERT_EQ(hipDeviceSynchronize(), hipSuccess);
|
|
|
|
MemFootprint before = captureMemFootprint(comm);
|
|
//printMemFootprint(comm->rank, "BEFORE SUSPEND", before);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
|
|
MemFootprint suspended = captureMemFootprint(comm);
|
|
//printMemFootprint(comm->rank, "AFTER SUSPEND ", suspended);
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
|
|
MemFootprint resumed = captureMemFootprint(comm);
|
|
//printMemFootprint(comm->rank, "AFTER RESUME ", resumed);
|
|
|
|
EXPECT_EQ(before.mgrSuspended, 0u);
|
|
EXPECT_EQ(suspended.mgrSuspended, 1u);
|
|
EXPECT_EQ(resumed.mgrSuspended, 0u);
|
|
|
|
EXPECT_GE(suspended.cpuBackupUsage,
|
|
before.cpuBackupUsage + static_cast<uint64_t>(kNumBuffers) * kBufBytes)
|
|
<< "Suspend must back up at least our Offload buffers to CPU";
|
|
EXPECT_EQ(resumed.cpuBackupUsage, before.cpuBackupUsage);
|
|
|
|
EXPECT_LT(suspended.gpuUsedByProc, before.gpuUsedByProc)
|
|
<< "Suspend should free GPU backing for Scratch + Offload entries";
|
|
|
|
// Resume must restore the exact pre-Suspend footprint: manager-tracked
|
|
// totals don't change at all across the cycle (entries stay in the list,
|
|
// only `state` toggles), and real GPU usage measured by hipMemGetInfo
|
|
// returns to baseline once cuMemCreate + cuMemMap re-back the released VAs.
|
|
EXPECT_EQ(resumed.mgrTotal, before.mgrTotal);
|
|
EXPECT_EQ(resumed.mgrPersist, before.mgrPersist);
|
|
EXPECT_EQ(resumed.mgrSuspendable, before.mgrSuspendable);
|
|
EXPECT_EQ(resumed.gpuUsedByProc, before.gpuUsedByProc)
|
|
<< "Resume must restore the exact GPU memory footprint";
|
|
|
|
for (int i = 0; i < kNumBuffers; ++i) {
|
|
untrackAndRelease(comm, bufs[i].ptr, &bufs[i]);
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Two-rank suite (-np 2) - exercises bootstrap-coupled paths
|
|
// ===========================================================================
|
|
|
|
class MemManagerTwoRank : public MPITestBase
|
|
{
|
|
protected:
|
|
void SetUp() override
|
|
{
|
|
MPITestBase::SetUp();
|
|
if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) {
|
|
GTEST_SKIP() << "Two-rank suite, run with mpirun -np 2";
|
|
}
|
|
ASSERT_EQ(createTestCommunicator(), ncclSuccess);
|
|
}
|
|
};
|
|
|
|
// Both ranks own only local entries. No exports, no peer imports.
|
|
// Verifies:
|
|
// - bootstrapBarrier(0xBEEF) on Suspend entry succeeds across ranks.
|
|
// - bootstrapBarrier(0xBEEF) after Resume Step 1 succeeds.
|
|
// - Resume Step 3 short-circuits when nobody has exports
|
|
// (allCounts all zero, totalInfoCount == 0, no Send/Recv).
|
|
// - Final bootstrapBarrier(0xCAFE) succeeds.
|
|
TEST_F(MemManagerTwoRank, SuspendResume_LocalOnly)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
VmmAlloc a{};
|
|
allocateVmmPosixFd(comm->cudaDev, 1 << 20, &a);
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, a.ptr, a.size, a.handle,
|
|
hipMemHandleTypePosixFileDescriptor, ncclMemScratch),
|
|
ncclSuccess);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 1);
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 0);
|
|
|
|
// VA must be live again on both ranks.
|
|
ASSERT_EQ(hipMemset(a.ptr, 0x55, a.size), hipSuccess);
|
|
|
|
ASSERT_EQ(ncclMemUntrack(comm->memManager, a.ptr, a.size), ncclSuccess);
|
|
releaseVmm(&a);
|
|
}
|
|
|
|
// Inject a synthetic peer-imported entry on rank 1 whose ownerPtr has no
|
|
// matching local export on rank 0. Resume Step 4 must surface this as
|
|
// ncclSystemError on rank 1 (RCCL divergence from NCCL upstream silent skip)
|
|
// and leave the entry in Released state. Rank 0 has no entries -> ncclSuccess.
|
|
TEST_F(MemManagerTwoRank, Resume_PeerImport_NoMatch_ReturnsErrorEntryStaysReleased)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
int rank = comm->rank;
|
|
|
|
// Rank 1 reserves a VA region (no physical backing) and registers it as
|
|
// a peer import that points at a fake ownerPtr on rank 0. Rank 0 does
|
|
// nothing - it'll have no exports to broadcast.
|
|
VmmAlloc fakeImport{};
|
|
if (rank == 1) {
|
|
reserveVaOnly(comm->cudaDev, 64 * 1024, &fakeImport);
|
|
void* fakeOwnerPtr = reinterpret_cast<void*>(0xFEEDFACE0000ULL);
|
|
ASSERT_EQ(ncclMemTrackImportFromPeer(
|
|
comm->memManager, fakeImport.ptr, fakeImport.size,
|
|
/*handle=*/0, hipMemHandleTypePosixFileDescriptor,
|
|
ncclMemScratch, /*ownerRank=*/0, /*ownerDev=*/0, fakeOwnerPtr),
|
|
ncclSuccess);
|
|
|
|
// Pretend Suspend already ran for this entry so Resume Step 4 considers
|
|
// it a candidate for re-import (which is then expected to no-match).
|
|
ncclDynMemEntry* entry = comm->memManager->entries;
|
|
ASSERT_NE(entry, nullptr);
|
|
entry->state = ncclDynMemStateReleased;
|
|
}
|
|
|
|
// Drive the full Suspend / Resume cycle. We expect:
|
|
// - Suspend on both ranks succeeds (rank 1's "released" entry is skipped
|
|
// in Pass 1 since state already == Released; rank 0 has no entries).
|
|
// - Resume on rank 0 succeeds (no peer-imports to re-map).
|
|
// - Resume on rank 1 returns ncclSystemError because the fake entry has
|
|
// no matching broadcast info; entry stays Released so caller can clean
|
|
// up via Destroy.
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
ncclResult_t resumeRet = ncclCommMemResume(comm);
|
|
|
|
if (rank == 0) {
|
|
ASSERT_EQ(resumeRet, ncclSuccess) << "Rank 0 has no entries, Resume must succeed";
|
|
} else {
|
|
ASSERT_EQ(resumeRet, ncclSystemError)
|
|
<< "Rank 1 has a no-match peer-import; Resume must surface the error";
|
|
|
|
ncclDynMemEntry* entry = comm->memManager->entries;
|
|
ASSERT_NE(entry, nullptr);
|
|
EXPECT_EQ(entry->state, ncclDynMemStateReleased)
|
|
<< "Resume must leave a no-match peer entry in Released state";
|
|
|
|
// released flag must remain set so the manager is in a clearly
|
|
// suspended state and the caller knows to retry or Destroy.
|
|
EXPECT_EQ(comm->memManager->released, 1);
|
|
|
|
// Cleanup: drop the fake entry and free the VA reservation.
|
|
ASSERT_EQ(ncclMemUntrack(comm->memManager, fakeImport.ptr, fakeImport.size),
|
|
ncclSuccess);
|
|
releaseVmm(&fakeImport);
|
|
}
|
|
}
|
|
|
|
// End-to-end happy path of the Suspend/Resume P2P protocol with a real,
|
|
// mutual peer import driven through the proxy:
|
|
//
|
|
// Setup (before Suspend):
|
|
// rank 0: hipMemCreate(POSIX_FD) -> hipMemMap -> hipMemSetAccess
|
|
// -> ncclMemTrack(..., ncclMemOffload)
|
|
// -> ncclDynMemMarkExportToPeer(peer=1)
|
|
// -> hipMemset(canary)
|
|
// -> bootstrapSend(meta = {handleVal, ownerPtr, size, ownerDev}, tag=0xC0FFEE)
|
|
//
|
|
// rank 1: bootstrapRecv(meta, tag=0xC0FFEE)
|
|
// -> ncclProxyClientGetFdBlocking(rank 0, &handleVal, &fd) <-- UDS round-trip
|
|
// -> hipMemImportFromShareableHandle(fd) -> hipMemMap + SetAccess
|
|
// -> ncclMemTrackImportFromPeer(..., ncclMemOffload, owner=rank 0)
|
|
// -> (sanity) hipMemcpy DToH and verify canary on imported VA
|
|
//
|
|
// Drive Suspend then Resume on both ranks. This exercises:
|
|
// - Suspend Step 1: rank 1 unmaps the imported entry, releases its handle.
|
|
// - Suspend Step 2: rank 0 copies the offload buffer to CPU backup,
|
|
// closes the shareable FD, unmaps + releases the physical handle.
|
|
// - Resume Step 1: rank 0 re-creates a fresh physical handle, re-maps to
|
|
// the same VA, re-applies POSIX-FD path's empty same-process peer-ACL
|
|
// loop (cross-process here, so no cuMemSetAccess), and restores data
|
|
// from the CPU backup. rank 1 has no local entries to restore.
|
|
// - Resume Step 3: AllGather counts (rank 0 reports 1 export, rank 1
|
|
// reports 0), Send/Recv the new ncclDynMemP2pHandleInfo, both ranks
|
|
// end up with allInfos[0] == rank 0's new handle.
|
|
// - Resume Step 4 (positive matching): rank 1 finds the matching entry
|
|
// (ownerRank/ownerPtr/size all match), calls ncclProxyClientGetFdBlocking
|
|
// on the new handle, hipMemImportFromShareableHandle, ncclCuMemMapAndSetAccess
|
|
// to the same imported VA. Entry transitions Released -> Active.
|
|
// - Final 0xCAFE barrier completes.
|
|
//
|
|
// Verify: rank 1 hipMemcpy DToH from imported VA -> canary preserved.
|
|
//
|
|
// Failure of any step short-circuits and asserts so we get a precise diagnosis
|
|
// instead of a silent stale-data success.
|
|
TEST_F(MemManagerTwoRank, SuspendResume_PeerExportImport_RoundtripCanary)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
constexpr size_t kRequestedSize = 1u << 20;
|
|
constexpr uint8_t kCanary = 0xAB;
|
|
constexpr int kMetaTag = 0xC0FFEE;
|
|
|
|
if (comm->rank == 0) {
|
|
VmmAlloc owner{};
|
|
allocateVmmPosixFd(comm->cudaDev, kRequestedSize, &owner);
|
|
ASSERT_EQ(hipMemset(owner.ptr, kCanary, owner.size), hipSuccess);
|
|
ASSERT_EQ(hipDeviceSynchronize(), hipSuccess);
|
|
|
|
exportAndShipMeta(comm, owner, /*peerRank=*/1, ncclMemOffload, kMetaTag);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 1);
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 0);
|
|
|
|
untrackAndRelease(comm, owner.ptr, &owner);
|
|
return;
|
|
}
|
|
|
|
ASSERT_EQ(comm->rank, 1);
|
|
VmmAlloc imp{};
|
|
recvAndImportPeerBuffer(comm, /*ownerRank=*/0, ncclMemOffload, kMetaTag, &imp);
|
|
|
|
// Sanity-check the primary import path BEFORE Suspend/Resume - if this
|
|
// fails, the GetFd / import / map chain is broken and any post-Resume
|
|
// verification would be a stale-data success.
|
|
verifyCanaryReadback(imp.ptr, imp.size, kCanary, "primary peer import");
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 1);
|
|
{
|
|
ncclDynMemEntry* e = comm->memManager->entries;
|
|
ASSERT_NE(e, nullptr);
|
|
EXPECT_EQ(e->state, ncclDynMemStateReleased);
|
|
EXPECT_EQ(static_cast<void*>(e->handle), nullptr)
|
|
<< "Suspend must release imported handle";
|
|
}
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 0);
|
|
|
|
ncclDynMemEntry* entry = comm->memManager->entries;
|
|
ASSERT_NE(entry, nullptr);
|
|
EXPECT_EQ(entry->state, ncclDynMemStateActive)
|
|
<< "Resume Step 4 must transition the matched peer entry back to Active";
|
|
EXPECT_NE(static_cast<void*>(entry->handle), nullptr)
|
|
<< "Resume must install the re-imported handle";
|
|
|
|
verifyCanaryReadback(imp.ptr, imp.size, kCanary,
|
|
"post-Resume imported VA (rank 0's offload data)");
|
|
|
|
untrackAndRelease(comm, imp.ptr, &imp);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Stress / coverage tests for AnyRanks (np >= 1)
|
|
// ===========================================================================
|
|
|
|
// A: no user-tracked entries. Suspend/Resume must drive the bootstrap barriers
|
|
// + flip released without inventing or losing entries. Pre-CUMEM the manager
|
|
// was always empty here; with NCCL_CUMEM_ENABLE=1 internal entries exist, so
|
|
// we check that the entry count and cpuBackup are preserved across the cycle.
|
|
TEST_F(MemManagerAnyRanks, EmptyManager_SuspendResume_NoOp)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
const MgrBaseline base = captureBaseline(comm);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 1);
|
|
EXPECT_EQ(comm->memManager->numEntries, base.numEntries)
|
|
<< "Suspend must not invent or lose entries";
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->released, 0);
|
|
EXPECT_EQ(comm->memManager->numEntries, base.numEntries)
|
|
<< "Resume must not invent or lose entries";
|
|
EXPECT_EQ(comm->memManager->cpuBackupUsage, base.cpuBackupUsage);
|
|
}
|
|
|
|
// B: Persist + Scratch + Offload tracked together. Suspend / Resume releases
|
|
// + restores Scratch and Offload but Persist is invisible to it (lives outside
|
|
// the linked list). Verifies stat invariants:
|
|
// - Total = Persist + Scratch + Offload (constant before and after)
|
|
// - Persist (constant)
|
|
// - Suspend = Scratch + Offload (constant - tracked, not freed)
|
|
// - Suspended toggles 0 -> 1 -> 0
|
|
TEST_F(MemManagerAnyRanks, MixedMemTypes_StatsConsistent)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
const MgrBaseline base = captureBaseline(comm);
|
|
|
|
constexpr size_t kPersistReq = 8 * 1024;
|
|
constexpr size_t kScratchReq = 256 * 1024;
|
|
constexpr size_t kOffloadReq = 128 * 1024;
|
|
constexpr uint8_t kPersistByte = 0xC1;
|
|
|
|
VmmAlloc persistVmm{};
|
|
allocateVmmPosixFd(comm->cudaDev, kPersistReq, &persistVmm);
|
|
ASSERT_EQ(hipMemset(persistVmm.ptr, kPersistByte, persistVmm.size), hipSuccess);
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, persistVmm.ptr, persistVmm.size,
|
|
persistVmm.handle, hipMemHandleTypePosixFileDescriptor,
|
|
ncclMemPersist),
|
|
ncclSuccess);
|
|
|
|
VmmAlloc scratchVmm{};
|
|
allocateVmmPosixFd(comm->cudaDev, kScratchReq, &scratchVmm);
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, scratchVmm.ptr, scratchVmm.size,
|
|
scratchVmm.handle, hipMemHandleTypePosixFileDescriptor,
|
|
ncclMemScratch),
|
|
ncclSuccess);
|
|
|
|
VmmAlloc offloadVmm{};
|
|
allocateVmmPosixFd(comm->cudaDev, kOffloadReq, &offloadVmm);
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, offloadVmm.ptr, offloadVmm.size,
|
|
offloadVmm.handle, hipMemHandleTypePosixFileDescriptor,
|
|
ncclMemOffload),
|
|
ncclSuccess);
|
|
|
|
auto readStat = [&](ncclCommMemStat_t s) {
|
|
uint64_t v = ~0ULL;
|
|
EXPECT_EQ(ncclCommMemStats(comm, s, &v), ncclSuccess);
|
|
return v;
|
|
};
|
|
|
|
const uint64_t deltaTotal = persistVmm.size + scratchVmm.size + offloadVmm.size;
|
|
const uint64_t deltaPersist = persistVmm.size;
|
|
const uint64_t deltaDynamic = scratchVmm.size + offloadVmm.size;
|
|
|
|
EXPECT_EQ(readStat(ncclStatGpuMemTotal) - base.total, deltaTotal);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemPersist) - base.persist, deltaPersist);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemSuspend) - base.suspendBytes, deltaDynamic);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemSuspended), 0u);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
|
|
EXPECT_EQ(readStat(ncclStatGpuMemTotal) - base.total, deltaTotal);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemPersist) - base.persist, deltaPersist);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemSuspend) - base.suspendBytes, deltaDynamic);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemSuspended), 1u);
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemTotal) - base.total, deltaTotal);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemPersist) - base.persist, deltaPersist);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemSuspend) - base.suspendBytes, deltaDynamic);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemSuspended), 0u);
|
|
|
|
verifyCanaryReadback(persistVmm.ptr, persistVmm.size, kPersistByte,
|
|
"persist after Suspend/Resume");
|
|
|
|
untrackAndRelease(comm, scratchVmm.ptr, &scratchVmm);
|
|
untrackAndRelease(comm, offloadVmm.ptr, &offloadVmm);
|
|
ASSERT_EQ(ncclMemUntrack(comm->memManager, persistVmm.ptr, persistVmm.size),
|
|
ncclSuccess);
|
|
releaseVmm(&persistVmm);
|
|
|
|
EXPECT_EQ(readStat(ncclStatGpuMemTotal), base.total);
|
|
EXPECT_EQ(readStat(ncclStatGpuMemPersist), base.persist);
|
|
}
|
|
|
|
// C: multiple Suspend/Resume cycles on the same Offload buffer. Verifies that
|
|
// the CPU-backup -> GPU restore loop is idempotent across many cycles - no
|
|
// cpuBackup leaks, no handle leaks, canary preserved every iteration.
|
|
TEST_F(MemManagerAnyRanks, MultipleSuspendResumeCycles_DataPreserved)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
const uint64_t internalOffload = captureInternalOffloadCpuBackup(comm);
|
|
|
|
constexpr size_t kSize = 64 * 1024;
|
|
constexpr int kCycles = 5;
|
|
constexpr uint8_t kInitial = 0x5A;
|
|
|
|
VmmAlloc a{};
|
|
allocateVmmPosixFd(comm->cudaDev, kSize, &a);
|
|
|
|
std::vector<uint8_t> seed(a.size);
|
|
for (size_t i = 0; i < a.size; ++i) {
|
|
seed[i] = static_cast<uint8_t>(kInitial ^ (i & 0xFFu));
|
|
}
|
|
ASSERT_EQ(hipMemcpy(a.ptr, seed.data(), a.size, hipMemcpyHostToDevice), hipSuccess);
|
|
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, a.ptr, a.size, a.handle,
|
|
hipMemHandleTypePosixFileDescriptor, ncclMemOffload),
|
|
ncclSuccess);
|
|
|
|
for (int cycle = 0; cycle < kCycles; ++cycle) {
|
|
SCOPED_TRACE("cycle=" + std::to_string(cycle));
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->cpuBackupUsage, internalOffload + a.size)
|
|
<< "Suspend must allocate one CPU backup per Offload entry";
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->cpuBackupUsage, 0u)
|
|
<< "Resume must free every CPU backup after restore";
|
|
|
|
std::vector<uint8_t> readback(a.size, 0u);
|
|
ASSERT_EQ(hipMemcpy(readback.data(), a.ptr, a.size, hipMemcpyDeviceToHost),
|
|
hipSuccess);
|
|
EXPECT_EQ(std::memcmp(readback.data(), seed.data(), a.size), 0)
|
|
<< "data corrupted after cycle " << cycle;
|
|
}
|
|
|
|
untrackAndRelease(comm, a.ptr, &a);
|
|
}
|
|
|
|
// D: stress - many small Scratch + Offload entries tracked together, single
|
|
// Suspend/Resume cycle. Catches O(n^2) regressions in the linked-list scan,
|
|
// cumulative leaks, granularity-padding bugs, and quietly-skipped entries.
|
|
TEST_F(MemManagerAnyRanks, Suspend_LargeNumberOfEntries_AllRestored)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
const MgrBaseline base = captureBaseline(comm);
|
|
const uint64_t internalOffload = captureInternalOffloadCpuBackup(comm);
|
|
|
|
constexpr int kNumEntries = 32;
|
|
constexpr int kNumOffload = 8; // first kNumOffload are Offload, rest are Scratch
|
|
|
|
std::vector<VmmAlloc> entries(kNumEntries);
|
|
std::vector<uint8_t> canaries(kNumEntries);
|
|
uint64_t testDynamicBytes = 0;
|
|
uint64_t testOffloadBytes = 0;
|
|
|
|
for (int i = 0; i < kNumEntries; ++i) {
|
|
size_t requested = (4 * 1024) + (i * 8 * 1024);
|
|
allocateVmmPosixFd(comm->cudaDev, requested, &entries[i]);
|
|
|
|
canaries[i] = static_cast<uint8_t>(0x40 + i);
|
|
ASSERT_EQ(hipMemset(entries[i].ptr, canaries[i], entries[i].size), hipSuccess);
|
|
|
|
bool isOffload = (i < kNumOffload);
|
|
ncclMemType_t type = isOffload ? ncclMemOffload : ncclMemScratch;
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, entries[i].ptr, entries[i].size,
|
|
entries[i].handle,
|
|
hipMemHandleTypePosixFileDescriptor, type),
|
|
ncclSuccess);
|
|
testDynamicBytes += entries[i].size;
|
|
if (isOffload) testOffloadBytes += entries[i].size;
|
|
}
|
|
ASSERT_EQ(hipDeviceSynchronize(), hipSuccess);
|
|
EXPECT_EQ(comm->memManager->numEntries - base.numEntries, kNumEntries);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->cpuBackupUsage, internalOffload + testOffloadBytes)
|
|
<< "CPU backup pool must equal sum of all Offload sizes (internal + test)";
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->cpuBackupUsage, 0u);
|
|
|
|
for (int i = 0; i < kNumEntries; ++i) {
|
|
SCOPED_TRACE("entry=" + std::to_string(i));
|
|
if (i < kNumOffload) {
|
|
verifyCanaryReadback(entries[i].ptr, entries[i].size, canaries[i],
|
|
"offload after Resume");
|
|
} else {
|
|
ASSERT_EQ(hipMemset(entries[i].ptr, 0xAA, entries[i].size), hipSuccess);
|
|
}
|
|
}
|
|
|
|
auto readStat = [&](ncclCommMemStat_t s) {
|
|
uint64_t v = ~0ULL;
|
|
EXPECT_EQ(ncclCommMemStats(comm, s, &v), ncclSuccess);
|
|
return v;
|
|
};
|
|
EXPECT_EQ(readStat(ncclStatGpuMemSuspend) - base.suspendBytes, testDynamicBytes);
|
|
|
|
for (auto& e : entries) untrackAndRelease(comm, e.ptr, &e);
|
|
EXPECT_EQ(comm->memManager->numEntries, base.numEntries);
|
|
}
|
|
|
|
// E: Persist physical memory must survive Suspend/Resume completely untouched
|
|
// (it lives outside the linked list, so neither Pass nor Step iterates over
|
|
// it). PersistUntouched (counter-only) leaves room for a regression where the
|
|
// VA gets accidentally unmapped; this catches it via canary roundtrip.
|
|
TEST_F(MemManagerAnyRanks, Persist_PhysicalMemoryUntouched)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
const MgrBaseline base = captureBaseline(comm);
|
|
const uint64_t internalOffload = captureInternalOffloadCpuBackup(comm);
|
|
|
|
constexpr size_t kSize = 32 * 1024;
|
|
constexpr uint8_t kCanary = 0xE7;
|
|
|
|
VmmAlloc persistVmm{};
|
|
allocateVmmPosixFd(comm->cudaDev, kSize, &persistVmm);
|
|
ASSERT_EQ(hipMemset(persistVmm.ptr, kCanary, persistVmm.size), hipSuccess);
|
|
ASSERT_EQ(hipDeviceSynchronize(), hipSuccess);
|
|
|
|
ASSERT_EQ(ncclMemTrack(comm->memManager, persistVmm.ptr, persistVmm.size,
|
|
persistVmm.handle, hipMemHandleTypePosixFileDescriptor,
|
|
ncclMemPersist),
|
|
ncclSuccess);
|
|
|
|
EXPECT_EQ(comm->memManager->numEntries, base.numEntries)
|
|
<< "Persist must not enter the linked list (only counter)";
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
EXPECT_EQ(comm->memManager->cpuBackupUsage, internalOffload)
|
|
<< "Persist must NOT contribute to CPU backup pool";
|
|
|
|
verifyCanaryReadback(persistVmm.ptr, persistVmm.size, kCanary, "persist mid-suspend");
|
|
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
verifyCanaryReadback(persistVmm.ptr, persistVmm.size, kCanary, "persist post-resume");
|
|
|
|
ASSERT_EQ(ncclMemUntrack(comm->memManager, persistVmm.ptr, persistVmm.size),
|
|
ncclSuccess);
|
|
releaseVmm(&persistVmm);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Stress / coverage tests for TwoRank (np == 2)
|
|
// ===========================================================================
|
|
|
|
// F: rank 0 exports N=3 distinct buffers, rank 1 imports all of them, full
|
|
// Suspend/Resume cycle, every canary preserved. Exercises:
|
|
// - Suspend Step 1: rank 1's Pass-1 unmap loop iterates over multiple
|
|
// peer-imported entries.
|
|
// - Suspend Step 2: rank 0's Pass-2 offload loop runs CPU-backup over N
|
|
// distinct entries.
|
|
// - Resume Step 3: rank 0's localBroadcastCount = N, localInfos buffer
|
|
// holds N elements, AllGather counts come out as [N, 0].
|
|
// - Resume Step 4: rank 1's matching loop matches N times in the same
|
|
// allInfos array (positive branch, multiple iterations).
|
|
TEST_F(MemManagerTwoRank, Multiple_PeerEntries_RoundtripCanary)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
constexpr int kNumBuffers = 3;
|
|
constexpr int kBaseTag = 0xC0FFEE;
|
|
const size_t kSizes[kNumBuffers] = { 64 * 1024, 96 * 1024, 128 * 1024 };
|
|
const uint8_t kCanaries[kNumBuffers] = { 0xA1, 0xB2, 0xC3 };
|
|
|
|
if (comm->rank == 0) {
|
|
std::vector<VmmAlloc> owners(kNumBuffers);
|
|
for (int i = 0; i < kNumBuffers; ++i) {
|
|
allocateVmmPosixFd(comm->cudaDev, kSizes[i], &owners[i]);
|
|
ASSERT_EQ(hipMemset(owners[i].ptr, kCanaries[i], owners[i].size), hipSuccess);
|
|
}
|
|
ASSERT_EQ(hipDeviceSynchronize(), hipSuccess);
|
|
|
|
for (int i = 0; i < kNumBuffers; ++i) {
|
|
exportAndShipMeta(comm, owners[i], /*peerRank=*/1,
|
|
ncclMemOffload, kBaseTag + i);
|
|
}
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
|
|
for (auto& o : owners) untrackAndRelease(comm, o.ptr, &o);
|
|
return;
|
|
}
|
|
|
|
ASSERT_EQ(comm->rank, 1);
|
|
std::vector<VmmAlloc> imports(kNumBuffers);
|
|
for (int i = 0; i < kNumBuffers; ++i) {
|
|
recvAndImportPeerBuffer(comm, /*ownerRank=*/0, ncclMemOffload, kBaseTag + i,
|
|
&imports[i]);
|
|
verifyCanaryReadback(imports[i].ptr, imports[i].size, kCanaries[i],
|
|
"primary import of buffer #" + std::to_string(i));
|
|
}
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
|
|
// Look up each test-owned import explicitly. With NCCL_CUMEM_ENABLE=1 the
|
|
// manager also tracks ~64 internal peer-imports from src/transport/p2p.cc
|
|
// (P2P proxy setup), so we cannot count by `isImportedFromPeer` alone.
|
|
int activeCount = 0;
|
|
for (int i = 0; i < kNumBuffers; ++i) {
|
|
auto* e = findEntryByPtr(comm, imports[i].ptr);
|
|
ASSERT_NE(e, nullptr) << "Import #" << i << " missing from manager";
|
|
EXPECT_TRUE(e->isImportedFromPeer);
|
|
EXPECT_EQ(e->state, ncclDynMemStateActive);
|
|
++activeCount;
|
|
}
|
|
EXPECT_EQ(activeCount, kNumBuffers);
|
|
|
|
for (int i = 0; i < kNumBuffers; ++i) {
|
|
verifyCanaryReadback(imports[i].ptr, imports[i].size, kCanaries[i],
|
|
"post-Resume import of buffer #" + std::to_string(i));
|
|
}
|
|
for (auto& imp : imports) untrackAndRelease(comm, imp.ptr, &imp);
|
|
}
|
|
|
|
// G: bidirectional - both ranks export to each other. Each rank has BOTH a
|
|
// local exported buffer AND an imported one. Exercises Resume Step 3 with
|
|
// localBroadcastCount > 0 on EVERY rank simultaneously: localInfos contains
|
|
// our own export, allInfos receives both ranks' contributions, allCounts
|
|
// comes out as [1, 1] (not [N, 0] like in F).
|
|
TEST_F(MemManagerTwoRank, Bidirectional_PeerExchange_RoundtripCanary)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
int rank = comm->rank;
|
|
int peer = 1 - rank;
|
|
|
|
const MgrBaseline base = captureBaseline(comm);
|
|
|
|
constexpr size_t kSize = 64 * 1024;
|
|
const uint8_t myCanary = (rank == 0) ? 0xD1 : 0xD2;
|
|
const uint8_t peerCanary = (rank == 0) ? 0xD2 : 0xD1;
|
|
const int myExportTag = 0xC0FFEE0 + rank;
|
|
const int peerExportTag = 0xC0FFEE0 + peer;
|
|
|
|
VmmAlloc myExport{};
|
|
allocateVmmPosixFd(comm->cudaDev, kSize, &myExport);
|
|
ASSERT_EQ(hipMemset(myExport.ptr, myCanary, myExport.size), hipSuccess);
|
|
ASSERT_EQ(hipDeviceSynchronize(), hipSuccess);
|
|
|
|
exportAndShipMeta(comm, myExport, peer, ncclMemOffload, myExportTag);
|
|
|
|
VmmAlloc peerImport{};
|
|
recvAndImportPeerBuffer(comm, peer, ncclMemOffload, peerExportTag, &peerImport);
|
|
verifyCanaryReadback(peerImport.ptr, peerImport.size, peerCanary,
|
|
"primary import of peer's buffer");
|
|
|
|
EXPECT_EQ(comm->memManager->numEntries - base.numEntries, 2)
|
|
<< "Each rank should add 1 local + 1 imported entry on top of baseline";
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
|
|
verifyCanaryReadback(peerImport.ptr, peerImport.size, peerCanary,
|
|
"post-Resume import of peer's buffer");
|
|
|
|
untrackAndRelease(comm, myExport.ptr, &myExport);
|
|
untrackAndRelease(comm, peerImport.ptr, &peerImport);
|
|
}
|
|
|
|
// Rank 1 has 1 real peer-import + 1 stale (fake ownerPtr). Per RCCL divergence
|
|
// (mem_manager.cc:905), Step 4 partial-success: real -> Active, stale stays
|
|
// Released, ncclCommMemResume returns ncclSystemError.
|
|
TEST_F(MemManagerTwoRank, PartialPeerImport_MixedMatch)
|
|
{
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
constexpr size_t kSize = 64 * 1024;
|
|
constexpr uint8_t kCanary = 0x9E;
|
|
constexpr int kRealTag = 0xC0FFEE;
|
|
|
|
if (comm->rank == 0) {
|
|
VmmAlloc realOwner{};
|
|
allocateVmmPosixFd(comm->cudaDev, kSize, &realOwner);
|
|
ASSERT_EQ(hipMemset(realOwner.ptr, kCanary, realOwner.size), hipSuccess);
|
|
ASSERT_EQ(hipDeviceSynchronize(), hipSuccess);
|
|
|
|
exportAndShipMeta(comm, realOwner, /*peerRank=*/1, ncclMemOffload, kRealTag);
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
ASSERT_EQ(ncclCommMemResume(comm), ncclSuccess);
|
|
|
|
untrackAndRelease(comm, realOwner.ptr, &realOwner);
|
|
return;
|
|
}
|
|
|
|
ASSERT_EQ(comm->rank, 1);
|
|
VmmAlloc realImport{};
|
|
recvAndImportPeerBuffer(comm, /*ownerRank=*/0, ncclMemOffload, kRealTag, &realImport);
|
|
|
|
// Inject a synthetic "stale" peer-imported entry that points at an
|
|
// ownerPtr rank 0 doesn't have. Step 4 will gather rank 0's only
|
|
// localInfo (matching realImport), the matching loop will then hit our
|
|
// stale entry, fail to find a match, and WARN+skip it.
|
|
VmmAlloc stale{};
|
|
reserveVaOnly(comm->cudaDev, kSize, &stale);
|
|
void* fakeOwnerPtr = reinterpret_cast<void*>(0xDEADBEEF000ULL);
|
|
ASSERT_EQ(ncclMemTrackImportFromPeer(
|
|
comm->memManager, stale.ptr, stale.size, /*handle=*/0,
|
|
hipMemHandleTypePosixFileDescriptor, ncclMemOffload,
|
|
/*ownerRank=*/0, /*ownerDev=*/0, fakeOwnerPtr),
|
|
ncclSuccess);
|
|
|
|
// Pretend Suspend already ran for the stale entry so Step 4 considers it
|
|
// a candidate. realImport will go through the normal Suspend/Resume path.
|
|
for (auto* e = comm->memManager->entries; e != nullptr; e = e->next) {
|
|
if (e->ptr == stale.ptr) {
|
|
e->state = ncclDynMemStateReleased;
|
|
break;
|
|
}
|
|
}
|
|
|
|
ASSERT_EQ(ncclCommMemSuspend(comm), ncclSuccess);
|
|
// Per RCCL divergence: stale's no-match -> ncclSystemError. Real import is
|
|
// still re-imported successfully in the same pass (partial-success).
|
|
EXPECT_EQ(ncclCommMemResume(comm), ncclSystemError)
|
|
<< "Stale no-match peer-import must surface ncclSystemError";
|
|
EXPECT_EQ(comm->memManager->released, 1)
|
|
<< "Partial-failure must leave manager in suspended state";
|
|
|
|
// Look up the two test-owned entries explicitly. With NCCL_CUMEM_ENABLE=1
|
|
// the manager also tracks ~64 internal peer-imports (proxy P2P from
|
|
// src/transport/p2p.cc:325,380) so we cannot count by isImportedFromPeer.
|
|
auto* realEntry = findEntryByPtr(comm, realImport.ptr);
|
|
auto* staleEntry = findEntryByPtr(comm, stale.ptr);
|
|
ASSERT_NE(realEntry, nullptr);
|
|
ASSERT_NE(staleEntry, nullptr);
|
|
EXPECT_EQ(realEntry->state, ncclDynMemStateActive)
|
|
<< "Real import must be re-imported despite stale's failure";
|
|
EXPECT_EQ(staleEntry->state, ncclDynMemStateReleased)
|
|
<< "Stale entry must stay Released (no-match branch)";
|
|
|
|
verifyCanaryReadback(realImport.ptr, realImport.size, kCanary,
|
|
"real import survived mixed-match Resume");
|
|
|
|
ASSERT_EQ(ncclMemUntrack(comm->memManager, stale.ptr, stale.size), ncclSuccess);
|
|
releaseVmm(&stale);
|
|
untrackAndRelease(comm, realImport.ptr, &realImport);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Public API tests (ncclCommSuspend / ncclCommResume / ncclCommMemStats).
|
|
//
|
|
// The internal ncclCommMemSuspend/Resume/Stats path is exercised in detail by
|
|
// the MemManagerAnyRanks / MemManagerTwoRank suites above (peer export/import
|
|
// canary, manager state machine, etc). The suites below target the public API:
|
|
//
|
|
// - ncclCommSuspend(comm, NCCL_SUSPEND_MEM)
|
|
// - ncclCommResume(comm)
|
|
// - ncclCommMemStats(comm, stat, &value)
|
|
//
|
|
// Coverage matrix:
|
|
//
|
|
// Happy paths
|
|
// - SuspendResumeBasic.BasicCycle one Suspend -> Resume round-trip
|
|
// - SuspendResumeBasic.MultipleCycles 5 back-to-back cycles
|
|
// - SuspendResumeBasic.BarrierSync rank 0 sleeps before suspend;
|
|
// every rank should observe the
|
|
// cross-rank bootstrap barrier
|
|
// - SuspendResumeMemStats.BeforeAndAfter ncclStatGpuMem* across the
|
|
// active/suspended/resumed
|
|
// transitions
|
|
// - SuspendResumeMemStats.Conservation total + suspended bytes is
|
|
// conserved across a Suspend
|
|
// - SuspendResumeCollectiveIntegrity.AllReduceAfterResume
|
|
// AllReduce produces correct
|
|
// result after Suspend/Resume
|
|
// - SuspendResumeCollectiveIntegrity.AllReduceTwoCycles
|
|
// same after multiple cycles
|
|
// - SuspendResumeCollectiveIntegrity.AllReduceP2pAutoMark
|
|
// verifies that p2p collectives
|
|
// auto-mark exported buffers via
|
|
// ncclDynMemMarkExportToPeer
|
|
// (Bucket 1)
|
|
// - SuspendResumeLifecycle.DestroyWhileSuspended
|
|
// ncclCommDestroy succeeds on a
|
|
// still-suspended comm (drains
|
|
// pending tasks + manager
|
|
// destruction frees released VAs)
|
|
// - SuspendResumeGroup.AtomicSuspend ncclGroupStart() + Suspend +
|
|
// ncclGroupEnd() drains the
|
|
// pending suspend task
|
|
// - SuspendResumeGroup.AtomicResume same for Resume
|
|
// - SuspendResumeGroup.MixedSuspendResume Suspend + Resume in the same
|
|
// group leaves the comm active
|
|
//
|
|
// Argument validation (no bootstrap barrier reached, so individual ranks can
|
|
// fail without deadlocking peers)
|
|
// - SuspendResumeArgValidation.ZeroFlags Suspend(comm, 0) is rejected
|
|
// - SuspendResumeArgValidation.UnknownFlagsRejected bogus high bits rejected
|
|
// - SuspendResumeArgValidation.DoubleSuspend Suspend twice -> 2nd rejected
|
|
// - SuspendResumeArgValidation.ResumeWhenActive Resume on active comm rejected
|
|
// - SuspendResumeArgValidation.DoubleResume Resume twice -> 2nd rejected
|
|
// - SuspendResumeMemStatsValidation.NullValuePtr NULL output pointer rejected
|
|
// - SuspendResumeMemStatsValidation.UnknownStat bogus enum value rejected
|
|
// ============================================================================
|
|
|
|
namespace SuspendResumeTestConfig
|
|
{
|
|
constexpr int kMinRanks = 2; // multi-rank tests need >= 2
|
|
constexpr size_t kAllReduceCount = 1024; // 4 KiB float buffer
|
|
constexpr float kEpsilon = 1e-3f;
|
|
constexpr int kMultiCycleCount = 5;
|
|
constexpr int kBarrierSleepMs = 200; // rank-0 stagger time
|
|
} // namespace SuspendResumeTestConfig
|
|
|
|
using namespace SuspendResumeTestConfig;
|
|
|
|
/**
|
|
* @class SuspendResumePublicAPITestBase
|
|
* @brief Shared fixture for all public-API suspend/resume MPI tests.
|
|
*
|
|
* Wraps MPITestBase and adds a convenience accessor for ncclCommMemStats and
|
|
* a self-checking AllReduce helper.
|
|
*/
|
|
class SuspendResumePublicAPITestBase : public MPITestBase
|
|
{
|
|
protected:
|
|
// Read one ncclCommMemStat as uint64_t. Fails the test on any non-success
|
|
// return so callers can use the value directly.
|
|
uint64_t readStat(ncclComm_t comm, ncclCommMemStat_t stat)
|
|
{
|
|
uint64_t v = ~uint64_t{0};
|
|
ncclResult_t r = ncclCommMemStats(comm, stat, &v);
|
|
EXPECT_EQ(r, ncclSuccess) << "ncclCommMemStats failed for stat " << (int)stat;
|
|
return v;
|
|
}
|
|
|
|
// Run an AllReduce with float-sum and verify each element equals
|
|
// sum(1..nranks) = nranks * (nranks + 1) / 2. Returns true on success.
|
|
bool runAllReduceAndVerify(ncclComm_t comm, hipStream_t stream)
|
|
{
|
|
const size_t n = kAllReduceCount;
|
|
const int rank = MPIEnvironment::world_rank;
|
|
const int nrnks = MPIEnvironment::world_size;
|
|
|
|
void* sendbuf = nullptr;
|
|
void* recvbuf = nullptr;
|
|
if (hipMalloc(&sendbuf, n * sizeof(float)) != hipSuccess) return false;
|
|
if (hipMalloc(&recvbuf, n * sizeof(float)) != hipSuccess)
|
|
{
|
|
(void)hipFree(sendbuf);
|
|
return false;
|
|
}
|
|
auto sendGuard = makeDeviceBufferAutoGuard(sendbuf);
|
|
auto recvGuard = makeDeviceBufferAutoGuard(recvbuf);
|
|
|
|
if (initializeBufferWithPattern<float>(
|
|
sendbuf, n,
|
|
[rank](size_t) { return static_cast<float>(rank + 1); }) != hipSuccess)
|
|
return false;
|
|
if (zeroInitializeBuffer<float>(recvbuf, n) != hipSuccess) return false;
|
|
|
|
if (ncclAllReduce(sendbuf, recvbuf, n, ncclFloat32, ncclSum, comm, stream)
|
|
!= ncclSuccess)
|
|
return false;
|
|
if (hipStreamSynchronize(stream) != hipSuccess) return false;
|
|
|
|
const float expected = static_cast<float>(nrnks * (nrnks + 1) / 2);
|
|
return verifyBufferData<float>(
|
|
recvbuf, n,
|
|
[expected](size_t) { return expected; },
|
|
0,
|
|
static_cast<double>(kEpsilon * expected));
|
|
}
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 1. Basic happy-path tests
|
|
// ----------------------------------------------------------------------------
|
|
|
|
class SuspendResumeBasic : public SuspendResumePublicAPITestBase {};
|
|
|
|
TEST_F(SuspendResumeBasic, BasicCycle)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended))
|
|
<< "Newly initialized comm must report not-suspended";
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
EXPECT_EQ(1u, readStat(comm, ncclStatGpuMemSuspended))
|
|
<< "After Suspend, ncclStatGpuMemSuspended must be 1";
|
|
|
|
// ncclStatGpuMemSuspend == totalScratch + totalOffload of *tracked* entries
|
|
// With NCCL_CUMEM_ENABLE=0 the comm's internal allocations are not
|
|
// VMM-backed so the count is legitimately zero; with CUMEM=1 it is > 0.
|
|
uint64_t suspBytes = readStat(comm, ncclStatGpuMemSuspend);
|
|
TEST_INFO("Rank %d ncclStatGpuMemSuspend after Suspend = %llu bytes",
|
|
MPIEnvironment::world_rank, (unsigned long long)suspBytes);
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended))
|
|
<< "After Resume, ncclStatGpuMemSuspended must be 0";
|
|
}
|
|
|
|
TEST_F(SuspendResumeBasic, MultipleCycles)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
for (int i = 0; i < kMultiCycleCount; ++i)
|
|
{
|
|
TEST_INFO("Cycle %d: Suspend", i);
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
EXPECT_EQ(1u, readStat(comm, ncclStatGpuMemSuspended)) << "cycle " << i;
|
|
|
|
TEST_INFO("Cycle %d: Resume", i);
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended)) << "cycle " << i;
|
|
}
|
|
}
|
|
|
|
TEST_F(SuspendResumeBasic, BarrierSync)
|
|
{
|
|
// Rank 0 stages a deliberate delay before calling Suspend. Without the
|
|
// bootstrapBarrier inside ncclCommMemSuspend, the other ranks would return
|
|
// immediately and the elapsed time on those ranks would be much smaller
|
|
// than rank 0's. With the barrier wired up correctly, every rank must
|
|
// observe at least kBarrierSleepMs of elapsed wall-clock between the
|
|
// (cross-rank) start of the call and its return.
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
MPI_Barrier(MPI_COMM_WORLD);
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
|
|
if (MPIEnvironment::world_rank == 0)
|
|
{
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(kBarrierSleepMs));
|
|
}
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
|
|
auto elapsed_ms =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
|
|
|
|
// Generous slack: this is a synchronization assertion, not a microbenchmark.
|
|
// Without a barrier, non-zero ranks would typically observe < 10 ms.
|
|
EXPECT_GE(elapsed_ms, kBarrierSleepMs / 2)
|
|
<< "Rank " << MPIEnvironment::world_rank
|
|
<< ": Suspend returned in " << elapsed_ms
|
|
<< " ms (rank 0 staged a " << kBarrierSleepMs
|
|
<< " ms delay; expected the barrier to hold every rank)";
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 2. Memory-statistics correctness
|
|
// ----------------------------------------------------------------------------
|
|
|
|
class SuspendResumeMemStats : public SuspendResumePublicAPITestBase {};
|
|
|
|
TEST_F(SuspendResumeMemStats, BeforeAndAfter)
|
|
{
|
|
// ncclCommMemStats reads per-type counters on the memory manager.
|
|
// The counters cover *tracked* allocations only. With NCCL_CUMEM_ENABLE=0
|
|
// RCCL skips tracking on most internal allocations so all counters
|
|
// legitimately read zero; the suspended boolean still toggles. With
|
|
// CUMEM=1, counters are non-zero and persist across the suspend boundary
|
|
// (Suspend doesn't change totalScratch/totalOffload -- it only flips
|
|
// released).
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
uint64_t totalActive = readStat(comm, ncclStatGpuMemTotal);
|
|
uint64_t suspActive = readStat(comm, ncclStatGpuMemSuspend);
|
|
uint64_t persActive = readStat(comm, ncclStatGpuMemPersist);
|
|
uint64_t isSusActive = readStat(comm, ncclStatGpuMemSuspended);
|
|
EXPECT_EQ(isSusActive, 0u) << "fresh comm must not be suspended";
|
|
EXPECT_EQ(totalActive, persActive + suspActive)
|
|
<< "Total = persist + (scratch+offload) invariant";
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
|
|
uint64_t totalSusp = readStat(comm, ncclStatGpuMemTotal);
|
|
uint64_t suspSusp = readStat(comm, ncclStatGpuMemSuspend);
|
|
uint64_t isSusSusp = readStat(comm, ncclStatGpuMemSuspended);
|
|
EXPECT_EQ(isSusSusp, 1u) << "after Suspend, suspended boolean must be 1";
|
|
EXPECT_EQ(totalSusp, totalActive)
|
|
<< "Suspend does not change tracked byte counters (mirrors NCCL)";
|
|
EXPECT_EQ(suspSusp, suspActive)
|
|
<< "Suspend does not change scratch/offload byte counters";
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
|
|
uint64_t totalResumed = readStat(comm, ncclStatGpuMemTotal);
|
|
uint64_t isSusResumed = readStat(comm, ncclStatGpuMemSuspended);
|
|
EXPECT_EQ(isSusResumed, 0u);
|
|
EXPECT_EQ(totalResumed, totalActive)
|
|
<< "Resume does not change tracked byte counters either";
|
|
|
|
TEST_INFO("rank %d: total=%llu persist=%llu suspendable=%llu "
|
|
"[scratch+offload] (CUMEM gates whether non-zero)",
|
|
MPIEnvironment::world_rank,
|
|
(unsigned long long)totalActive,
|
|
(unsigned long long)persActive,
|
|
(unsigned long long)suspActive);
|
|
}
|
|
|
|
TEST_F(SuspendResumeMemStats, Conservation)
|
|
{
|
|
// ncclCommMemStats reports per-type byte counters; Suspend doesn't move
|
|
// bytes between persist / scratch / offload buckets, it only flips the
|
|
// released boolean. So Total = Persist + (Scratch+Offload) holds in every
|
|
// state, and Total/Persist/Suspend are constant across a Suspend->Resume
|
|
// round-trip. (CPU backup memory used by ncclMemOffload entries is tracked
|
|
// separately on manager->cpuBackupUsage and not part of the public stats.)
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
uint64_t totalA = readStat(comm, ncclStatGpuMemTotal);
|
|
uint64_t persA = readStat(comm, ncclStatGpuMemPersist);
|
|
uint64_t suspA = readStat(comm, ncclStatGpuMemSuspend);
|
|
EXPECT_EQ(totalA, persA + suspA);
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
uint64_t totalS = readStat(comm, ncclStatGpuMemTotal);
|
|
uint64_t persS = readStat(comm, ncclStatGpuMemPersist);
|
|
uint64_t suspS = readStat(comm, ncclStatGpuMemSuspend);
|
|
EXPECT_EQ(totalS, totalA);
|
|
EXPECT_EQ(persS, persA);
|
|
EXPECT_EQ(suspS, suspA);
|
|
EXPECT_EQ(totalS, persS + suspS);
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
|
|
uint64_t totalR = readStat(comm, ncclStatGpuMemTotal);
|
|
EXPECT_EQ(totalR, totalA);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 3. Collective integrity across a suspend/resume cycle
|
|
// ----------------------------------------------------------------------------
|
|
|
|
class SuspendResumeCollectiveIntegrity : public SuspendResumePublicAPITestBase
|
|
{
|
|
protected:
|
|
void SetUp() override
|
|
{
|
|
SuspendResumePublicAPITestBase::SetUp();
|
|
if (!ncclCuMemEnable()) {
|
|
GTEST_SKIP() << "NCCL_CUMEM_ENABLE=0 — skipped";
|
|
}
|
|
}
|
|
};
|
|
|
|
TEST_F(SuspendResumeCollectiveIntegrity, AllReduceAfterResume)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
hipStream_t stream = getActiveStream();
|
|
|
|
EXPECT_TRUE(runAllReduceAndVerify(comm, stream))
|
|
<< "AllReduce baseline failed before any Suspend";
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
|
|
EXPECT_TRUE(runAllReduceAndVerify(comm, stream))
|
|
<< "AllReduce produced wrong values after Suspend/Resume cycle";
|
|
}
|
|
|
|
TEST_F(SuspendResumeCollectiveIntegrity, AllReduceTwoCycles)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
hipStream_t stream = getActiveStream();
|
|
|
|
EXPECT_TRUE(runAllReduceAndVerify(comm, stream)) << "baseline AllReduce";
|
|
|
|
for (int i = 0; i < 2; ++i)
|
|
{
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
|
|
EXPECT_TRUE(runAllReduceAndVerify(comm, stream))
|
|
<< "AllReduce wrong after cycle " << i;
|
|
}
|
|
}
|
|
|
|
// Regression: ncclP2pImportShareableBuffer (and the intra-PID different-GPU
|
|
// branch in p2pMap) must call ncclMemTrackImportFromPeer so the importer side
|
|
// of every cuMem-backed proxy buffer is visible to ncclMemManager. Without
|
|
// this wiring Resume Step 4 has nothing to re-import: after Suspend the owner
|
|
// rebuilds its physical pages, but the importer's local VA stays mapped to
|
|
// the OLD physical pages, owner/importer drift apart, and the next collective
|
|
// hangs in hipStreamSynchronize waiting on flags that never converge across
|
|
// the diverged buffers.
|
|
//
|
|
// We catch the bug structurally instead of as a hang:
|
|
// 1. Force p2p proxy connections via a warm-up AllReduce.
|
|
// 2. Walk manager->entries and require >= 1 entry with isImportedFromPeer
|
|
// and a non-zero live handle. Pre-fix this set is empty under CUMEM=1;
|
|
// post-fix every cross-PID p2p connection adds one.
|
|
// 3. Suspend/Resume and verify that those imported entries return Active
|
|
// with rebuilt (still non-zero) handles and a non-stale ownerPtr.
|
|
TEST_F(SuspendResumeCollectiveIntegrity, P2pPeerImports_TrackedAndRebuilt)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
hipStream_t stream = getActiveStream();
|
|
|
|
ASSERT_NE(comm->memManager, nullptr);
|
|
|
|
EXPECT_TRUE(runAllReduceAndVerify(comm, stream))
|
|
<< "warm-up AllReduce must succeed before peer-import inspection";
|
|
|
|
auto countPeerImports = [&](int* nActive, int* nReleased) {
|
|
*nActive = *nReleased = 0;
|
|
std::lock_guard<std::mutex> lk(comm->memManager->lock);
|
|
for (auto* e = comm->memManager->entries; e != nullptr; e = e->next) {
|
|
if (!e->isImportedFromPeer) continue;
|
|
if (e->state == ncclDynMemStateActive) (*nActive)++;
|
|
else if (e->state == ncclDynMemStateReleased) (*nReleased)++;
|
|
}
|
|
};
|
|
|
|
int activeBefore = 0, releasedBefore = 0;
|
|
countPeerImports(&activeBefore, &releasedBefore);
|
|
EXPECT_EQ(releasedBefore, 0)
|
|
<< "no entry should be Released while comm is unsuspended";
|
|
EXPECT_GT(activeBefore, 0)
|
|
<< "ncclP2pImportShareableBuffer must register every cross-PID p2p "
|
|
"import via ncclMemTrackImportFromPeer (regression: 0 imports "
|
|
"tracked => Resume Step 4 is a no-op => post-Resume collectives "
|
|
"hang on stale peer mappings)";
|
|
|
|
if (activeBefore == 0) return;
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
|
|
int activeMid = 0, releasedMid = 0;
|
|
countPeerImports(&activeMid, &releasedMid);
|
|
EXPECT_EQ(activeMid, 0)
|
|
<< "Suspend must move every peer-imported entry to Released";
|
|
EXPECT_EQ(releasedMid, activeBefore)
|
|
<< "Suspend must Release exactly the entries that were Active before";
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
|
|
int activeAfter = 0, releasedAfter = 0;
|
|
countPeerImports(&activeAfter, &releasedAfter);
|
|
EXPECT_EQ(releasedAfter, 0)
|
|
<< "Resume must rebuild every peer-imported entry";
|
|
EXPECT_EQ(activeAfter, activeBefore)
|
|
<< "Resume must restore the same number of peer-imported entries";
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lk(comm->memManager->lock);
|
|
for (auto* e = comm->memManager->entries; e != nullptr; e = e->next) {
|
|
if (!e->isImportedFromPeer) continue;
|
|
EXPECT_NE(e->ptr, nullptr) << "imported VA must be preserved";
|
|
EXPECT_NE(e->desc.imported.ownerPtr, nullptr)
|
|
<< "ownerPtr must survive Suspend/Resume so Step 4 can match";
|
|
// handle == 0 is legal for the intra-PID different-GPU branch
|
|
// (handle pre-released by p2pMap), so we don't assert non-zero
|
|
// here. The cross-PID branch will have a fresh non-zero handle.
|
|
}
|
|
}
|
|
|
|
EXPECT_TRUE(runAllReduceAndVerify(comm, stream))
|
|
<< "post-Resume AllReduce must complete (no hang on stale imports)";
|
|
}
|
|
|
|
TEST_F(SuspendResumeCollectiveIntegrity, AllReduceP2pAutoMark)
|
|
{
|
|
// Bucket 1 invariant: p2pSendProxySetup / p2pRecvProxySetup pass req->peerRank
|
|
// into ncclP2pAllocateShareableBuffer, which in turn calls
|
|
// ncclDynMemMarkExportToPeer on every cuMem-allocated proxy buffer. After
|
|
// Suspend, the manager's Step 3 in ncclCommMemResume re-exports those
|
|
// buffers to the same peer using the recorded peerRank, so the post-Resume
|
|
// AllReduce can rebuild peer mappings without manual MarkExport calls.
|
|
//
|
|
// This test exercises the full path end-to-end: an AllReduce that uses the
|
|
// p2p transport must continue to work after a full Suspend/Resume cycle.
|
|
// If peerRank is lost this test still passes when CUMEM=0
|
|
// (no peer marking happens at all) but exposes the regression
|
|
// as a SIGSEGV during Resume Step 4 with CUMEM=1 + nRanks >= 2.
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
hipStream_t stream = getActiveStream();
|
|
|
|
// Warm-up AllReduce so the p2p proxy buffers are actually allocated and
|
|
// marked for export before we suspend.
|
|
EXPECT_TRUE(runAllReduceAndVerify(comm, stream)) << "warm-up AllReduce";
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
|
|
EXPECT_TRUE(runAllReduceAndVerify(comm, stream))
|
|
<< "AllReduce broken after Suspend/Resume - peer-export marking "
|
|
"regression?";
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 4. Lifecycle: destroy while suspended
|
|
// ----------------------------------------------------------------------------
|
|
|
|
class SuspendResumeLifecycle : public SuspendResumePublicAPITestBase
|
|
{
|
|
protected:
|
|
// We tear down our own comm in this test rather than relying on the base
|
|
// class, so that we drive ncclCommDestroy from a suspended state.
|
|
void TearDown() override
|
|
{
|
|
// Skip MPITestBase::TearDown's cleanupTestCommunicator since the test
|
|
// already destroyed it.
|
|
test_comm_ = nullptr;
|
|
test_stream_ = nullptr;
|
|
}
|
|
};
|
|
|
|
TEST_F(SuspendResumeLifecycle, DestroyWhileSuspended)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
hipStream_t stream = getActiveStream();
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
EXPECT_EQ(1u, readStat(comm, ncclStatGpuMemSuspended));
|
|
|
|
// Tear the comm down without resuming. commFree drains pending suspend/resume
|
|
// tasks and ncclMemManagerDestroy reclaims VAs of Released entries, so the
|
|
// destructor walk must not SIGSEGV.
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommDestroy(comm));
|
|
test_comm_ = nullptr;
|
|
if (stream != nullptr)
|
|
{
|
|
(void)hipStreamDestroy(stream);
|
|
test_stream_ = nullptr;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 5. Group context (Bucket 3): Suspend/Resume inside ncclGroupStart/End
|
|
// ----------------------------------------------------------------------------
|
|
|
|
class SuspendResumeGroup : public SuspendResumePublicAPITestBase {};
|
|
|
|
TEST_F(SuspendResumeGroup, AtomicSuspend)
|
|
{
|
|
// ncclCommSuspend always wraps the body in
|
|
// ncclGroupStartInternal/EndInternal, so a user-level
|
|
// ncclGroupStart/Suspend/ncclGroupEnd nesting must still drain the pending
|
|
// suspend task and leave the comm in the suspended state.
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclGroupStart());
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclGroupEnd());
|
|
|
|
EXPECT_EQ(1u, readStat(comm, ncclStatGpuMemSuspended))
|
|
<< "Suspend inside a group must drain in groupLaunch";
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended));
|
|
}
|
|
|
|
TEST_F(SuspendResumeGroup, AtomicResume)
|
|
{
|
|
// Same as AtomicSuspend but for Resume.
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
EXPECT_EQ(1u, readStat(comm, ncclStatGpuMemSuspended));
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclGroupStart());
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclGroupEnd());
|
|
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended))
|
|
<< "Resume inside a group must drain in groupLaunch";
|
|
}
|
|
|
|
TEST_F(SuspendResumeGroup, MixedSuspendResume)
|
|
{
|
|
// Suspend + Resume in the same group: groupLaunch drains suspendTaskQueue
|
|
// first, then resumeTaskQueue, so the net effect is "active" again.
|
|
// The RCCL pre-check in ncclCommResume_impl accepts this case because at
|
|
// the moment Resume is called there is still a Suspend task pending in
|
|
// suspendTaskQueue (i.e. the comm is "going to be suspended" — not
|
|
// currently active in the sense of the validation).
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended));
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclGroupStart());
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclGroupEnd());
|
|
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended))
|
|
<< "After Suspend+Resume in the same group, comm must be active";
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 6. Argument validation / corner cases
|
|
//
|
|
// Each of these returns from inside ncclCommSuspend/Resume/MemStats BEFORE the
|
|
// bootstrapBarrier is reached, so individual ranks failing the call do not
|
|
// leave their peers blocked on a barrier.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
class SuspendResumeArgValidation : public SuspendResumePublicAPITestBase {};
|
|
|
|
TEST_F(SuspendResumeArgValidation, ZeroFlags)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
ASSERT_MPI_EQ(ncclInvalidArgument, ncclCommSuspend(comm, 0));
|
|
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended))
|
|
<< "rejected Suspend should not transition the comm";
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
}
|
|
|
|
TEST_F(SuspendResumeArgValidation, UnknownFlagsRejected)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
ASSERT_MPI_EQ(ncclInvalidArgument, ncclCommSuspend(comm, 0xffff));
|
|
ASSERT_MPI_EQ(ncclInvalidArgument,
|
|
ncclCommSuspend(comm, NCCL_SUSPEND_MEM | 0x80));
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
}
|
|
|
|
TEST_F(SuspendResumeArgValidation, DoubleSuspend)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
|
|
// Second suspend rejects on every rank before reaching the barrier
|
|
// (state check sees comm->memManager->released==1).
|
|
ASSERT_MPI_EQ(ncclInvalidUsage, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended));
|
|
}
|
|
|
|
TEST_F(SuspendResumeArgValidation, ResumeWhenActive)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
// Resume on an already-active comm rejects before any barrier.
|
|
ASSERT_MPI_EQ(ncclInvalidUsage, ncclCommResume(comm));
|
|
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended))
|
|
<< "rejected Resume should not transition the comm";
|
|
}
|
|
|
|
TEST_F(SuspendResumeArgValidation, DoubleResume)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommSuspend(comm, NCCL_SUSPEND_MEM));
|
|
ASSERT_MPI_EQ(ncclSuccess, ncclCommResume(comm));
|
|
|
|
// Second resume rejects: comm is no longer suspended.
|
|
ASSERT_MPI_EQ(ncclInvalidUsage, ncclCommResume(comm));
|
|
|
|
EXPECT_EQ(0u, readStat(comm, ncclStatGpuMemSuspended));
|
|
}
|
|
|
|
// ----- ncclCommMemStats validation --------------------------------------
|
|
|
|
class SuspendResumeMemStatsValidation : public SuspendResumePublicAPITestBase {};
|
|
|
|
TEST_F(SuspendResumeMemStatsValidation, NullValuePtr)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
EXPECT_EQ(ncclInvalidArgument,
|
|
ncclCommMemStats(comm, ncclStatGpuMemTotal, nullptr));
|
|
EXPECT_EQ(ncclInvalidArgument,
|
|
ncclCommMemStats(comm, ncclStatGpuMemSuspend, nullptr));
|
|
}
|
|
|
|
TEST_F(SuspendResumeMemStatsValidation, UnknownStat)
|
|
{
|
|
ASSERT_TRUE(validateTestPrerequisites(kMinRanks)) << "Need >= 2 ranks";
|
|
ASSERT_MPI_EQ(ncclSuccess, createTestCommunicator());
|
|
ncclComm_t comm = getActiveCommunicator();
|
|
|
|
uint64_t v = 0;
|
|
EXPECT_EQ(ncclInvalidArgument,
|
|
ncclCommMemStats(comm, static_cast<ncclCommMemStat_t>(999), &v));
|
|
EXPECT_EQ(ncclInvalidArgument,
|
|
ncclCommMemStats(comm, static_cast<ncclCommMemStat_t>(-1), &v));
|
|
}
|
|
|
|
#endif // MPI_TESTS_ENABLED
|