Skip to content

Commit 2cade61

Browse files
authored
fix(cuda): preserve dictionary value nulls (#8774)
Export logical dictionary validity through Arrow Device arrays. Add a packed-boolean dictionary gather so device-resident validity remains on the GPU. Signed-off-by: Alexander Droste <alexander.droste@protonmail.com>
1 parent cdaa5a5 commit 2cade61

3 files changed

Lines changed: 219 additions & 8 deletions

File tree

vortex-cuda/kernels/src/dict.cu

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,51 @@ __device__ void dict_kernel(const IndexT *const __restrict codes,
1919
(block_start + elements_per_block < codes_len) ? (block_start + elements_per_block) : codes_len;
2020

2121
for (uint64_t idx = block_start + threadIdx.x; idx < block_end; idx += blockDim.x) {
22-
IndexT code = codes[idx];
22+
const IndexT code = codes[idx];
2323
output[idx] = values[code];
2424
}
2525
}
2626

27-
// Macro to generate dict kernels for all value/index type combinations
27+
// Nullable dictionary values require gathering validity through the row codes:
28+
//
29+
// row_validity[i] = values_validity[codes[i]]
30+
//
31+
// Vortex represents this gather lazily as `Dict<bool>`; null codes are carried by the resulting
32+
// boolean array's own validity. Consequently, this kernel may materialize validity for a
33+
// dictionary whose logical values are strings or another non-boolean type.
34+
//
35+
// Vortex booleans are bit-packed, so fixed-width `output[i] = values[codes[i]]` semantics do not
36+
// apply. Assigning each complete output byte to one thread avoids races between individual bit
37+
// writes.
38+
template <typename IndexT>
39+
__device__ void dict_bool_kernel(const IndexT *const __restrict codes,
40+
uint64_t codes_len,
41+
const uint8_t *const __restrict values,
42+
uint64_t values_bit_offset,
43+
uint8_t *const __restrict output) {
44+
const uint64_t output_len = (codes_len + 7) / 8;
45+
const uint32_t elements_per_block = blockDim.x * ELEMENTS_PER_THREAD;
46+
const uint64_t block_start = static_cast<uint64_t>(blockIdx.x) * elements_per_block;
47+
const uint64_t block_end =
48+
(block_start + elements_per_block < output_len) ? (block_start + elements_per_block) : output_len;
49+
50+
for (uint64_t output_idx = block_start + threadIdx.x; output_idx < block_end; output_idx += blockDim.x) {
51+
const uint64_t row_start = output_idx * 8;
52+
uint8_t packed = 0;
53+
#pragma unroll
54+
for (uint32_t bit = 0; bit < 8; ++bit) {
55+
const uint64_t row = row_start + bit;
56+
if (row < codes_len) {
57+
const uint64_t value_idx = values_bit_offset + static_cast<uint64_t>(codes[row]);
58+
const uint8_t value = (values[value_idx / 8] >> (value_idx % 8)) & 1;
59+
packed |= static_cast<uint8_t>(value << bit);
60+
}
61+
}
62+
output[output_idx] = packed;
63+
}
64+
}
65+
66+
// Macro to generate dict kernels for all fixed-width value/index type combinations.
2867
#define GENERATE_DICT_KERNEL(value_suffix, ValueType, index_suffix, IndexType) \
2968
extern "C" __global__ void dict_##value_suffix##_##index_suffix( \
3069
const IndexType *const __restrict codes, \
@@ -41,5 +80,21 @@ __device__ void dict_kernel(const IndexT *const __restrict codes,
4180
GENERATE_DICT_KERNEL(value_suffix, ValueType, u32, uint32_t) \
4281
GENERATE_DICT_KERNEL(value_suffix, ValueType, u64, uint64_t)
4382

44-
// Generate for all native ptypes & decimal values
83+
#define GENERATE_DICT_BOOL_KERNEL(index_suffix, IndexType) \
84+
extern "C" __global__ void dict_bool_##index_suffix(const IndexType *const __restrict codes, \
85+
uint64_t codes_len, \
86+
const uint8_t *const __restrict values, \
87+
uint64_t values_bit_offset, \
88+
uint8_t *const __restrict output) { \
89+
dict_bool_kernel<IndexType>(codes, codes_len, values, values_bit_offset, output); \
90+
}
91+
92+
// Generate fixed-width kernels for all native ptypes and decimal values.
4593
FOR_EACH_NUMERIC(GENERATE_DICT_FOR_ALL_INDICES)
94+
95+
// Boolean values use a different physical layout and launch unit, but still dispatch over the
96+
// same unsigned dictionary-code types.
97+
GENERATE_DICT_BOOL_KERNEL(u8, uint8_t)
98+
GENERATE_DICT_BOOL_KERNEL(u16, uint16_t)
99+
GENERATE_DICT_BOOL_KERNEL(u32, uint32_t)
100+
GENERATE_DICT_BOOL_KERNEL(u64, uint64_t)

vortex-cuda/src/arrow/canonical.rs

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -371,12 +371,14 @@ async fn export_dict(
371371
ctx: &mut CudaExecutionCtx,
372372
) -> VortexResult<(ArrowArray, SyncEvent)> {
373373
let len = array.len();
374+
let validity = array.validity()?;
374375
let parts = array.into_parts();
375-
let PrimitiveDataParts {
376-
buffer, validity, ..
377-
} = export_dictionary_codes(parts.codes, ctx).await?;
376+
let PrimitiveDataParts { buffer, .. } = export_dictionary_codes(parts.codes, ctx).await?;
378377
let (validity_buffer, null_count) = export_arrow_validity_buffer(validity, len, 0, ctx).await?;
379378
let codes_buffer = ctx.ensure_on_device(buffer).await?;
379+
// Arrow permits null dictionary values, so preserve the child's validity bitmap. The outer
380+
// bitmap independently marks each row whose code selects a null dictionary value, ensuring
381+
// consumers that require non-null dictionary keys do not lose the logical nulls.
380382
let (dictionary, _) = export_array(parts.values, ctx).await?;
381383

382384
let mut private_data = PrivateData::new_with_dictionary(
@@ -1837,6 +1839,35 @@ mod tests {
18371839
Ok(())
18381840
}
18391841

1842+
#[crate::test]
1843+
async fn test_export_dictionary_propagates_value_nulls_to_codes() -> VortexResult<()> {
1844+
let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session())
1845+
.vortex_expect("failed to create execution context");
1846+
1847+
let array = DictArray::try_new(
1848+
PrimitiveArray::from_iter([0u8, 1, 2, 1]).into_array(),
1849+
VarBinViewArray::from_iter_nullable_str([
1850+
Some("alpha"),
1851+
None,
1852+
Some("a dictionary value stored out-of-line"),
1853+
])
1854+
.into_array(),
1855+
)?
1856+
.into_array();
1857+
let mut exported = array.export_device_array_with_schema(&mut ctx).await?;
1858+
1859+
assert_eq!(exported.array.array.null_count, 2);
1860+
assert_eq!(
1861+
private_data_buffer_bytes(&exported.array.array, 0)?.as_ref(),
1862+
&[0b0000_0101]
1863+
);
1864+
let dictionary = unsafe { &*exported.array.array.dictionary };
1865+
assert_eq!(dictionary.null_count, 1);
1866+
1867+
unsafe { release_exported_array(&raw mut exported.array.array) };
1868+
Ok(())
1869+
}
1870+
18401871
#[crate::test]
18411872
async fn test_export_struct_preserves_dictionary_child() -> VortexResult<()> {
18421873
let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session())
@@ -1900,7 +1931,11 @@ mod tests {
19001931
true,
19011932
)
19021933
);
1903-
assert_eq!(exported.array.array.null_count, 0);
1934+
assert_eq!(exported.array.array.null_count, 1);
1935+
assert_eq!(
1936+
private_data_buffer_bytes(&exported.array.array, 0)?.as_ref(),
1937+
&[0b0000_0101]
1938+
);
19041939
assert_eq!(
19051940
private_data_buffer_i16_values(&exported.array.array, 1)?,
19061941
[0, 1, 0]
@@ -2691,7 +2726,11 @@ mod tests {
26912726

26922727
let children = unsafe { std::slice::from_raw_parts(exported.array.array.children, 1) };
26932728
let elements = unsafe { &*children[0] };
2694-
assert_eq!(elements.null_count, 0);
2729+
assert_eq!(elements.null_count, 2);
2730+
assert_eq!(
2731+
private_data_buffer_bytes(elements, 0)?.as_ref(),
2732+
&[0b0001_0101]
2733+
);
26952734
assert_eq!(
26962735
private_data_buffer_i16_values(elements, 1)?,
26972736
[0, 1, 0, 1, 2]

vortex-cuda/src/kernel/arrays/dict.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ use tracing::instrument;
1010
use vortex::array::ArrayRef;
1111
use vortex::array::Canonical;
1212
use vortex::array::IntoArray;
13+
use vortex::array::arrays::BoolArray;
1314
use vortex::array::arrays::DecimalArray;
1415
use vortex::array::arrays::Dict;
1516
use vortex::array::arrays::DictArray;
1617
use vortex::array::arrays::PrimitiveArray;
1718
use vortex::array::arrays::VarBinViewArray;
19+
use vortex::array::arrays::bool::BoolDataParts;
1820
use vortex::array::arrays::decimal::DecimalDataParts;
1921
use vortex::array::arrays::dict::DictArraySlotsExt;
2022
use vortex::array::arrays::primitive::PrimitiveDataParts;
@@ -29,6 +31,7 @@ use vortex::dtype::NativePType;
2931
use vortex::error::VortexExpect;
3032
use vortex::error::VortexResult;
3133
use vortex::error::vortex_bail;
34+
use vortex::error::vortex_ensure;
3235

3336
use crate::CudaBufferExt;
3437
use crate::CudaDeviceBuffer;
@@ -55,6 +58,8 @@ impl CudaExecute for DictExecutor {
5558

5659
let values_dtype = dict_array.values().dtype().clone();
5760
match &values_dtype {
61+
// Nullable dictionary values expose their logical validity as a lazy `Dict<bool>`.
62+
DType::Bool(..) => execute_dict_bool(dict_array, ctx).await,
5863
DType::Decimal(..) => execute_dict_decimal(dict_array, ctx).await,
5964
DType::Primitive(..) => execute_dict_prim(dict_array, ctx).await,
6065
DType::Utf8(..) | DType::Binary(..) => execute_dict_varbinview(dict_array, ctx).await,
@@ -86,6 +91,80 @@ async fn execute_dict_prim(dict: DictArray, ctx: &mut CudaExecutionCtx) -> Vorte
8691
})
8792
}
8893

94+
/// Gather bit-packed boolean values through dictionary codes.
95+
///
96+
/// This path is especially important for dictionary validity. When dictionary values are
97+
/// nullable, `Dict::validity()` represents `take(values_validity, codes)` lazily as a `Dict<bool>`.
98+
/// Materializing that validity on the GPU therefore requires a boolean dictionary gather even
99+
/// when the user-visible array contains strings or another non-boolean type.
100+
async fn execute_dict_bool(dict: DictArray, ctx: &mut CudaExecutionCtx) -> VortexResult<Canonical> {
101+
let values = dict.values().clone().execute_cuda(ctx).await?.into_bool();
102+
let codes = dict
103+
.codes()
104+
.clone()
105+
.execute_cuda(ctx)
106+
.await?
107+
.into_primitive();
108+
let codes_ptype = codes.ptype();
109+
110+
match_each_integer_ptype!(codes_ptype, |I| {
111+
execute_dict_bool_typed::<I>(values, codes, ctx).await
112+
})
113+
}
114+
115+
async fn execute_dict_bool_typed<I: DeviceRepr + NativePType>(
116+
values: BoolArray,
117+
codes: PrimitiveArray,
118+
ctx: &mut CudaExecutionCtx,
119+
) -> VortexResult<Canonical> {
120+
vortex_ensure!(!codes.is_empty(), "cannot CUDA-decode an empty dictionary");
121+
let codes_len = codes.len();
122+
123+
let values_len = values.len();
124+
let values_validity = values.validity()?;
125+
let BoolDataParts {
126+
bits: values_buffer,
127+
meta: values_meta,
128+
} = values.into_data().into_parts(values_len);
129+
let output_validity = values_validity.take(&codes.clone().into_array())?;
130+
let PrimitiveDataParts {
131+
buffer: codes_buffer,
132+
..
133+
} = codes.into_data_parts();
134+
135+
let values_device = ctx.ensure_on_device(values_buffer).await?;
136+
let codes_device = ctx.ensure_on_device(codes_buffer).await?;
137+
138+
// Each CUDA thread owns complete output bytes, avoiding races between threads writing
139+
// different bits in the same byte. The kernel handles the final partial byte explicitly.
140+
let output_bytes = codes_len.div_ceil(8);
141+
let output_slice = ctx.device_alloc::<u8>(output_bytes)?;
142+
let output_device = CudaDeviceBuffer::new(output_slice);
143+
144+
let values_view = values_device.cuda_view::<u8>()?;
145+
let codes_view = codes_device.cuda_view::<I>()?;
146+
let output_view = output_device.as_view::<u8>();
147+
let codes_len_u64 = codes_len as u64;
148+
let values_offset_u64 = values_meta.offset() as u64;
149+
150+
let codes_ptype = I::PTYPE.to_string();
151+
let kernel_function = ctx.load_function_with_suffixes("dict", &["bool", &codes_ptype])?;
152+
ctx.launch_kernel(&kernel_function, output_bytes, |args| {
153+
args.arg(&codes_view)
154+
.arg(&codes_len_u64)
155+
.arg(&values_view)
156+
.arg(&values_offset_u64)
157+
.arg(&output_view);
158+
})?;
159+
160+
Ok(Canonical::Bool(BoolArray::new_handle(
161+
BufferHandle::new_device(Arc::new(output_device)),
162+
0,
163+
codes_len,
164+
output_validity,
165+
)))
166+
}
167+
89168
async fn execute_dict_prim_typed<V: DeviceRepr + NativePType, I: DeviceRepr + NativePType>(
90169
values: PrimitiveArray,
91170
codes: PrimitiveArray,
@@ -298,6 +377,7 @@ async fn execute_dict_varbinview(
298377
#[cfg(test)]
299378
mod tests {
300379
use vortex::array::IntoArray;
380+
use vortex::array::arrays::BoolArray;
301381
use vortex::array::arrays::DecimalArray;
302382
use vortex::array::arrays::DictArray;
303383
use vortex::array::arrays::PrimitiveArray;
@@ -323,6 +403,43 @@ mod tests {
323403
))
324404
}
325405

406+
#[crate::test]
407+
async fn test_cuda_dict_bool_gathers_packed_validity_bits() -> VortexResult<()> {
408+
let mut ctx = vortex_array::array_session().create_execution_ctx();
409+
let mut cuda_ctx = CudaSession::create_execution_ctx(&crate::cuda_session())
410+
.vortex_expect("failed to create execution context");
411+
412+
// Slicing leaves the dictionary values at a non-zero bit offset. Thirteen codes also
413+
// exercise a final partial output byte.
414+
let values = BoolArray::from_iter([
415+
false, true, false, true, false, true, true, false, true, false,
416+
])
417+
.into_array()
418+
.slice(3..8)?;
419+
let codes = PrimitiveArray::new(
420+
Buffer::from(vec![0u8, 1, 2, 3, 4, 3, 2, 1, 0, 4, 1, 3, 0]),
421+
NonNullable,
422+
);
423+
let expected = DictArray::try_new(codes.clone().into_array(), values.clone())?.into_array();
424+
425+
let codes_handle = cuda_ctx
426+
.ensure_on_device(codes.buffer_handle().clone())
427+
.await?;
428+
let device_codes =
429+
PrimitiveArray::from_buffer_handle(codes_handle, codes.ptype(), codes.validity()?);
430+
let dict = DictArray::try_new(device_codes.into_array(), values)?.into_array();
431+
432+
let actual = DictExecutor
433+
.execute(dict, &mut cuda_ctx)
434+
.await?
435+
.into_host()
436+
.await?
437+
.into_bool();
438+
439+
assert_arrays_eq!(actual.into_array(), expected, &mut ctx);
440+
Ok(())
441+
}
442+
326443
#[crate::test]
327444
async fn test_cuda_dict_u32_values_u8_codes() -> VortexResult<()> {
328445
let mut ctx = vortex_array::array_session().create_execution_ctx();

0 commit comments

Comments
 (0)