Skip to content

GPU-accelerated AprilTag detection (OpenCL), bit-exact drop-in - #9

Draft
jameshmcvay wants to merge 18 commits into
masterfrom
james/gpu
Draft

GPU-accelerated AprilTag detection (OpenCL), bit-exact drop-in#9
jameshmcvay wants to merge 18 commits into
masterfrom
james/gpu

Conversation

@jameshmcvay

@jameshmcvay jameshmcvay commented Jun 12, 2026

Copy link
Copy Markdown

GPU-accelerated AprilTag detection on the NUC integrated GPU (OpenCL), as a bit-exact drop-in: same apriltag_detector_detect() call, no caller changes, output identical to the CPU detector down to the float bit. Enabled per-tier with an env flag; any GPU failure falls back transparently to the unmodified CPU path.

~2x detection rate at ~87-90% less detector CPU on both NUC generations. In service, vide drops from ~3.5 cores to ~1.0-1.3 per robot.

How to review this

The hard part — "is the output really identical?" — is answered by gates, not by reading OpenCL line by line:

  • Integration surface is tiny. The existing detector is touched in one place (apriltag_quad_thresh.c, ~48 lines: call-site hooks that try the GPU path and fall back) plus CMake wiring. Everything else is new, isolated files (ocl_threshold.c/h) that do nothing unless APRILTAG_OPENCL is set.
  • Every GPU stage has a self-validating mode that re-runs the CPU reference and asserts equality, so correctness is mechanically checkable rather than eyeballed:
    • APRILTAG_OPENCL_FIT_VALIDATE=1 — re-fits every GPU-fitted cluster with the production CPU fit_quad and asserts verdict + corner bits match.
    • APRILTAG_OPENCL_GATHER_VALIDATE=1 — checks the on-device cluster records against the CPU-built clusters.
    • Per-stage equivalence harnesses in ocl_harness/ (threshold byte-parity, connected components, cluster extraction, fit prep) each diff against the CPU implementation.
  • Suggested review path: skim the integration hooks + fallback logic, run the harnesses (ocl_harness/README.md), then architectural spot-check of the kernels. The gates carry the correctness burden.

Correctness contract (why bit-exact, not "within tolerance")

Detection outcomes depend on the order points come out of a sort, including how equal values tie — ~88% of clusters on a real frame have slope ties, so "close enough" arithmetic silently flips which tags are detected (a stable sort alone loses detections). The GPU therefore replicates the CPU's exact sort order (ptsort's tie behaviour included) and every relevant float operation bit-for-bit; the specific decisions are listed below.

Verified:

  • 90/90 real robot frames (30 each from 3 robots) bit-exact — 5442 tags, 0 corner deltas.
  • 120-case corpus (10 images x 4 families x 3 decimations), all 0.0000 px, including zero-tag/small-image edge cases and tag16h5 (the false-positive-prone canary).
  • 2510 quad fits on a single frame re-fit against the production CPU fit_quad — verdicts + corner bits identical.
  • Production binary side-by-side in the same process via dt_apriltags: 32/32 tags, 0.000000000 px.
  • Deterministic run-to-run (0.000000 px), and per-stage parity (threshold byte-identical, CCL identical components, cluster content + within-cluster point order identical across 2.68M boundary records).

Intentional exactness decisions (what to scrutinise)

Each of these deliberately mirrors a CPU behaviour the detector's discrete decisions depend on — they're the load-bearing spots to review:

  • Sort tie-order. The CPU's ptsort is a hand-rolled merge sort whose tie behaviour is incidental: <=5-element sorting networks at the leaves, right-biased merges (ties take the right run), a recursion tree fixed by cluster size. The GPU replicates that network exactly, and the lane-parallel version uses a merge-path search to produce byte-identical output to the serial merge. (~88% of clusters have slope ties, so this is not optional.)
  • No FMA contraction (FP_CONTRACT OFF) — the CPU build emits no fused multiply-add, so the GPU must not either, or products round differently.
  • Correctly-rounded fp32 divide/sqrt (-cl-fp32-correctly-rounded-divide-sqrt) — the slope expression needs the last-ulp-correct divide the CPU gets.
  • Mixed float/double evaluation replicated — the cluster centre is computed in double then narrowed to float, and sqrtf on a double expression is modelled as (double)sqrt((float)x), matching where the CPU narrows.
  • Serial accumulation order preserved — the gradient dot and the compute_lfps moments are summed sequentially in point order (one lane, no tree reduction); reassociating float adds changes the bits.
  • Filter taps from the host libm — the quad-segment-maxima Gaussian taps are computed at init with the same expressions/libm the CPU uses, then baked into the kernel as exact hex-float literals.
  • Emission order preserved — boundary records are emitted in the CPU emitter's raster order (per-256px-row-segment count -> scan -> sequential emit) and the CCL edge rules are replicated verbatim (border bounds and redundancy skips included), so cluster content and within-cluster point order match — which is what keeps the downstream sort's ties identical.
  • Combo search in CPU order — quad-corner combos are evaluated in the CPU's lexicographic order, err ties broken by lower rank (first-wins), and the final corner checks subtract in float before promoting to double, exactly where the CPU reads quad->p.

Development phases (why the code is shaped this way)

The port landed in numbered phases that the commit history follows. This also explains a couple of artefacts that exist only as validation scaffolding (e.g. the radix sort), so a reviewer isn't left wondering why two paths do the same thing. The tiers map onto the phases: midway = Frontend only (fit stays on CPU); full = Frontend + P1b-P4 (fit on GPU).

  • Frontend — threshold, connected components, and boundary/cluster extraction moved to the GPU. Most of the detector's CPU is in clustering, so this is the first and largest CPU win. Records are emitted in raster order so the CPU grouping walk reproduces exact cluster content and within-cluster order.
  • P1 — stable GPU radix sort by cluster key (validation scaffolding only). Proved records can be grouped on-device with the CPU's exact cluster content/order. The radix scatter is too slow for production (scattered 16-byte writes), so it's kept as a gate, not on the hot path — superseded by P1b.
  • P1b — gather permutation path. The production grouping: the CPU build walk already discovers the grouping, so it emits a permutation and one coalesced GPU gather materialises cluster-contiguous records on-device — the layout the fit chain consumes — with no GPU key sort.
  • P2 — fit preparation + exact ptsort slope sort. Per-cluster filter cascade, bbox, centre, gradient dot, and the slope sort replicating ptsort's tie order exactly. Gets sorted per-cluster point data on-device.
  • P3 — full fit_quads tail on the GPU. compute_lfps moments, segment-maxima, the combo search, and corner fitting, all on-device, reading back only the accepted quads (KB). Moves the single biggest remaining CPU stage across.
  • P4 — slim build walk. Once the GPU owns the frame's fit, the CPU walk no longer needs to copy point data (the GPU reads the gathered records directly), so it runs count-only; clusters the GPU declines are materialised from the gathered records on demand. Removes the now-redundant CPU point-copying.

Post-P4 is kernel-level polish: non-blocking sort-list upload, SLM gradient-dot accumulation, a lane-parallel SLM sort, and a fused lfps prep/scan kernel.

Performance (controlled, same frames both machines, EPP-fixed)

                       W3.2 / 225H            W3.1.W3 / 125H
Original vide  ms/f    65-94 / 166-227 cpu    65-94 / 178-251 cpu
Midway (front) ms/f    28-46 / 55-106 cpu     30-48 / 60-109 cpu   (-53..-67% cpu)
Full GPU (fit) ms/f    28-36 / 21-25 cpu      28-36 / 21-25 cpu    (-87..-90% cpu, ~2x rate)
  • Three runtime tiers: off / midway (GPU front-end, CPU fit) / full (fit on GPU too).
  • The two generations converge to within ~5% per GPU cell once the CPU governor is unclamped.
  • GPU cost ~25-40% of the iGPU at 10 Hz; the detector now outruns the cameras (7-19.5 fps, exposure-limited).
  • Package-power coupling is not material on this hardware: a busy P-core loses ~0.3% when the GPU engages, ~38 W peak against a 64 W PL2, and the GPU path draws less package power than CPU detect while running ~35C cooler.

Soak / fleet validation

Run live in production vide via a library bind-mount (no closure rebuild): full GPU fit tier soaked multi-hour on a W3.2 with zero OpenCL errors and RSS within vide's normal range (one upward drift flagged to chase down), marker counts in band throughout; midway tier soaked on two W3.1s; combined with an unrelated spectacular-vio optimisation on one robot the whole vide service dropped from 3.95 to 2.58 cores.

The fleet test also surfaced a unit-specific GPU fault (apparently hardware, pending confirmation — see follow-ups) that motivates a startup self-bench gate. Two same-generation W3.1s (NUC14/125H) ran the identical library and the identical frame set, but one was ~10x slower on every frame — fit-tier ~50 ms on the healthy unit vs ~460 ms on the slow one — and cross-testing the frames between them ruled out scene content entirely (each robot's own frames ran fast on the healthy unit and slow on the potato). The slow unit reports completely healthy: full GPU clocks (2.2 GHz), full topology (112 EUs), normal memory bandwidth, clean dmesg, and it survives a reboot. So the defect is invisible on the CPU path and only the GPU load exposed it — a startup self-bench (time a reference detect at init) would auto-quarantine units like it to the bit-exact CPU path and flag them for investigation.

Deployment

Flags only; this needs the GPU-capable library reaching the vision closure via an apriltag overlay before it does anything in production. The vide config option lives in a companion mech PR (BuildMonumental/mech#9280: services.vide.aprilTagGpu = off | midway | full). Merge order: this PR (+ overlay) first, then flip robots to a tier. Until then the flags set on a stock library are a harmless no-op (safe fallback).

Follow-ups (not in this PR)

  • apriltag overlay + closure wiring (the distribution piece).
  • Confirm the slow-unit root cause. It behaves like a hardware fault (full clocks/topology/bandwidth, clean dmesg, survives reboot), but that's elimination, not a positive diagnosis — worth deeper investigation (GuC/firmware state, GPU power states, BIOS config) before declaring it hardware vs a recoverable software/driver condition.
  • Wide-fleet soak + self-bench calibration. Run the GPU path across a broad range of robots and durations to characterise stability and fallback rates, and to collect the healthy distribution of the reference-detect time so the self-bench quarantine threshold reliably catches outliers like this one.
  • Startup self-bench health gate with an observable fallback signal. Time a reference detect at init and quarantine outliers to the bit-exact CPU path — but surface it as a metric (GPU-active vs CPU-fallback per robot, per-frame fallback count, and the bench time) so a degrading or quarantined unit shows up in monitoring instead of silently running slow.
  • Optional cross-frame overlap (enqueue the GPU front-end ahead of the CPU back half) to hide the remaining GPU time off the critical path.

jameshmcvay and others added 18 commits June 10, 2026 23:19
…usters

Runs the detector frontend on an Intel iGPU via OpenCL, gated behind
APRILTAG_OPENCL=1 with silent CPU fallback. The threshold stage is
byte-identical to the CPU implementation; connected components and
cluster extraction replicate the CPU edge rules exactly (validated
108/108 over a 9-image x 4-family x 3-decimation corpus, bit-exact with
APRILTAG_OPENCL_EXACT=1). On a Core Ultra 5 225H / Arc 140T,
apriltag_detector_detect drops from 52 ms / 255 core-ms to 37 ms /
133 core-ms on a 3088x2064 frame with 32 tagStandard52h13 tags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the atomic extract + histogram/scatter partition with a two-pass
per-row-segment emission (count, scan, sequential emit), so boundary
records land in the CPU emitter's exact raster order. The CPU build walk
becomes ordered hashmap grouping, reproducing the CPU path's cluster
content and within-cluster point order without the validation-only sort
the previous design needed. Detector output is now bit-identical to the
CPU implementation and deterministic run-to-run by default (corpus
108/108 cases, max corner delta 0.0 px); APRILTAG_OPENCL_EXACT is gone.
Also deletes the 128 MB partition buffer and two kernels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…euse

The per-segment serial emit loops strided adjacent SIMD lanes 256 bytes
apart; replace them with one 256-thread workgroup per segment (one pixel
per thread, coalesced reads, local prefix scan assigns record slots),
preserving raster record order. The count pass stores per-pixel emit
masks so the emit pass reads one byte instead of re-evaluating neighbour
conditions. The buffer cache now reuses oversized allocations across
frame-size changes (re-zeroing only the threshold output), so mixed-size
consumers stop paying full reallocation per size change.

countSegments 3.9 -> 1.1 ms, emitSegments 2.6 -> 1.8 ms on Arc 140T;
output remains bit-identical to the CPU implementation (corpus 108/108,
max corner delta 0.0 px, deterministic run-to-run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arc 140T exposes cl_khr_fp64; the calibrated lfps simulation probe
(9000 clusters x 300 pts, sequential double moment accumulation with
image sampling) runs in 2.56 ms vs ~15 ms for the CPU fit_quads stage.
Plan: stable key radix sort keeps records on-device, one workgroup per
cluster fits quads in double preserving CPU summation order, and only
the quad array returns to the host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six-pass 8-bit LSD radix over the compacted 46-bit key, thread-blocked
for stability so sorted clusters carry the emitter's exact raster point
order. Gated behind APRILTAG_OPENCL_SORTED: output validated bit-exact
(detect corner delta 0.0, corpus 108/108) but radixScatter costs
~10.9 ms/pass on Arc 140T — the plan pivots to a CPU-walk-emitted
permutation + GPU gather for the fit_quads port (see FIT_QUADS_PLAN.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The build walk's grouping becomes a counting-sort permutation: the walk
stores each record's task-local cluster index (one flat store per
record), the merge records each local cluster's final index and chunk
start, and a parallel pass writes each record's output slot directly
into the mapped staging buffer once final cluster offsets are known
(which also fills the per-cluster descriptors). One coalesced gather
kernel (~1.5 ms, async) then materializes cluster-contiguous records in
bufRecordsAlt for the upcoming GPU fit stages. An earlier per-cluster
index-list version cost ~13 ms/frame of host bookkeeping; this shape
measures ~3 ms and end-to-end detect is indistinguishable from the
no-gather baseline. Gated APRILTAG_OPENCL_GATHER=1; with
APRILTAG_OPENCL_GATHER_VALIDATE=1 the gathered records are read back
and checked against the CPU-built clusters (216/216 across the corpus;
detect 0.000000 px, corpus 108/108). APRILTAG_OPENCL_PROFILE=1 now also
prints host-side frontend stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three kernels over the gathered cluster-contiguous records, gated
APRILTAG_OPENCL_FIT=1: fitPrep replicates the do_quad_task/fit_quad
filter cascade, bbox, double-evaluated center, per-point slopes, and the
gradient dot (parallel terms, then one lane sums them in point order);
fitSortSlm and fitSortBig sort each cluster's keys by slope.

Slope ties turned out pervasive — 2266 of 2510 sorted clusters on the
vide frame — so a total-order sort would reorder points in ~88% of
clusters and break downstream bit-exactness. The sort instead replicates
ptsort's exact comparison network: its recursion tree is arithmetic on
the cluster size (floor-half splits terminating at the verbatim <=5
sorting networks), internal nodes run the verbatim right-biased merge,
and depth parity ping-pongs between two buffers. Big clusters batch one
workgroup each with scratch slices, splitting shallow merges across
lanes via an exact merge-path search. Bit-exactness also needs
FP_CONTRACT OFF (the CPU build has no FMA) and correctly-rounded fp32
division, both handled by a separate fp64 fit program.

Validation (APRILTAG_OPENCL_FIT_VALIDATE=1) checks flags, center/dot
bits, bbox, and the full sorted sequence against a host replication
including a verbatim ptsort: 25/25 on the vide frame, 216/216 across
the corpus, tie order matching ptsort exactly. Detect stays 0.000000 px
and corpus 108/108. Kernel times: fitPrep 3.7 ms, fitSortSlm 4.8 ms,
fitSortBig 4x0.6 ms (the first monolithic cut cost 30 ms; 33 KB of SLM
per workgroup crushed occupancy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
quad_segment_maxima only reads the sorted copy at index max_nmaxima as
a threshold value and filters maxima in original order with a strict
comparison, so the GPU port needs top-K-by-value selection rather than
a qsort replica. Also record where lfps weights come from (the original
decimated grayscale, resident in bufIm on the frontend path) and the
serial-scan shape for its double accumulation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three kernels after the P2 slope sort complete the fit on the GPU with
quads-only readback: fitLfpsPrep/fitLfpsScan (compute_lfps over six
moment planes — parallel exact terms, then per-(cluster,field) serial
add chains preserving CPU summation order), fitErrs (windowed errors,
host-libm filter constants baked in as exact hex floats, order-
preserving maxima compaction, top-K multiset threshold replicating the
order-irrelevant qsort), and fitCombos (C(m,2) forward + wraparound
pair fits cached in SLM, lex-rank combo scan with first-wins tie
argmin, final line fits, intersections, float-narrowed corners, area
and angle checks).

fit_quads() consumes the chain through oclFitQuads(): fitPrep flags
decide pre-fit rejections, GPU verdicts decide sorted clusters, and a
handled[] mask leaves too-big/over-cap/failed clusters to the CPU
tasks. lfps weights sample the device-resident grayscale, so the
handoff (pendingFit) only arms on the oclFrontend path.

Gates on w3cj: detect 32/32 at 0.000000 px, deterministic run-to-run;
corpus 120/120 at 0.0000 px; FIT_VALIDATE re-fits every GPU cluster
with the CPU fit_quad — verdicts and corner bits match on all 2510
vide fits. Detect: ~63-71 ms wall / ~82-88 core-ms vs CPU 58-60 /
275-290 (-70% CPU; ~+12 ms wall vs gather-only — the exactness
contract pins the chain to fp64, where the iGPU trails the 8-thread
CPU, so APRILTAG_OPENCL_FIT=1 is the max-CPU-offload mode).
With fit_quads on the GPU, the walk's appendPt/merge point copying only
fed descriptor sizes, the permutation passes, and CPU-fallback clusters.
The walk now skips it whenever the GPU fit will run: the hash grouping,
pass-A indices, and merge bookkeeping are unchanged, but clusters carry
sizes only (data NULL). buildWalk drops 25.8 -> 7.9 ms.

Shells never reach CPU code: materializeShells() rebuilds point data
from the gathered records (cluster-contiguous, CPU point order, payloads
are exactly struct pt's fields) for fallback clusters after the quad
readback and for every shell on oclFitQuads failure paths;
flushPendingFit() materializes a pending handoff that another entry
point invalidates; an unarmed slim walk is destroyed and re-walked fat
from the intact record buffer. oclFitQuads performs all acceptance
checks under the mutex so a rejected handoff is flushed, never stranded.
Slim mode is disabled when validation envs request host-side points.

Gates on w3cj: detect 32/32 at 0.000000 px deterministic; corpus
120/120 at 0.0000 px with slim active; FIT_VALIDATE (fat walk) still
2510/2510 corner-bit exact; out-of-range max_nmaxima interleave falls
back cleanly. Detect in fit mode: ~58 ms wall / ~50-74 core-ms vs CPU
~60 / ~290 — wall parity with the CPU baseline at -75-80% CPU.
The blocking map of bufSortList stalled the host behind the gather
kernel on the in-order queue. Staging the id list in a persistent host
scratch and enqueuing a non-blocking write removes the stall
(fitEnqueue stamp 8.9 ms -> 0.4 ms; the old stamp also included the
profiling-only clFinish, which now runs outside the stamp window).
The per-point dot terms went through a global scratch buffer (21 MB
written then re-read serially by one lane). Staging each 256-point
chunk in SLM and letting lane 0 sum it in cluster point order keeps
the CPU's exact float accumulation while dropping the round trip; the
four bbox reductions also share one tree (max fields complemented)
instead of four. fitPrep 2.8-3.1 -> 2.7 ms; dot bits still validate
exactly (2510/2510 fits bit-exact).
fitSortSlm assigned one lane per node at every depth, so the top
levels ran almost serial (the depth-0 merge was one lane over the
whole cluster). The shallow-depth lane-group split from fitSortBig is
now shared (SHALLOW_BODY, parameterized on address space) with a
__local merge-path search; deep levels are unchanged. fitSortSlm
4.2 -> 0.8 ms; sorted order still equals ptsort on all tie clusters
and fit corners stay bit-exact. The helpers string outgrew the 4095
literal limit and is split in two.
The split pair wrote all six raw term planes to global memory and read
them straight back (~2/3 of the chain's traffic). The fused kernel
computes each 256-point chunk of terms into local memory and lets
lanes 0-5 — one per field, SIMD lockstep — extend the six cumulative
sums in CPU accumulation order, so each cluster flows through prep and
scan independently. Output planes are unchanged and downstream kernels
untouched. fitLfpsPrep+fitLfpsScan 8.4 -> fitLfps 4.4 ms; fit-chain
span 15.6 -> 11.9 ms; corners stay bit-exact (2510/2510).
vide_rehearsal.py A/Bs this build against the stock library through the
production dt_apriltags entry point; vide_markers.json arms a robot's
vide detector loop over gRPC. The README documents the robot-local
deployment used on w3cj (staged .so + bind-mount drop-in, immutable-/etc
caveat, YAML camera config, rollback).
Python harnesses that ran on the robots via the vision closure's
dt_apriltags: tier_probe (stock/frontend/fit A/B on one frame),
tier_burn (extended reversed-order suite with thermal/clock
trajectory), power_probe (synchronized phase frequency/RAPL sampling
that settled the GPU power-coupling question), clock_spinner (pinned
work-rate clock proxy), galaxy_burst (raw camera capture while vide is
stopped), frame_density/frame_structure (threshold-level scene
analysis), and mock_vide (launcher attempting to shim the broken
upstream --mock camera path).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant