Skip to content

Commit 8fc1545

Browse files
swahtzclaude
andcommitted
NanoVDB: assign each upper-node tile to a single GPU in DistributedPointsToGrid
The device segment boundaries produced by the initial even split were rebalanced by adjusting only adjacent pairs of boundaries. That cannot consolidate an upper-node tile spanning three or more GPUs, since a fully-interior device lies entirely within the tile. Downstream construction assumes each tile - and therefore each lower node, leaf node and voxel - is owned by exactly one device, so multiple devices built the same leaf concurrently and raced on its value mask, silently dropping active voxels. Replace the pairwise GPU rebalance with a global, monotonic snap performed on the host over the (small) set of device boundaries. The keys are globally sorted at that point, so a tile boundary is simply a position where the key changes; fully-interior devices are left empty, which the rest of the pipeline already handles. Add two multi-GPU regression tests: SingleUpperNode, which places all input in one upper-node tile and asserts the exact unique active-voxel count, and MatchesSingleGpu, which cross-checks topology and occupancy against the single-GPU builder on the same input. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
1 parent d422d85 commit 8fc1545

3 files changed

Lines changed: 179 additions & 94 deletions

File tree

nanovdb/nanovdb/tools/cuda/DistributedPointsToGrid.cuh

Lines changed: 50 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,6 @@ private:
4040
uint32_t* mNodeCounts;
4141
};
4242

43-
/// @brief Indicator functor that returns 1 if the input value matches the member value, 0 otherwise
44-
template <typename T, typename std::enable_if<std::is_integral<T>::value>::type* = nullptr>
45-
struct EqualityIndicator
46-
{
47-
EqualityIndicator(const T* value) : mValue(value) {}
48-
49-
__hostdev__
50-
T operator()(const T& x) const
51-
{
52-
return x == (*mValue);
53-
}
54-
private:
55-
const T* mValue;
56-
};
57-
5843
/// @brief Find a partition at an arbitrary diagonal in the conceptual merge of two sorted input arrays.
5944
template<typename KeyIteratorIn>
6045
__device__
@@ -86,34 +71,6 @@ void mergePathKernel(KeyIteratorIn keys1, size_t keys1Count, KeyIteratorIn keys2
8671
mergePath(keys1, keys1Count, keys2, keys2Count, key1Intervals, key2Intervals, combinedIndex);
8772
}
8873

89-
/// @brief Extends or shortens the left end of an array interval
90-
template<typename DistanceIteratorIn, typename CountIteratorOut, typename OffsetIteratorOut>
91-
__global__
92-
void leftRebalanceKernel(DistanceIteratorIn leftDistance, DistanceIteratorIn rightDistance, CountIteratorOut leftCount, OffsetIteratorOut leftOffset)
93-
{
94-
if (*leftDistance < *rightDistance) {
95-
*leftCount -= *leftDistance;
96-
}
97-
else {
98-
*leftCount += *rightDistance;
99-
}
100-
}
101-
102-
/// @brief Extends or shortens the right end of an array interval
103-
template<typename DistanceIteratorIn, typename CountIteratorOut, typename OffsetIteratorOut>
104-
__global__
105-
void rightRebalanceKernel(DistanceIteratorIn leftDistance, DistanceIteratorIn rightDistance, CountIteratorOut rightCount, OffsetIteratorOut rightOffset)
106-
{
107-
if (*leftDistance < *rightDistance) {
108-
*rightCount += *leftDistance;
109-
*rightOffset -= *leftDistance;
110-
}
111-
else {
112-
*rightCount -= *rightDistance;
113-
*rightOffset += *rightDistance;
114-
}
115-
}
116-
11774
} // namespace kernels
11875

11976
// Define utility macro used to call cub functions that use dynamic temporary storage
@@ -580,8 +537,6 @@ void DistributedPointsToGrid<BuildT>::countNodes(const PtrT coords, size_t coord
580537
// to reduce overhead.
581538
std::vector<cudaEvent_t> sortEvents(mDeviceMesh.deviceCount());
582539
std::vector<cudaEvent_t> runLengthEncodeEvents(mDeviceMesh.deviceCount());
583-
std::vector<cudaEvent_t> transformReduceEvents(mDeviceMesh.deviceCount());
584-
std::vector<cudaEvent_t> rebalanceEvents(mDeviceMesh.deviceCount());
585540
std::vector<cudaEvent_t> tilePrefixSumEvents(mDeviceMesh.deviceCount());
586541
std::vector<cudaEvent_t> voxelCountEvents(mDeviceMesh.deviceCount());
587542
std::vector<cudaEvent_t> leafCountEvents(mDeviceMesh.deviceCount());
@@ -592,8 +547,6 @@ void DistributedPointsToGrid<BuildT>::countNodes(const PtrT coords, size_t coord
592547
cudaCheck(cudaSetDevice(deviceId));
593548
cudaEventCreateWithFlags(&sortEvents[deviceId], cudaEventDisableTiming);
594549
cudaEventCreateWithFlags(&runLengthEncodeEvents[deviceId], cudaEventDisableTiming);
595-
cudaEventCreateWithFlags(&transformReduceEvents[deviceId], cudaEventDisableTiming);
596-
cudaEventCreateWithFlags(&rebalanceEvents[deviceId], cudaEventDisableTiming);
597550
cudaEventCreateWithFlags(&tilePrefixSumEvents[deviceId], cudaEventDisableTiming);
598551
cudaEventCreateWithFlags(&voxelCountEvents[deviceId], cudaEventDisableTiming);
599552
cudaEventCreateWithFlags(&leafCountEvents[deviceId], cudaEventDisableTiming);
@@ -604,7 +557,6 @@ void DistributedPointsToGrid<BuildT>::countNodes(const PtrT coords, size_t coord
604557

605558
// Advise per-coord quantities to be split evenly across devices. Clamp each stripe to
606559
// the input range so that inputs smaller than the device count produce valid trailing empty stripes.
607-
std::vector<size_t> deviceStripeCounts(mDeviceMesh.deviceCount());
608560
const size_t deviceStripeSize = ::cuda::ceil_div(coordCount, mDeviceMesh.deviceCount());
609561
for (const auto& [deviceId, stream] : mDeviceMesh) {
610562
cudaCheck(cudaSetDevice(deviceId));
@@ -614,7 +566,6 @@ void DistributedPointsToGrid<BuildT>::countNodes(const PtrT coords, size_t coord
614566

615567
mStripeCounts[deviceId] = deviceStripeCount;
616568
mStripeOffsets[deviceId] = deviceStripeOffset;
617-
deviceStripeCounts[deviceId] = deviceStripeCount;
618569

619570
if (deviceStripeCount) {
620571
nanovdb::Coord* deviceCoords = coords + deviceStripeOffset;
@@ -650,60 +601,68 @@ void DistributedPointsToGrid<BuildT>::countNodes(const PtrT coords, size_t coord
650601

651602
radixSortAsync(mDeviceMesh, mTempDevicePools, mData->d_keys, mKeys, mData->d_indx, mIndices, coordCount, mIntervals, mStripeOffsets, mStripeCounts, sortEvents.data(), sortEvents.data());
652603

653-
// For each segment of sorted keys on each device, we count how many of the leftmost key occur past the left boundary of the segment. The same is done for the rightmost key with the right boundary of the segment.
654-
auto leftIntervals = mIntervals;
655-
auto rightIntervals = mIntervals + mDeviceMesh.deviceCount() + 1;
604+
// Rebalance the device segments so that a device boundary always coincides
605+
// with a change in key value. Because TileKeyFunctor assigns identical keys
606+
// to every point that falls in the same upper-node "tile", this aligns the
607+
// device ownership boundaries with tile boundaries. Downstream construction
608+
// assumes each tile (and therefore each lower node, leaf node, and voxel) is
609+
// owned by exactly one device; if a tile straddled a boundary, multiple
610+
// devices would concurrently build the same leaf and race on its value mask.
611+
//
612+
// A single tile can span three or more devices (e.g. one dense leaf whose
613+
// points are split evenly across the mesh). Adjusting only adjacent pairs of
614+
// boundaries cannot consolidate such a tile because a fully-interior device
615+
// lies entirely within it, so we compute the boundaries globally and
616+
// monotonically. mKeys is globally sorted at this point, so a tile boundary
617+
// is simply a position where mKeys changes. Snapping is performed on the host
618+
// over the (small) set of device boundaries; fully-interior devices are left
619+
// empty, which the rest of the pipeline already handles.
656620
for (const auto& [deviceId, stream] : mDeviceMesh) {
657621
cudaCheck(cudaSetDevice(deviceId));
658-
659-
auto deviceStripeCount = mStripeCounts[deviceId];
660-
auto deviceStripeOffset = mStripeOffsets[deviceId];
661-
uint64_t* deviceInputKeys = mKeys + deviceStripeOffset;
662-
663-
if (deviceStripeCounts[deviceId] && deviceId > 0 && deviceStripeCounts[deviceId - 1]) {
664-
cudaCheck(cudaStreamWaitEvent(stream, sortEvents[deviceId - 1]));
665-
EqualityIndicator<uint64_t> indicator(deviceInputKeys - 1);
666-
CUB_LAUNCH(DeviceReduce::TransformReduce, mTempDevicePools[deviceId], stream, deviceInputKeys, rightIntervals + deviceId, deviceStripeCount, ::cuda::std::plus(), indicator, 0);
667-
} else {
668-
rightIntervals[deviceId] = 0;
669-
}
670-
671-
if (deviceStripeCounts[deviceId] && deviceId < static_cast<int>(mDeviceMesh.deviceCount() - 1) && deviceStripeCounts[deviceId + 1]) {
672-
cudaCheck(cudaStreamWaitEvent(stream, sortEvents[deviceId + 1]));
673-
EqualityIndicator<uint64_t> indicator(deviceInputKeys + deviceStripeCount);
674-
CUB_LAUNCH(DeviceReduce::TransformReduce, mTempDevicePools[deviceId], stream, deviceInputKeys, leftIntervals + deviceId, deviceStripeCount, ::cuda::std::plus(), indicator, 0);
675-
} else {
676-
leftIntervals[deviceId] = 0;
677-
}
678-
cudaCheck(cudaEventRecord(transformReduceEvents[deviceId], stream));
622+
cudaCheck(cudaStreamSynchronize(stream));
679623
}
680624

681-
// Rebalance the segments so that a device segment boundary also corresponds to a change in key value. Effectively, this aligns upper node boundaries with device ownership boundaries.
682-
for (const auto& [deviceId, stream] : mDeviceMesh) {
683-
cudaCheck(cudaSetDevice(deviceId));
684-
685-
if (deviceId > 0 && deviceStripeCounts[deviceId] && deviceStripeCounts[deviceId - 1])
686-
{
687-
cudaCheck(cudaStreamWaitEvent(stream, transformReduceEvents[deviceId - 1]));
688-
kernels::rightRebalanceKernel<<<1, 1, 0, stream>>>(leftIntervals + deviceId - 1, rightIntervals + deviceId, mStripeCounts + deviceId, mStripeOffsets + deviceId);
689-
cudaCheckError();
625+
{
626+
const int deviceCount = static_cast<int>(mDeviceMesh.deviceCount());
627+
const ptrdiff_t keyCount = static_cast<ptrdiff_t>(coordCount);
628+
ptrdiff_t previousBoundary = mStripeOffsets[0]; // device 0 always starts at 0
629+
for (int deviceId = 1; deviceId < deviceCount; ++deviceId) {
630+
ptrdiff_t boundary = mStripeOffsets[deviceId];
631+
if (boundary >= keyCount) {
632+
boundary = keyCount;
633+
} else if (boundary > previousBoundary && mKeys[boundary] == mKeys[boundary - 1]) {
634+
// The even-split boundary falls inside a tile run; find its extent
635+
// and snap to whichever end keeps the boundary closest to the
636+
// even split without crossing the previous boundary.
637+
ptrdiff_t runStart = boundary;
638+
while (runStart > previousBoundary && mKeys[runStart - 1] == mKeys[boundary]) --runStart;
639+
ptrdiff_t runEnd = boundary;
640+
while (runEnd < keyCount && mKeys[runEnd] == mKeys[boundary]) ++runEnd;
641+
if (runStart <= previousBoundary) {
642+
boundary = runEnd; // the run reaches the previous device, give the whole tile away
643+
} else {
644+
boundary = (boundary - runStart <= runEnd - boundary) ? runStart : runEnd;
645+
}
646+
}
647+
if (boundary < previousBoundary) boundary = previousBoundary;
648+
mStripeOffsets[deviceId] = boundary;
649+
previousBoundary = boundary;
690650
}
691651

692-
if (deviceId < static_cast<int>(mDeviceMesh.deviceCount() - 1) && deviceStripeCounts[deviceId] && deviceStripeCounts[deviceId + 1])
693-
{
694-
cudaCheck(cudaStreamWaitEvent(stream, transformReduceEvents[deviceId + 1]));
695-
kernels::leftRebalanceKernel<<<1, 1, 0, stream>>>(leftIntervals + deviceId, rightIntervals + deviceId + 1, mStripeCounts + deviceId, mStripeOffsets + deviceId);
696-
cudaCheckError();
652+
// Recompute the per-device counts from the adjusted, monotonic offsets.
653+
for (int deviceId = 0; deviceId < deviceCount; ++deviceId) {
654+
const ptrdiff_t nextOffset = (deviceId + 1 < deviceCount)
655+
? mStripeOffsets[deviceId + 1]
656+
: keyCount;
657+
mStripeCounts[deviceId] = static_cast<size_t>(nextOffset - mStripeOffsets[deviceId]);
697658
}
698-
cudaCheck(cudaEventRecord(rebalanceEvents[deviceId], stream));
699659
}
700660

701-
// Parallel RLE in order to obtain tiles
661+
// Parallel RLE in order to obtain tiles. The device boundaries were finalized
662+
// synchronously on the host above, so no per-device rebalance event is needed.
702663
for (const auto& [deviceId, stream] : mDeviceMesh) {
703664
cudaCheck(cudaSetDevice(deviceId));
704665

705-
cudaCheck(cudaEventSynchronize(rebalanceEvents[deviceId]));
706-
707666
auto deviceStripeCount = mStripeCounts[deviceId];
708667
auto deviceStripeOffset = mStripeOffsets[deviceId];
709668

@@ -877,8 +836,6 @@ void DistributedPointsToGrid<BuildT>::countNodes(const PtrT coords, size_t coord
877836
cudaCheck(cudaSetDevice(deviceId));
878837
cudaEventDestroy(sortEvents[deviceId]);
879838
cudaEventDestroy(runLengthEncodeEvents[deviceId]);
880-
cudaEventDestroy(transformReduceEvents[deviceId]);
881-
cudaEventDestroy(rebalanceEvents[deviceId]);
882839
cudaEventDestroy(tilePrefixSumEvents[deviceId]);
883840
cudaEventDestroy(voxelCountEvents[deviceId]);
884841
cudaEventDestroy(leafCountEvents[deviceId]);

nanovdb/nanovdb/unittest/TestMultiGPU.cu

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
#include <cuda_runtime_api.h>
1111
#include <gtest/gtest.h>
1212
#include <thread> // for std::thread
13+
#include <algorithm> // for std::sort, std::unique
14+
#include <cstdint>
15+
#include <vector>
1316

1417
#include <thrust/fill.h>
1518
#include <thrust/universal_vector.h>
@@ -328,7 +331,7 @@ TEST(TestNanoVDBMultiGPU, DenseLeaf_DistributedCudaPointsToGrid_UnifiedBuffer)
328331
EXPECT_TRUE(data);
329332
grid = handle.grid<BuildT>();
330333
EXPECT_TRUE(grid);
331-
EXPECT_TRUE(grid->activeVoxelCount() == 512);
334+
EXPECT_EQ(voxelCount, grid->activeVoxelCount());
332335
EXPECT_EQ(nanovdb::Vec3d(1.0), grid->voxelSize());
333336

334337
cudaCheck(cudaFree(voxels));
@@ -500,3 +503,118 @@ TEST(TestNanoVDBMultiGPU, ManyTiles_DistributedCudaPointsToGrid)
500503
cudaCheck(cudaFree(voxels));
501504
cudaSetDevice(current);
502505
}// ManyTiles_DistributedCudaPointsToGrid
506+
507+
/// @brief Regression test for the multi-GPU shared-tile race. All coordinates
508+
/// fall inside a single upper-node tile (each upper node spans 4096
509+
/// voxels per axis, so ijk in [0, 4095] maps to tile (0,0,0)), yet they
510+
/// populate many lower and leaf nodes. With the default even initial
511+
/// split this one tile is shared across every GPU, so the builder must
512+
/// consolidate it onto a single device. A cross-device race in leaf
513+
/// construction would drop a device's contribution and undercount the
514+
/// active voxels, which the exact-count assertion below catches
515+
/// deterministically.
516+
TEST(TestNanoVDBMultiGPU, SingleUpperNode_DistributedCudaPointsToGrid_UnifiedBuffer)
517+
{
518+
int current = 0;
519+
cudaCheck(cudaGetDevice(&current));
520+
521+
using BufferT = nanovdb::cuda::UnifiedBuffer;
522+
using BuildT = nanovdb::ValueOnIndex;
523+
524+
const size_t inputCount = 1 << 18;// 262144
525+
nanovdb::Coord* voxels = nullptr;
526+
cudaCheck(cudaMallocManaged(&voxels, inputCount * sizeof(nanovdb::Coord)));
527+
std::srand(24680);
528+
auto op = [](){ return rand() % 4096; };// stays within a single upper node
529+
for (size_t i = 0; i < inputCount; ++i)
530+
voxels[i] = nanovdb::Coord(op(), op(), op());
531+
532+
// Deterministic expected active-voxel count (the input may contain duplicates).
533+
std::vector<uint64_t> packed(inputCount);
534+
for (size_t i = 0; i < inputCount; ++i)
535+
packed[i] = (uint64_t(voxels[i][0]) << 24) | (uint64_t(voxels[i][1]) << 12) | uint64_t(voxels[i][2]);
536+
std::sort(packed.begin(), packed.end());
537+
const size_t uniqueCount = static_cast<size_t>(std::unique(packed.begin(), packed.end()) - packed.begin());
538+
539+
nanovdb::cuda::DeviceMesh deviceMesh;
540+
nanovdb::tools::cuda::DistributedPointsToGrid<BuildT> converter(deviceMesh);
541+
auto handle = converter.getHandle(voxels, inputCount);
542+
543+
EXPECT_TRUE(handle.deviceData());
544+
EXPECT_TRUE(handle.deviceGrid<BuildT>());
545+
handle.deviceDownload();
546+
auto *grid = handle.grid<BuildT>();
547+
EXPECT_TRUE(grid);
548+
EXPECT_EQ(nanovdb::Vec3d(1.0), grid->voxelSize());
549+
// The input occupies exactly one upper-node tile, so the shared-tile
550+
// consolidation path is exercised.
551+
EXPECT_EQ(1u, grid->tree().nodeCount(2));
552+
// Every unique input voxel must be active exactly once.
553+
EXPECT_EQ(static_cast<uint64_t>(uniqueCount), grid->activeVoxelCount());
554+
555+
nanovdb::util::forEach(0, inputCount, 1, [&](const nanovdb::util::Range1D &r){
556+
auto acc = grid->getAccessor();
557+
for (size_t i=r.begin(); i!=r.end(); ++i) {
558+
const nanovdb::Coord &ijk = voxels[i];
559+
EXPECT_TRUE(acc.probeLeaf(ijk)!=nullptr);
560+
EXPECT_TRUE(acc.isActive(ijk));
561+
EXPECT_TRUE(acc.getValue(ijk) > 0u);
562+
}
563+
});
564+
565+
cudaCheck(cudaFree(voxels));
566+
cudaSetDevice(current);
567+
}// SingleUpperNode_DistributedCudaPointsToGrid_UnifiedBuffer
568+
569+
/// @brief Cross-checks the distributed builder against the trusted single-GPU
570+
/// PointsToGrid on an input that forces a single tile to be split across
571+
/// devices. Index assignment order may differ between the two builders,
572+
/// so we compare topology (node counts, active-voxel count) and voxel
573+
/// occupancy rather than the ValueOnIndex indices themselves.
574+
TEST(TestNanoVDBMultiGPU, MatchesSingleGpu_DistributedCudaPointsToGrid)
575+
{
576+
int current = 0;
577+
cudaCheck(cudaGetDevice(&current));
578+
579+
using BufferT = nanovdb::cuda::UnifiedBuffer;
580+
using BuildT = nanovdb::ValueOnIndex;
581+
582+
const size_t inputCount = 1 << 17;// 131072, all within a single upper node
583+
nanovdb::Coord* voxels = nullptr;
584+
cudaCheck(cudaMallocManaged(&voxels, inputCount * sizeof(nanovdb::Coord)));
585+
std::srand(1357);
586+
auto op = [](){ return rand() % 4096; };
587+
for (size_t i = 0; i < inputCount; ++i)
588+
voxels[i] = nanovdb::Coord(op(), op(), op());
589+
590+
nanovdb::cuda::DeviceMesh deviceMesh;
591+
nanovdb::tools::cuda::DistributedPointsToGrid<BuildT> converter(deviceMesh);
592+
auto distributedHandle = converter.getHandle(voxels, inputCount);
593+
distributedHandle.deviceDownload();
594+
auto *distributedGrid = distributedHandle.grid<BuildT>();
595+
EXPECT_TRUE(distributedGrid);
596+
597+
cudaSetDevice(current);
598+
auto referenceHandle = nanovdb::tools::cuda::voxelsToGrid<BuildT, nanovdb::Coord*, BufferT>(voxels, inputCount);
599+
referenceHandle.deviceDownload();
600+
auto *referenceGrid = referenceHandle.grid<BuildT>();
601+
EXPECT_TRUE(referenceGrid);
602+
603+
EXPECT_EQ(referenceGrid->activeVoxelCount(), distributedGrid->activeVoxelCount());
604+
EXPECT_EQ(referenceGrid->tree().nodeCount(0), distributedGrid->tree().nodeCount(0));
605+
EXPECT_EQ(referenceGrid->tree().nodeCount(1), distributedGrid->tree().nodeCount(1));
606+
EXPECT_EQ(referenceGrid->tree().nodeCount(2), distributedGrid->tree().nodeCount(2));
607+
608+
nanovdb::util::forEach(0, inputCount, 1, [&](const nanovdb::util::Range1D &r){
609+
auto distributedAcc = distributedGrid->getAccessor();
610+
auto referenceAcc = referenceGrid->getAccessor();
611+
for (size_t i=r.begin(); i!=r.end(); ++i) {
612+
const nanovdb::Coord &ijk = voxels[i];
613+
EXPECT_TRUE(referenceAcc.isActive(ijk));
614+
EXPECT_TRUE(distributedAcc.isActive(ijk));
615+
}
616+
});
617+
618+
cudaCheck(cudaFree(voxels));
619+
cudaSetDevice(current);
620+
}// MatchesSingleGpu_DistributedCudaPointsToGrid
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
NanoVDB:
2+
Bug Fixes:
3+
- Fixed a cross-device race in tools::cuda::DistributedPointsToGrid. The
4+
device segment boundaries were rebalanced by adjusting only adjacent pairs
5+
of boundaries, which cannot consolidate an upper-node tile that spans three
6+
or more GPUs, because a fully-interior device lies entirely within the tile.
7+
Multiple devices then built the same leaf concurrently and raced on its
8+
value mask, silently dropping active voxels. The boundaries are now snapped
9+
globally and monotonically to tile boundaries, so every tile - and therefore
10+
every lower node, leaf node and voxel - is owned by exactly one device.

0 commit comments

Comments
 (0)