ggml: add GGML_OP_SNAKE for fused Snake activation#22613
ggml: add GGML_OP_SNAKE for fused Snake activation#22613ServeurpersoCom wants to merge 5 commits into
Conversation
Snake : y = x + sin^2(a * x) * inv_b The op fuses the full activation into a single kernel pass per backend. A naive decomposition needs 12 ggml ops including two ggml_repeat broadcasts that copy the full tensor twice (e.g. koboldcpp/qwen3tts/ audio_tokenizer_decoder.cpp); the fused op cuts that to a single read + write. Backends : CPU, CUDA, Metal, Vulkan. F32, F16, BF16 inputs. Used by acestep.cpp on the SEANet decoder of the VAE.
Adds test_snake to test-backend-ops covering F32, F16 and BF16 inputs on a representative BigVGAN-style vocoder shape (24 kHz, 192 channels). Validates math correctness and per-backend support.
Adds test_snake_equiv to test-backend-ops which builds the naive 5 op decomposition (mul, sin, sqr, mul, add) and the fused ggml_snake on the same inputs, then compares backends on (naive - fused). Proves that the fused op preserves the math of the naive decomposition on every backend, not just that backends agree among themselves. Output is ~0 by construction, which makes the default nmse metric (mse(a, b) / mse(a, 0)) numerically unstable: empirically nmse blows up to 1.49 versus a 1e-7 threshold on a clean run. err() is overridden to mean absolute error between backends, well-defined near zero, with a 1e-5 tolerance. err() is virtual and already overridden by other tests in this file. Shape [256, 192] matches a BigVGAN-style vocoder layer (24 kHz).
|
This can just a be fusion inside the CUDA backend, as it's a pretty straight forward one. |
Thanks for the look. Quick questions before deciding, just want to make sure I understand the fusion path: Does it cover all backends or only CUDA? Snake is exercised on CUDA, Vulkan, Metal, WebGPU and CPU SIMD via acestep.cpp, so I'd want non-CUDA users to get the fused path too. Does it handle multiple input shapes of the same logical op? Users write Snake differently depending on precomputation: the full form y = x + sin(exp(a)x)^2 * exp(-b) (12 ops, used by koboldcpp/qwen3tts) versus the reduced form y = x + sin(ax)^2 * inv_b with a and inv_b precomputed at load (5 ops, used by koboldcpp/acestep). And can downstream code still call something like ggml_snake(x, a, inv_b) directly, or does every user have to emit the decomposition and trust the matcher? |
GGML_OP_SNAKE pushes GGML_OP_COUNT from 96 to 97. Bump RPC_PROTO_PATCH_VERSION from 0 to 1 and update the static_assert guard so old RPC servers cleanly reject graphs containing the new op via version mismatch instead of treating it as garbage.
|
No each backend will need to detect these nodes in the graph and do the fusion. It's what we do for frankly way common operations like topk-moe and gated ffn. So I don't see why this should be any different. Supporting a new OP is a maintenance burden, and as such adding new ops must be drastically effective. Just as an example, acestep would fall back to the CPU if any backend doesn't implement this op, making it way slower than the unfused version. |
|
Maybe it would be good to have some common fusion infrastructure. It is a lot of duplicated work to detect and fuse the same pattern in each backend. |
|
We do have something like this for FA and now GDN, where ggml looks for the fused op. However if you look at the machinery involved in doing that, it has to be really worth it to do this. |
|
I add the missing abstraction layer... |
|
This is great! The fusion machinery works perfectly. I'm seeing less than 1% overhead compared to a direct kernel call, within run-to-run variance, while the naive 5-op path loses 30% on CUDA (20% on Vulkan) on my Oobleck VAE, so the difference between fused and naive is clearly measurable (I observed the full loss when my matching was incorrectly written, before I fixed it). I'm checking the correctness for Vulkan and how to perform clean tests like existing fused ops. then remove the public GGML_OP_SNAKE op as you requested. |
|
ggml_flash_attn_ext under Vulkan causes non-deterministic BF16 divergences, and work some time! For documentation purpose : |
What do you mean? |
On acestep.cpp I spent the day narrowing this down: cosine similarities were blowing up in my DiT, only in BF16, mostly on the deeper / longer models (SFT, Xl-sft, 24-32 layers x 50 steps), and non-deterministically (roughly 50% per run). I eventually tried --no-fa and everything became bit-exact good across all 4 models, all runs. Root cause: under Vulkan, ggml_flash_attn_ext only has f32_f16 pipelines, no BF16 variant. K/V in BF16 get silently down-cast to F16 before the kernel, so any activation above 65504 becomes ±Inf and explodes through soft_max. CUDA has native BF16 FA pipelines, so it didn't trigger there. That detour delayed me on finishing the Vulkan pattern matching work. |
|
Please file a bug for that, with instructions for how to reproduce it. If you can catch it with GGML_VULKAN_CHECK_RESULTS, even better. |
Of course, I'll file a separate bug for it with a proper repro. I'm planning to isolate it, possibly via a small standalone C++ |
|
Measured on acestep, which I migrated to fully use the GGML autofuse pass for all snake kernels. The test script runs sequentially across all DiT quants (BF16, Q8_0, Q6_K, Q5_K_M) while the VAE itself stays BF16. Metal is still pending measurement, where the speedup should be even more pronounced thanks to the threadgroup-dispatched fused kernel avoiding the 5 separate command-encoder roundtrips of the naive chain. Env var used for the NAIVE pass The metric is the total VAE execution time; only the snake activation path differs between the two columns (autofused kernel vs naive mul/sin/sqr/mul/add chain), everything else including the optimized col2im_1d kernel is identical. |
|
Therefore, this approach would allow us to separate the kernels we want upstream into separate PR since everything is decoupled |
|
Creation of decoupled mini-PRs |
|
Noting two BF16 coverage gaps on the Vulkan backend: (1) FlashAttention has no BF16 pipeline variant, so K/V get silently downcast to F16 and overflow to ±Inf above 65504, and (2) the CPY matcher is missing the bf16 -> f32 pipeline, which aborts hard (hit downstream in acestep.cpp on LoRA merges). Both stem from the same root cause: BF16 was added to Vulkan piecewise without a full op-by-op audit. |
|
Why would bf16 FA get swapped with f16? It should just fall back to CPU if the op is not available. |
You're right, the fallback path does work. supports_op correctly returns false for FLASH_ATTN_EXT with BF16 K/V, so it dispatches to CPU. Thanks, this kind of pushback genuinely helps. |
Overview
Adds "GGML_OP_SNAKE", a fused snake activation "y = x + sin(a * x)^2 * inv_b" for audio decoders. The naive decomposition needs 12 ggml ops including two "ggml_repeat" broadcasts that materialize the full tensor twice; the fused op cuts that to one read + one write per backend.
Ships with backend coverage on CPU, CUDA, Metal and Vulkan (F32, F16, BF16) and a naive vs fused equivalence test that proves the fused op produces the same result as a decomposition built from already validated ggml ops ("mul", "sin", "sqr", "mul", "add").
Additional information
Used by acestep.cpp on the SEANet decoder of the VAE, and ready to drop into koboldcpp's qwen3-tts (currently the naive 12 op form with "ggml_repeat" broadcasts) and ace-step (currently a 5 op form with "exp(alpha)" and "1/exp(beta)" precomputed at load) audio decoders.
Beyond music generation, this is the same "y = x + sin(a*x)^2 * inv_b" activation introduced by Ziyin et al. (NeurIPS 2020) and adopted as the standard non-linearity in the BigVGAN family of neural vocoders (Lee et al., ICLR 2023), which is the audio decoder backbone shared by Qwen3-TTS, Qwen3-Omni, OmniVoice, DAC (Descript Audio Codec) and most current speech and music generation models.
Validation
The op is exercised across CPU, CUDA, ROCm, Metal and Vulkan via "acestep.cpp" real-world music generation, with active backend-specific tuning in the upstream repo. Coverage of exotic architectures (Apple Silicon, ARM Linux, AMD discrete) has been gathered through rented Metal instances and user SSH access. On this PR's machine (RTX PRO 6000 Blackwell + Ryzen 9 9950X3D) the test suite passes 14/14:
Remaining backends will be covered by the standard ggml CI matrix.
The equivalence test uses mean absolute error between backends (override of the virtual "err()", already used by other tests in the file) instead of the default NMSE. NMSE is "mse(a, b) / mse(a, 0)" and collapses when the reference signal is ~0 by construction (delta of two equivalent paths); empirically NMSE blows up to 1.49 on a clean run versus a 1e-7 threshold. MAE is well defined near zero with a 1e-5 tolerance.
A companion PR for "GGML_OP_COL2IM_1D" (memory efficient transposed 1d convolution, used by the same audio decoders to avoid the dilated buffer that "ggml_conv_transpose_1d" allocates) will follow once this lands.
Requirements