Skip to content

silu: serve f16 from a table instead of widening every chunk to f32 - #2583

Open
czoli1976 wants to merge 1 commit into
sonos:mainfrom
czoli1976:perf/silu-f16-lut
Open

silu: serve f16 from a table instead of widening every chunk to f32#2583
czoli1976 wants to merge 1 commit into
sonos:mainfrom
czoli1976:perf/silu-f16-lut

Conversation

@czoli1976

Copy link
Copy Markdown
Contributor

The aarch64 f16 SiLU (arm64simd_silu_f16_4n) converts each chunk into an f32 scratch, runs the NEON f32 kernel over it and converts back, on every call — it carried a // TODO: Change this SiLU kernel once we have a native-FP16 one. Since SiLU is a unary f16 -> f16 map, the whole thing collapses into a 2^16-entry table: one load per element.

Why it is safe

The table is filled by calling the same f32 kernel the roundtrip path runs, over an aligned whole number of nr-blocks — which is exactly the slice the element-wise frame hands that kernel directly. f16 -> f32 is exact and the store rounds to nearest-even, so the table reproduces the roundtrip bit for bit rather than re-deriving SiLU from a formula. lut_matches_the_f32_roundtrip_on_every_f16 checks that over the full domain.

Calling the kernel raw rather than through the frame is also load-bearing, not a shortcut: map_slice_with_alignment holds a borrow_mut on the frame's thread-local scratch for the duration of a kernel call, so building the table through the boxed ElementWise panics with RefCell already borrowed the first time a model hits the op. Worth knowing before anyone adds a second table this way.

It is built lazily behind a OnceLock, so a model with no f16 SiLU never allocates the 128 KiB.

Numbers

Kernel, all three variants in one process so there is no cross-run drift:

elements generic f32 roundtrip lut
1024 541 Melem/s 1.560 Gelem/s 3.670 Gelem/s
65536 537 Melem/s 1.574 Gelem/s 3.626 Gelem/s
1048576 536 Melem/s 1.528 Gelem/s 3.116 Gelem/s

2.0-2.35x over the path it replaces. End to end, f16 through a real plan:

block roundtrip lut
SwiGLU — down(silu(gate(x)) * up(x)) 2.767 ms 2.604 ms -5.9%
plain FFN — matmul -> silu -> matmul 1.683 ms 1.592 ms -5.4%

The SwiGLU shape is the one that matters, since that is what Llama/Qwen/Mistral actually run — SiLU is the activation inside the gate, not the block itself, so the extra projection dilutes the kernel win. Arms were interleaved within one session; on this box a criterion baseline compared across processes drifts far enough to invert the sign of a result this size.

x86 is untouched — it has its own x86_64_avx512_silu_f16_16n.

Tests

tract-linalg 4415, tract-core 270, test-f16 2378, test-unit-core 816 — green. cargo fmt --all clean; cargo clippy clean (the one warning in benches/silu.rs is pre-existing, in the scalar reference helper).

🍍

The aarch64 f16 SiLU converted each chunk into an f32 scratch, ran the NEON f32
kernel over it and converted back, on every call. Map the 65536 f16 bit
patterns through that same kernel once and keep the result, so the activation
is a single load per element. The table is filled by calling the f32 kernel
over an aligned whole number of nr-blocks, which is what the element-wise frame
hands it directly, so the two agree bit for bit.

Co-Authored-By: Claude Opus 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