Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
3257434
Add TSV detection/timing output flags to apriltag_demo
bouk Jun 10, 2026
164eaff
Add hyperfine benchmark script
bouk Jun 10, 2026
dc04beb
Memoize segment fits across the candidate-quad search
bouk Jun 10, 2026
98d072c
Hoist the Gaussian low-pass kernel into a per-thread table
bouk Jun 10, 2026
9c55305
Replace modulo indexing in quad_segment_maxima with wrap adjustments
bouk Jun 10, 2026
8a54b42
Reuse per-task scratch buffers in fit_quad
bouk Jun 10, 2026
541bd01
Ping-pong merge sort: copy points only at the leaves
bouk Jun 10, 2026
fe15384
Add output-equivalence check script
bouk Jun 10, 2026
cf1ac9c
Build with -fno-math-errno
bouk Jun 10, 2026
c88e79c
Destroy clusters inside the quad tasks
bouk Jun 10, 2026
4fd699c
Run the cluster merge tree on the worker pool
bouk Jun 10, 2026
34ff77b
Size the per-task cluster hash table to a power of two
bouk Jun 10, 2026
20f4bd8
Grow the mem_pools array instead of sizing it from nclustermap
bouk Jun 10, 2026
62511ad
Reuse the bucket index for cluster_hash->hash
bouk Jun 10, 2026
5a296bd
Cache the last-hit cluster entry
bouk Jun 10, 2026
5ae0a9c
Defer the pixel's union-find representative lookup
bouk Jun 10, 2026
06e4fb8
Inline the union-find set-size lookups in the cluster pass
bouk Jun 10, 2026
80812d4
Sort cluster points as packed u64 keys
bouk Jun 10, 2026
d1d70af
Run-driven gradient clusters with chunk-pool point storage
bouk Jun 10, 2026
716a229
Cache threshold buffers across detect calls
bouk Jun 10, 2026
8eb1235
Heads-only union-find
bouk Jun 10, 2026
8c0bae8
Batch interior overlap points in gradient clusters
bouk Jun 10, 2026
8fb183e
Make pt_compare_angle static inline
bouk Jun 10, 2026
4be5395
Lean inline path for non-wrapping window fit errors
bouk Jun 10, 2026
b21cf0c
Branchless key_merge select
bouk Jun 10, 2026
516bb6e
Replace pairwise cluster merge tree with single-pass k-way heap merge
bouk Jun 10, 2026
4802e8d
Shrink struct pt to 8 bytes; build sort keys in the slope loop
bouk Jun 10, 2026
c8ae44f
Sort keys only; lfps reads points through the key indices
bouk Jun 10, 2026
2100301
AVX2 paths for the threshold tile min/max and blur
bouk Jun 10, 2026
20a1044
Build shared per-frame run tables once, SIMD RLE scans
bouk Jun 10, 2026
04b39d4
Emit fast-path point pairs as packed u64 stores
bouk Jun 10, 2026
2faa318
Vectorize the cluster bounding-box scan
bouk Jun 10, 2026
233fea8
Two-pass compute_lfps with 4-wide sqrt for the gradient weights
bouk Jun 10, 2026
78ddcc2
Vectorize the angle-key loop
bouk Jun 10, 2026
d8a7030
Replace modf calls in refine_edges with truncating casts
bouk Jun 10, 2026
36db2cc
Per-task decode scratch instead of per-quad calloc/malloc
bouk Jun 10, 2026
79ecb2b
Benchmark builds with -march=native
bouk Jun 10, 2026
4936d9f
Store line-fit moments in SoA layout
bouk Jun 10, 2026
0e195ab
Vectorize the window-error loop
bouk Jun 10, 2026
1779e2a
Vectorize the low-pass filter loop
bouk Jun 10, 2026
541796e
Add interleaved A/B benchmark harness
bouk Jun 10, 2026
2ccbe33
Index the union-find by run instead of by pixel
bouk Jun 10, 2026
add855c
Segment-driven cluster emission
bouk Jun 10, 2026
f44781c
Read-only finds in the cluster gates
bouk Jun 10, 2026
b3170b2
AVX2 path for the per-tile threshold compare
bouk Jun 10, 2026
615a88d
Use 64-point cluster chunks
bouk Jun 10, 2026
8134857
Add pgo.sh profile-guided build helper
bouk Jun 10, 2026
e22e362
Initialize run nodes of rows the union-find chunking leaves uncovered
bouk Jun 10, 2026
7c0434b
Quickselect for the maxima threshold
bouk Jun 10, 2026
c9e1956
Integer index math in the lfps weight pass
bouk Jun 10, 2026
6631b72
Interleaved root walks and full path compression for run connects
bouk Jun 10, 2026
36e5b5a
Fuse two merge levels of the key sort into 4-way merges
bouk Jun 10, 2026
13e2e8b
Vectorize the interior pair batch stores
bouk Jun 10, 2026
8b53559
Fuse run-length encoding into the threshold tasks
bouk Jun 10, 2026
dd9e601
Vectorize the window-error maxima scan
bouk Jun 10, 2026
1bad9ad
Gather split-cluster fragments and concatenate once
bouk Jun 10, 2026
08d458e
Fuse the border-orientation dot product into the vectorized key loop
bouk Jun 10, 2026
4d7bb51
Tune task granularity to 6 tasks per thread
bouk Jun 10, 2026
83a2962
Pin the A/B harness to one CCD
bouk Jun 10, 2026
87dd08d
Pin the official benchmark to one CCD
bouk Jun 10, 2026
409ef5c
Pool cluster_hash entries per task
bouk Jun 10, 2026
c70f32f
Flat cluster representation
bouk Jun 10, 2026
937120c
Skip the per-quad copy when decoding a single family
bouk Jun 10, 2026
e672960
Parallelize split-cluster concatenation
bouk Jun 10, 2026
f99006c
Compute lfps weights in emission order
bouk Jun 10, 2026
5f69010
Hoist homography loads out of the bit-sampling loop
bouk Jun 10, 2026
d85fd8f
Hoist homography loads in the pattern-sampling loop too
bouk Jun 10, 2026
f74d9cb
Keep graymodel coefficients in registers in the bit loop
bouk Jun 10, 2026
73ff7a8
Vectorize the refine_edges sampling loop
bouk Jun 10, 2026
cdb964d
Document the optimization campaign: methodology, invariants, dead ends
bouk Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ example/opencv_demo
example/apriltag_demo
build/

dets.tsv
timing.tsv
benchmark_results/
vide_images/
build/
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ endif()
if(CMAKE_COMPILER_IS_GNUCC OR CMAKE_C_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra)
add_compile_options(-Wpedantic)
# sqrt() & friends never need errno here; this lets them compile to bare
# sqrt instructions (vectorizable) instead of guarded libm calls
add_compile_options(-fno-math-errno)
if(CMAKE_C_COMPILER_ID MATCHES "Clang")
add_compile_options(
-Wno-gnu-zero-variadic-macro-arguments
Expand Down
94 changes: 94 additions & 0 deletions PERF_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Detector optimization notes (faster branch)

Campaign: 88 ms → 39.8 ms/image detector total (2.21x) on the 133-image
`vide_images` corpus, 4 threads, default parameters, tagStandard52h13.
Output is byte-equivalent to baseline acf5e20 at every commit (4583/4583
detections, coords within 1e-4 px — FMA contraction noise only).

## Measurement (do not skip this section)

- Machine: AMD EPYC 9454P (8 CCDs × 6 cores), NixOS, shared with a CI
runner that randomly loads it 20-100x. Absolute timings swing wildly.
- `./benchmark.sh` — official number (hyperfine wall + per-stage detector
table from `--save-timing`). Pinned via `taskset -c 0-3`.
- `./ab.sh <buildA> <buildB> [rounds]` — interleaved A/B with paired
per-round ratios; the only trustworthy comparison under load. Additive
noise biases ratios toward 1, so quiet-window numbers are the honest ones.
- `./check.sh` — epsilon-gated output equivalence vs the baseline corpus
run (id/hamming exact, coords ≤0.1 px, margin ≤1.0).
- **Pinning is worth ~28 ms/frame**: unpinned, the idle-machine scheduler
spreads the 4 workers across CCDs and the shared structures bounce
between L3s. Exactly 4 CPUs is optimal; 5-6 in the affinity set lets the
scheduler migrate threads and is *much* worse. Under CI load the numbers
can look *better* than quiet because load packs the threads onto one CCD.

## What the speedup is made of (stage ms, baseline → now)

- threshold 3.6 → 1.7: SIMD tile min/max + blur + compare; RLE fused into
the threshold tasks (per-task contiguous buffers, one memcpy each);
buffers cached across frames.
- unionfind 13.8 → 4.7: union-find indexed by *run* (~1.6 MB, not 50 MB);
one union per adjacent same-value run pair; interleaved-root connect
with full path compression; no per-frame reset (cached).
- make clusters 27.1 → 14.0: segment-driven emission (entry resolved once
per run pair, interior pairs as packed u64 / AVX2 stores); per-run
rep+gate cache with read-only finds; k-way heap merge of per-task lists
with exact-size fragment-group concatenation (parallelized); pooled
cluster_hash entries; flat single-allocation clusters {size, pts[]}.
- fit quads 34.6 → ~14: 8-byte points; u64 sort keys (slope<<32 | ~idx);
vectorized key/bbox/dot loops; 4-way fused merge sort; SoA moments;
memoized segment fits; vectorized window-error + 7-tap filter + maxima
scan; quickselect threshold; per-task scratch reuse; lfps pass 1 in
emission order (image-locality), pass 2 gathers through key indices.
- post-fit serial 2.2 → ~0.07: per-task quad accumulation.
- decode+refine 6.5 → ~3.2: per-task scratch; truncating casts replacing
modf; homography + graymodel coefficient hoists; refine_edges sampling
loop vectorized 4-wide (masked lanes contribute exact 0.0).

## Load-bearing invariants (violating these changes detections)

- **Angle-sort tie order.** 87% of clusters contain equal slope keys: the
±2^16 quadrant constants crush the dy/dx mantissa. The legacy order is
produced by hi-word-only leaf networks plus take-right-on-tie merges —
an inconsistent comparator, so NO standard sort reproduces it; only the
exact merge-tree structure does. Full-u64 leaves (a consistent order)
lose 1 detection in 4583 with 0.25 px shifts.
- The (y, x, conn-order) emission sequence of cluster points, the
connected_last suppression, and the component-size gates (lazy,
evaluated against sizes at scan time).
- Union-find *rep values* may change freely (tree shape is internal);
components and sizes may not.
- The union-find task chunking can leave the last image row uncovered for
some heights; those run nodes must be initialized (see covered_end).

## Measured dead ends (don't re-try without new information)

- LSD radix sort on full-u64 keys: only −2 ms vs the 4-way fused merge,
and −1 detection. Even ignoring output, sorts are within ~2 ms of the
committed one.
- 8-way fused merge: +2.5 ms (7-compare tournament too serial). 4-way is
the optimal fusion depth; 2-way costs an extra pass.
- Two-pass emission (count, allocate exact, write direct): +8.5 ms — the
per-pair sweep machinery dominates, not the point stores.
- Bit-loop and patterns-loop SIMD in decode: neutral (too few samples per
quad to amortize lane fold-out). refine_edges SIMD was the win (−1.5).
- Frame arena for cluster allocations: neutral (glibc tcache already
covers it). Merged clusters can exceed 256 KB — size slots if revived.
- Big-cluster-first scheduling in fit_quads: +4 ms (count-based chunking
concentrates the heavy clusters; hash order already scatters them).
- `__attribute__((flatten))` on the sort: −3% (I-cache bloat).
- `-funroll-loops`: −7%. PGO: +7% bit-identical (excluded by decision).
- Hugepage threshim, SMT-sibling affinity, malloc tunables, 512-bit
vector width: all neutral or worse.
- Rem's union-find: incompatible with the size gates (splices merge
components without visiting roots).
- No duplicate cluster points exist (measured) — nothing to dedup.
- No over-cap fragments exist (measured) — nothing to truncate early.

## Where the remaining time is (quiet, pinned, 39.8 total)

threshold 1.7 · unionfind 4.7 · make clusters 14.0 · fit quads ~14 ·
decode ~3.2 · serial glue ~1.5. Profiles are flat inside the big two
(cost spread across pair machinery and merge passes; no hotspot).
3x (29.3 ms) was determined infeasible under the output-equivalence
constraint on this machine — and relaxing the constraint only buys ~2 ms.
45 changes: 45 additions & 0 deletions ab.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Interleaved A/B of two apriltag_demo builds. Runs alternate A,B,A,B,...
# so machine-state noise (CI load, frequency scaling) biases both sides
# equally; the detector-total ratio is what to trust.
#
# usage: ./ab.sh <build_dir_A> <build_dir_B> [rounds]
set -euo pipefail
cd "$(dirname "$0")"

A=${1:?usage: ab.sh <build_dir_A> <build_dir_B> [rounds]}
B=${2:?}
ROUNDS=${3:-8}

ARGS=(-t 4 -i 1 -x 1.0 -f tagStandard52h13
--save-detections /tmp/ab_dets.tsv --save-timing /tmp/ab_timing.tsv)
imgs=(vide_images/*.jpg)

run_one() { # <build_dir> -> prints detector ms/image
taskset -c 0-3 env LD_LIBRARY_PATH=$1 "$1/apriltag_demo" "${ARGS[@]}" "${imgs[@]}" > /dev/null
awk -F'\t' 'NR>1 { s += $5; img[$1]=1 } END { c=0; for (i in img) c++; printf "%.3f\n", s/c }' /tmp/ab_timing.tsv
}

# warmup
run_one "$A" > /dev/null
run_one "$B" > /dev/null

a_runs=()
b_runs=()
ratios=()
for ((r = 0; r < ROUNDS; r++)); do
a=$(run_one "$A")
b=$(run_one "$B")
a_runs+=("$a")
b_runs+=("$b")
ratio=$(awk -v a="$a" -v b="$b" 'BEGIN { printf "%.4f", a/b }')
ratios+=("$ratio")
echo "round $((r+1)): A=$a B=$b ms/image ratio=$ratio"
done

stats() { printf '%s\n' "$@" | sort -n | awk '{v[NR]=$1; s+=$1} END {printf "mean %.3f median %.3f min %.3f", s/NR, v[int((NR+1)/2)], v[1]}'; }
echo
echo "A ($A): $(stats "${a_runs[@]}")"
echo "B ($B): $(stats "${b_runs[@]}")"
# paired per-round ratios cancel machine-state drift between rounds
echo "paired ratio (A_i/B_i): $(stats "${ratios[@]}")"
Loading