Skip to content

x86 + ARM SIMD - #10

Draft
bouk wants to merge 86 commits into
masterfrom
faster3
Draft

x86 + ARM SIMD#10
bouk wants to merge 86 commits into
masterfrom
faster3

Conversation

@bouk

@bouk bouk commented Jun 16, 2026

Copy link
Copy Markdown
Member

Thx fable

bouk and others added 30 commits June 10, 2026 18:56
--save-detections/--save-timing write per-detection and per-stage TSVs
so builds can be compared for identical output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
benchmark.sh wraps hyperfine over vide_images with git-rev-labeled
artifacts; .gitignore covers the TSV and benchmark outputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
quad_segment_maxima's 4-deep maxima loop re-fit identical segments for
every outer combination; memoize fit_line results per maxima pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fixed sigma=1/cutoff=0.05 kernel (size 7) was recomputed with exp()
for every cluster; compute it once per thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The error, low-pass, and maxima loops indexed errs[] with % sz; a single
branch adjustment wraps without the integer divide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fit_quad malloc'd five buffers and compute_lfps calloc'd a sixth for
every cluster; thread one growable per-task scratch struct through
quad_segment_maxima/compute_lfps/do_quad_task instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old ptsort copied the whole range into temp at every recursion
level. Alternate between two buffers so data is copied only at the
<=5-element leaves. Same splits, leaf networks, and merge tie behavior,
so the sorted order is bit-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
check.sh diffs --save-detections TSVs against a baseline run with
per-field epsilon gates (id/hamming exact, coords and margin tolerant).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lets sqrt() and friends compile to bare (vectorizable) instructions
instead of errno-guarded libm calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Free each cluster in its quad task (parallel, cache-warm) instead of a
serial ~11k-free loop after fit_quads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each merge-tree level's pairwise merges fan out to the worker pool
instead of running serially on the calling thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bucket selection becomes a mask instead of an integer divide, and the
table is sized to the slab (well under one entry per 64 slab pixels) so
it stays cache resident instead of spread across 0.2*w*h callocs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the hash table sized independently of the slab, the pool-chunk
array is seeded small and realloc-doubled as chunks fill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The collection loop already iterates buckets in order, so the secondary
key is just the bucket index instead of a re-hash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consecutive boundary points almost always extend the same cluster, so
remember the last entry and skip the hash lookup when the id matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Most pixels are interior to a region and never reach a black/white
boundary neighbor; compute rep0 (and its size gate) lazily on the first
one instead of eagerly for every boundary-candidate pixel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read uf->size[rep] + 1 directly instead of calling
unionfind_get_set_size in the hot boundary loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
key64 = monotone(slope) << 32 | ~index. Full-u64 merge comparisons
reproduce the historical merge's take-right-on-tie rule (left-run
elements always carry smaller original indices -> larger complements);
leaf networks compare the slope word only, matching no-swap-on-tie.
Bit-identical ordering, but the sort moves 8-byte keys instead of
12-byte structs and compares without function calls; points are
gathered once at the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drive cluster construction from row runs: component representatives
resolve once per run (cached) instead of per boundary pixel, 127 spans
skip wholesale, and the same (y, x, neighbor) emission order is kept so
output is bit-identical.

Cluster points accumulate in fixed-size chunks bump-allocated from a
per-task pool instead of doubling zarrays (a frame can produce ~3M
points; growth reallocs were copying tens of MB per frame). Clusters
materialize into exact-size zarrays at collection.

Output verified byte-identical over the 133-image corpus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
threshim and the four tile min/max arrays are kept on the detector and
reused instead of allocated and freed every frame; every entry is
rewritten before use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Only run heads (plus the lazily-initialized last column) enter the
union-find: the run-driven cluster pass resolves representatives through
run heads, so the per-pixel parent fill (~25MB of writes/frame) was
waste. Unions operate on head ids -- same union graph, same nodes, so
roots, sizes, and output are identical. The serial stitch becomes a
run-pair pass and the per-pixel line code is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While a run and the run below keep overlapping, each pixel emits
exactly its (0,1) and (1,1) points into the same cluster entry, so emit
them in a tight loop without per-pixel pointer or gate checks.

Output verified byte-identical over the 133-image corpus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-point window error loop computes the small eigenvalue inline
(identical arithmetic) for windows that don't wrap the cluster ends;
only the wrapping windows still call the general fit_line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merge comparisons are data-dependent coin flips; select with a
conditional move instead of a ~50%-mispredicted branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same output: clusters ascend by (hash, id) and split clusters
concatenate their points in task order, exactly as the pairwise tree
produced. Saves a workerpool barrier per level and all intermediate
lists. Detections verified within 1e-4 px of baseline (the build now
uses -march=native, whose FMA contraction shifts coordinates by ~1e-4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The slope was only ever used to order points, and the ordering now runs
on packed u64 keys built directly in fit_quad's angle loop, so the
stored float is dead weight. Dropping it cuts a third off all point
traffic (chunk storage, merges, collection copies, sort gathers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sorted point array was only consumed by compute_lfps. Sort just the
key array and have compute_lfps gather points through the key indices,
skipping the per-cluster point permutation and its scratch buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
__AVX2__-guarded vectorization (scalar fallback kept) for the threshold
stage's 4x4 tile min/max and the 3x3 tile blur. Integer min/max only, so
results are exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rows were being run-length encoded up to four times per frame in scalar
byte loops (union-find tasks, stitch, and gradient clustering each
re-scanned threshim). A single two-pass parallel build (SIMD count via
change masks, prefix-sum, SIMD fill) now produces packed row-run tables
cached on the detector, and all consumers read those instead of
re-scanning the 6.4MB image.

Output verified within 1e-4 px over the 133-image corpus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bouk and others added 30 commits June 10, 2026 19:34
Eight-lane accumulation summed once at the end; the dot only decides
the reversed-border sign, which sits far from zero for any usable
cluster, and the detection set is unchanged on the corpus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Balances per-chunk overhead against load balance across the worker pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
taskset the four workers onto a single CCD so the small shared
structures don't bounce between L3s when the scheduler spreads them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One allocation backs all of a task's collection entries instead of
~11k mallocs+frees per frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clusters are now a single allocation (header + points) instead of a
zarray shell plus separately-malloc'd data: one malloc and one free per
cluster, and merged fragments concatenate into one exact-size block.

Output verified within 1e-4 px; all 8 ctest pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decoding does not modify the quad; the copy only matters when several
families restart from the same fit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The k-way heap walk now only records fragments and group boundaries;
the exact-size concatenations fan out to the worker pool into
order-preserving output slots.

Output verified within 1e-4 px; all 8 ctest pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pass 1 now walks points sequentially (the cluster's raster-coherent
emission order), keeping its four image-gradient loads cache-local
instead of scattering them around the perimeter in angle order; the
prefix pass gathers the per-point values from the small scratch arrays
through the sorted key indices instead. Identical arithmetic per point.

Output verified within 1e-4 px; all 8 ctest pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same projection expressions; the matd element loads happen once per
quad instead of per bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four steps per iteration: both bilinear samples, the bounds checks, and
the gradient test run as 4-wide doubles; rejected lanes contribute an
exact 0.0 to the weight accumulators, so only the final reduction order
differs (within 1e-4 px on the corpus). Indices are clamped so masked
lanes still load safely. Scalar tail handles max_steps % 4.

All 8 ctest pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Baseline on Apple M3 Pro, 133-image vide_images2 corpus, 4 threads,
tagStandard52h13: 29.0 ms/image detector total, 6.28 s wall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 tiles (16 columns) per iteration vs AVX2's 8; pairwise min/max
collapses the 4x4 tile reduction, vqtbl1q expands tile min/max to
columns, and the same saturating-subtract compare tricks carry over.
Output byte-identical to the scalar baseline (4583/4583 detections).

threshold 3.13 -> 2.07 ms/image on M3 Pro (vide_images2, 4 threads).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 bytes per iteration; vshrn narrows the byte-compare mask to a
nibble mask (4 bits per byte), giving ctz/popcount-driven iteration
over change positions like the AVX2 movemask version.
Output byte-identical (4583/4583).

threshold (which includes the fused RLE passes) 2.07 -> 1.68 ms/image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two uint64x2 registers stride the packed (x,y,gx,gy) pair pattern
forward 4 points per iteration, same as the AVX2 version's single
256-bit register. Output byte-identical (4583/4583).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vld4 deinterleaves x/y/gx/gy directly, so the key loop is 4 points
per iteration with none of the AVX2 blend/permute shuffling. Keys are
bit-identical to scalar; the 4-lane dot accumulator only decides the
border-orientation sign. Output byte-identical (4583/4583).

Also gate bench_neon.sh on a quiet-load window; this machine is
shared and numbers taken under load are garbage.

fit quads 12.9 -> 11.6 ms/image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Window-error eigenvalues 2-wide (with the exact double->float->
sqrtf->double sequence via vcvt_f32_f64/vsqrt_f32), the 7-tap
low-pass filter 4-wide with two accumulator chains, the maxima scan
4-wide with a lane-weighted-add movemask, and the lfps gradient
weights through vsqrtq_f64. Output byte-identical (4583/4583).

fit quads 11.6 -> 10.8 ms/image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two steps per iteration in f64x2 with truncating vcvtzs casts,
s64-compare bounds masks, scalar-clamped gathers, and bit-masked
weights so rejected lanes contribute exactly 0.0, mirroring the
4-wide AVX2 version. Output byte-identical (4583/4583).

decode+refinement 1.70 -> 1.59 ms/image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The serial image_u8_decimate had exactly one caller, the detector,
which always has a worker pool. Replace it with
image_u8_decimate_parallel in image_u8_parallel: output rows are
chunked across the pool, in whole 2-row blocks for the 1.5 path.
Output is identical for every factor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decimating by 2 now averages each 2x2 input block instead of point
sampling, with AVX2 and ARM NEON paths (identical rounding tree:
vertical rounding average per column, then a rounding average across
the column pair). Point sampling aliases thin tag borders — whether a
1-2 px border survives decimation depends on its phase relative to the
sample grid — while the box average makes segmentation deterministic
and slightly improves corner accuracy. Sequential instead of strided
reads also make the stage ~7x faster (1.59 -> 0.21 ms/frame on the
benchmark corpus).

On the 133-frame corpus at quad_decimate=2 vs the point sampler:
detection retention statistically unchanged (901 vs 883 missing of
4583), spurious detections down 17 -> 5, mean corner error 0.056 ->
0.052 px. quad_decimate=1 output is unaffected. In the pixel-corner
convention a decimated coordinate c maps to 2*c on the input, so the
existing corner scale-up holds; the residual half-pixel sample-center
shift is absorbed by refine_edges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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