webarkit / webarkit/purecv

perf(simd): close the WASM SIMD gap — measure on target, restructure loops, then vectorize

Open
#97 0 comments 0 reactions 1 assignee View on GitHub

@kalwalt is already working on this.

Since Aug 22, 2026.

enhancement rust rust-code SIMD wasm
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 generic Matrix<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::dispatch emits vectorized code at all, or degrades to a transparent
    wrapper around the scalar loop
  • -C target-feature=+simd128 is required in RUSTFLAGS regardless of the simd
    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_bench to wasm32-unknown-unknown
  • Run in-browser across three configurations:
    scalar / +simd128 / +simd128 + threads (wasm-bindgen-rayon)
  • Record results in benches/benchmark_results.md under a clearly separated
    WASM section
  • Annotate existing native tables to state that -C target-cpu=native rows 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_f32 beyond f32 (u8, i16 currently fall back to
    scalar — see the "Only f32/f64 have simd_*" comments in src/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 on i32x4 lanes; RGBA maps one pixel per lane in
    little-endian, avoiding the stride-3 problem that chunks_exact(3) has for RGB.
    Should beat the current 1.9×.
  • Hamming distance for ORB matching — i8x16.popcnt exists 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-rayon behind a parallel-wasm feature
  • 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.md contains 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 +simd128 must be set explicitly, and the build scripts
    set it if so
  • ORB detect_and_compute on 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.