Skip to content

Give elementwise maps a schedule, and fix what that exposed - #459

Open
ealmloff wants to merge 17 commits into
mainfrom
perf-kernels
Open

ealmloff wants to merge 17 commits into
mainfrom
perf-kernels

Conversation

@ealmloff

@ealmloff ealmloff commented Sep 8, 2026

Copy link
Copy Markdown
Member

Stacked on #458, which is what made the benchmark numbers trustworthy enough to chase. It is based on that branch, so the two commits from #458 ride along here until it merges; the new work is the last commit, cb0bb6269. One kernel gap closed, two correctness bugs fixed on the way, and the rest of the losses measured rather than guessed at.

An elementwise map had no schedule to choose from

lower_floor::lower_map minted every Launch::Map with ScheduleDomain::Point, a domain with no alternatives in it. Nothing for the extractor to choose between and nothing the tuner could race, so every elementwise kernel on the GPU ran one output per thread, forever. The tuner said so when asked: "not raced: no launch of 1 offers a variant above 0 macs".

fusor_tile::rules already explains the intended shape of the fix, in a comment on why there is deliberately no TILE_MAP rule: a Map domain minted as an additive alternative regresses extraction, so it has to be attached where the node is minted. It now is.

The cost model picks tm = 4 unprompted, and racing the domain confirms that is the right call: 28.5 us untiled against 19.5 us tiled, and no other offered tiling beats it.

512x512 f32 add and square kernel
before 28.2 us
after 19.7 us

The linear tiling wrote a quarter of its output

Attaching the domain exposed a miscompile that had been sitting behind it. tiled_grid divides the dispatch by tm, so a thread owns tm elements, but the dim: None arm of lower_kmap wrote exactly one and left the rest of the output untouched. The member sweep caught 20 of them the moment the tilings became selectable, every one reading incumbent -1.82 vs 0.

The arm now walks its tm elements, taking them a grid apart rather than consecutively so the threads of a workgroup stay on consecutive elements and the accesses keep coalescing.

This was reachable before this branch: fusion::linear_domain_of hands composites the same MapDomain::linear, whose entries are all dim: None.

The member sweep called f16 rounding a miscompile

agrees compared bit patterns for every dtype except f32, so two members of a class that evaluate the same expression through different intermediates were reported as computing wrong values whenever the result was half precision. dtypes::arithmetic_in_every_float_dtype tripped it.

Integers still have to match exactly. Floats are compared to the precision they carry, f16 and bf16 through a wider relative bound than f32 because half precision holds about three decimal digits.

Conformance: 746 results, 737 passed, 9 skipped, 0 failed, 0 miscompiles under the member sweep.

What is still slow, and why

Measured, not guessed. Per-kernel spans are from FUSOR_TIME_RANGE, host cost from FUSOR_RESOLVE_PROFILE.

conv1d, 14x behind burn. Four dispatches: scatter_dense 96 us, kmap 12 us, kfold 97 us, kmap 11 us, for a convolution of 645 kFLOP. Two causes, both structural. The zero padding is materialized as a Scatter{Set} into a zeros leaf, which is 45% of the time and writes 33 KB at about 0.34 GB/s. And because that scatter is in place, the whole plan is disqualified from tuning"not raced: the plan has an in-place launch" — so nothing ever corrects the second cause: the contraction's fold dispatches grid=[16384,1,1], a whole workgroup per output element to reduce 40 values.

A k-aware move-ordering seed for fold strategies was tried and reverted: it left conv's fold at 96.5 us, so the selection is the cost model's rather than the seed's, and shipping an inert reordering that perturbs every other fold is not worth it.

The cheap cases are dispatch-bound in the browser, not kernel-bound. Their browser times cluster at 0.033 to 0.044 ms whatever the workload: a 50k-element rank-4 multiply costs 0.033 and a 262k-element add costs 0.044. A resolve on a replay hit with one launch costs about 35 us of host time natively, which is why the kernel win above does not show up on the page. Closing that gap is host-path work, not kernel work.

Attention and dense matmul, around 2x. attention_causal_small spends 115 us in one sgemm; dense_matmul_square runs a 256-cube coop_matmul at 62 us, about 540 GFLOP/s.

https://claude.ai/code/session_01AUTdnWQ8FGUP5FKR7dJsKs

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Preview available at https://floneum.github.io/kalosm/pr-preview/pr-459/

…ove its indices in range

A fold's work did not mention its lane group, so every reduction strategy
priced the same and the choice fell to a seed order that led with the widest.
A 40-element reduction ran on 256 lanes where 2 measured eleven times faster.
Charging the slots a strategy issues, and seeding by the narrowest group that
still fills the device, picks what the hardware measures.

Alongside: the tuner refused to race any plan holding an in-place launch, when
what it needs is repeatability -- a Set scatter re-runs to the same bytes, and
padding, cat, stack and slice_assign are all Set scatters, so no convolution
could ever have a schedule measured. And the sampler took two points off a
schedule domain, so three of an elementwise map's four tilings were never
timed.

A split-K coop point is now illegal rather than merely unbuildable: no emitter
combines the partials, and a cost change was enough to make one win.

conv1d 12.0x slower than burn -> 2.3x; batched matmul 2.4x slower -> 2.7x
faster. 746 conformance results, 0 failed, 0 miscompiles.
Opening a command encoder, closing it and submitting cost 6.4 us of host time
between them in a browser, where every call crosses the wasm boundary. Paid
once per resolve against kernels that often run in less, it was most of what a
small dispatch cost: 19 us of a 20 us host path, measured per phase in the
page itself.

Work is now recorded into an encoder that stays open, and submitted when
something needs the results -- a wait, a readback, a timestamp read, an upload
that would otherwise jump the queue, or 32 dispatches, whichever comes first.
Nothing observes the difference: submissions run in order, so a dispatch
recorded later still sees an earlier one's writes, which is the same guarantee
the buffer pool already recycles on.

A timed resolve keeps its own submission, so the tuner still measures what it
thinks it measures.

Six elementwise cases went from 1.7-2.3x slower than burn to parity or ahead;
decode-shaped kernels gained too (q4k gemv 7.7x -> 10.8x faster, rope 3.5x ->
5.6x). 746 conformance results, 0 failed, 0 miscompiles.
wasm requests neither cooperative matrices nor subgroups, so a native run
exercises schedules the page never sees -- which is how a matrix-vector kernel
winning a matrix-matrix product stayed hidden. FUSOR_NO_COOP already dropped
the first; FUSOR_NO_SUBGROUP drops the second, and together they reproduce
what a page runs.
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