/************************************************************************* * Copyright (c) 2026 Advanced Micro Devices, Inc. All rights reserved. * * See LICENSE.txt for license information ************************************************************************/ // MPI tests for the device-side GIN proxy backend. Each test launches a real // gin.{put|putValue|waitSignal|...} kernel against a real proxy thread + IB, // and validates the wire-level result on the receiving rank. #include "MPITestBase.hpp" #include "ResourceGuards.hpp" #include "TestChecks.hpp" #include "nccl_device.h" #include #include #include #include #include #include #include #include #ifdef MPI_TESTS_ENABLED using namespace RCCLTestGuards; namespace { std::string ginEnvDisabledReason() { if (const char* e = std::getenv("NCCL_GIN_ENABLE"); e && std::strcmp(e, "0") == 0) return "GIN explicitly disabled by environment (NCCL_GIN_ENABLE=0)"; return ""; } // GIN type requested for this run (2=proxy, 4=rocshmem-gda); 0 if unset. int requestedGinType() { const char* t = std::getenv("NCCL_GIN_TYPE"); return t ? std::atoi(t) : 0; } std::string ginTypeReason() { const char* ginType = std::getenv("NCCL_GIN_TYPE"); if (!ginType) return "GIN type not set (required NCCL_GIN_TYPE=2 [proxy] or 4 [rocshmem-gda])"; int t = requestedGinType(); if (t != 2 && t != 4) return std::string("Invalid GIN type: ") + ginType + " (required NCCL_GIN_TYPE=2 [proxy] or 4 [rocshmem-gda])"; return ""; } std::string cuMemReason() { const char* cumem = std::getenv("NCCL_CUMEM_ENABLE"); if (!cumem || std::strcmp(cumem, "1") != 0) return "Symmetric memory required (NCCL_CUMEM_ENABLE=1)"; return ""; } // Number of MPI ranks co-located on this rank's node. int nodeLocalRanks() { MPI_Comm nodeComm; MPI_Comm_split_type(MPI_COMM_WORLD, MPI_COMM_TYPE_SHARED, 0, MPI_INFO_NULL, &nodeComm); int n = 0; MPI_Comm_size(nodeComm, &n); MPI_Comm_free(&nodeComm); return n; } // Single-node runs need intranet mode -- otherwise the topology pruner // removes the NET node and GIN has no path to bind. std::string intranetReason() { int worldSize = 0; MPI_Comm_size(MPI_COMM_WORLD, &worldSize); if (nodeLocalRanks() != worldSize) return ""; const char* intra = std::getenv("RCCL_ENABLE_INTRANET"); if (!intra || std::strcmp(intra, "1") != 0) return "Intranet mode required for single-node run (RCCL_ENABLE_INTRANET=1)"; return ""; } // Skip if all ranks share a node -- IB would silently loopback. std::string crossNodeReason() { int worldSize = 0; MPI_Comm_size(MPI_COMM_WORLD, &worldSize); if (nodeLocalRanks() == worldSize) return "Cross-node test requires ranks on >=2 physical nodes"; return ""; } // First failing prerequisite, or "" if all met. std::string ginProxyTestSkipReason() { for (auto check : {ginEnvDisabledReason, ginTypeReason, cuMemReason, intranetReason}) { if (auto reason = check(); !reason.empty()) return reason; } return ""; } // Initialized reqs with ginConnectionType=FULL; default for all tests here. ncclDevCommRequirements defaultGinReqs() { ncclDevCommRequirements r = NCCL_DEV_COMM_REQUIREMENTS_INITIALIZER; r.ginConnectionType = NCCL_GIN_CONNECTION_FULL; return r; } } // namespace // Producer: thread 0 of block 0 issues one put with a SignalInc; CTA flushes. __global__ void putBasicProducerKernel( ncclWindow_t srcWin, size_t srcOff, ncclWindow_t dstWin, size_t dstOff, size_t bytes, ncclGinSignal_t sigIdx, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { gin.put(ncclTeamWorld(devComm), peer, dstWin, dstOff, srcWin, srcOff, bytes, ncclGin_SignalInc{sigIdx}); } // Drain the posted GFD before the kernel exits. gin.flush(ncclCoopCta()); } // Consumer: whole CTA cooperatively waits for the signal to reach the target. __global__ void putBasicConsumerKernel( ncclGinSignal_t sigIdx, uint64_t expectedSignalValue, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; gin.waitSignal(ncclCoopCta(), sigIdx, expectedSignalValue); } // Combined producer + consumer for alltoall: thread 0 puts to every non-self // peer (one slot each), the CTA flushes, then waits for the same number of // signal increments to arrive from peers. __global__ void alltoallKernel( ncclWindow_t sendWin, ncclWindow_t recvWin, size_t bytesPerSlot, int nRanks, int myRank, size_t slotStrideBytes, ncclGinSignal_t sigIdx, uint64_t expectedSignalValue, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { auto team = ncclTeamWorld(devComm); // Send slot p of our send buffer into peer p's recv slot for our rank. for (int p = 0; p < nRanks; ++p) { if (p == myRank) continue; gin.put(team, p, recvWin, /*dstOff=*/(size_t)myRank * slotStrideBytes, sendWin, /*srcOff=*/(size_t)p * slotStrideBytes, bytesPerSlot, ncclGin_SignalInc{sigIdx}); } } gin.flush(ncclCoopCta()); gin.waitSignal(ncclCoopCta(), sigIdx, expectedSignalValue); } class GinMPIDeviceTests : public MPITestBase { protected: // Minimal 64-byte put + waitSignal round-trip from rank 0 to rank 1. // Used by the Invalid_*Pool tests to confirm comm bring-up + the GIN // data path still work after the runtime clamps an oversized pool. void runBasicPutSelfCheck() { // Bring up the comm + stream from the fixture. ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // Tiny geometry: full-buffer transfer, signal 0, peer is rank 1. constexpr size_t kBufBytes = 64; constexpr size_t kTransferBytes = 64; constexpr ncclGinSignal_t kSigIdx = 0; constexpr int kPeer = 1; // Allocate symmetric src/dst on every rank; freed on scope exit. void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); // Register collective symmetric windows so the device side can address // peer memory through srcWin/dstWin. ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // Bring up the GIN device comm (1 barrier slot, 1 signal cell). ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Stage a deterministic byte pattern in the source buffer; dst stays zero. std::vector hostSrc(kBufBytes, 0); std::vector hostDst(kBufBytes, 0); for (size_t i = 0; i < kTransferBytes; i++) { hostSrc[i] = static_cast(0xA0 + (i & 0x3F)); } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); // Sync so neither rank launches before the other has finished setup. MPI_Barrier(MPI_COMM_WORLD); // Rank 0 puts the payload + bumps the signal; rank 1 waits on it. if (rank == 0) { putBasicProducerKernel<<<1, 32, 0, stream>>>( srcWin, /*srcOff=*/0, dstWin, /*dstOff=*/0, kTransferBytes, kSigIdx, kPeer, devComm); } else { putBasicConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, /*expectedSignalValue=*/1, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); // Sync so rank 1's verify isn't racing rank 0's kernel completion. MPI_Barrier(MPI_COMM_WORLD); // Rank 1 reads dst back and checks every byte landed. if (rank == 1) { std::vector hostResult(kBufBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(hostResult.data(), dDst, kBufBytes, hipMemcpyDeviceToHost)); for (size_t i = 0; i < kTransferBytes; i++) { ASSERT_EQ(hostSrc[i], hostResult[i]) << "byte " << i << " mismatched"; } } } }; // Smallest end-to-end exercise of the device put -> proxy -> IB -> peer // signal chain, with non-zero src/dst/signal offsets so address-arithmetic // regressions surface as a verification mismatch. TEST_F(GinMPIDeviceTests, Put_BasicAndOffsets) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // 8 KiB symmetric buffers; 4 KiB transfer at non-zero src/dst offsets; // signal at non-zero index. Forces every offset to be exercised. constexpr size_t kBufBytes = 8 * 1024; constexpr size_t kTransferBytes = 4 * 1024; constexpr size_t kSrcOff = 4 * 1024; constexpr size_t kDstOff = 2 * 1024; constexpr ncclGinSignal_t kSigIdx = 1; constexpr int kPeer = 1; // Allocate symmetric src/dst on every rank. void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); // Register collective windows over the symmetric buffers. ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // Bring up GIN with 2 signals so kSigIdx=1 is a valid in-pool index. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 2; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Stage source pattern in [kSrcOff, kSrcOff+kTransferBytes); rest is zero. std::vector hostSrc(kBufBytes, 0); std::vector hostDst(kBufBytes, 0); for (size_t i = 0; i < kTransferBytes; i++) { hostSrc[kSrcOff + i] = static_cast(0x40 + (i & 0xFF)); } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); // Sync so neither rank launches its kernel before setup is done globally. MPI_Barrier(MPI_COMM_WORLD); // Rank 0 puts payload + bumps signal; rank 1 waits on the same signal. if (rank == 0) { putBasicProducerKernel<<<1, 32, 0, stream>>>( srcWin, kSrcOff, dstWin, kDstOff, kTransferBytes, kSigIdx, kPeer, devComm); } else { putBasicConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, /*expectedSignalValue=*/1, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); // Sync before verify; both ranks have finished their kernel. MPI_Barrier(MPI_COMM_WORLD); // Rank 1 verifies: payload landed exactly in [kDstOff, +kTransferBytes), // and bytes before/after that range are still zero. if (rank == 1) { std::vector hostResult(kBufBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(hostResult.data(), dDst, kBufBytes, hipMemcpyDeviceToHost)); for (size_t i = 0; i < kTransferBytes; i++) { const uint8_t expected = static_cast(0x40 + (i & 0xFF)); ASSERT_EQ(expected, hostResult[kDstOff + i]) << "byte " << i << " in [dstOff, dstOff+xfer) differs"; } for (size_t i = 0; i < kDstOff; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " before dstOff was unexpectedly written"; } for (size_t i = kDstOff + kTransferBytes; i < kBufBytes; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " after dstOff+xfer was unexpectedly written"; } } } // Same wire-level put as Put_BasicAndOffsets, but requires the two ranks to // live on different physical nodes so IB actually traverses the fabric // instead of falling back to a single-node loopback path. TEST_F(GinMPIDeviceTests, Put_CrossNode) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; // Skip on single-node runs; otherwise we'd just be retesting loopback. if (auto reason = crossNodeReason(); !reason.empty()) GTEST_SKIP() << reason; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // Same geometry as Put_BasicAndOffsets so the comparison is apples-to-apples. constexpr size_t kBufBytes = 8 * 1024; constexpr size_t kTransferBytes = 4 * 1024; constexpr size_t kSrcOff = 4 * 1024; constexpr size_t kDstOff = 2 * 1024; constexpr ncclGinSignal_t kSigIdx = 1; constexpr int kPeer = 1; // Allocate symmetric src/dst on every rank. void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); // Register collective windows over the symmetric buffers. ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // Bring up GIN with 2 signals so kSigIdx=1 is valid. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 2; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Stage source pattern in [kSrcOff, kSrcOff+kTransferBytes); rest zero. std::vector hostSrc(kBufBytes, 0); std::vector hostDst(kBufBytes, 0); for (size_t i = 0; i < kTransferBytes; i++) { hostSrc[kSrcOff + i] = static_cast(0x40 + (i & 0xFF)); } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); // Rank 0 puts + signals; rank 1 waits. if (rank == 0) { putBasicProducerKernel<<<1, 32, 0, stream>>>( srcWin, kSrcOff, dstWin, kDstOff, kTransferBytes, kSigIdx, kPeer, devComm); } else { putBasicConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, /*expectedSignalValue=*/1, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // Verify payload + zero-tails on rank 1. if (rank == 1) { std::vector hostResult(kBufBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(hostResult.data(), dDst, kBufBytes, hipMemcpyDeviceToHost)); for (size_t i = 0; i < kTransferBytes; i++) { const uint8_t expected = static_cast(0x40 + (i & 0xFF)); ASSERT_EQ(expected, hostResult[kDstOff + i]) << "byte " << i << " in [dstOff, dstOff+xfer) differs"; } for (size_t i = 0; i < kDstOff; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " before dstOff was unexpectedly written"; } for (size_t i = kDstOff + kTransferBytes; i < kBufBytes; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " after dstOff+xfer was unexpectedly written"; } } } // Producer: putValue carries the 8-byte payload inside the GFD itself // (no source MR is dereferenced); also bumps a signal so the receiver // can wait synchronously. __global__ void putValueInlineProducerKernel( ncclWindow_t dstWin, size_t dstOff, uint64_t value, ncclGinSignal_t sigIdx, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { gin.putValue(ncclTeamWorld(devComm), peer, dstWin, dstOff, value, ncclGin_SignalInc{sigIdx}); } gin.flush(ncclCoopCta()); } // Consumer: just waits for the inline-value signal. __global__ void putValueInlineConsumerKernel( ncclGinSignal_t sigIdx, uint64_t expectedSignalValue, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; gin.waitSignal(ncclCoopCta(), sigIdx, expectedSignalValue); } // Sends a single 8-byte uint64_t inline (no source buffer / MR involved) // and verifies it lands intact at the requested offset on the peer. TEST_F(GinMPIDeviceTests, PutValue_Inline) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // kValue exercises all three pieces of the inline 4+2+2 byte split, // so any field-reconstruction regression in the host proxy surfaces. constexpr size_t kBufBytes = 4 * 1024; constexpr size_t kDstOff = 1 * 1024; constexpr uint64_t kValue = 0x123456789ABCDEF0ULL; constexpr ncclGinSignal_t kSigIdx = 1; constexpr int kPeer = 1; // Allocate symmetric dst on every rank (no src needed: value is inline). void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dDst) (void)ncclMemFree(dDst); }); // Register dst window collectively. ncclWindow_t dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // Bring up GIN with 2 signals so kSigIdx=1 is valid. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 2; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Zero dst so any spurious write outside the 8-byte landing surfaces. std::vector hostDst(kBufBytes, 0); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); // Rank 0 sends the inline value + signal; rank 1 waits. if (rank == 0) { putValueInlineProducerKernel<<<1, 32, 0, stream>>>( dstWin, kDstOff, kValue, kSigIdx, kPeer, devComm); } else { putValueInlineConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, /*expectedSignalValue=*/1, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // Rank 1 verifies the 8 bytes at dstOff match kValue and nothing else moved. if (rank == 1) { std::vector hostResult(kBufBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(hostResult.data(), dDst, kBufBytes, hipMemcpyDeviceToHost)); uint64_t got = 0; std::memcpy(&got, hostResult.data() + kDstOff, sizeof(got)); ASSERT_EQ(kValue, got) << "inline value mismatch (4+2+2 split likely corrupted)"; for (size_t i = 0; i < kDstOff; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " before dstOff was unexpectedly written"; } for (size_t i = kDstOff + sizeof(uint64_t); i < kBufBytes; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " after dstOff+8 was unexpectedly written"; } } } // Producer: thread 0 issues a zero-byte gin.signal (no src/dst windows). // The GFD becomes a non-inline put with size=0, hasInline=false; the proxy // dispatches it through the signal-only iputSignal path // (gin_host_proxy.cc:271-273). __global__ void signalNoPayloadProducerKernel( ncclGinSignal_t sigIdx, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { gin.signal(ncclTeamWorld(devComm), peer, ncclGin_SignalInc{sigIdx}); } // Drain the posted GFD before the kernel exits. gin.flush(ncclCoopCta()); } // Consumer: whole CTA cooperatively waits for the signal to reach the target. __global__ void signalNoPayloadConsumerKernel( ncclGinSignal_t sigIdx, uint64_t expectedSignalValue, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; gin.waitSignal(ncclCoopCta(), sigIdx, expectedSignalValue); } // Bare-minimum signal RTT: rank 0 issues a zero-byte gin.signal that only // bumps rank 1's signal cell -- no data, no src/dst windows. It's the // latency-floor primitive the barrier is built on and exercises the proxy's // signal-only path. Signal index is non-zero so a regression that drops the // index multiplier would land at cell 0 and the consumer's waitSignal(1) // would never observe the bump. TEST_F(GinMPIDeviceTests, Signal_NoPayload) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); constexpr ncclGinSignal_t kSigIdx = 1; constexpr int kPeer = 1; // rank 0 -> rank 1 // No buffers, no windows: gin.signal is a zero-byte put. The runtime's // own signal pool is the only memory touched by the GFD; it's allocated // and registered by ncclDevCommCreate based on ginSignalCount. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 2; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Sync so neither rank launches its kernel before setup is done globally. MPI_Barrier(MPI_COMM_WORLD); // Producer (rank 0) emits a single signal; consumer (rank 1) waits for // signal cell kSigIdx to reach 1. The successful return of waitSignal on // the consumer IS the assertion -- there is no payload to verify. if (rank == 0) { signalNoPayloadProducerKernel<<<1, 32, 0, stream>>>(kSigIdx, kPeer, devComm); } else { signalNoPayloadConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, /*expectedSignalValue=*/1, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); // Both kernels are done; rank 1's signal cell is settled. MPI_Barrier(MPI_COMM_WORLD); } // Validates the NCCL 2.29.7 defect (AICOMRCCL-1115): "Fix a 16-bit overflow of // signal and counter ids with GIN proxy." Pre-fix, the proxy GPU-flush // descriptor packed signalId/counterId into uint16_t fields // (gin_proxy_device_host_common.h), so any id >= 65536 truncated to (id & // 0xFFFF). The fix widens those fields to uint32_t bitfields (signalId : 24, // counterId : 23) and grows the pools/validation to match. Here rank 0 bumps a // signal whose index is exactly one past the old 16-bit range; rank 1 waits on // that same index. With the fix the bump lands at cell 65536 and waitSignal // returns; pre-fix the id truncates to cell 0, the waiter on 65536 never // observes it, and the consumer hangs (surfaced as a timeout) -- exactly the // discrimination we want. This reuses the Signal_NoPayload kernels unchanged; // only the index and pool size differ from the in-range control above. TEST_F(GinMPIDeviceTests, Signal_HighIdNoOverflow) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // Index one past the old uint16_t range. ginSignalCount must exceed it so the // cell is a valid in-pool index (post-fix default pool is 512Ki). constexpr ncclGinSignal_t kSigIdx = 65536; // > 0xFFFF constexpr uint32_t kSignalPoolCount = 65537; constexpr int kPeer = 1; // rank 0 -> rank 1 ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = kSignalPoolCount; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); MPI_Barrier(MPI_COMM_WORLD); // Producer (rank 0) bumps signal cell kSigIdx; consumer (rank 1) waits for it // to reach 1. The consumer's successful return IS the assertion -- pre-fix it // never returns because the bump truncates to cell 0. if (rank == 0) { signalNoPayloadProducerKernel<<<1, 32, 0, stream>>>(kSigIdx, kPeer, devComm); } else { signalNoPayloadConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, /*expectedSignalValue=*/1, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); } // Producer-only kernel: rank 0 issues a put with no remote action and a // CounterInc local action, then waits on its OWN counter. The counter is // bumped by the local proxy thread when the IB CQE for the put lands // (gin_host_proxy.cc:147-152, inside proxyGinPollCompletions). That's a // distinct path from the remote-signal write dispatched via iputSignal // (gin_host_proxy.cc:271-273) which the prior put/signal tests exercise. __global__ void waitCounterLocalProducerKernel( ncclWindow_t srcWin, size_t srcOff, ncclWindow_t dstWin, size_t dstOff, size_t bytes, ncclGinCounter_t cntIdx, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { gin.put(ncclTeamWorld(devComm), peer, dstWin, dstOff, srcWin, srcOff, bytes, ncclGin_None{}, // no remote action (no signal) ncclGin_CounterInc{cntIdx}); // local: bump cntIdx on IB CQE } // CTA-collective wait; only thread 0 spins on the cell. Once it returns, // the proxy thread has observed the IB CQE for the put and bumped the // counter. gin.waitCounter(ncclCoopCta(), cntIdx, /*least=*/1); } // Exercises the local IB-completion -> counter-bump path. Rank 1 is a // passive RDMA target (no kernel) - the payload lands in its dst window // via the IB plugin. The successful return of waitCounter(1) on rank 0 // (gated by hipStreamSynchronize) IS the assertion. TEST_F(GinMPIDeviceTests, WaitCounter_Local) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // 8 KiB symmetric buffers; 4 KiB payload at non-zero src/dst offsets so // the put is a realistic data move. The counter (not the payload) is // what we verify -- this test is about the LOCAL completion path. constexpr size_t kBufBytes = 8 * 1024; constexpr size_t kTransferBytes = 4 * 1024; constexpr size_t kSrcOff = 4 * 1024; constexpr size_t kDstOff = 2 * 1024; constexpr ncclGinCounter_t kCntIdx = 1; // non-zero counter index constexpr int kPeer = 1; // Symmetric src + dst (every rank allocates because window registration // is collective for SYMMETRIC-mode windows). void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // ginCounterCount=2 so kCntIdx=1 is in range; no ginSignalCount needed // (the put has no remote-signal action). railGinBarrierCount=1 because // the runtime allocates per-CTA barrier state for any 1-CTA launch. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginCounterCount = 2; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Known fill on src so the put has real bytes to move; both ranks zero // their dst as a baseline. We don't verify the destination -- this test // is about the local counter, not data delivery. std::vector hostSrc(kBufBytes, 0xCC); std::vector hostDst(kBufBytes, 0); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); // Sync so neither rank launches its kernel before setup is done globally. MPI_Barrier(MPI_COMM_WORLD); // Only rank 0 launches a kernel; the local counter lives on rank 0. Rank // 1 is purely the RDMA target -- the IB plugin lands the payload in its // dst window passively. if (rank == 0) { waitCounterLocalProducerKernel<<<1, 32, 0, stream>>>( srcWin, kSrcOff, dstWin, kDstOff, kTransferBytes, kCntIdx, kPeer, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); // Sync so ScopeGuards (window/comm teardown is collective) see both // ranks past the kernel phase. MPI_Barrier(MPI_COMM_WORLD); } // Producer kernel: rank 0 issues a single put carrying BOTH a remote // SignalInc action and a local CounterInc action, then waits on its OWN // counter. The remote SignalInc bumps the peer's signal cell when the IB // write lands at the target (gin_host_proxy.cc:271-273); the local // CounterInc bumps rank 0's counter when the IB CQE for the put is // observed by the local proxy (gin_host_proxy.cc:147-152). One put, // two completion sites. __global__ void waitCounterAndSignalProducerKernel( ncclWindow_t srcWin, size_t srcOff, ncclWindow_t dstWin, size_t dstOff, size_t bytes, ncclGinSignal_t sigIdx, ncclGinCounter_t cntIdx, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { gin.put(ncclTeamWorld(devComm), peer, dstWin, dstOff, srcWin, srcOff, bytes, ncclGin_SignalInc{sigIdx}, // remote: bump peer's signal cell ncclGin_CounterInc{cntIdx}); // local: bump cntIdx on IB CQE } // CTA-collective wait gating kernel exit on local CQ completion. On the // proxy backend, the local CQE strictly follows the remote SignalInc // landing, so once this returns the consumer's waitSignal is unblocked // (or already past). gin.waitCounter(ncclCoopCta(), cntIdx, /*least=*/1); } // Consumer kernel: rank 1 waits on its signal cell to reach 1. No put is // issued from this side -- the IB plugin lands rank 0's payload + signal // passively. __global__ void waitCounterAndSignalConsumerKernel( ncclGinSignal_t sigIdx, uint64_t expectedSignalValue, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; gin.waitSignal(ncclCoopCta(), sigIdx, expectedSignalValue); } // Exercises both completion sites of a single gin.put with combined // (SignalInc, CounterInc) actions: // - Remote signal notification: rank 1's waitSignal returns when the // IB write of the SignalInc lands on its signal cell. // - Local IB-CQE -> counter bump: rank 0's waitCounter returns when the // local proxy thread has observed the CQE for the put. // Together with the post-sync host-side payload check on rank 1, this // proves the same put delivered (a) bytes, (b) remote signal, and // (c) local counter -- all driven by one gin.put. TEST_F(GinMPIDeviceTests, WaitCounterAndSignal) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // 8 KiB symmetric buffers; 4 KiB payload at non-zero src/dst offsets so // the put is a realistic data move. Both signal and counter indices are // non-zero so a regression that drops the index multiplier would land // at cell 0 and the waits would never observe the bump. constexpr size_t kBufBytes = 8 * 1024; constexpr size_t kTransferBytes = 4 * 1024; constexpr size_t kSrcOff = 4 * 1024; constexpr size_t kDstOff = 2 * 1024; constexpr ncclGinSignal_t kSigIdx = 1; constexpr ncclGinCounter_t kCntIdx = 1; constexpr int kPeer = 1; // Symmetric src + dst on every rank (window registration is collective // for SYMMETRIC-mode windows). void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // ginSignalCount=2 and ginCounterCount=2 so kSigIdx=1 and kCntIdx=1 are // both in range. railGinBarrierCount=1 covers the 1-CTA launch. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 2; reqs.ginCounterCount = 2; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Deterministic byte pattern in src so the post-sync data check on // rank 1 catches wrong-bytes / wrong-offset regressions. Both ranks // zero their dst as a baseline. std::vector hostSrc(kBufBytes, 0); std::vector hostDst(kBufBytes, 0); for (size_t i = 0; i < kTransferBytes; i++) { hostSrc[kSrcOff + i] = static_cast(0x40 + (i & 0xFF)); } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); // Sync so neither rank launches before setup is done globally. MPI_Barrier(MPI_COMM_WORLD); // Rank 0 puts + bumps remote signal + bumps local counter; rank 1 waits // for the signal. Each side's gin wait IS its assertion (counter on // rank 0, signal on rank 1); the host-side memcmp on rank 1 below // verifies the payload. if (rank == 0) { waitCounterAndSignalProducerKernel<<<1, 32, 0, stream>>>( srcWin, kSrcOff, dstWin, kDstOff, kTransferBytes, kSigIdx, kCntIdx, kPeer, devComm); } else { waitCounterAndSignalConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, /*expectedSignalValue=*/1, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); // Sync so rank 1's verify isn't racing rank 0's kernel completion. MPI_Barrier(MPI_COMM_WORLD); // Rank 1 verifies the payload landed at [kDstOff, kDstOff+kTransferBytes) // and nothing outside that range was touched. if (rank == 1) { std::vector hostResult(kBufBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(hostResult.data(), dDst, kBufBytes, hipMemcpyDeviceToHost)); for (size_t i = 0; i < kDstOff; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " before dstOff was unexpectedly written"; } for (size_t i = 0; i < kTransferBytes; i++) { ASSERT_EQ(hostSrc[kSrcOff + i], hostResult[kDstOff + i]) << "payload byte " << i << " mismatched"; } for (size_t i = kDstOff + kTransferBytes; i < kBufBytes; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " after dstOff+kTransferBytes was unexpectedly written"; } } // Final sync so collective teardown (ScopeGuards) see both ranks past // the verification phase. MPI_Barrier(MPI_COMM_WORLD); } // Collective kernel: every rank runs the same code. The barrier composes // signal + waitSignal + an epoch counter (gin_barrier__funcs.h:42-60) -- each // sync sends SignalInc to every other rank and waits for the local signal // cell to reach a higher epoch, so consecutive syncs don't collide on the // same expected value. The ncclTeamTagRail{} overload routes to // comm.railGinBarrier and the rail team automatically. __global__ void barrier2RanksKernel(int iters, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; // barrierIndex must be < railGinBarrierCount (we set =1, so 0). ncclGinBarrierSession bar{ ncclCoopCta(), gin, ncclTeamTagRail{}, /*barrierIndex=*/0}; for (int i = 0; i < iters; i++) { bar.sync(ncclCoopCta(), cuda::memory_order_relaxed, ncclGinFenceLevel::Relaxed); } } // First test that actually USES the railGinBarrier resource (prior tests // only provisioned it). Kernel completion after kIters rounds proves both // ranks moved through the barrier in lockstep -- if the epoch math broke, // iter 1 would deadlock waiting for an epoch the peer never reaches. TEST_F(GinMPIDeviceTests, Barrier_TwoRanks) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // Large enough that a stuck-at-zero epoch would deadlock at iter 1 // (not silently pass on iter 0); small enough to stay fast. Each round // sends 1 SignalInc per peer (1 in the 2-rank case) and waits for the // corresponding signal cell to reach the new epoch. constexpr int kIters = 16; // railGinBarrierCount=1 covers our 1-CTA launch (barrierIndex=0). The // barrier session uses the rail-team GIN barrier pool that the runtime // allocates via ncclGinBarrierCreateRequirement; its signal cells live // at (handle.signal0 + barrierIndex), separate from the user-facing // ginSignalCount pool. // // ginForceEnable is REQUIRED. ncclDevCommCreateInternal only activates // GIN (populating ginHandles[]/ginSignalBase) when nNodes>1 OR // ginForceEnable OR ginSignalCount!=0 OR ginCounterCount!=0 -- the gate // intentionally ignores railGinBarrierCount. Without this flag a // single-node test that asks only for railGinBarrierCount gets NULL // ginHandles[0], and the barrier's internal waitSignal -> getSignalPtr // dispatch dereferences a NULL ncclGinProxyGpuCtx and faults at (nil) // on the GPU. Other tests dodge the gate accidentally by setting // ginSignalCount/ginCounterCount non-zero; this one has neither so we // ask for GIN explicitly. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginForceEnable = true; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // All ranks finished setup before any kernel launches. MPI_Barrier(MPI_COMM_WORLD); // Both ranks launch the same kernel -- barrier is collective, no // producer/consumer split. Each rank's iter i sends SignalInc to the // other rank and waits for its own signal cell to reach epoch+1; if the // barrier raced past the peer (epoch broken), iter i+1 would either spin // forever or read a stale epoch. Kernel completion means all kIters // rounds stayed in lockstep. barrier2RanksKernel<<<1, 32, 0, stream>>>(kIters, devComm); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); // Successful return of the kernel IS the assertion: signal + waitSignal // + epoch all worked together for kIters rounds. MPI_Barrier(MPI_COMM_WORLD); } // Direct tests for ncclBarrierSession (barrier.h), separate from the alltoall // kernels that use it incidentally: BarrierSession_LsaOnly (LSA-only subset, // ncclTeamTagLsa, no GIN) and BarrierSession_Hybrid (world-team: inner LSA + // outer rail-GIN). // LSA-team collective for the LSA-only subset. Each round every rank stamps the // iteration into its slot of each peer's window, barriers, then checks all // peers' stamps are visible. acq_rel publishes our writes and acquires theirs; // a broken barrier leaves a stale slot in dErr (or deadlocks on a broken epoch). __global__ void barrierSessionLsaKernel( ncclWindow_t win, size_t off, int iters, int* dErr, struct ncclDevComm devComm) { // Dedicated LSA barrier session reads comm.lsaBarrier (sized by reqs.lsaBarrierCount, no GIN); the composite // ncclBarrierSession(ncclTeamTagLsa) reads the 0-sized comm.hybridLsaBarrier and overruns for LSA teams >2 ranks. ncclLsaBarrierSession bar{ ncclCoopCta(), devComm, ncclTeamTagLsa(), /*index=*/blockIdx.x}; ncclTeam lsa = ncclTeamLsa(devComm); int* myBuf = static_cast(ncclGetLocalPointer(win, off)); for (int it = 1; it <= iters; ++it) { if (threadIdx.x == 0) { for (int p = 0; p < lsa.nRanks; ++p) { int* peer = static_cast(ncclGetLsaPointer(win, off, p)); peer[lsa.rank] = it; } } // Publish our writes to LSA peers and acquire theirs (acq_rel so the loads // below are ordered after every peer's store, not relaxed). bar.sync(ncclCoopCta(), cuda::memory_order_acq_rel); if (threadIdx.x == 0) { for (int p = 0; p < lsa.nRanks; ++p) { if (myBuf[p] != it) atomicExch(dErr, it); } } // Gate the next round's overwrites on all peers having read this round. bar.sync(ncclCoopCta(), cuda::memory_order_acquire); } } // Exercises the LSA-only subset in isolation. Needs symmetric memory and >=2 // ranks co-located on a node; it does NOT require the GIN proxy, so it gates on // cuMem + a node-local size check rather than the full GIN prerequisites. TEST_F(GinMPIDeviceTests, BarrierSession_LsaOnly) { if (auto reason = cuMemReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/8)) GTEST_SKIP() << "Requires 2-8 ranks"; // Needs >=2 co-located ranks; otherwise the LSA team is size 1 (vacuous). if (nodeLocalRanks() < 2) GTEST_SKIP() << "Requires >=2 ranks co-located on a node for a non-trivial LSA team"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int nRanks = -1; ncclCommCount(comm, &nRanks); ASSERT_GE(nRanks, 2); ASSERT_LE(nRanks, 8); // LSA-only path: start from the plain initializer (GIN disabled) and ask only // for the inner LSA barrier pool, so ncclDevCommCreate does not activate GIN. ncclDevCommRequirements reqs = NCCL_DEV_COMM_REQUIREMENTS_INITIALIZER; reqs.lsaBarrierCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // One int slot per possible LSA peer (<= nRanks). Symmetric window so peers // can store into our buffer via ncclGetLsaPointer. constexpr int kIters = 16; const size_t bytes = static_cast(nRanks) * sizeof(int); void* dBuf = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dBuf, bytes)); auto memCleanup = makeScopeGuard([&]() { if (dBuf) (void)ncclMemFree(dBuf); }); ncclWindow_t win = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dBuf, bytes, &win, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (win) (void)ncclCommWindowDeregister(comm, win); }); // Device-side error flag: set to the failing iteration if a peer's write // was not visible after the barrier. int* dErr = nullptr; ASSERT_MPI_EQ(hipSuccess, hipMalloc(&dErr, sizeof(int))); auto errCleanup = makeScopeGuard([&]() { if (dErr) (void)hipFree(dErr); }); ASSERT_MPI_EQ(hipSuccess, hipMemset(dErr, 0, sizeof(int))); ASSERT_MPI_EQ(hipSuccess, hipMemset(dBuf, 0, bytes)); MPI_Barrier(MPI_COMM_WORLD); // Collective: every rank runs the same kernel on its node's LSA team. barrierSessionLsaKernel<<<1, 64, 0, stream>>>(win, /*off=*/0, kIters, dErr, devComm); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); int hErr = 0; ASSERT_MPI_EQ(hipSuccess, hipMemcpy(&hErr, dErr, sizeof(int), hipMemcpyDeviceToHost)); ASSERT_EQ(0, hErr) << "LSA barrier failed to make a peer's write visible at iteration " << hErr; MPI_Barrier(MPI_COMM_WORLD); } // Collective over the world team: composes the inner LSA barrier with the // outer rail-GIN barrier (ncclTeamTagWorld ctor). Mirrors Barrier_TwoRanks' // "completion == success" philosophy -- a broken inner or outer epoch // deadlocks at iter 1 -- but routes through both substrates. On single node // the outer rail arm degenerates; multi-node it crosses rails. __global__ void barrierSessionHybridKernel(int iters, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; ncclBarrierSession bar{ ncclCoopCta(), ncclTeamTagWorld(), gin, /*index=*/blockIdx.x}; for (int i = 0; i < iters; i++) { bar.sync(ncclCoopCta(), cuda::memory_order_relaxed, ncclGinFenceLevel::Relaxed); } } TEST_F(GinMPIDeviceTests, BarrierSession_Hybrid) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/8)) GTEST_SKIP() << "Requires 2-8 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int nRanks = -1; ncclCommCount(comm, &nRanks); ASSERT_GE(nRanks, 2); ASSERT_LE(nRanks, 8); constexpr int kIters = 16; // The world-team ncclBarrierSession sizes its hybrid LSA+rail-GIN barriers from // reqs.barrierCount (not lsa/railGinBarrierCount); 0 hangs the rail arm. ginForceEnable // activates GIN so the outer barrier's waitSignal has a valid ctx (see Barrier_TwoRanks). ncclDevCommRequirements reqs = defaultGinReqs(); reqs.barrierCount = 1; reqs.ginForceEnable = true; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); MPI_Barrier(MPI_COMM_WORLD); // All ranks run the same collective barrier loop; completion means every // round's inner LSA + outer rail-GIN epochs stayed in lockstep. barrierSessionHybridKernel<<<1, 32, 0, stream>>>(kIters, devComm); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); } // --------------------------------------------------------------------------- // SignalAdd_AndShadow // Walks the signal-shadow API family end-to-end. SignalAdd (variable add) // takes a different proxy host path than SignalInc (+1): // gin_proxy.h -> ncclGinProxyOpWithSignalAdd. The shadow-side calls // (readSignal, increaseSignalShadow, waitSignalMeetShadow, // waitSignalFollowShadow) are not exercised by any other test. The shadow // lives in GPU memory at comm.ginSignalShadows + ... so it persists across // kernel launches on the same rank; only increaseSignalShadow and // resetSignal mutate it. // // Phases (rank 0 = producer, rank 1 = consumer): // P1: producer 1x put(SignalAdd+5, CounterInc); waitCounter(1). // consumer waitSignal(5); readSignal -> must be 5. // P2: producer 3x put(SignalAdd+7, CounterInc); waitCounter(4). // consumer waitSignalFollowShadow(leastDelta=1, &b, &d) -> b=0, // d=26 (signal moved 0->26 since the last shadow snapshot). Then // increaseSignalShadow(+100): shadow := 126. // P3: producer 1x put(SignalAdd+100); no counter; drained via flush. // consumer waitSignalMeetShadow returns when signal >= 126. // P4: producer readCounter -> must be 4 (4 counter-bearing puts; P3 // carried no counter). // // Why waitCounter at the end of P1/P2 producer kernels: local CQ // completion (counter bump) implies the remote SignalAdd write for that // put has landed on the consumer's signal cell. Without it, the // inter-phase MPI_Barrier would not guarantee in-flight signal writes // have reached the peer, and the consumer could observe an intermediate // signal value (e.g. 5 instead of 26 in P2). // // P2 ordering quirk: waitSignalFollowShadow(leastDelta=1) returns as soon // as signal > shadow. If P2's puts and the consumer's wait launched // concurrently, the consumer could observe signal=5 (still just P1) and // return with delta=5 -- the d==26 assertion would fail spuriously. So // P2 producer runs to completion first, then an MPI_Barrier, then the P2 // consumer launches. P1 and P3 don't have this issue and run concurrently. // --------------------------------------------------------------------------- // Producer with counter. Used by P1 (nPuts=1, signalDelta=5) and P2 // (nPuts=3, signalDelta=7). waitCounter at the end gates exit on local CQ // completion for all `nPuts` puts, which on the proxy backend implies the // remote SignalAdd writes have landed at the peer. __global__ void signalAddProducerWithCounterKernel( ncclWindow_t srcWin, size_t srcOff, ncclWindow_t dstWin, size_t dstOff, size_t bytes, ncclGinSignal_t sigIdx, uint64_t signalDelta, ncclGinCounter_t cntIdx, uint64_t counterExpected, int nPuts, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { for (int i = 0; i < nPuts; i++) { gin.put(ncclTeamWorld(devComm), peer, dstWin, dstOff, srcWin, srcOff, bytes, ncclGin_SignalAdd{sigIdx, signalDelta}, // remote: bump peer's signal by delta ncclGin_CounterInc{cntIdx}); // local: bump cntIdx on IB CQE } } gin.waitCounter(ncclCoopCta(), cntIdx, counterExpected); } // Producer without counter. Used by P3: a single put carrying only // ncclGin_SignalAdd{sig, 100}. flush() drains posted GFDs before exit so // the kernel doesn't return while the SignalAdd is still in flight on the // proxy. __global__ void signalAddProducerNoCounterKernel( ncclWindow_t srcWin, size_t srcOff, ncclWindow_t dstWin, size_t dstOff, size_t bytes, ncclGinSignal_t sigIdx, uint64_t signalDelta, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { gin.put(ncclTeamWorld(devComm), peer, dstWin, dstOff, srcWin, srcOff, bytes, ncclGin_SignalAdd{sigIdx, signalDelta}); } gin.flush(ncclCoopCta()); } // Producer P4: read the counter back. Must be 4 = 1 (P1) + 3 (P2); P3 had // no counter so it cannot have bumped it. __global__ void signalAddProducerReadCounterKernel( ncclGinCounter_t cntIdx, uint64_t* outCounter, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { *outCounter = gin.readCounter(cntIdx); } } // Consumer P1: waitSignal(>=5) then readSignal. After P1's single // SignalAdd+5 the signal cell is exactly 5, so readSignal must return 5. __global__ void signalAddConsumerPhase1Kernel( ncclGinSignal_t sigIdx, uint64_t least, uint64_t* outReadSignal, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; gin.waitSignal(ncclCoopCta(), sigIdx, least); if (threadIdx.x == 0 && blockIdx.x == 0) { *outReadSignal = gin.readSignal(sigIdx); } } // Consumer P2: waitSignalFollowShadow(leastDelta=1) returns once // signal > shadow by >= 1; P2 producer's waitCounter(4) guarantees signal // has reached 5+3*7=26 here, so before==0 (initial shadow) and delta==26. // Then increaseSignalShadow(+100) raises shadow from 26 to 126 for P3. __global__ void signalAddConsumerPhase2Kernel( ncclGinSignal_t sigIdx, uint64_t leastDelta, uint64_t shadowBump, uint64_t* outBefore, uint64_t* outDelta, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; uint64_t before = 0, delta = 0; gin.waitSignalFollowShadow(ncclCoopCta(), sigIdx, leastDelta, &before, &delta); if (threadIdx.x == 0 && blockIdx.x == 0) { *outBefore = before; *outDelta = delta; gin.increaseSignalShadow(sigIdx, shadowBump); } } // Consumer P3: shadow was raised to 126 in P2; this waits for the // producer's SignalAdd+100 to bring signal from 26 -> 126. __global__ void signalAddConsumerPhase3Kernel( ncclGinSignal_t sigIdx, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; gin.waitSignalMeetShadow(ncclCoopCta(), sigIdx); } TEST_F(GinMPIDeviceTests, SignalAdd_AndShadow) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // The put is just a vehicle for the SignalAdd + CounterInc actions; 8 // bytes is the smallest reasonable payload. Bytes correctness is covered // by Put_BasicAndOffsets and is not asserted here. constexpr size_t kBufBytes = 64; constexpr size_t kTransferBytes = 8; constexpr size_t kSrcOff = 0; constexpr size_t kDstOff = 0; constexpr ncclGinSignal_t kSigIdx = 1; // non-zero so signal-pool indexing is exercised constexpr ncclGinCounter_t kCntIdx = 1; constexpr int kPeer = 1; // rank 0 -> rank 1 // Symmetric src + dst (window registration is collective for // SYMMETRIC-mode windows, so every rank allocates). void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // sigIdx=1 and cntIdx=1 -> ginSignalCount/ginCounterCount must be >= 2. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 2; reqs.ginCounterCount = 2; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Producer needs a known fill (bytes aren't asserted but the put must // have something to move). Consumer's dst gets zeroed. if (rank == 0) { std::vector hostSrc(kBufBytes, 0xA5); ASSERT_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); } std::vector hostZero(kBufBytes, 0); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostZero.data(), kBufBytes, hipMemcpyHostToDevice)); // Per-rank result buffer. Layout (uint64_t slots): // producer (rank 0): [0] readCounter (P4) // consumer (rank 1): [0] readSignal (P1), [1] before (P2), [2] delta (P2) // Initialized to a sentinel so an unwritten slot is obvious in failure logs. constexpr size_t kResultSlots = 4; constexpr uint64_t kSentinel = 0xFFFFFFFFFFFFFFFFull; uint64_t* dResults = nullptr; ASSERT_EQ(hipSuccess, hipMalloc(&dResults, kResultSlots * sizeof(uint64_t))); auto resultsCleanup = makeScopeGuard([&]() { if (dResults) (void)hipFree(dResults); }); std::vector hostInit(kResultSlots, kSentinel); ASSERT_EQ(hipSuccess, hipMemcpy(dResults, hostInit.data(), kResultSlots * sizeof(uint64_t), hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); // --- P1: producer 1x SignalAdd+5/CounterInc, consumer waitSignal(5) + // readSignal. Concurrent: the consumer's waitSignal blocks until // the put lands, and no later producer activity is in flight that // could push signal>5. if (rank == 0) { signalAddProducerWithCounterKernel<<<1, 32, 0, stream>>>( srcWin, kSrcOff, dstWin, kDstOff, kTransferBytes, kSigIdx, /*signalDelta=*/5, kCntIdx, /*counterExpected=*/1, /*nPuts=*/1, kPeer, devComm); } else { signalAddConsumerPhase1Kernel<<<1, 32, 0, stream>>>( kSigIdx, /*least=*/5, &dResults[0], devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // --- P2: producer 3x SignalAdd+7/CounterInc, ending with waitCounter(4). // MUST complete BEFORE the consumer's waitSignalFollowShadow runs // (see banner: leastDelta=1 could otherwise observe signal=5 from // P1 and return with delta=5 instead of 26). if (rank == 0) { signalAddProducerWithCounterKernel<<<1, 32, 0, stream>>>( srcWin, kSrcOff, dstWin, kDstOff, kTransferBytes, kSigIdx, /*signalDelta=*/7, kCntIdx, /*counterExpected=*/4, /*nPuts=*/3, kPeer, devComm); ASSERT_EQ(hipSuccess, hipStreamSynchronize(stream)); } MPI_Barrier(MPI_COMM_WORLD); // gate consumer P2 on producer P2 done if (rank == 1) { signalAddConsumerPhase2Kernel<<<1, 32, 0, stream>>>( kSigIdx, /*leastDelta=*/1, /*shadowBump=*/100, &dResults[1], &dResults[2], devComm); ASSERT_EQ(hipSuccess, hipStreamSynchronize(stream)); } MPI_Barrier(MPI_COMM_WORLD); // shadow on rank 1 is now 26+100=126 // --- P3: producer 1x SignalAdd+100 (no counter, drained via flush); // consumer waitSignalMeetShadow returns when signal >= 126. // Concurrent: consumer is gated on the SignalAdd landing. if (rank == 0) { signalAddProducerNoCounterKernel<<<1, 32, 0, stream>>>( srcWin, kSrcOff, dstWin, kDstOff, kTransferBytes, kSigIdx, /*signalDelta=*/100, kPeer, devComm); } else { signalAddConsumerPhase3Kernel<<<1, 32, 0, stream>>>(kSigIdx, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // --- P4: producer reads counter back. The 4 counter-bearing puts (P1: 1, // P2: 3) were all waited for via waitCounter in their kernels; // P3 had no counter. Counter must be exactly 4. if (rank == 0) { signalAddProducerReadCounterKernel<<<1, 1, 0, stream>>>( kCntIdx, &dResults[0], devComm); ASSERT_EQ(hipSuccess, hipStreamSynchronize(stream)); } MPI_Barrier(MPI_COMM_WORLD); // Pull results back per-rank and assert. These branches are // rank-divergent, so use plain ASSERT_EQ (ASSERT_MPI_EQ would deadlock on // its internal MPI_Allreduce). std::vector hostResults(kResultSlots, 0); ASSERT_EQ(hipSuccess, hipMemcpy(hostResults.data(), dResults, kResultSlots * sizeof(uint64_t), hipMemcpyDeviceToHost)); if (rank == 0) { ASSERT_EQ(4ull, hostResults[0]) << "P4 readCounter must equal 4 (1 from P1 + 3 from P2)"; } else { ASSERT_EQ(5ull, hostResults[0]) << "P1 readSignal must equal 5"; ASSERT_EQ(0ull, hostResults[1]) << "P2 waitSignalFollowShadow before must be 0"; ASSERT_EQ(26ull, hostResults[2]) << "P2 waitSignalFollowShadow delta must be 26"; } // ScopeGuards run in reverse order on return. } // Producer: drives BOTH symPtr-form shims in a single kernel: // 1) gin.put(team, peer, dstSym, srcSym, nElts, SignalInc) -- the // typed-pointer put shim (gin__funcs.h:168) unpacks // ncclSymPtr::{window,offset} and dispatches to the window-form put // with byte-size = nElts*sizeof(T). // 2) gin.putValue(team, peer, valDstSym, value, SignalInc) -- // the typed-pointer putValue shim (gin__funcs.h:231) unpacks valDstSym // and dispatches to the window-form putValue with the value carried // inline in the GFD itself. This drives the 4-byte inline branch of // buildGfd (only inlineLow.inlineValLow set, inlineValLow2/inlineValHigh // stay zero because sizeof(T) is not > 4 and not > 6 -- gin_proxy.h:84-95), // distinct from the uint64_t 4+2+2 split exercised by PutValue_Inline. // Both posts bump kSigIdx by 1 via SignalInc; the consumer waits on 2. __global__ void symPtrProducerKernel( ncclSymPtr dstSym, ncclSymPtr srcSym, size_t nElts, ncclSymPtr valDstSym, uint32_t inlineValue, ncclGinSignal_t sigIdx, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { gin.put(ncclTeamWorld(devComm), peer, dstSym, srcSym, nElts, ncclGin_SignalInc{sigIdx}); gin.putValue(ncclTeamWorld(devComm), peer, valDstSym, inlineValue, ncclGin_SignalInc{sigIdx}); } // Drain both posted GFDs before exit. gin.flush(ncclCoopCta()); } // Consumer: whole CTA waits for both signal increments from the two posts. __global__ void symPtrConsumerKernel( ncclGinSignal_t sigIdx, uint64_t expectedSignalValue, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; gin.waitSignal(ncclCoopCta(), sigIdx, expectedSignalValue); } // Exercises the typed-pointer shims (ncclSymPtr) for both gin.put and // gin.putValue. Rank 0 sends a 512-float array via symPtr put and a 4-byte // uint32 sentinel via symPtr putValue; rank 1 verifies both // land at their respective offsets and nothing else moved. TEST_F(GinMPIDeviceTests, SymPtr_PutAndPutValue) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // Layout: src window holds a 512-float array starting at srcOff. dst // window receives the float array at dstOff (deliberately != srcOff to // exercise non-zero offsets), and the 4-byte uint32 inline value lands // at valOff (well past the float region so a misrouted put surfaces as // a verification mismatch). No src memory is needed for the inline // putValue -- the 4 bytes ride inside the GFD itself. constexpr size_t kBufBytes = 8 * 1024; constexpr size_t kNElts = 512; constexpr size_t kSrcOff = 1 * 1024; // bytes constexpr size_t kDstOff = 2 * 1024; // bytes constexpr size_t kValOff = 6 * 1024; // bytes (non-overlapping) constexpr uint32_t kInlineValue = 0xCAFEBABEu; constexpr ncclGinSignal_t kSigIdx = 1; constexpr int kPeer = 1; static_assert(kSrcOff + kNElts * sizeof(float) <= kBufBytes, "src overflow"); static_assert(kDstOff + kNElts * sizeof(float) <= kBufBytes, "dst overflow"); static_assert(kValOff + sizeof(uint32_t) <= kBufBytes, "val overflow"); static_assert(kValOff >= kDstOff + kNElts * sizeof(float), "val region must not overlap dst float region"); void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // ginSignalCount=2 so kSigIdx=1 is in range. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 2; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Rank 0 fills its src float array with a deterministic pattern. Both // ranks zero their dst so any spurious write outside [dstOff, dstOff+xfer) // or near kValOff surfaces as a verification mismatch. std::vector hostSrc(kBufBytes, 0); std::vector hostDst(kBufBytes, 0); if (rank == 0) { float* srcFloats = reinterpret_cast(hostSrc.data() + kSrcOff); for (size_t i = 0; i < kNElts; i++) { srcFloats[i] = static_cast(i) + 0.5f; } } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); // Sync so neither rank launches its kernel before setup is done globally. MPI_Barrier(MPI_COMM_WORLD); // Producer issues both the float-array put and the inline uint32 putValue // from a single kernel; consumer waitSignal(2) drains both. Two posts // each bump kSigIdx by 1 via SignalInc, so the final value is 2. if (rank == 0) { ncclSymPtr srcSym(srcWin, kSrcOff); ncclSymPtr dstSym(dstWin, kDstOff); ncclSymPtr valDstSym(dstWin, kValOff); symPtrProducerKernel<<<1, 32, 0, stream>>>( dstSym, srcSym, kNElts, valDstSym, kInlineValue, kSigIdx, kPeer, devComm); } else { symPtrConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, /*expectedSignalValue=*/2, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // Receiver-side verification. if (rank == 1) { std::vector hostResult(kBufBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(hostResult.data(), dDst, kBufBytes, hipMemcpyDeviceToHost)); // 1) The float array landed at dstOff and matches rank 0's source. const float* gotFloats = reinterpret_cast(hostResult.data() + kDstOff); for (size_t i = 0; i < kNElts; i++) { const float expected = static_cast(i) + 0.5f; ASSERT_EQ(expected, gotFloats[i]) << "float[" << i << "] mismatch at dstOff via symPtr put"; } // 2) The 4-byte uint32 inline value landed at valOff. These bytes came // from the inline GFD slot (putValue), not from any source // MR -- the symPtr shim only carried the destination offset. uint32_t gotInline = 0; std::memcpy(&gotInline, hostResult.data() + kValOff, sizeof(gotInline)); ASSERT_EQ(kInlineValue, gotInline) << "inline uint32 mismatch at valOff (symPtr putValue path)"; // 3) Nothing was written outside the two destination regions. for (size_t i = 0; i < kDstOff; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " before float-dst region was unexpectedly written"; } for (size_t i = kDstOff + kNElts * sizeof(float); i < kValOff; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " between float-dst and val-dst was unexpectedly written"; } for (size_t i = kValOff + sizeof(uint32_t); i < kBufBytes; i++) { ASSERT_EQ(0u, hostResult[i]) << "byte " << i << " after val-dst region was unexpectedly written"; } } } // Reference single-node alltoall: every rank puts its slice for every peer // into that peer's recvbuf via gin.put + SignalInc, then waits on its own // signal cell for nRanks increments. Ported one-for-one from // examples/06_device_api/02_gin_alltoall_pure/main.cu so a regression in // put + signal + barrier + flush composed under realistic load surfaces here. __global__ void alltoallPureKernel( ncclWindow_t sendwin, size_t sendoffset, ncclWindow_t recvwin, size_t recvoffset, size_t count, struct ncclDevComm devComm) { constexpr int ginContext = 0; unsigned int signalIndex = blockIdx.x; ncclGin gin{devComm, ginContext}; // Capture current signal cell so a count-sweep that reuses the same // kernel/devComm doesn't conflate increments from past iterations. uint64_t signalValue = gin.readSignal(signalIndex); // Cross-rank sync ensures every rank's sendbuf is registered before any // peer reads from it via gin.put below. ncclGinBarrierSession bar{ ncclCoopCta(), gin, ncclTeamWorld(devComm), devComm.railGinBarrier, blockIdx.x}; bar.sync(ncclCoopCta(), cuda::memory_order_acquire, ncclGinFenceLevel::Relaxed); int tid = threadIdx.x + blockIdx.x * blockDim.x; int nthreads = blockDim.x * gridDim.x; // Each rank writes its per-peer slice into the destination's recvbuf // slot (offset = my rank * size). All puts bump signalIndex by 1 on the // destination, so the destination's signal cell receives nRanks // increments (one per source rank including self). const size_t size = count * sizeof(float); for (int r = tid; r < devComm.nRanks; r += nthreads) { gin.put(ncclTeamWorld(devComm), r, recvwin, recvoffset + devComm.rank * size, sendwin, sendoffset + r * size, size, ncclGin_SignalInc{signalIndex}); } int receivingCta = (devComm.rank % nthreads) / blockDim.x; if (blockIdx.x == receivingCta) gin.waitSignal(ncclCoopCta(), signalIndex, signalValue + devComm.nRanks); gin.flush(ncclCoopCta()); } // Single-node 2-8 ranks; count sweep {1, 1024, 1<<16}. Bit-for-bit verifies // the deterministic sendbuf[i] = rank*1000 + dst*100 + i pattern landed on // the right peer slot. The 1<<16 case (256 KiB / direction / peer) saturates // ring credit to exercise the postGfd credit-wait path. TEST_F(GinMPIDeviceTests, Alltoall_PureReference) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/8)) GTEST_SKIP() << "Requires 2-8 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_GE(nRanks, 2); ASSERT_LE(nRanks, 8); // ginSignalCount=1 covers signalIndex=0; railGinBarrierCount=1 matches // our single-CTA launch. Both non-zero so GIN activates without // ginForceEnable. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // 1 (alignment/tail edges), 1024 (medium), 65536 (saturating). const std::vector counts = {1, 1024, size_t{1} << 16}; constexpr int kCTAs = 1; // == railGinBarrierCount constexpr int kThreadsPerCTA = 512; // matches the production example launch for (size_t count : counts) { SCOPED_TRACE(::testing::Message() << "count=" << count); const size_t totalElements = count * static_cast(nRanks); const size_t sizeBytes = totalElements * sizeof(float); void* dSend = nullptr; void* dRecv = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSend, sizeBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dRecv, sizeBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSend) (void)ncclMemFree(dSend); if (dRecv) (void)ncclMemFree(dRecv); }); ncclWindow_t sendWin = nullptr, recvWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSend, sizeBytes, &sendWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dRecv, sizeBytes, &recvWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (sendWin) (void)ncclCommWindowDeregister(comm, sendWin); if (recvWin) (void)ncclCommWindowDeregister(comm, recvWin); }); // Per-source/per-dest pattern: every byte of every slice has a unique // expected value, so a misrouted slice surfaces as a mismatch. std::vector hostSend(totalElements, 0.0f); std::vector hostRecv(totalElements, 0.0f); for (int dst = 0; dst < nRanks; dst++) { for (size_t i = 0; i < count; i++) { hostSend[static_cast(dst) * count + i] = static_cast(rank * 1000 + dst * 100 + static_cast(i)); } } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSend, hostSend.data(), sizeBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dRecv, hostRecv.data(), sizeBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); alltoallPureKernel<<>>( sendWin, /*sendoffset=*/0, recvWin, /*recvoffset=*/0, count, devComm); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // recvbuf[src*count + i] on rank R should equal src*1000 + R*100 + i. ASSERT_EQ(hipSuccess, hipMemcpy(hostRecv.data(), dRecv, sizeBytes, hipMemcpyDeviceToHost)); for (int src = 0; src < nRanks; src++) { for (size_t i = 0; i < count; i++) { const float expected = static_cast(src * 1000 + rank * 100 + static_cast(i)); const float got = hostRecv[static_cast(src) * count + i]; ASSERT_EQ(expected, got) << "rank=" << rank << " src=" << src << " i=" << i; } } } } // Hybrid alltoall: LSA stores for intra-node peers + gin.put for cross-node // peers, bracketed by entry/exit barriers. Ported from // examples/06_device_api/03_gin_alltoall_hybrid/main.cu (HybridAlltoAllKernel). // On single-node nNodes==1 so world.nRanks==lsa.nRanks; numRemotePeers==0 // and the gin.put loops are no-ops -- this run primarily exercises the // LSA-store path + entry/exit barrier composition. The cross-node arm is // covered by Alltoall_CrossNode per the plan. __global__ void alltoallHybridKernel( ncclWindow_t sendwin, size_t sendoffset, ncclWindow_t recvwin, size_t recvoffset, size_t count, struct ncclDevComm devComm) { constexpr int ginContext = 0; constexpr unsigned int signalIndex = 0; ncclGin gin{devComm, ginContext}; uint64_t signalValue = gin.readSignal(signalIndex); // ncclBarrierSession (not the gin-only variant) routes through both LSA // and rail-team GIN under ncclTeamTagWorld(). Single-node degenerates to // an LSA-team barrier; multi-node crosses rails. ncclBarrierSession bar{ ncclCoopCta(), ncclTeamTagWorld(), gin, blockIdx.x}; bar.sync(ncclCoopCta(), cuda::memory_order_relaxed, ncclGinFenceLevel::Relaxed); int tid = threadIdx.x + blockIdx.x * blockDim.x; int nthreads = blockDim.x * gridDim.x; ncclTeam world = ncclTeamWorld(devComm); ncclTeam lsa = ncclTeamLsa(devComm); const int startLsa = world.rank - lsa.rank; const int lsaSize = lsa.nRanks; // Cross-node peers: covers everything outside this node's LSA team // (no-op on single-node). const size_t size = count * sizeof(float); for (int r = tid; r < startLsa; r += nthreads) { gin.put(world, r, recvwin, recvoffset + world.rank * size, sendwin, sendoffset + r * size, size, ncclGin_SignalInc{signalIndex}); } for (int r = startLsa + lsaSize + tid; r < world.nRanks; r += nthreads) { gin.put(world, r, recvwin, recvoffset + world.rank * size, sendwin, sendoffset + r * size, size, ncclGin_SignalInc{signalIndex}); } // Intra-node peers: write each LSA peer's slot in their recv buffer // directly. ncclGetLocalPointer resolves our own sendbuf; // ncclGetLsaPointer resolves an LSA peer's recvbuf into our address space. float* sendLocal = (float*)ncclGetLocalPointer(sendwin, sendoffset); for (size_t offset = tid; offset < count; offset += nthreads) { for (int lp = 0; lp < lsa.nRanks; lp++) { int wr = startLsa + lp; float* recvPtr = (float*)ncclGetLsaPointer(recvwin, recvoffset, lp); recvPtr[world.rank * count + offset] = sendLocal[wr * count + offset]; } } // Only wait for remote peers' increments; LSA peers don't bump the // signal cell. On single-node this returns immediately (delta=0). int numRemotePeers = world.nRanks - lsa.nRanks; gin.waitSignal(ncclCoopCta(), signalIndex, signalValue + numRemotePeers); gin.flush(ncclCoopCta()); // Final barrier: every rank's LSA stores into peer recvbufs are issued // and the local writes from peers are visible before any rank consumes // its recvbuf. bar.sync(ncclCoopCta(), cuda::memory_order_release, ncclGinFenceLevel::Relaxed); } // Same shape and verification as Alltoall_PureReference but exercises the // LSA-store path and the dual-pool barrier. On single-node the gin.put arm // is a no-op; the test still validates LSA + rail-team-GIN barrier // composition + readSignal/waitSignal plumbing with delta=0. TEST_F(GinMPIDeviceTests, AlltoallHybrid_Reference) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/8)) GTEST_SKIP() << "Requires 2-8 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_GE(nRanks, 2); ASSERT_LE(nRanks, 8); // The world-team ncclBarrierSession sizes its hybrid LSA+rail-GIN barriers from // reqs.barrierCount (not lsa/railGinBarrierCount); 0 hangs the rail arm. // ginSignalCount=1 covers the cross-node signal cell and triggers GIN activation. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.barrierCount = 1; reqs.ginSignalCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); const std::vector counts = {1, 1024, size_t{1} << 16}; constexpr int kCTAs = 1; constexpr int kThreadsPerCTA = 512; for (size_t count : counts) { SCOPED_TRACE(::testing::Message() << "count=" << count); const size_t totalElements = count * static_cast(nRanks); const size_t sizeBytes = totalElements * sizeof(float); void* dSend = nullptr; void* dRecv = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSend, sizeBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dRecv, sizeBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSend) (void)ncclMemFree(dSend); if (dRecv) (void)ncclMemFree(dRecv); }); ncclWindow_t sendWin = nullptr, recvWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSend, sizeBytes, &sendWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dRecv, sizeBytes, &recvWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (sendWin) (void)ncclCommWindowDeregister(comm, sendWin); if (recvWin) (void)ncclCommWindowDeregister(comm, recvWin); }); // Same deterministic pattern as Alltoall_PureReference. std::vector hostSend(totalElements, 0.0f); std::vector hostRecv(totalElements, 0.0f); for (int dst = 0; dst < nRanks; dst++) { for (size_t i = 0; i < count; i++) { hostSend[static_cast(dst) * count + i] = static_cast(rank * 1000 + dst * 100 + static_cast(i)); } } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSend, hostSend.data(), sizeBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dRecv, hostRecv.data(), sizeBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); alltoallHybridKernel<<>>( sendWin, /*sendoffset=*/0, recvWin, /*recvoffset=*/0, count, devComm); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); ASSERT_EQ(hipSuccess, hipMemcpy(hostRecv.data(), dRecv, sizeBytes, hipMemcpyDeviceToHost)); for (int src = 0; src < nRanks; src++) { for (size_t i = 0; i < count; i++) { const float expected = static_cast(src * 1000 + rank * 100 + static_cast(i)); const float got = hostRecv[static_cast(src) * count + i]; ASSERT_EQ(expected, got) << "rank=" << rank << " src=" << src << " i=" << i; } } } } // Producer: one block per GIN context. Block b uses ginContext=b, puts into // its own slot, and signals signal[b] in that context. __global__ void multiContextProducerKernel( ncclWindow_t srcWin, ncclWindow_t dstWin, size_t slotStride, size_t bytes, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, (int)blockIdx.x}; const size_t off = (size_t)blockIdx.x * slotStride; if (threadIdx.x == 0) { gin.put(ncclTeamWorld(devComm), peer, dstWin, off, srcWin, off, bytes, ncclGin_SignalInc{(ncclGinSignal_t)blockIdx.x}); } gin.flush(ncclCoopCta()); } // Consumer: block b waits on signal[b] in its own context, mirroring the // producer-side mapping. __global__ void multiContextConsumerKernel( uint64_t expectedSignalValue, struct ncclDevComm devComm) { ncclGin gin{devComm, (int)blockIdx.x}; gin.waitSignal(ncclCoopCta(), (ncclGinSignal_t)blockIdx.x, expectedSignalValue); } // Drives all NCCL_GIN_MAX_CONTEXTS contexts in parallel, each with its own // slot + per-context signal. Confirms every contextId has a working // proxy ring + IB QP and that there's no cross-context contamination. TEST_F(GinMPIDeviceTests, MultiContext_AllFourRoute) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // 4 per-context slots; 1 KiB payload into each 4 KiB slot. The 3 KiB // tail per slot is asserted zero to catch cross-context contamination. constexpr int kNumContexts = NCCL_GIN_MAX_CONTEXTS; // 4 constexpr size_t kSlotStride = 4 * 1024; constexpr size_t kTransferBytes = 1 * 1024; constexpr size_t kBufBytes = kNumContexts * kSlotStride; constexpr int kPeer = 1; // Allocate symmetric src/dst large enough for all contexts' slots. void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); // Register collective windows over the symmetric buffers. ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // Bring up GIN. ginContextCount on reqs is a hint; the authoritative // value is env-driven and read back from devComm. Each block uses a // signal id == blockIdx.x, so we need kNumContexts signal cells per ctx. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginContextCount = kNumContexts; reqs.ginSignalCount = kNumContexts; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Skip if the runtime didn't actually give us the requested number of ctxs. if ((int)devComm.ginContextCount != kNumContexts) { GTEST_SKIP() << "Test requires " << kNumContexts << " GIN contexts, got " << (int)devComm.ginContextCount << " (set NCCL_GIN_NCONTEXTS=" << kNumContexts << ")"; } // Stage a distinct pattern (0x10 + b) per context slot so cross-context // landings show up as value mismatches rather than missing writes. std::vector hostSrc(kBufBytes, 0); std::vector hostDst(kBufBytes, 0); for (int b = 0; b < kNumContexts; b++) { const uint8_t pattern = static_cast(0x10 + b); std::fill_n(hostSrc.begin() + b * kSlotStride, kTransferBytes, pattern); } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); // Launch kNumContexts blocks on each side; one block per context. if (rank == 0) { multiContextProducerKernel<<>>( srcWin, dstWin, kSlotStride, kTransferBytes, kPeer, devComm); } else { multiContextConsumerKernel<<>>( /*expectedSignalValue=*/1, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // Verify every context's slot independently: payload range matches // pattern, slot tail is still zero. if (rank == 1) { std::vector hostResult(kBufBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(hostResult.data(), dDst, kBufBytes, hipMemcpyDeviceToHost)); for (int b = 0; b < kNumContexts; b++) { const uint8_t pattern = static_cast(0x10 + b); const size_t base = (size_t)b * kSlotStride; for (size_t i = 0; i < kTransferBytes; i++) { ASSERT_EQ(pattern, hostResult[base + i]) << "ctx " << b << ": byte " << i << " in transferred range mismatched (expected 0x" << std::hex << (int)pattern << ")"; } for (size_t i = kTransferBytes; i < kSlotStride; i++) { ASSERT_EQ(0u, hostResult[base + i]) << "ctx " << b << ": byte " << i << " in slot tail was unexpectedly written"; } } } } // Exercises the non-power-of-2 context count (3), which forces the modulo // arm of the ncclGin ctor. Producer launches 6 blocks; pairs (0,3), (1,4), // (2,5) collide on contextId 0/1/2 and each pair writes a disjoint sub-slot. // Each producer signals signal 0 of its ctx; consumer waits for value 2. // Requires NCCL_GIN_NCONTEXTS=3 (skipped otherwise). __global__ void multiContextNpo2ProducerKernel( ncclWindow_t srcWin, ncclWindow_t dstWin, int numContexts, size_t slotStride, size_t subSlotBytes, int peer, struct ncclDevComm devComm) { const int ctx = (int)blockIdx.x % numContexts; const int subSlotIx = (int)blockIdx.x / numContexts; const size_t off = (size_t)ctx * slotStride + (size_t)subSlotIx * subSlotBytes; ncclGin gin{devComm, ctx}; if (threadIdx.x == 0) { gin.put(ncclTeamWorld(devComm), peer, dstWin, off, srcWin, off, subSlotBytes, ncclGin_SignalInc{(ncclGinSignal_t)0}); } gin.flush(ncclCoopCta()); } __global__ void multiContextNpo2ConsumerKernel( uint64_t expectedSignalValue, struct ncclDevComm devComm) { ncclGin gin{devComm, (int)blockIdx.x}; gin.waitSignal(ncclCoopCta(), (ncclGinSignal_t)0, expectedSignalValue); } TEST_F(GinMPIDeviceTests, MultiContext_NonPowerOf2) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // 3 ctx slots * 4 KiB stride; 2 sub-slots of 1 KiB per slot (one per // producer block hitting that ctx); 2 KiB tail per slot asserted zero. constexpr int kNumContexts = 3; constexpr int kBlocksPerCtx = 2; constexpr int kProducerBlocks = kNumContexts * kBlocksPerCtx; constexpr size_t kSubSlotBytes = 1 * 1024; constexpr size_t kSlotStride = 4 * 1024; constexpr size_t kBufBytes = kNumContexts * kSlotStride; constexpr int kPeer = 1; // Allocate symmetric src/dst. void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); // Register collective windows over the symmetric buffers. ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // Bring up GIN with 3 contexts, 1 signal each (signal pools are per-ctx, // so signal 0 is independent across the three contexts). ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginContextCount = kNumContexts; reqs.ginSignalCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Skip unless the runtime gave us exactly 3 contexts (this test is the // only thing exercising the modulo arm of the ctor). if ((int)devComm.ginContextCount != kNumContexts) { GTEST_SKIP() << "Test requires " << kNumContexts << " GIN contexts, got " << (int)devComm.ginContextCount << " (set NCCL_GIN_NCONTEXTS=" << kNumContexts << ")"; } // Stage a distinct pattern per producer block (0x10 + b) so a stray // landing surfaces as a value mismatch. std::vector hostSrc(kBufBytes, 0); std::vector hostDst(kBufBytes, 0); for (int b = 0; b < kProducerBlocks; b++) { const int ctx = b % kNumContexts; const int subSlotIx = b / kNumContexts; const size_t off = (size_t)ctx * kSlotStride + (size_t)subSlotIx * kSubSlotBytes; const uint8_t pattern = static_cast(0x10 + b); std::fill_n(hostSrc.begin() + off, kSubSlotBytes, pattern); } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); // Producer launches 6 blocks (2 per ctx); consumer launches one block // per ctx and waits for value 2 on signal 0 of that ctx. if (rank == 0) { multiContextNpo2ProducerKernel<<>>( srcWin, dstWin, kNumContexts, kSlotStride, kSubSlotBytes, kPeer, devComm); } else { multiContextNpo2ConsumerKernel<<>>( /*expectedSignalValue=*/static_cast(kBlocksPerCtx), devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // Verify both sub-slots of every ctx hold the right pattern, and the // tail past the two sub-slots is still zero. if (rank == 1) { std::vector hostResult(kBufBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(hostResult.data(), dDst, kBufBytes, hipMemcpyDeviceToHost)); for (int ctx = 0; ctx < kNumContexts; ctx++) { const size_t base = (size_t)ctx * kSlotStride; for (int subSlotIx = 0; subSlotIx < kBlocksPerCtx; subSlotIx++) { const int b = subSlotIx * kNumContexts + ctx; const uint8_t pattern = static_cast(0x10 + b); const size_t off = base + (size_t)subSlotIx * kSubSlotBytes; for (size_t i = 0; i < kSubSlotBytes; i++) { ASSERT_EQ(pattern, hostResult[off + i]) << "ctx " << ctx << " sub-slot " << subSlotIx << " (block " << b << "): byte " << i << " mismatched (expected 0x" << std::hex << (int)pattern << ")"; } } const size_t tailStart = base + (size_t)kBlocksPerCtx * kSubSlotBytes; for (size_t i = tailStart; i < base + kSlotStride; i++) { ASSERT_EQ(0u, hostResult[i]) << "ctx " << ctx << ": byte " << (i - base) << " in slot tail was unexpectedly written"; } } } } // Sweep a matrix of aligned + unaligned transfer sizes through the same // put kernel; reuses one comm / window pair / signal cell across iters // (signal monotonically bumped, consumer waits for iter+1). TEST_F(GinMPIDeviceTests, LargeBuffer_Sweep) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // Aligned + unaligned matrix; the +1 variants force a trailing fragment // so RDMA tail-byte handling regressions surface. const std::vector kSizes = { 1, 64, 4 * 1024, 4 * 1024 + 1, 1 * 1024 * 1024, 4 * 1024 * 1024, 16 * 1024 * 1024, 16 * 1024 * 1024 + 1, }; const size_t kMaxBytes = kSizes.back(); constexpr ncclGinSignal_t kSigIdx = 0; constexpr int kPeer = 1; // Allocate symmetric src/dst sized for the largest sweep entry. void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kMaxBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kMaxBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); // Register collective windows; reused across all iterations. ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kMaxBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kMaxBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // Bring up GIN with one signal cell; we'll bump it once per iter. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Stage src once: src[i] = i & 0xFF. Distinct bytes mod 256 catch // byte-shift / off-by-one regressions in the RDMA path. std::vector hostSrc(kMaxBytes, 0); for (size_t i = 0; i < kMaxBytes; i++) hostSrc[i] = static_cast(i & 0xFF); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kMaxBytes, hipMemcpyHostToDevice)); // Reusable buffers: zero pattern for clearing dst, scratch for verify. const std::vector hostZero(kMaxBytes, 0); std::vector hostResult(kMaxBytes, 0); uint64_t signalExpected = 0; for (size_t iter = 0; iter < kSizes.size(); iter++) { const size_t sz = kSizes[iter]; // Clear dDst so the previous iter's larger payload doesn't leak into // this iter's tail-byte assertions. ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostZero.data(), kMaxBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); // Each iter bumps signal[0] by 1; consumer waits for the cumulative count. signalExpected++; if (rank == 0) { putBasicProducerKernel<<<1, 32, 0, stream>>>( srcWin, /*srcOff=*/0, dstWin, /*dstOff=*/0, sz, kSigIdx, kPeer, devComm); } else { putBasicConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, signalExpected, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // Rank 1 verifies: payload [0,sz) matches src; tail [sz,kMaxBytes) zero. if (rank == 1) { ASSERT_EQ(hipSuccess, hipMemcpy(hostResult.data(), dDst, kMaxBytes, hipMemcpyDeviceToHost)); const int payloadCmp = std::memcmp(hostResult.data(), hostSrc.data(), sz); ASSERT_EQ(0, payloadCmp) << "size=" << sz << ": payload range [0," << sz << ") differs from source"; if (sz < kMaxBytes) { const int tailCmp = std::memcmp(hostResult.data() + sz, hostZero.data(), kMaxBytes - sz); ASSERT_EQ(0, tailCmp) << "size=" << sz << ": bytes in [" << sz << "," << kMaxBytes << ") were unexpectedly written"; } } } } // Negative test: with NCCL_GIN_ENABLE=0, ncclDevCommCreate must fail. TEST_F(GinMPIDeviceTests, Disable_Error) { const char* e = std::getenv("NCCL_GIN_ENABLE"); if (!e || std::strcmp(e, "0") != 0) GTEST_SKIP() << "Negative-path test; opt in by setting NCCL_GIN_ENABLE=0"; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; // Skip ginProxyTestSkipReason: bare comm bring-up does not call into GIN, // so the data-path gates don't apply here. ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); // ginSignalCount > 0 + ginConnectionType=FULL forces ncclDevCommCreate to // attempt GIN bring-up. ncclDevCommRequirements reqs = defaultGinReqs(); reqs.ginSignalCount = 1; ncclDevComm devComm{}; ncclResult_t r = ncclDevCommCreate(comm, &reqs, &devComm); ASSERT_TRUE(r == ncclInvalidUsage || r == ncclInvalidArgument) << "ncclDevCommCreate must fail with a request-rejected error when " "NCCL_GIN_ENABLE=0; got result " << (int)r; } // With an oversized signal pool the runtime should silently clamp to its // internal max; confirm the data path still works after the clamp. TEST_F(GinMPIDeviceTests, Invalid_SignalPool) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; // Opt in with an oversized signal pool (>= 1 GiB) to exercise the clamp. const char* env = std::getenv("NCCL_GIN_SIGNAL_POOL_SIZE"); if (!env || std::strtoull(env, nullptr, 0) < (1ULL << 30)) GTEST_SKIP() << "Set NCCL_GIN_SIGNAL_POOL_SIZE>=0x40000000 to opt in"; // If clamp + data path are healthy, the basic put round-trip succeeds. runBasicPutSelfCheck(); } // Counterpart of Invalid_SignalPool for the counter pool. TEST_F(GinMPIDeviceTests, Invalid_CounterPool) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; // Opt in with an oversized counter pool (>= 1 GiB). const char* env = std::getenv("NCCL_GIN_COUNTER_POOL_SIZE"); if (!env || std::strtoull(env, nullptr, 0) < (1ULL << 30)) GTEST_SKIP() << "Set NCCL_GIN_COUNTER_POOL_SIZE>=0x40000000 to opt in"; runBasicPutSelfCheck(); } // Run a put round-trip, then explicitly tear the comm down and check // cleanup returns success. Failure here indicates a leak / unjoined proxy // thread / undeleased MR or QP somewhere in ncclGinFinalize. TEST_F(GinMPIDeviceTests, Teardown_NoLeaks) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; // Inner scope so window/devComm/mem guards fire before we call // cleanupTestCommunicator() below (they hold refs to the comm). { // Setup: comm, stream, geometry. ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); constexpr size_t kBufBytes = 64; constexpr ncclGinSignal_t kSigIdx = 0; constexpr int kPeer = 1; int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); // Allocate symmetric src/dst. void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); // Register collective windows. ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); // Bring up GIN (1 signal cell suffices for a single round-trip). ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); MPI_Barrier(MPI_COMM_WORLD); // Run a single basic put + waitSignal round-trip. if (rank == 0) { putBasicProducerKernel<<<1, 32, 0, stream>>>( srcWin, /*srcOff=*/0, dstWin, /*dstOff=*/0, kBufBytes, kSigIdx, kPeer, devComm); } else { putBasicConsumerKernel<<<1, 32, 0, stream>>>( kSigIdx, /*expectedSignalValue=*/1, devComm); } ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); } // mem/window/devComm guards fire here while comm is still live. // Explicit destroy: success implies ncclGinFinalize ran to completion // (proxy thread joined, MR/QP released). ASSERT_EQ(ncclSuccess, cleanupTestCommunicator()); ASSERT_EQ(nullptr, getActiveCommunicator()); ASSERT_EQ(nullptr, getActiveStream()); } // Hammer create/destroy in a loop. Catches refcount, mutex, and // finalize-order regressions that only surface across multiple lifecycles. TEST_F(GinMPIDeviceTests, Init_Destroy_Stress) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; constexpr int kIterations = 10; for (int i = 0; i < kIterations; ++i) { // Fresh comm each iter. ASSERT_EQ(ncclSuccess, createTestCommunicator()) << "iter " << i; ncclComm_t comm = getActiveCommunicator(); // Allocate + register a tiny window to actually trigger the GIN // connect machinery (not just bare comm bring-up). void* d = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&d, 64)); ncclWindow_t win = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, d, 64, &win, NCCL_WIN_COLL_SYMMETRIC)); MPI_Barrier(MPI_COMM_WORLD); // Tear everything down in reverse order before the next iter. ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowDeregister(comm, win)); ASSERT_MPI_EQ(ncclSuccess, ncclMemFree(d)); ASSERT_EQ(ncclSuccess, cleanupTestCommunicator()) << "iter " << i; } } // Cross-node alltoall using device-side put. Each rank sends one slot to // every other rank and waits for nRanks-1 signal increments per iter. // Sweeps tiny/medium/saturating sizes through the same comm and buffers. TEST_F(GinMPIDeviceTests, Alltoall_CrossNode) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; // Single-node would loopback IB; require real cross-node ranks. if (auto reason = crossNodeReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2)) GTEST_SKIP() << "Requires >=2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); // Geometry: per-peer slot is sized for the largest sweep entry; smaller // counts just use the head of each slot. using T = uint32_t; static constexpr size_t kCounts[] = {1, 1u << 10, 1u << 20}; static constexpr size_t kMaxCount = 1u << 20; const size_t slotStrideBytes = kMaxCount * sizeof(T); const size_t bufBytes = (size_t)nRanks * slotStrideBytes; constexpr ncclGinSignal_t kSigIdx = 0; // Allocate symmetric send/recv buffers (one slot per peer). void* dSend = nullptr; void* dRecv = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSend, bufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dRecv, bufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSend) (void)ncclMemFree(dSend); if (dRecv) (void)ncclMemFree(dRecv); }); // Register collective windows over send/recv. ncclWindow_t sendWin = nullptr, recvWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSend, bufBytes, &sendWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dRecv, bufBytes, &recvWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (sendWin) (void)ncclCommWindowDeregister(comm, sendWin); if (recvWin) (void)ncclCommWindowDeregister(comm, recvWin); }); // Bring up GIN with one signal cell (cumulative across iters). ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Encoding: byte[3] = sender rank, byte[2] = dest rank, byte[1:0] = // element index. Lets the receiver decode both source and dest from a slot. auto pack = [](int sender, int dest, size_t i) -> T { return (T)((((uint32_t)sender & 0xFFu) << 24) | (((uint32_t)dest & 0xFFu) << 16) | ((uint32_t)i & 0xFFFFu)); }; // Stage send buffer: slot p contains values addressed to peer p. std::vector hostSend((size_t)nRanks * kMaxCount); for (int p = 0; p < nRanks; ++p) { for (size_t i = 0; i < kMaxCount; ++i) { hostSend[(size_t)p * kMaxCount + i] = pack(rank, p, i); } } ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSend, hostSend.data(), bufBytes, hipMemcpyHostToDevice)); // Cumulative across iters; the signal cell is never reset between iters. std::vector hostRecv((size_t)nRanks * kMaxCount); uint64_t expectedSignal = 0; for (size_t count : kCounts) { // Kernel skips p == myRank; fill our self slot locally so verify // covers all nRanks rows uniformly. ASSERT_MPI_EQ(hipSuccess, hipMemcpyAsync((uint8_t*)dRecv + (size_t)rank * slotStrideBytes, (uint8_t*)dSend + (size_t)rank * slotStrideBytes, count * sizeof(T), hipMemcpyDeviceToDevice, stream)); // We expect to receive nRanks-1 signal increments per iter. expectedSignal += (uint64_t)(nRanks - 1); MPI_Barrier(MPI_COMM_WORLD); // Single combined kernel: put to every peer + flush + waitSignal. alltoallKernel<<<1, 32, 0, stream>>>( sendWin, recvWin, count * sizeof(T), nRanks, rank, slotStrideBytes, kSigIdx, expectedSignal, devComm); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // Verify each row of the recv buffer holds the bytes packed by sender r. ASSERT_EQ(hipSuccess, hipMemcpy(hostRecv.data(), dRecv, bufBytes, hipMemcpyDeviceToHost)); for (int r = 0; r < nRanks; ++r) { for (size_t i = 0; i < count; ++i) { T expected = pack(r, rank, i); T actual = hostRecv[(size_t)r * kMaxCount + i]; ASSERT_EQ(expected, actual) << "count=" << count << " sender=" << r << " i=" << i; } } // Hold all ranks here so a fast peer can't start the next iter and // overwrite a slow rank's recvbuf before it has verified. MPI_Barrier(MPI_COMM_WORLD); } } // ===================================================================== // Coverage for the new NCCL 2.29.7 GIN device-API features. All run on the // device-API NCCL_GIN_TYPE=2 path with cross-node placement (2 ranks on 2 // nodes). Tests for features that depend on topology/library support that // may be unavailable skip gracefully rather than fail. // ===================================================================== // nLsaTeams / ginType / railedGinType via ncclCommQueryProperties (2.29.7). TEST_F(GinMPIDeviceTests, Properties_NLsaTeams) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/8)) GTEST_SKIP() << "Requires 2-8 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); int nRanks = -1; ncclCommCount(comm, &nRanks); ncclCommProperties_t props = NCCL_COMM_PROPERTIES_INITIALIZER; ASSERT_EQ(ncclSuccess, ncclCommQueryProperties(comm, &props)); // Backend type must match the requested NCCL_GIN_TYPE (2=proxy, 4=rocshmem-gda). EXPECT_EQ(requestedGinType(), (int)props.ginType); // nLsaTeams = nRanks / lsaSize: >= 1 and must evenly divide nRanks. // Skip when the runtime reports nLsaTeams==0 on this configuration. if (props.nLsaTeams == 0) GTEST_SKIP() << "nLsaTeams reported 0 on this configuration"; EXPECT_GE(props.nLsaTeams, 1); EXPECT_EQ(0, nRanks % props.nLsaTeams) << "nRanks=" << nRanks << " not divisible by nLsaTeams=" << props.nLsaTeams; } // Exclusive GIN contexts -- reqs.ginExclusiveContexts (2.29.7). TEST_F(GinMPIDeviceTests, MultiContext_Exclusive) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); constexpr int kNumContexts = 2; constexpr size_t kSlotStride = 4 * 1024; constexpr size_t kTransferBytes = 1 * 1024; constexpr size_t kBufBytes = kNumContexts * kSlotStride; constexpr int kPeer = 1; void* dSrc = nullptr; void* dDst = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBufBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBufBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); }); ncclWindow_t srcWin = nullptr, dstWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBufBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBufBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); }); ncclDevCommRequirements reqs = defaultGinReqs(); reqs.ginExclusiveContexts = true; // <-- feature under test reqs.railGinBarrierCount = 1; reqs.ginContextCount = kNumContexts; reqs.ginSignalCount = kNumContexts; ncclDevComm devComm{}; // Exclusive contexts are carved from the same pool as shared contexts: shared // allocations grow from index 0 upward while exclusive ones are reserved from // the top down. The pool size is NCCL_GIN_NCONTEXTS (rounded up to the GIN // connection count). On a small topology (few connections) the default pool // can be fully consumed by shared allocations, leaving none to reserve // exclusively. Run with NCCL_GIN_NCONTEXTS large enough to leave headroom // (e.g. >= shared usage + ginContextCount; 8 is sufficient on a 2-rank/1-NIC // setup). Skip (rather than fail) when the pool has no room on this config. ncclResult_t cr = ncclDevCommCreate(comm, &reqs, &devComm); if (cr != ncclSuccess) GTEST_SKIP() << "Exclusive GIN contexts unavailable on this configuration " "(increase NCCL_GIN_NCONTEXTS for more pool headroom); rc=" << (int)cr; auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // The runtime rounds the requested context count up to a multiple of the GIN // connection count, so expect ROUNDUP(kNumContexts, ginConnectionCount) rather // than an exact match. Kernels below only drive [0, kNumContexts), so extras are harmless. const int connCount = (int)devComm.ginConnectionCount; const int expectedCtxs = connCount > 0 ? ((kNumContexts + connCount - 1) / connCount) * connCount : kNumContexts; ASSERT_EQ(expectedCtxs, (int)devComm.ginContextCount) << "exclusive allocation returned " << (int)devComm.ginContextCount << " for " << connCount << " connection(s) (requested " << kNumContexts << ")"; ASSERT_GE((int)devComm.ginContextCount, kNumContexts); std::vector hostSrc(kBufBytes, 0), hostDst(kBufBytes, 0); for (int b = 0; b < kNumContexts; b++) std::fill_n(hostSrc.begin() + b * kSlotStride, kTransferBytes, static_cast(0x20 + b)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hostSrc.data(), kBufBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hostDst.data(), kBufBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); if (rank == 0) multiContextProducerKernel<<>>( srcWin, dstWin, kSlotStride, kTransferBytes, kPeer, devComm); else multiContextConsumerKernel<<>>( /*expectedSignalValue=*/1, devComm); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); if (rank == 1) { std::vector res(kBufBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(res.data(), dDst, kBufBytes, hipMemcpyDeviceToHost)); for (int b = 0; b < kNumContexts; b++) { const uint8_t pat = static_cast(0x20 + b); const size_t base = (size_t)b * kSlotStride; for (size_t i = 0; i < kTransferBytes; i++) ASSERT_EQ(pat, res[base + i]) << "ctx " << b << " byte " << i; } } } // VA-based GIN signals -- ncclGin_VASignalInc + waitSignal(window, offset) (2.29.7). // The other tests use the INDEXED signal API (ncclGin_SignalInc + a pool slot // index). This one addresses the signal by its virtual address -- a (window, // offset) pair backed by a user-registered symmetric window -- which drives the // NCCL_GIN_SIGNAL_TYPE_VA descriptor path instead of NCCL_GIN_SIGNAL_TYPE_INDEXED. // On the proxy backend the VA signal is emitted as a separate GFD after the // data put (see gin/proxy/gin_proxy.h), so a successful payload+signal here // proves the VA signal landed. __global__ void putVASignalProducerKernel( ncclWindow_t srcWin, ncclWindow_t dstWin, size_t bytes, ncclWindow_t sigWin, size_t sigOff, int peer, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; if (threadIdx.x == 0 && blockIdx.x == 0) { gin.put(ncclTeamWorld(devComm), peer, dstWin, /*dstOff=*/0, srcWin, /*srcOff=*/0, bytes, ncclGin_VASignalInc{sigWin, sigOff}); // <-- VA signal (window,offset) } gin.flush(ncclCoopCta()); } __global__ void putVASignalConsumerKernel( ncclWindow_t sigWin, size_t sigOff, uint64_t expectedSignalValue, struct ncclDevComm devComm) { ncclGin gin{devComm, /*ginContext=*/0}; gin.waitSignal(ncclCoopCta(), sigWin, sigOff, expectedSignalValue); // <-- VA wait } TEST_F(GinMPIDeviceTests, VASignal_Put) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; // rocSHMEM-GDA (type 4) implements only INDEXED signals; VA signals are // unsupported (Put/PutValue address the signal via indexedSignal.signalId). if (requestedGinType() == 4) GTEST_SKIP() << "VA signals not supported by rocSHMEM-GDA (NCCL_GIN_TYPE=4)"; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_EQ(2, nRanks); constexpr size_t kBytes = 4096; constexpr size_t kSigBytes = 64; // one 8B signal cell lives at offset 0 constexpr size_t kSigOff = 0; constexpr int kPeer = 1; // Separate buffers for payload (src/dst) and the VA signal cell. void* dSrc = nullptr; void* dDst = nullptr; void* dSig = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSrc, kBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dDst, kBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSig, kSigBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSrc) (void)ncclMemFree(dSrc); if (dDst) (void)ncclMemFree(dDst); if (dSig) (void)ncclMemFree(dSig); }); // The VA signal is addressed through its own symmetric window; the payload // uses ordinary symmetric windows. ncclWindow_t srcWin = nullptr, dstWin = nullptr, sigWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSrc, kBytes, &srcWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dDst, kBytes, &dstWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSig, kSigBytes, &sigWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (srcWin) (void)ncclCommWindowDeregister(comm, srcWin); if (dstWin) (void)ncclCommWindowDeregister(comm, dstWin); if (sigWin) (void)ncclCommWindowDeregister(comm, sigWin); }); ncclDevCommRequirements reqs = defaultGinReqs(); reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); auto devCommCleanup = makeScopeGuard([&]() { (void)ncclDevCommDestroy(comm, &devComm); }); // Stage payload; zero the destination and the VA signal cell on every rank // (the signal is incremented from 0 -> 1 by the producer's put). std::vector hs(kBytes, 0), hd(kBytes, 0), hsig(kSigBytes, 0); for (size_t i = 0; i < kBytes; i++) hs[i] = static_cast(0x37 + (i & 0x1F)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSrc, hs.data(), kBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dDst, hd.data(), kBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSig, hsig.data(), kSigBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); if (rank == 0) putVASignalProducerKernel<<<1, 32, 0, stream>>>(srcWin, dstWin, kBytes, sigWin, kSigOff, kPeer, devComm); else putVASignalConsumerKernel<<<1, 32, 0, stream>>>(sigWin, kSigOff, /*expectedSignalValue=*/1, devComm); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); if (rank == 1) { std::vector r(kBytes, 0); ASSERT_EQ(hipSuccess, hipMemcpy(r.data(), dDst, kBytes, hipMemcpyDeviceToHost)); for (size_t i = 0; i < kBytes; i++) ASSERT_EQ(hs[i], r[i]) << "byte " << i; } } // RAIL / no-cross-rail connection type -- ginConnectionType=RAIL (2.29.7). TEST_F(GinMPIDeviceTests, RailConnection_Create) { if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/2)) GTEST_SKIP() << "Requires exactly 2 ranks"; // ginIsRailed reflects the communicator's connection mode (RAIL only when the // system cannot cross NICs). Opt in by disabling cross-NIC: NCCL_CROSS_NIC=0. const char* crossNic = std::getenv("NCCL_CROSS_NIC"); if (!crossNic || std::strcmp(crossNic, "0") != 0) GTEST_SKIP() << "RAIL connection requires NCCL_CROSS_NIC=0 (rail-only mode)"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); // RAIL is only valid when the communicator advertises a railed GIN type. ncclCommProperties_t props = NCCL_COMM_PROPERTIES_INITIALIZER; ASSERT_EQ(ncclSuccess, ncclCommQueryProperties(comm, &props)); if (props.railedGinType == NCCL_GIN_TYPE_NONE) GTEST_SKIP() << "Communicator does not advertise railed GIN (railedGinType=NONE)"; ncclDevCommRequirements reqs = defaultGinReqs(); reqs.ginConnectionType = NCCL_GIN_CONNECTION_RAIL; // <-- feature under test reqs.railGinBarrierCount = 1; reqs.ginSignalCount = 1; ncclDevComm devComm{}; ASSERT_MPI_EQ(ncclSuccess, ncclDevCommCreate(comm, &reqs, &devComm)); EXPECT_TRUE(devComm.ginIsRailed) << "devComm should report railed GIN"; (void)ncclDevCommDestroy(comm, &devComm); } std::string intraNodeSymReason() { MPI_Comm nodeComm; MPI_Comm_split_type(MPI_COMM_WORLD, MPI_COMM_TYPE_SHARED, 0, MPI_INFO_NULL, &nodeComm); int nodeSize = 0; MPI_Comm_size(nodeComm, &nodeSize); MPI_Comm_free(&nodeComm); if (nodeSize < 2) return "Symmetric ReduceScatter requires >=2 ranks per node"; return ""; } // End-to-end ReduceScatter through the public collective API on symmetric // (NCCL_WIN_COLL_SYMMETRIC) buffers. Registering the buffers makes the // symmetric-memory RS kernel (RailA2A_LsaLD) eligible, so this drives the // production kernel path -- not a hand-written reference kernel. TEST_F(GinMPIDeviceTests, ReduceScatter_Symmetric) { if (requestedGinType() == 4) GTEST_SKIP() << "Skipping symmetric ReduceScatter (RailA2A_LsaLD) for rocSHMEM-GDA"; if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (auto reason = crossNodeReason(); !reason.empty()) GTEST_SKIP() << reason; if (auto reason = intraNodeSymReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/8)) GTEST_SKIP() << "Requires 2-8 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_GE(nRanks, 2); ASSERT_LE(nRanks, 8); // 1 (alignment/tail edges), 1024 (medium), 65536 (saturating). const std::vector counts = {1, 1024, size_t{1} << 16}; for (size_t count : counts) { SCOPED_TRACE(::testing::Message() << "count=" << count); // Send buffer holds one block per rank; recv holds this rank's block. const size_t sendElems = count * static_cast(nRanks); const size_t sendBytes = sendElems * sizeof(float); const size_t recvBytes = count * sizeof(float); void* dSend = nullptr; void* dRecv = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSend, sendBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dRecv, recvBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSend) (void)ncclMemFree(dSend); if (dRecv) (void)ncclMemFree(dRecv); }); ncclWindow_t sendWin = nullptr, recvWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSend, sendBytes, &sendWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dRecv, recvBytes, &recvWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (sendWin) (void)ncclCommWindowDeregister(comm, sendWin); if (recvWin) (void)ncclCommWindowDeregister(comm, recvWin); }); // sendbuf[j] = j + rank. After RS-sum, rank r's output element i is // sum over all ranks s of ((r*count + i) + s) -- a closed form we check // exactly (values stay < 2^24, so float arithmetic is exact here). std::vector hostSend(sendElems); for (size_t j = 0; j < sendElems; j++) hostSend[j] = static_cast(j + static_cast(rank)); std::vector hostRecv(count, -1.0f); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSend, hostSend.data(), sendBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dRecv, hostRecv.data(), recvBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); ASSERT_MPI_EQ(ncclSuccess, ncclReduceScatter(dSend, dRecv, count, ncclFloat32, ncclSum, comm, stream)); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // Expected[i] on rank r = nRanks*(r*count + i) + nRanks*(nRanks-1)/2. ASSERT_EQ(hipSuccess, hipMemcpy(hostRecv.data(), dRecv, recvBytes, hipMemcpyDeviceToHost)); const double sumOfRanks = static_cast(nRanks) * (nRanks - 1) / 2.0; for (size_t i = 0; i < count; i++) { const double base = static_cast(static_cast(rank) * count + i); const float expected = static_cast(nRanks * base + sumOfRanks); ASSERT_EQ(expected, hostRecv[i]) << "rank=" << rank << " i=" << i; } } } // Same as ReduceScatter_Symmetric but exercises ncclAvg, which drives the // FuncSumPostDiv post-divide path on the symmetric RS kernel (RailA2A_LsaLD). TEST_F(GinMPIDeviceTests, ReduceScatter_Symmetric_Avg) { if (requestedGinType() == 4) GTEST_SKIP() << "Skipping symmetric ReduceScatter (RailA2A_LsaLD) for rocSHMEM-GDA"; if (auto reason = ginProxyTestSkipReason(); !reason.empty()) GTEST_SKIP() << reason; if (auto reason = crossNodeReason(); !reason.empty()) GTEST_SKIP() << reason; if (auto reason = intraNodeSymReason(); !reason.empty()) GTEST_SKIP() << reason; if (!validateTestPrerequisites(/*min_processes=*/2, /*max_processes=*/8)) GTEST_SKIP() << "Requires 2-8 ranks"; ASSERT_EQ(ncclSuccess, createTestCommunicator()); ncclComm_t comm = getActiveCommunicator(); hipStream_t stream = getActiveStream(); int rank = -1, nRanks = -1; ncclCommUserRank(comm, &rank); ncclCommCount(comm, &nRanks); ASSERT_GE(nRanks, 2); ASSERT_LE(nRanks, 8); const std::vector counts = {1, 1024, size_t{1} << 16}; for (size_t count : counts) { SCOPED_TRACE(::testing::Message() << "count=" << count); const size_t sendElems = count * static_cast(nRanks); const size_t sendBytes = sendElems * sizeof(float); const size_t recvBytes = count * sizeof(float); void* dSend = nullptr; void* dRecv = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dSend, sendBytes)); ASSERT_MPI_EQ(ncclSuccess, ncclMemAlloc(&dRecv, recvBytes)); auto memCleanup = makeScopeGuard([&]() { if (dSend) (void)ncclMemFree(dSend); if (dRecv) (void)ncclMemFree(dRecv); }); ncclWindow_t sendWin = nullptr, recvWin = nullptr; ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dSend, sendBytes, &sendWin, NCCL_WIN_COLL_SYMMETRIC)); ASSERT_MPI_EQ(ncclSuccess, ncclCommWindowRegister(comm, dRecv, recvBytes, &recvWin, NCCL_WIN_COLL_SYMMETRIC)); auto winCleanup = makeScopeGuard([&]() { if (sendWin) (void)ncclCommWindowDeregister(comm, sendWin); if (recvWin) (void)ncclCommWindowDeregister(comm, recvWin); }); std::vector hostSend(sendElems); for (size_t j = 0; j < sendElems; j++) hostSend[j] = static_cast(j + static_cast(rank)); std::vector hostRecv(count, -1.0f); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dSend, hostSend.data(), sendBytes, hipMemcpyHostToDevice)); ASSERT_MPI_EQ(hipSuccess, hipMemcpy(dRecv, hostRecv.data(), recvBytes, hipMemcpyHostToDevice)); MPI_Barrier(MPI_COMM_WORLD); ASSERT_MPI_EQ(ncclSuccess, ncclReduceScatter(dSend, dRecv, count, ncclFloat32, ncclAvg, comm, stream)); ASSERT_MPI_EQ(hipSuccess, hipStreamSynchronize(stream)); MPI_Barrier(MPI_COMM_WORLD); // Avg = sum/nRanks; expected[i] on rank r = (r*count + i) + (nRanks-1)/2. // The kernel multiplies by a float 1/nRanks, so allow a small tolerance. ASSERT_EQ(hipSuccess, hipMemcpy(hostRecv.data(), dRecv, recvBytes, hipMemcpyDeviceToHost)); const double sumOfRanks = static_cast(nRanks) * (nRanks - 1) / 2.0; for (size_t i = 0; i < count; i++) { const double base = static_cast(static_cast(rank) * count + i); const double expected = (nRanks * base + sumOfRanks) / nRanks; const double tol = (expected < 0 ? -expected : expected) * 1e-5 + 1e-3; ASSERT_NEAR(expected, static_cast(hostRecv[i]), tol) << "rank=" << rank << " i=" << i; } } } #endif // MPI_TESTS_ENABLED