Repository navigation
Conversation
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On the CPU,
argmin,argmax,minandmax(ReduceIndexincpu_backend) use a scalar loop: one compare and one data-dependent branch per element. Forf16andbf16each compare also converts both values tof32, which costs about 3.5 ns per element. These ops run in greedy and Gumbel sampling (argmax over the vocabulary), incandle_nn::ops::softmax(max over the last dim), and in codebook lookups such as snac's quantizer (argmin over 4096 distances).The loop also gives a NaN result that depends on where the NaN is. It starts from the first element, and every compare with NaN is false. So a NaN in the first position always wins, and a NaN anywhere else is skipped:
Changes
min()1. Rows without NaN give the same results as before. The check is one compare per row, outside the loop: a check inside the loop made thef32loop up to 5x slower.argminorargmaxfrom theargminmaxcrate. It selects SSE4.1, AVX2 or AVX-512 at run time on x86 and uses NEON on aarch64.u8,u32,i16,i32,i64,f16,bf16,f32andf64.f8e4m3keeps the loop.f16andbf16, which win at every length (1.4-2x already at 2 elements). Below 128, argminmax's fixed cost per call and the scalar handling of the last partial vector make it slower than the loop for some lengths. For example,f32on AVX2 runs at 0.78x for rows of 63 and 0.96x for rows of 79. The threshold is checked once per call, not per row.-0.0and+0.0compare equal.argminmax0.7.1 (MIT), with itshalffeature. Its only dependencies arenum-traitsandhalf, which candle already uses. It has over 15 million downloads on crates.io, and Polars uses it forarg_minandarg_max. Disclosure: I maintain argminmax.arg_reduce_skips_nan(CPU only): rows of 6 (the loop) and 143 (argminmax, with the extremes after the last full SIMD vector), the strided and non-contiguous paths,f32,f64,f16andbf16, a leading NaN followed by a second NaN, ties, and rows of only NaN values. It fails without the first commit.Benchmarks
A candle-core example, not part of this PR (I can share it). The inputs are uniform in [-1, 1) from a fixed random stream, so both builds get the same data, and every call gets fresh data. The checksums of all outputs are equal between main and this PR, except for Gumbel sampling, which is random. Single thread (
RAYON_NUM_THREADS=1), pinned to one core. Each cell is the median of 7 rounds, with main and this PR alternating. Built with the repository's.cargo/config.toml(target-cpu=native). argminmax picks its instruction set at run time, so builds withouttarget-cpu=nativetake the same SIMD code.f32, 1 row of 32 000f32, 1 row of 151 936f32, 1 row of 262 144bf16, 1 row of 151 936arg_reducebench),f32f16/bf16f32[1, 1024, 1024] over dim 2f32[512, 4096] over dim 1f32, rows of 256f16/bf16f32, rows of 4 / 10 / 64 (the loop)f32, rows of 4 (the loop)f32[1, 1024, 1024] over dim 1 (strided, the loop)The cases with many rows (rows of 4 to 256) reduce about one million elements per call.
The rows below 128 run the loop. A row of 4 takes 2-3 ns, and at that size code layout alone moves the result by ±10-20%. In a variant whose short-row loop was textually identical to main, the rows-of-4 cases still moved: one went from 0.78x to 1.00x, another from 1.05x to 0.91x.
The ops are 5-12x faster for
f32and 50-77x forf16andbf16. In the callers above, the gain is smaller:lm_head(896 → 151 936,f32) + argmaxcandle_nn::sampling::gumbel_softmaxover 151 936 logitscandle_nn::ops::softmaxover the last dim (bert, clip and stable diffusion attention), 12 heads, key length 128 / 512VectorQuantize: distance + argmin, 512 frames, codebook 4096 × 8Greedy sampling saves about 0.1 ms per token, which is within the noise of the matmul.
candle_nn::ops::softmax_last_dimhas its own kernel and does not change.The i7 columns are from this PR's code. The Ryzen columns are from a clean run of the second commit before the NaN fix was added; the NaN fix only adds the first-value check in the loop. A run of the final code on the Ryzen gave ratios within a few percent of these, but other jobs were running on the machine (load 6-13), so I did not use it.
Notes
argmaxandmaxreturn the NaN instead. If you prefer that, argminmax hasnanargminandnanargmax(the first NaN wins), and the fix in the loops is just as small. The CUDA and Metal kernels have no NaN handling either, and this PR does not change them.cargo fmt --all -- --checkandcargo clippy -p candle-core --tests --examples --benches -- -D warnings.cargo test -p candle-coreon aarch64 (NEON), and on x86 with AVX-512 and with AVX2. The lib andtensor_testsbinaries also under Intel SDE as Haswell (AVX2) and Nehalem (SSE4.2).