Skip to content

erf: serve f16 from a table instead of converting the whole tensor each eval - #2579

Open
czoli1976 wants to merge 1 commit into
sonos:mainfrom
czoli1976:fix/erf-f16-scratch
Open

erf: serve f16 from a table instead of converting the whole tensor each eval#2579
czoli1976 wants to merge 1 commit into
sonos:mainfrom
czoli1976:fix/erf-f16-scratch

Conversation

@czoli1976

Copy link
Copy Markdown
Contributor

Erf's f16 arm allocated a fresh Vec<f32> the size of the input on every eval, ran the f32 kernel over it, then converted back. Since erf is a unary f16 -> f16 map, the whole thing collapses into a 2^16-entry table: one load per element, nothing allocated per call.

Why it is safe

The table is built by running the registered ops().erf_f32 over all 65536 f16 values, not from a formula. So it reproduces whatever kernel this host would have dispatched to — generic on aarch64, AVX-512 on x86 — and the output is bit-identical on each. erf_f16_lut_matches_the_f32_kernel_on_every_f16 checks that over the full domain.

It is built lazily behind a OnceLock, so a model with no f16 erf never allocates the 128 KiB, and the one-time build is 65536 kernel evaluations.

Numbers

f16 Erf through eval_in_place, criterion against a saved baseline on the merge-base:

elements before after
256 385 ns 108 ns -71.4%
4096 5.32 us 1.33 us -75.3%
65536 83.5 us 20.6 us -75.8%
1048576 1.43 ms 335 us -76.6%

p = 0.00 throughout.

What I tried first

The obvious reading of "remove the allocation" is to keep the convert-run-convert shape and use a fixed stack scratch instead of a Vec. That is a net regression and I would rather record it than have someone repeat it: at a 256-element chunk the per-chunk kernel dispatch costs more than the malloc saved (+5.7% at 4096, +6.7% at 65536), and at a 4096-element chunk the [0f32; N] literal memsets 16 KiB on every eval regardless of input size (+43% at 256 elements). MaybeUninit would fix the second half but this is core, not linalg. The allocation was never the real cost; the per-element compute was.

Relation to #2568

That PR introduces the same table technique for f16 GELU in linalg. The two are independent — different op, different crate, and this one has to build from the registered kernel because erf_f32 differs per arch, whereas the GELU table is built from a fixed scalar. If both land, happy to factor out a shared builder in whichever goes second.

Tests

tract-core 270, test-f16 2378, test-unit-core 816 — green. cargo fmt --all clean; cargo clippy clean (the two remaining warnings are pre-existing on main, in softmax/mod.rs and optim/propagate_roi.rs).

🍍

…ch eval

The f16 arm allocated a fresh Vec<f32> the size of the input on every eval,
ran the f32 kernel over it and converted back. Map the 65536 f16 bit patterns
through the registered f32 kernel once and keep the result, so the activation
is a single load per element and nothing is allocated per call. Building the
table from the registered kernel rather than from a formula keeps the output
identical to what the host dispatched to before.

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