Skip to content

Winograd streaming forward pass: 5.6-5.9x throughput, portable and safe - #8

Open
ealmloff wants to merge 3 commits into
mainfrom
streaming-forward-pass
Open

Winograd streaming forward pass: 5.6-5.9x throughput, portable and safe#8
ealmloff wants to merge 3 commits into
mainfrom
streaming-forward-pass

Conversation

@ealmloff

@ealmloff ealmloff commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

Rebuilds the forward pass as a streaming, length-aware Winograd pipeline for a ~2x-5.9x single-inference throughput gain (see table), built entirely on fearless_simd — the crate compiles under #![forbid(unsafe_code)], with no FFI, no intrinsics, no platform cfg, no new dependencies, and the same engine on every target. The old repeated-tail tensor pipeline is deleted outright (~750 lines; layers.rs goes 951 → 81 lines).

Commit 1 — streaming engine:

  • Padded input buffers — conv padding is materialized, so the kernels are branch-free with no bounds checks or edge cases in the hot loops.
  • Precomputed tails (ForwardPlan, once per process) — every row at or beyond the data-dependent range of a stage equals the same row of a forward pass over an all-padding sequence; suffix max/sum tables make the global pool O(1) in the padded tail. Per call, only input-dependent rows are computed.
  • Endpoint max-pooling — GELU is unimodal (single minimum at x ≈ -0.7517), so max(gelu(x_i)) over a pool group is attained at an endpoint of [min x_i, max x_i]: two GELU evaluations per pooled output, fused in registers.

Commit 2 — Winograd + assembly-driven kernel work:

  • The disassembly showed the original inner loop spending 695 instructions per 144 FMAs on accumulator spills, bounds checks, and re-streaming the whole kernel per position block. After packing weights into contiguous per-16-channel groups, moving the group loop outermost, and register-blocking the accumulators, the hot loop is 22 instructions per 16 FMAs (~95% of the NEON f32 FMA ceiling).
  • At the FMA ceiling, only fewer multiplies go faster: every conv stage now runs as F(4, k) Cook-Toom minimal filtering (1.9-2.8x multiply reduction). Transform kernels are generated from exact rational matrices by scripts/gen_winograd.py, with ±a interpolation points factored into shared even/odd halves. fp32 drift vs direct convolution is ~1e-5, two orders below the runtime's Padé GELU approximation error.
  • The reference pipeline is replaced by a plain scalar oracle written in test code; the forward plan bootstraps its tail rows by running the Winograd stages once over an all-padding sequence.

cargo bench --bench detect, M2 Max, same machine:

Case main this PR speedup
short (68 B) 134.1 µs 23.8 µs 5.6x
full window (4.6 KB) 2.277 ms 386.3 µs 5.9x

detect() reads a fixed ≤2 KB window, so per-file cost is constant in file size — a 1 MB input classifies at multiple GB/s; the bench cases are the worst case by construction (noted in BENCHMARKS.md). The short case is floor-bound by streaming the ~720 KB of transformed weights through a fixed-cost input.

Test plan

  • cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings, typos
  • cargo test --release: 27 tests + 10 doctests, including a fuzz test comparing the engine against a naive scalar oracle across 18 unit-sequence lengths (tolerance 1e-2 on logits) and a unit test for the pooled endpoint identity over adversarial negative mixes
  • #![forbid(unsafe_code)] enforced at the crate root
  • Tree-mode output on a local wasm-bindgen checkout byte-identical (95.38% accuracy, unchanged)

Generated with Devin

ealmloff and others added 2 commits July 23, 2026 20:41
Replace the per-call repeated-tail tensor machinery with a streaming
length-aware pipeline built entirely on fearless_simd, and add
#![forbid(unsafe_code)]: no FFI, no intrinsics, no platform cfg, and
the same engine on every target.

- Padded input buffers: each stage writes into a buffer with the conv
  padding materialized, so every conv block takes the branch-free SIMD
  fast path with no edge handling in the hot loop.
- Precomputed tails (ForwardPlan): every row at or beyond the
  data-dependent range of a stage equals the same row of a forward
  pass over an all-padding sequence, computed once per process,
  including suffix max/sum tables that make the global pool O(1) in
  the padded tail. Per call, only input-dependent rows are computed.
- Endpoint max-pooling: GELU is unimodal with a single minimum at
  x ~= -0.7517, so max(gelu(x_i)) over a pool group is attained at an
  endpoint of [min x_i, max x_i] - two GELU evaluations per pooled
  output instead of one per position. The final stage folds GELU
  straight into the global max/sum reduction.
- Scratch lives in a reused per-thread buffer.

cargo bench --bench detect on an M2 Max (same machine, before -> after):
- short (68 B): 134.1 us -> 35.9 us (3.7x)
- full window (4.6 KB): 2.277 ms -> 753 us (3.0x)

detect() reads a fixed <=2 KB window, so per-file cost is constant in
file size; a 1 MB input classifies at multiple GB/s.

The previous tensor pipeline is kept as logits_reference: a fuzz test
checks the fast path against it across 107 unit-sequence lengths (max
logit drift < 1e-2; observed ~1e-6 from pooling reassociation), plus a
unit test for the pooled endpoint identity. Tree-mode outputs on local
repos are unchanged.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Convert every conv stage to F(4, k) Cook-Toom minimal filtering,
cutting multiplies per output tile 1.9-2.8x: weights are transformed
once at load, inputs once per stage, and the pointwise stage keeps the
group-outer/packed-weight/register-blocked structure that the assembly
showed was needed (the previous inner loop spent 695 instructions per
144 FMAs on accumulator spills, bounds checks, and re-streaming the
whole kernel per position block; the hot loop is now 22 instructions
per 16 FMAs). Transform kernels are generated from exact rational
matrices by scripts/gen_winograd.py, with +-a interpolation points
factored into shared even/odd halves. fp32 drift versus direct
convolution is ~1e-5, two orders below the runtime's Pade GELU
approximation error.

The old repeated-tail tensor pipeline is gone (~750 lines): the
forward plan now builds its input-independent tail rows by running the
Winograd stages densely over an all-padding sequence, and the fuzz
test checks against a plain scalar oracle written in the tests
instead.

cargo bench --bench detect on an M2 Max (same machine, this branch
before -> after):
- short (68 B): 35.9 us -> 23.8 us (1.5x)
- full window (4.6 KB): 750.6 us -> 386.3 us (1.9x)

Cumulative versus main before this branch: 5.7x and 5.9x. The short
case is floor-bound by streaming the ~720 KB of transformed weights
through a fixed-cost input; the full window sits within ~15% of the
NEON f32 FMA ceiling for the remaining multiply count.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
@ealmloff ealmloff changed the title Restructure the forward pass for 3-3.7x throughput, portable and safe Winograd streaming forward pass: 5.6-5.9x throughput, portable and safe Jul 24, 2026
There is only one forward pass now; drop the qualifier left over from
when the reference pipeline existed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.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