GPUSort
Overview
GPUSort adds one stable, out-of-place key/value ordering to a GPUCommandGraph.
GPUBatchSort applies the same ordering independently to aligned GPU vector chunks, preserving
streaming and record-batch boundaries without implicitly packing them.
GPUSegmentedSort batches
many small, independent domains that already share packed parent buffers into at most eight graph
dispatches. None of these APIs submits commands or reads results back to the CPU.
Concepts
Paired stable sorting preserves identity
Paired sorting moves each value with its key, so values commonly hold stable source-row IDs. A stable sort preserves the original order of equal keys, and out-of-place output leaves the source buffers unchanged. This is useful for transparent draw ordering, label priority, event ordering, and table permutation because a later consumer can still recover the canonical source row.
Global order and batch order answer different questions
GPUSort treats one packed view as one global comparison domain. Use it when every row must be
ranked against every other row—for example, one back-to-front draw list or one global event
timeline.
GPUBatchSort treats every GraphVectorView chunk as an independent comparison domain. It keeps
the number, order, and length of chunks unchanged. This is the right contract when boundaries are
meaningful: streaming record batches may have separate lifetimes, map tiles may render
independently, and incremental ingestion may need newly arrived data sorted without rewriting
older batches. A row never crosses a boundary, even when its key would place it in another batch
under a global sort.
The distinction is deliberate. Silently concatenating chunks would allocate packed storage, discard useful partition metadata, and turn an incremental operation into whole-dataset work. Callers that need a global order across chunks must explicitly choose and provision a packed representation.
When independent domains are already packed into four shared parent buffers,
GPUSegmentedSort keeps the
boundaries and inter-segment padding intact while sorting equal-width domains together. It does
not combine separately allocated streaming chunks; it only exploits storage that the application
explicitly packed in advance.
Algorithm selection follows the work unit
Bitonic sort favors smaller fixed networks; radix sort scales larger inputs by partitioning key
bits while preserving stability. GPUSort selects once for its packed view. GPUBatchSort
selects independently for every chunk, so one graph can use bitonic for small batches and radix
for a large batch. resolvedAlgorithms reports those choices in source-chunk order.
Scratch remains graph-owned and batch-local. Empty chunks add no nodes, single-row chunks use the copy fast path, and later chunk sorts can reuse transient allocations from earlier chunks.
The live example switches between one packed Arrow column and preserved Arrow chunks. Its streaming case preserves batch boundaries and reports the algorithm selected independently for each chunk:
import {GPUCommandGraph, GPUSort} from '@luma.gl/experimental';
const graph = new GPUCommandGraph(device, {id: 'sort-records'});
const keyChunks = graph.importGPUVector('keys', keyVector);
const valueChunks = graph.importGPUVector('values', rowIdVector);
const keys = keyChunks.data[0]!;
const values = valueChunks.data[0]!;
const outputKeyHandle = graph.importBuffer(
{id: 'output-keys', byteLength, usage: outputKeyBuffer.usage},
outputKeyBuffer
);
const outputValueHandle = graph.importBuffer(
{id: 'output-values', byteLength, usage: outputValueBuffer.usage},
outputValueBuffer
);
const sort = new GPUSort({
keys,
values,
outputKeys: graph.createDataView(outputKeyHandle, {format: 'uint32', length}),
outputValues: graph.createDataView(outputValueHandle, {format: 'uint32', length}),
algorithm: 'auto',
direction: 'ascending'
});
sort.addToGraph(graph);
const compiled = graph.compile();
const commandEncoder = device.createCommandEncoder({id: 'sort-records'});
compiled.encode(commandEncoder, {parameters: undefined});
device.submit(commandEncoder.finish());
For independent batch order, import aligned input and output vectors directly:
import {GPUBatchSort, GPUCommandGraph} from '@luma.gl/experimental';
const graph = new GPUCommandGraph(device, {id: 'sort-stream'});
const sort = new GPUBatchSort({
keys: graph.importGPUVector('keys', keyVector),
values: graph.importGPUVector('row-ids', rowIdVector),
outputKeys: graph.importGPUVector('sorted-keys', outputKeyVector),
outputValues: graph.importGPUVector('sorted-row-ids', outputRowIdVector),
algorithm: 'auto',
direction: 'ascending'
});
sort.addToGraph(graph);
Constructor
new GPUSort(props)
type GPUSortProps = {
id?: string;
keys: GraphDataView<'uint32'>;
values: GraphDataView<'uint32'>;
outputKeys: GraphDataView<'uint32'>;
outputValues: GraphDataView<'uint32'>;
algorithm?: 'auto' | 'bitonic' | 'radix';
direction?: 'ascending' | 'descending';
keyBits?: number;
};
- Every view must have the same logical length and packed, aligned
uint32storage. - Output keys and values use separate buffers from the inputs and from each other.
- Equal keys retain their input order in both directions.
- Inputs are not modified. The caller owns all four views and their imported buffers.
algorithm defaults to auto. Inputs containing at most 256 rows use a complete, stable bitonic
sorting network in workgroup memory, requiring only one graph node and one dispatch. Its workgroup
contains only the padded power-of-two row count, and each source key is loaded into shared memory
once before sorting. Larger inputs use a stable four-bit least-significant-digit radix sort: each
digit contributes a workgroup-local histogram, a reusable GPUScan, and an order-preserving
scatter. The radix implementation supports both sort directions, partial final digits, and the
eight-storage-buffer CORE WebGPU limit without requiring subgroup features. Explicit algorithm
selection remains available for measurement and testing; resolvedAlgorithm reports the concrete
selection.
keyBits defaults to 32 and controls how many least-significant bits the radix implementation
processes. A 32-bit sort therefore needs eight radix digits instead of 32 binary partitions. Radix
destinations alternate between graph-owned scratch and caller-owned outputs so the final digit
always writes directly into the requested destination.
new GPUBatchSort(props)
type GPUBatchSortProps = {
id?: string;
keys: GraphVectorView<'uint32'>;
values: GraphVectorView<'uint32'>;
outputKeys: GraphVectorView<'uint32'>;
outputValues: GraphVectorView<'uint32'>;
algorithm?: 'auto' | 'bitonic' | 'radix';
direction?: 'ascending' | 'descending';
};
- All four vectors must have identical ordered chunk lengths and packed
uint32chunks. - Output chunks cannot alias any input chunk or the other output vector.
- Sorting is stable within each chunk. Stability and ordering do not extend across boundaries.
resolvedAlgorithmscontains one concrete choice per chunk in source order.- Inputs and outputs remain caller-owned; no vector is concatenated or repacked.
addToGraph(graph)
Adds all compute passes and transient scratch declarations to the supplied graph. The graph must
own every input and output view. Scratch buffers are graph-owned, participate in transient lifetime
reuse, and are released by CompiledGPUCommandGraph.destroy().
The method does not compile the graph, create an encoder, submit work, or map output buffers.
Edge cases
- Empty inputs and empty batches add no graph nodes.
- A single pair or single-row batch adds one copy node.
- Bitonic sort internally pads irregular lengths without exposing sentinels in the output; inputs up to 256 rows complete entirely within one workgroup.
- Four-bit radix sorting preserves the relative input order of equal significant key bits in both ascending and descending order.
- At most
0x80000000rows are accepted; practical limits are normally lower device buffer and dispatch limits.
See the runnable GPU sort example for packed and preserved-batch Arrow upload, per-batch algorithm selection, graph compilation statistics, explicit submission, and CPU-oracle validation.