Radix Sorting Point Clouds on the GPU

A radix sort is the only practical way to order millions of points on a GPU, and it is built entirely from the primitive on the previous page: each pass histograms one digit of the key, scans the histogram to get output offsets, and scatters every element to its offset. Four passes of eight bits sorts a 32-bit Morton key, each pass is stable, and the result is a point cloud whose memory order matches its spatial order. This page is the pass structure, the buffers it needs, the float-key transformation, and the places where a sort silently stops being stable. It is one stage of GPU sorting and prefix sums for spatial data.

One radix pass: histogram, scan, scatter Three stages left to right within a single pass. The histogram counts how many elements have each of the 256 possible values of the current digit, using per-workgroup privatised counters so contention stays local. The scan turns those counts into starting offsets, one per digit. The scatter then writes every element to its digit offset plus a per-digit cursor, moving it from the input buffer to the output half of a ping-pong pair. ONE RADIX PASS · THREE STAGES Histogram 256 buckets, privatised one digit of the key Scan counts → start offsets one per digit Scatter offset + per-digit cursor into the other buffer Stability is the load-bearing property — a single unstable pass undoes every pass before it.
Four of these passes sorts a 32-bit key. Each is individually stable, and it is the composition of stable passes that makes the final ordering correct on all 32 bits.

Runnable reference implementation

Each pass is a histogram, a scan, and a scatter. The histogram is a privatised count per workgroup, exactly as in parallel histogram binning, because 256 buckets is small enough to hold in workgroup memory.

wgsl
const RADIX : u32 = 256u;                  // one 8-bit digit per pass
const WG    : u32 = 256u;

struct PassInfo { shift : u32 };           // 0, 8, 16, 24 across the four passes
@group(0) @binding(0) var<uniform> info : PassInfo;
@group(0) @binding(1) var<storage, read>       keys_in  : array<u32>;
@group(0) @binding(2) var<storage, read_write> counts   : array<atomic<u32>>;

var<workgroup> local_counts : array<atomic<u32>, RADIX>;

@compute @workgroup_size(256)
fn histogram(@builtin(global_invocation_id) gid : vec3<u32>,
             @builtin(local_invocation_id)  lid : vec3<u32>) {
  atomicStore(&local_counts[lid.x], 0u);
  workgroupBarrier();

  let i = gid.x;
  if (i < arrayLength(&keys_in)) {
    let digit = (keys_in[i] >> info.shift) & (RADIX - 1u);
    atomicAdd(&local_counts[digit], 1u);   // contention stays inside the workgroup
  }
  workgroupBarrier();

  // One global atomic per bucket per workgroup, not one per element.
  atomicAdd(&counts[lid.x], atomicLoad(&local_counts[lid.x]));
}

The scatter reads the scanned offsets and writes each element to its destination. The key detail is that the destination buffer is the other half of a ping-pong pair, because a sort cannot be done in place.

wgsl
@group(0) @binding(0) var<uniform> info : PassInfo;
@group(0) @binding(1) var<storage, read>       keys_in     : array<u32>;
@group(0) @binding(2) var<storage, read>       payload_in  : array<u32>;
@group(0) @binding(3) var<storage, read>       offsets     : array<u32>;   // scanned
@group(0) @binding(4) var<storage, read_write> keys_out    : array<u32>;
@group(0) @binding(5) var<storage, read_write> payload_out : array<u32>;
@group(0) @binding(6) var<storage, read_write> cursor      : array<atomic<u32>>;

@compute @workgroup_size(256)
fn scatter(@builtin(global_invocation_id) gid : vec3<u32>) {
  let i = gid.x;
  if (i >= arrayLength(&keys_in)) { return; }

  let key   = keys_in[i];
  let digit = (key >> info.shift) & (RADIX - 1u);
  // Claim the next slot within this digit's range. Sequential within a digit,
  // which is what preserves stability across passes.
  let dst   = offsets[digit] + atomicAdd(&cursor[digit], 1u);
  keys_out[dst]    = key;
  payload_out[dst] = payload_in[i];
}

After each pass the two buffers swap roles. Four passes means four swaps, so the sorted result ends up back in the original buffer — a small convenience that disappears if the key width ever changes to an odd number of passes.

Parameter reference

Value Setting here Guidance
Digit width 8 bits 256 buckets fits comfortably in workgroup memory. 4 bits halves the buckets and doubles the passes; 16 bits needs 65 536 buckets and does not fit.
Passes 4 keyBits / digitBits. A 64-bit key needs eight, which is the single strongest argument for a 32-bit key.
Scratch buffers 2 × (keys + payload) The sort cannot run in place. Four million points with a 32-bit key and payload needs 32 MiB of scratch.
Cursor buffer 256 u32 Reset to zero before every pass; a stale cursor produces overlapping writes.
Workgroup size 256 Matches the radix so each lane owns one bucket in the histogram and the merge.
What a four-million-point sort holds in VRAM A bar chart in mebibytes of the buffers a radix sort over four million points needs. The input keys occupy 16 mebibytes and the input payload another 16. The ping-pong copies of both occupy a further 32. The histogram and offset buffers are one kibibyte each and are invisible at this scale. The total working set is 64 mebibytes, twice what the data alone occupies. SORT WORKING SET · 4 M POINTS · MiB Keys in 16 MiB Payload in 16 MiB Ping-pong copies 32 MiB Histograms 2 KiB 0 6 12 18 24 30 36 MiB the data scratch — not optional negligible Allocate the ping-pong pair once for the largest sort the pipeline will ever run.
The scratch is the same size as the data, and it is the part most easily forgotten when a VRAM budget is drawn up. A sort that fits the data exactly does not fit.

Sorting keys that are not unsigned integers

A radix sort compares bit patterns, which is only equivalent to comparing values for unsigned integers. Morton codes already are unsigned, which is convenient and is one more reason to sort by them rather than by a raw coordinate. Where a float key is unavoidable — sorting by distance from the camera, say — the bit pattern has to be transformed first.

IEEE 754 floats have the useful property that positive values compare correctly as unsigned integers, and the awkward property that negative values compare backwards, because the sign bit is set and the magnitude bits run the wrong way. The standard fix is one branchless expression:

wgsl
/// Map a float's bit pattern to a u32 that sorts in the same order.
fn float_key(value : f32) -> u32 {
  let bits = bitcast<u32>(value);
  // If the sign bit is set, invert everything; otherwise set the sign bit.
  let mask = select(0x80000000u, 0xFFFFFFFFu, (bits & 0x80000000u) != 0u);
  return bits ^ mask;
}

The inverse, needed if the key has to be read back as a float, applies the opposite mask. In practice it rarely does — the payload carries the index, and the index is what later passes want.

A distance key also raises a design question worth answering before implementing it. Sorting by distance changes every frame the camera moves, which means a per-frame radix sort over the whole cloud: the most expensive primitive in the pipeline at the highest possible frequency. For transparency, order-independent techniques avoid it entirely. For level-of-detail selection, a Morton sort done once at load plus a per-frame filter achieves the same visual result at a fraction of the cost.

Where the four passes actually go

Instrumenting a radix sort with per-pass timestamps is worth doing once, because the distribution is not what most people expect.

The histogram passes are cheap: one read per element and an atomic into workgroup memory, which is close to a pure streaming read. The scans between them are cheaper still, because they operate on 256 counts rather than on the data. The scatter passes dominate, and they dominate for a reason that is structural rather than fixable — a scatter writes each element to an address determined by its data, so consecutive lanes write to addresses that are not consecutive, and the write coalescing that makes the histogram fast is unavailable.

That gives a rough split of one part histogram, four parts scatter, per pass. It has two practical consequences. Reducing the number of passes helps far more than optimising any individual one, which is the argument for a 32-bit key over a 64-bit one restated in measurements rather than in principle. And carrying a smaller payload helps proportionally, because the payload is scattered alongside the key: sorting an index rather than a full record, and gathering the records once at the end, moves four bytes per element per pass instead of thirty-two.

The second of those is the optimisation worth doing by default. Sort (key, index) pairs, then perform one gather pass at the end to reorder the actual data — five passes instead of four, each moving a fraction of the bytes.

Failure modes

  • The sort is correct for one pass and wrong after four. A pass is not stable — usually because the scatter used a global atomic across all digits rather than a per-digit cursor, so elements with equal digits reorder and earlier passes’ work is undone.
  • The output has holes. The cursor buffer was not reset between passes, so the second pass started counting from where the first finished and wrote past its range.
  • Memory use doubles unexpectedly. The ping-pong buffers. They are not optional; budget for two of everything the sort touches.
  • Negative distances sort to the wrong end. A float key used without the sign transformation.
  • The sort is slower than expected on one vendor. The histogram’s global atomics are contending. Check that the privatised per-workgroup counts are actually being used — a version that skips them does one global atomic per element rather than one per bucket per workgroup.
Making three kinds of key sortable A table of three key types with whether a bit-pattern sort orders them correctly and what transformation is needed. An unsigned integer, which is what a Morton code is, sorts correctly with no transformation at all. A signed integer sorts incorrectly because the sign bit inverts the order, and is fixed by flipping the sign bit. A float sorts correctly for positive values and backwards for negative ones, and is fixed by setting the sign bit for positives and inverting all bits for negatives. KEY TYPE · SORTS CORRECTLY? · FIX As bits? Transformation u32 (Morton code) yes none i32 no flip the sign bit f32 positives only flip or invert by sign The float transformation is branchless and costs two instructions; do it when building the key, not in the sort.
Morton codes needing no transformation is a small but real argument for them over any key derived directly from a coordinate — one fewer place for a sort to be silently wrong.

Backend / Python interop note

Sorting on the server is worth considering seriously, because a point cloud’s spatial ordering is a property of the data rather than of the view, and a tile that arrives already Morton-ordered needs no sort on the client at all.

python
import numpy as np

def morton_sort(xs: np.ndarray, ys: np.ndarray, payload: np.ndarray):
    """Return the arrays reordered by Morton key, plus the keys themselves."""
    keys = morton_2d(quantise(xs), quantise(ys))     # see the Morton page
    order = np.argsort(keys, kind="stable")
    return keys[order], xs[order], ys[order], payload[order]

The wire-format benefit is real: a Morton-ordered coordinate column has neighbouring rows that share high bits, which any general-purpose compressor exploits, and delta encoding within the column becomes genuinely effective. A tile can shrink by a third or more purely from being sorted, before any other encoding decision.

The cost is that the ordering has to be preserved end to end. A pipeline that sorts server-side and then filters client-side must keep the survivors in order — which is exactly what the deterministic scan on the previous page provides and what an atomic counter does not. That is the clearest case where the scan’s extra pass pays for itself.