GPU-accelerated AprilTag detection (OpenCL), bit-exact drop-in - #9
Draft
jameshmcvay wants to merge 18 commits into
Draft
GPU-accelerated AprilTag detection (OpenCL), bit-exact drop-in#9jameshmcvay wants to merge 18 commits into
jameshmcvay wants to merge 18 commits into
Conversation
…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).
jameshmcvay
marked this pull request as draft
June 18, 2026 08:03
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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 unlessAPRILTAG_OPENCLis set.APRILTAG_OPENCL_FIT_VALIDATE=1— re-fits every GPU-fitted cluster with the production CPUfit_quadand asserts verdict + corner bits match.APRILTAG_OPENCL_GATHER_VALIDATE=1— checks the on-device cluster records against the CPU-built clusters.ocl_harness/(threshold byte-parity, connected components, cluster extraction, fit prep) each diff against the CPU implementation.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:
fit_quad— verdicts + corner bits identical.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:
ptsortis 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.)FP_CONTRACT OFF) — the CPU build emits no fused multiply-add, so the GPU must not either, or products round differently.-cl-fp32-correctly-rounded-divide-sqrt) — the slope expression needs the last-ulp-correct divide the CPU gets.sqrtfon a double expression is modelled as(double)sqrt((float)x), matching where the CPU narrows.compute_lfpsmoments are summed sequentially in point order (one lane, no tree reduction); reassociating float adds changes the bits.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).compute_lfpsmoments, 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.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)
off/midway(GPU front-end, CPU fit) /full(fit on GPU too).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
apriltagoverlay 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)
apriltagoverlay + closure wiring (the distribution piece).