GPU Programming with Vulkan Columnar
This guide introduces GPU programming through the lens of Vulkan Columnar, a GPU columnar compute library built on Vulkan 1.3 compute shaders. After reading it, you will understand every design decision, shader, and dispatch in the codebase well enough to contribute.
1. Why a GPU for Columnar Data?
GPUs execute thousands of threads in lockstep, trading single-thread latency for aggregate throughput. A modern GPU can have 3,000–16,000 arithmetic units running concurrently. Each thread is simple – no branch prediction, no out-of-order execution – but collectively they deliver 10–60 TFLOPS and 1–2 TB/s of memory bandwidth. By contrast, a high-end server CPU peaks at 2–3 TFLOPS and 100–200 GB/s.
Columnar data is the ideal GPU workload:
Regular element size. Every element in a
U32column is exactly 4 bytes. Threads read contiguous, aligned memory; no pointer-chasing or variable-length encoding.No inter-element dependencies. Filtering row i does not depend on row i+1. Arithmetic on element i is independent of element j. Perfectly data-parallel.
Uniform control flow. Every thread applies the same operation. No per-element
ifdivergence (except at null boundaries, handled by masking).
The trade-off is the PCI Express bus. Data must travel from host RAM to GPU VRAM and back. PCIe Gen 4 x16 provides ~25 GB/s in each direction – slow compared to the GPU’s 1 TB/s internal bandwidth. A single GPU operation shipping data over PCIe may break even or lose to a CPU. The win comes from chained operations: filter, then cast, then sort, then reduce – all while the data stays in VRAM between dispatches. This library targets that exact pattern.
2. The Vulkan Compute Pipeline (What You Actually Need)
Vulkan is a large API. Compute-only usage strips it down to six concepts:
- Instance and Device
A
VkInstancebinds the library to the Vulkan loader. AVkPhysicalDevicerepresents one GPU. AVkDeviceis the logical handle for submitting work.vc_create_contexthandles enumeration, queue-family selection, and optional feature detection.- Shader Module
GLSL source is compiled offline by
glslcinto SPIR-V, a binary intermediate representation. The SPIR-V byte array is embedded in the C++ binary viaxxd -iand passed tovkCreateShaderModule. This eliminates runtime compilation and file I/O.- Pipeline
A
VkPipelinebinds a shader module to a pipeline layout. For compute, this is aVkComputePipelineCreateInfowith one stage. Pipelines are created once and cached. Seevc_device.hvc_create_compute_pipeline. The layout declares which resources (descriptor sets, push constants) the shader expects.- Descriptor Sets
These bind GPU buffers to shader binding points. Each
VkWriteDescriptorSetpoints alayout(binding=N)in the shader to aVkBuffer. The library uses up to six SSBO bindings per operation (input data, input validity, second input, output, output validity, etc.).- Push Constants
Small (<=128 bytes) values written directly into the command buffer, readable by the shader as
layout(push_constant) uniform. Used for element counts, operation codes, element sizes, and flags. No descriptor allocation required.- Command Buffers
Recorded once, submitted to a
VkQueue, and waited on. Pattern: reset, begin, bind pipeline, bind descriptors, push constants, dispatch, barrier, end, submit, wait.- Vulkan Memory Allocator (VMA)
The library uses VMA instead of raw
vkAllocateMemory. CallvmaCreateBufferwithVMA_MEMORY_USAGE_AUTOand let VMA choose the best heap (device-local, host-visible, etc.).
3. The GPU Memory Model
GPU memory is a hierarchy, not a flat pool:
- Global Memory (VRAM / HBM)
The large pool (4–48 GB) accessible by all threads. Bandwidth is high (~1 TB/s on modern GPUs) but latency is hundreds of cycles. Every SSBO binding in this library maps to global memory.
- Shared Memory
Per-workgroup scratchpad, typically 32–100 KB. Accessed at register speed. Threads within a workgroup use it to communicate via
barrier(). Used in filter (prefix sum), reduce (tree reduction), elementwise (sub-32-bit widening), and join (hash table).- Registers
Per-thread storage, the fastest memory. GLSL local variables (
uint a_lo) map to registers when the compiler can fit them.- Host-Visible Memory
A window of VRAM mapped into the CPU’s address space. Used for read-back:
vmaInvalidateAllocationmakes GPU writes visible to the CPU. TheHOST_ACCESS_RANDOM_BITflag tells VMA to keep the buffer in a host-accessible heap.
Critical optimization: memory coalescing. GPUs fetch 32–128 bytes per memory transaction. If 32 threads in a warp request 32 consecutive 4-byte words, the GPU satisfies this with one or two transactions (coalesced). If they request scattered addresses, 32 separate transactions are needed (up to 32x slowdown). All column buffers are stored as dense arrays to guarantee coalesced access.
4. Shader Design Patterns
All shaders in this library follow five rules:
One workgroup = 256 threads.
layout(local_size_x = 256) in;. Matches the typical warp/wavefront size (32/64) evenly.Push constants for parameters. Never use specialization constants for runtime values. Push constants change per dispatch without recompiling pipelines.
SSBOs over UBOs. Storage buffers support arbitrary sizes and are the natural fit for column data. All bindings are
std430.Barriers are cooperative. Every thread in a workgroup must reach a
barrier()call. If some threads take an earlyreturnwhile others wait at the barrier, the GPU hangs. This is the most common shader bug.Validation layers in debug.
VK_LAYER_KHRONOS_validationcatches out-of-bounds access, mismatched pipeline layouts, and missing barriers. The debug build enables it automatically.
Example: the element-wise shader (elementwise.comp)
This is the library’s most complex shader and demonstrates several patterns:
layout(local_size_x = 256) in;
layout(std430, binding = 0) readonly buffer InBuf { uint in_data[]; };
layout(push_constant) uniform Params { uint element_count; uint op; ... };
void main() {
uint gid = gl_GlobalInvocationID.x;
if (gid >= params.element_count) return;
// 64-bit arithmetic: load as (lo, hi) pairs
uint a_lo, a_hi, b_lo, b_hi;
load_elem(gid, false, a_lo, a_hi);
load_elem(gid, true, b_lo, b_hi);
// ADD with carry chain
uint s = a_lo + b_lo;
uint r_lo = s;
uint r_hi = a_hi + b_hi + ((s < a_lo) ? 1u : 0u);
store_elem(gid, r_lo, r_hi);
}
Key design decisions in this shader:
Sub-32-bit widening via shared memory. U8, U16, I8, and I16 columns are stored as packed arrays (4 or 2 elements per u32 word). The shader reads packed data in phase 1 (“widen”), expanding each byte or half-word into a full u32 in shared memory. All 256 workgroup threads participate in the barrier. Phase 2 operates on widened values. Phase 3 packs results back (only for sub-32-bit output, e.g. U8+U8->U8). For U32 or U64 output from sub-32-bit input (e.g. U8 comparison produces U32 mask), phase 3 is skipped entirely.
Signed widening.
I8 and I16 values are sign-extended during widening: if the high bit is set, the
upper 24 or 16 bits are filled with 1s. This is controlled by an is_signed
push constant. Comparisons use int() for signed comparison; arithmetic
operates on widened unsigned values (modular arithmetic is correct).
Single-workgroup limit for sub-32-bit.
The shared memory array is shared uint smem_lhs[256] – exactly one
workgroup of elements. The C++ side rejects sub-32-bit operations with more
than 256 elements. This is documented in api-reference.rst and will be
lifted in v2 with workgroup-offset support.
5. Parallel Algorithms in the Library
Filter: Stream Compaction via Blelloch Scan
vc_filter is implemented as a three-pass Blelloch parallel prefix sum.
Pass 1 (count valid): Each thread checks one mask element, writes 1 or 0 to a scratch buffer. Workgroup-level reduction via subgroup operations (
subgroupAdd) whenVK_KHR_shader_subgroup_arithmeticis available, falling back to shared-memory tree reduction.Pass 2 (exclusive scan): Builds a prefix sum of the counts. The scan gives each valid element its destination index in the compacted output.
Pass 3 (scatter): Each thread with a valid mask element copies its data element to the output at the prefix-sum address.
The algorithm is work-efficient (O(n) total operations) and performs ~29x faster
than single-threaded CPU at 1M elements – see performance-optimizations.rst.
Sort: LSD Radix Sort
vc_sort implements a least-significant-digit radix sort in four passes per
32-bit element (eight for 64-bit). Each pass:
Histogram: Each thread counts occurrences of each radix digit (0-255) in shared memory, then atomically adds to a global histogram.
Prefix-sum histogram: The CPU computes the exclusive prefix sum of the histogram. (A future optimization will move this to the GPU.)
Reorder: Each thread reads its element, extracts the digit, and writes to
output[histogram[digit] + local_offset].
Floating-point columns are bit-flipped (sign-bit complement) so that the unsigned radix sort produces correct signed/float ordering.
Reduce: Tree Reduction
vc_reduce performs sum, min, max, mean, count, first, and last operations
via a workgroup-local tree reduction:
Each thread loops over stride-sized chunks of data (to handle inputs larger than one workgroup).
A shared-memory binary tree reduction collapses 256 values to 1 per workgroup.
Multiple workgroups write their partial results to an intermediate buffer.
The CPU merges partial results in O(num_workgroups) time.
Group-By: Sort + Segment Reduce
vc_groupby sorts the key-value pair by key (GPU radix sort), then uses a
GPU shader to find segment boundaries and reduce each segment. The multi-WG
merge runs on the CPU as a simpler first implementation.
6. Cross-Vendor Architecture
The library runs on any GPU with a Vulkan 1.3 driver - AMD, NVIDIA, Intel, Apple Silicon (MoltenVK), and software renderers (lavapipe). Four mechanisms ensure this:
- Feature Detection
At
vc_create_context, the library queries optional features:VK_KHR_push_descriptor– enablesvkCmdPushDescriptorSet, eliminating per-dispatch descriptor pool allocation.VK_KHR_shader_subgroup_arithmetic– providessubgroupAddand friends for fast workgroup-wide reductions.Subgroup size query (
VkPhysicalDeviceSubgroupProperties).
Results are stored in
VcContext::has_push_descriptorsandhas_subgroup_arithmetic. Operations check these flags and take the accelerated path when available, falling back to portable implementations.- No Vendor ID Hardcodes
VcContextdoes not store a vendor ID. Shaders use portable GLSL with no vendor-specific extensions. When an AMD-specific optimization is applied (e.g. subgroup-based filter pass 1), it is guarded by a feature check, notif (vendor == AMD).- Single-Workgroup Dispatch
All operations dispatch one-dimensional workgroups (
vkCmdDispatch(wgs, 1, 1)). No multi-dimensional grids, no workgroup ID-dependent logic that could expose scheduling differences.- lavapipe as Minimum Bar
Every change is tested against lavapipe (Vulkan software renderer). If it passes there, it will pass on any conformant Vulkan driver.
7. Library Architecture
A[Polars / DataFusion / Spark] –> B[C ABI] B –> C[vc_arith / vc_filter / vc_sort / …] C –> D[dispatch_elementwise / vc_submit_and_wait] D –> E[Vulkan Queue] E –> F[GPU] D –> G[VMA] C –> H[SPIR-V shaders] H -. embedded via xxd -i .-> C
The architecture has three layers:
C ABI (``include/vulkan_columnar/vc_context.h``).
Every function takes a VcContext* as its first argument and returns
opaque handles (VcColumn*, VcGroupByResult*). No C++ types, no
exceptions, no STL in the public surface. This makes the library bindable
from Rust, Python (cffi), Go (cgo), and Zig.
Operation Layer (``src/ops/<name>/vc_<name>.cpp``). Each operation is a single translation unit with:
A
PipelineCachestruct holdingVkShaderModule,VkPipeline, andVkDescriptorSetLayoutobjects. Created once, reused across calls.A dispatch function that allocates scratch buffers, binds descriptors, sets push constants, dispatches workgroups, inserts barriers, and reads results.
Public functions that validate inputs and call the dispatch function.
Core Layer (``src/core/vc_context.cpp``).
vc_create_context, vc_create_column, vc_read_column, and
vc_export_arrow. Column buffers are VMA-allocated with
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | TRANSFER_SRC | TRANSFER_DST.
Host data is uploaded via staging buffer (vkCmdCopyBuffer).
Shared Helpers (``include/vulkan_columnar/vc_device.h``).
Static inline functions: vc_create_shader_module, vc_create_compute_pipeline,
vc_submit_and_wait, vc_begin_command_buffer, vc_end_command_buffer,
vc_allocate_descriptor_set, vc_bind_descriptor_binding. Every operation
links against this header; no duplicated Vulkan boilerplate.
Shader Embedding (``src/ops/<name>/CMakeLists.txt``). The build pipeline for every shader is:
glslc -O -o <name>.spv <name>.comp
xxd -i <name>.spv | sed ... -> <name>_spv.h
The sed step renames xxd symbols to stable names (<name>_spv,
<name>_spv_len) instead of path-derived names. The C++ code includes the
generated header and passes the byte array to vc_create_shader_module.
8. Optimization Principles
- Memory Bandwidth is the Bottleneck
Almost every operation in this library is memory-bound, not compute-bound. A U32 addition has one arithmetic op per 12 bytes of I/O (read A, read B, write result). At 1 TB/s bandwidth, that is ~80 billion operations/second – far below the GPU’s compute capacity. Optimizations focus on:
Reducing transfers: chaining operations in VRAM.
Coalescing: contiguous array access.
Avoiding atomics: shared-memory reduction followed by a single global write, instead of per-element global atomics.
- Descriptor Overhead Matters
vkAllocateDescriptorSets+vkUpdateDescriptorSetscosts ~0.01 ms per dispatch. With hundreds of dispatches per operation (sort uses 4–8 radix passes), this adds up.VK_KHR_push_descriptorreplaces pool allocation with a command-buffer call, saving 10–30%.- Pipeline Reuse
vc_filter_pipeline_cache_createcreates shader modules and pipelines once. Subsequentvc_filtercalls bind the cached pipelines. The cache is stored inVcContextand destroyed at context teardown.- Prefetching
Where possible, shaders load data into shared memory early (phase 1 of elementwise), then compute in phase 2. This overlaps global memory latency with computation across threads.
9. Testing and Benchmarking
Every operation has three artifacts:
Artifact |
Location |
Validates |
|---|---|---|
Shader |
|
GLSL correctness, SPIR-V validation |
Unit test |
|
GPU output matches CPU reference for 10+ cases |
Benchmark |
|
Wall time, kernel time, bandwidth, speedup vs CPU |
Tests always compare GPU output to CPU reference. They cover valid inputs,
null handling, edge cases (zero-length, single-element, dtype mismatch), and
error returns. See tests/ops/test_elementwise.cpp for 32 tests spanning
all 10 dtypes, arithmetic, comparison, null ops, and boolean ops.
Benchmarks report four numbers:
Wall time:
clock_gettimearound the Vulkan submit+wait (includes PCIe transfer for single operations).Kernel time: GPU timestamp queries (
vkGetQueryPoolResults).Bandwidth:
bytes_read + bytes_written / kernel_time.CPU reference: single-threaded
std::equivalent.
The 2x speedup requirement applies to chained operations only (data stays in VRAM between dispatches). Single-operation speedup is allowed to be modest due to PCIe tax.
Vulkan validation layers are enabled in debug builds. A validation error is a broken build. The layer prints to stderr; tests fail if any error appears.
ASan + UBSan must be clean: -fsanitize=address,undefined in debug.
clang-tidy enforces modernize-*, bugprone-*, performance-*,
readability-* rules. Clang-format enforces Chromium style at 100 columns.
10. Contributing a New Operation
Use src/ops/filter/ as the canonical template. Steps:
Create
src/ops/<name>/CMakeLists.txtandvc_<name>.cpp.Write a GLSL compute shader in
shaders/<name>.comp.Add the shader embedding pipeline to
CMakeLists.txt.Implement the public API function (e.g.
vc_<name>) with input validation.Implement the dispatch function: pipeline creation (reuse across calls), scratch buffer allocation, descriptor binding, push constants, dispatch, barrier, read-back.
Write tests in
tests/ops/test_<name>.cpp. Test at least: empty input, single element, maximum single-WG size, multi-WG, nulls (if applicable), dtype mismatch, and error returns.Write benchmarks in
benchmarks/ops/bench_<name>.cpp.Update
docs/api-reference.rstwith: the API function signature, Status line, Vulkan primitives used, algorithm description, and edge case behavior.Update
docs/performance-optimizations.rstwhen optimized.Run
ctest --output-on-failurebefore submitting.
The vc_device.h header provides all Vulkan boilerplate. Avoid raw
vkCreate* calls; use the helpers. Every dispatch pattern is demonstrated
in an existing operation – pick the closest match.