Skip to content

Commit e538a06

Browse files
harrismclaude
andauthored
NanoVDB: handle empty partitions in the distributed merge path search (#2248)
* NanoVDB: handle empty partitions in the distributed merge path search mergePath's bounds checks compare against (keys1Count - 1) and (keys2Count - 1) on size_t counts, so a zero count underflows to SIZE_MAX and the guards never fire; the binary search then reads the empty array (and past either array) and produces wrong or garbage intervals. An empty partition is reachable from radixSortAsync's ceil-split whenever a device receives no items, e.g. sorting a single element across two devices. Return the trivial split before the search: with an empty side, every element up to the diagonal comes from the non-empty side. New single-GPU test MergePathEmptyPartition covers the empty-right, empty-left, both-empty, and non-empty control cases; the empty cases fail deterministically without the fix (inverted split / underflow garbage) and run clean under compute-sanitizer with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Mark Harris <mharris@nvidia.com> * NanoVDB: compute the empty-partition merge split on the host An empty side needs no merge path search, so skip the kernel launch: the trivial split (everything up to the diagonal comes from the non-empty side) is computed on the host by mergePathTrivial at the launch site. mergePath reverts to the unguarded search and documents that both inputs must be non-empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Mark Harris <mharris@nvidia.com> --------- Signed-off-by: Mark Harris <mharris@nvidia.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 586dde7 commit e538a06

2 files changed

Lines changed: 67 additions & 1 deletion

File tree

nanovdb/nanovdb/tools/cuda/DistributedPointsToGrid.cuh

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,21 @@ private:
5555
const T* mValue;
5656
};
5757

58+
/// @brief Computes the trivial merge path split for the case where either
59+
/// input is empty: every element up to the diagonal comes from the
60+
/// non-empty side. Host-callable so callers can skip the kernel launch.
61+
inline void mergePathTrivial(size_t keys1Count, size_t keys2Count, ptrdiff_t* key1Intervals, ptrdiff_t* key2Intervals, int intervalIndex)
62+
{
63+
const size_t combinedIndex = intervalIndex * (keys1Count + keys2Count) / 2;
64+
*key1Intervals = static_cast<ptrdiff_t>(combinedIndex < keys1Count ? combinedIndex : keys1Count);
65+
*key2Intervals = static_cast<ptrdiff_t>(combinedIndex < keys2Count ? combinedIndex : keys2Count);
66+
}
67+
5868
/// @brief Implements the merge path binary search algorithm in order to find the median across two sorted input key arrays
69+
/// @warning Both inputs must be non-empty: the binary search reads both
70+
/// arrays, and its unsigned (keysNCount - 1) bounds checks underflow
71+
/// when a count is zero. Callers handle an empty side with
72+
/// mergePathTrivial instead of launching this search.
5973
template<typename KeyIteratorIn>
6074
__device__
6175
void mergePath(KeyIteratorIn keys1, size_t keys1Count, KeyIteratorIn keys2, size_t keys2Count, ptrdiff_t* key1Intervals, ptrdiff_t* key2Intervals, int intervalIndex)
@@ -226,7 +240,14 @@ void radixSortAsync(const nanovdb::cuda::DeviceMesh& deviceMesh, nanovdb::cuda::
226240
cudaCheck(cudaSetDevice(deviceId));
227241

228242
cudaCheck(cudaStreamWaitEvent(deviceMesh[deviceId].stream, postEvents[otherDeviceId]));
229-
kernels::mergePathKernel<<<1, 1, 0, deviceMesh[deviceId].stream>>>(leftDeviceKeysIn, leftDeviceItemCount, rightDeviceKeysIn, rightDeviceItemCount, leftIntervals + deviceId, rightIntervals + deviceId, intervalIndex);
243+
if (leftDeviceItemCount == 0 || rightDeviceItemCount == 0) {
244+
// An empty side makes the split trivial; no kernel launch
245+
// is needed and the search itself requires non-empty inputs.
246+
mergePathTrivial(leftDeviceItemCount, rightDeviceItemCount, leftIntervals + deviceId, rightIntervals + deviceId, intervalIndex);
247+
}
248+
else {
249+
kernels::mergePathKernel<<<1, 1, 0, deviceMesh[deviceId].stream>>>(leftDeviceKeysIn, leftDeviceItemCount, rightDeviceKeysIn, rightDeviceItemCount, leftIntervals + deviceId, rightIntervals + deviceId, intervalIndex);
250+
}
230251
cudaCheck(cudaEventRecord(postEvents[deviceId], deviceMesh[deviceId].stream));
231252
};
232253
mergePathSubfunc(leftDeviceId, rightDeviceId, 0);

nanovdb/nanovdb/unittest/TestMultiGPU.cu

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,51 @@ TEST(TestNanoVDBMultiGPU, RadixSort)
7171
}
7272
}
7373

74+
/// @brief Tests the merge path split for empty partitions. An empty side
75+
/// yields a trivial split (everything up to the diagonal comes from
76+
/// the non-empty side), computed on the host via mergePathTrivial —
77+
/// the device search requires non-empty inputs. Runs on a single GPU.
78+
TEST(TestNanoVDBMultiGPU, MergePathEmptyPartition)
79+
{
80+
using nanovdb::tools::cuda::mergePathTrivial;
81+
constexpr size_t count = 4;
82+
ptrdiff_t k1 = -1, k2 = -1;
83+
84+
// Median diagonal with an empty right partition: all elements from keys1.
85+
mergePathTrivial(count, 0, &k1, &k2, 1);
86+
EXPECT_EQ(k1, ptrdiff_t(count / 2));
87+
EXPECT_EQ(k2, ptrdiff_t(0));
88+
89+
// Median diagonal with an empty left partition: all elements from keys2.
90+
mergePathTrivial(0, count, &k1, &k2, 1);
91+
EXPECT_EQ(k1, ptrdiff_t(0));
92+
EXPECT_EQ(k2, ptrdiff_t(count / 2));
93+
94+
// Both partitions empty.
95+
mergePathTrivial(0, 0, &k1, &k2, 0);
96+
EXPECT_EQ(k1, ptrdiff_t(0));
97+
EXPECT_EQ(k2, ptrdiff_t(0));
98+
99+
// Control: the device search on two non-empty interleaved partitions
100+
// splits both arrays at the median diagonal.
101+
using KeyT = int;
102+
KeyT* keys = nullptr;
103+
cudaCheck(cudaMallocManaged(&keys, 2 * count * sizeof(KeyT)));
104+
for (size_t i = 0; i < count; ++i) keys[i] = 10 * (int(i) + 1); // 10 20 30 40
105+
for (size_t i = 0; i < count; ++i) keys[count + i] = 10 * (int(i) + 1) + 5; // 15 25 35 45
106+
ptrdiff_t* intervals = nullptr;
107+
cudaCheck(cudaMallocManaged(&intervals, 2 * sizeof(ptrdiff_t)));
108+
intervals[0] = intervals[1] = -1;
109+
nanovdb::tools::cuda::kernels::mergePathKernel<<<1, 1>>>(keys, count, keys + count, count, intervals, intervals + 1, size_t(1));
110+
cudaCheck(cudaDeviceSynchronize());
111+
// Merged head is 10 15 20 25, so the median splits both arrays at 2.
112+
EXPECT_EQ(intervals[0], ptrdiff_t(2));
113+
EXPECT_EQ(intervals[1], ptrdiff_t(2));
114+
115+
cudaCheck(cudaFree(intervals));
116+
cudaCheck(cudaFree(keys));
117+
}
118+
74119
/// @brief Tests the correctness of multi-GPU exclusive sums against an equivalent CPU implementation
75120
TEST(TestNanoVDBMultiGPU, ExclusiveSum)
76121
{

0 commit comments

Comments
 (0)