perf(simd): close the WASM SIMD gap — measure on target, restructure loops, then vectorize
@kalwalt is already working on this.
Since Aug 22, 2026.
- Dominant language
- Rust
- Stars
- 25
- Forks
- 2
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 15
Description
perf(simd): close the WASM SIMD gap — measure on target, restructure loops, then vectorize
Context
purecv is intended to serve as the compute backend for WebAR workloads in the browser.
That target has a constraint the current benchmark suite does not reflect:
rayon is not available in the WASM build. crates/wasm/Cargo.toml sets
default-features = false explicitly to keep it out. Yet benches/benchmark_results.md
concludes that parallelism is the dominant optimization for nearly all operations, with
SIMD providing meaningful additional gains only for pixel-level math and f32 derivative
kernels.
In other words: the browser gets the one column of the benchmark table where purecv
performs worst, and no benchmark has ever been run on the actual target.
Concretely, orb_detect_512x512 measures 30.7 ms in Parallel+SIMD — comfortably above
30 FPS — but 97.8 ms in SIMD Only, which is roughly 10 FPS. The 30 FPS figure quoted
in the ORB analysis section is not achievable in the default WASM build.
Finding: there is no explicit SIMD code in the repository
A survey of src/**/simd.rs shows that every so-called SIMD kernel is a plain scalar
loop wrapped in pulp::Arch::dispatch. There are no explicit vector operations
anywhere in the codebase — no Simd:: types, no .splat(), no lane-wise intrinsics.
Example, src/imgproc/simd.rs:
let arch = pulp::Arch::new();
arch.dispatch(|| {
for (out, inp) in gray_data.iter_mut().zip(rgba_data.chunks_exact(4)) {
let r = inp[0] as u16; let g = inp[1] as u16; let b = inp[2] as u16;
*out = ((r * 77 + g * 150 + b * 29 + 128) >> 8) as u8;
}
});
This is not a criticism of the approach — it works, and it is far more maintainable than
intrinsics. But it means the two benchmark columns are both measuring LLVM
auto-vectorization:
-C target-cpu=native |
--features simd |
|
|---|---|---|
| What it is | compiler flag | dependency + source-level closure |
| Who vectorizes | LLVM | LLVM |
| ISA selection | compile time | runtime |
| Scope | whole crate | the dispatch closure only |
| Portable binary | no | yes |
Why sobel_3x3_f32 gets 4.5× and generic sobel_3x3 gets nothing
Not because one is "hand-written". Because simd_deriv_3x3_row_f32 is shaped so LLVM
can vectorize it:
- flat
&[f32]slices rather than genericMatrix<T> - kernel coefficients hoisted out of the loop into
[f32; 9] - interior columns only — borders are the caller's responsibility, so no
data-dependent branching in the loop body - no intermediate allocations, contiguous access
The lever is loop restructuring, not intrinsics. That is good news: it is cheaper,
stays architecture-neutral, and there is already a working reference implementation
in-tree.
Phase 0 — Blocking verification: does pulp do anything on wasm32?
pulp::Arch::dispatch performs runtime ISA selection. On x86 this reads CPUID and
picks a variant. WASM has no runtime instruction-set switching — a module either
validates with simd128 or fails to load entirely.
It must therefore be established whether, on wasm32-unknown-unknown:
-
Arch::dispatchemits vectorized code at all, or degrades to a transparent
wrapper around the scalar loop -
-C target-feature=+simd128is required inRUSTFLAGSregardless of thesimd
cargo feature - if so, whether
crates/wasm/scripts/*currently sets it
If +simd128 is not being set, today's WASM build is entirely scalar even with
--features simd — and no existing benchmark would have revealed this, since every
measurement in the report is native x86.
This is blocking: nothing below is worth doing until it is answered.
Phase 1 — Benchmark harness on the real target
- Port
imgproc_bench/features2d_bench/video_benchtowasm32-unknown-unknown - Run in-browser across three configurations:
scalar /+simd128/+simd128+ threads (wasm-bindgen-rayon) - Record results in
benches/benchmark_results.mdunder a clearly separated
WASM section - Annotate existing native tables to state that
-C target-cpu=nativerows measure
LLVM auto-vectorization with wide (AVX2/AVX-512) registers and do not transfer to
WASM, where SIMD128 is 128-bit only
Without this, every optimization decision below is guesswork.
Phase 2 — Restructure loops for auto-vectorization (cheap, proven)
Apply the simd_deriv_3x3_row_f32 pattern — monomorphic slices, hoisted constants,
interior/border split, no allocation — to the hot paths of the WebAR pipeline.
Priority order follows the tracking pipeline, not the benchmark table:
-
gaussian_blur/blur— separable convolution, vertical pass first -
pyr_down - FAST corner score + non-max suppression
- ORB: intensity centroid and BRIEF pattern rotation (currently 17% gain — headroom)
- Extend
simd_deriv_3x3_row_f32beyondf32(u8,i16currently fall back to
scalar — see the "Only f32/f64 have simd_*" comments insrc/core/arithm.rs)
Phase 3 — Explicit SIMD128 where restructuring is not enough
Only for kernels where Phase 1 measurements show auto-vectorization plateauing:
-
rgba_to_gray— mask/shift oni32x4lanes; RGBA maps one pixel per lane in
little-endian, avoiding the stride-3 problem thatchunks_exact(3)has for RGB.
Should beat the current 1.9×. - Hamming distance for ORB matching —
i8x16.popcntexists in baseline SIMD128 and
is a strong fit -
resize— integer-factor downscale only, where reads remain contiguous
Note the SIMD128 constraints that shape this: no gather instruction; i8x16.shuffle
requires compile-time constant indices (dynamic shuffling is limited to single-vector
i8x16.swizzle); fma and rsqrt are Relaxed SIMD, not baseline.
Phase 4 — Evaluate threads in the browser
Given that rayon delivers 4–12× where SIMD delivers 1–2×, this is likely the larger
lever for WebAR:
- Prototype
wasm-bindgen-rayonbehind aparallel-wasmfeature - Measure against Phase 1 baselines
- Document the deployment cost: COOP/COEP headers are mandatory for
SharedArrayBuffer, which constrains how downstream projects host their assets - Decide whether purecv ships single-threaded by default with threads opt-in
Non-goals
These are not worth vectorizing, for structural reasons. Recording them here so the
question is not reopened:
| Function | Reason |
|---|---|
integral_image |
prefix sum — serial data dependency |
equalize_hist |
histogram build is scatter; no scatter in SIMD |
| Canny hysteresis stage | sequential flood fill (earlier stages do vectorize) |
bilateral_filter |
data-dependent exponential weights — report confirms no gain |
LUT |
pure gather |
remap / warp_affine |
gather; no gather instruction in WASM |
flip / transpose / split / merge |
memory-bandwidth-bound, not compute-bound |
element-wise add/sub on large matrices |
memory-bandwidth-bound |
The last two rows are worth distinguishing from the others: those operations are
vectorizable in principle, but the gain is absorbed by memory bandwidth. If the same
operation runs on cache-resident data inside a fused pipeline, SIMD becomes worthwhile
again — so this classification is context-dependent, not absolute.
Acceptance criteria
-
benches/benchmark_results.mdcontains a WASM section with scalar / simd128 /
simd128+threads columns - Existing native tables annotated to prevent their numbers being read as
WASM-applicable - It is documented whether
+simd128must be set explicitly, and the build scripts
set it if so - ORB
detect_and_computeon 512×512 measured in-browser, with a stated figure for
what is achievable single-threaded - Non-goals table reflected in module-level documentation so the rationale survives
Open question
If in-browser measurements show that real-time ORB is unreachable single-threaded, and
COOP/COEP headers are considered too costly a requirement for downstream consumers, then
the WebAR pipeline needs rethinking upstream of purecv — e.g. full detection every N
frames with optical-flow tracking in between, rather than per-frame detection.
That is an architectural decision for the WebAR layer, but the numbers from Phase 1 are
what should drive it.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.