eugen_sparse — sparse SwiGLU FFN in Triton
Code only — no model weights.
Model card summary
| What this repository is | A code library (Triton GPU kernels + PyTorch wrapper) for a sparse SwiGLU feed-forward layer. No model weights. |
| Developed by | Jakub Ćwirlej |
| Origin | The sparse model variants of the EUGEN project (the production EUGEN 160M model has a dense FFN and does not use this kernel) |
| Framework / hardware | PyTorch + Triton; NVIDIA GPU with bf16 tensor cores (Ampere or newer); a slow dense reference path runs anywhere |
| Status | Experimental: tested in our own training runs, early observations promising; it is an open question whether it pays off when a model is trained sparse from scratch and densified at the end |
| Intended use | Research and experiments with sparse feed-forward layers in transformer training |
| Out of scope | Commercial use (licence); production training without your own validation on your GPU and model shapes |
| Licence | CC BY-NC 4.0 |
In plain words
A transformer layer spends most of its compute in the feed-forward block, where every token goes through every neuron. Here the neurons are split into small groups, and for each token only the few groups that would respond most strongly are computed — the others are skipped entirely, also when computing gradients. This is a single Python file with GPU kernels written in Triton; it plugs into a PyTorch model in place of a normal SwiGLU feed-forward layer and works with torch.compile.
Status
Tested experimentally; early observations are promising. It is open whether sparse training pays off over the whole cycle — training from scratch and densifying later (switching the sparse layer to a dense one). The kernel comes from the sparse model variants of the EUGEN project; the production model EUGEN 160M has a dense FFN and does not use it. This is the kernel we trained with (not a simplified demo), cleaned up for publication. Tested on one GPU type only (see Requirements). Not a validated product.
What it does
make_subsample_index(d_ff, group_size, samples_per_group, seed)— a fixed random subset of gate rows per group, used to estimate group energy cheaply. Draw once per layer and save it with the model.grouped_topk_select(x, W_gate, group_size, num_active_groups, subsample_idx=None, samples_per_group=None, load_balance_bias=None)— for every token, the indices of thenum_active_groupsgroups with the largest energyΣ|SiLU(x·W_gate)|(optionally estimated on the subsample, optionally re-weighted by a balancing bias). Hard top-k, no gradient.sparse_swiglu_ffn(x, W_gate, W_up, W_down, group_idx, group_size)— the FFN outputΣ_selected (SiLU(x W_g) ⊙ x W_u) W_dwith gradients toxand all three weights. Weights innn.Linearlayout. Onetorch.library.triton_op: no graph breaks undertorch.compile.update_load_balance_bias(bias, freq_ema, current_freq, target_freq, gamma, bias_max, momentum)— auxiliary-loss-free load balancing (in the style of DeepSeek-V3): call once per optimizer step.reference_sparse_swiglu_ffn(...)— a dense masked PyTorch reference with the same math (tests, CPU path).kernel_supported(...),plan(...)— will the Triton path run for this shape, and how the work is split (no GPU work).
Requirements
- NVIDIA GPU with bf16 tensor cores (Ampere or newer). Tested only on an RTX PRO 6000 (Blackwell); other cards are untested.
- PyTorch with
torch.library.triton_op(2.6 or newer per the PyTorch docs; tested on 2.12). - Triton 3.x (tested on 3.7.0).
- Without a GPU (or with an unsupported shape / older PyTorch) the same math runs on the slow dense reference path. On a CPU you can still check the kernels in the Triton interpreter:
TRITON_INTERPRET=1 python test_eugen_sparse.py(needs Triton installed).
Install
No package to install: copy eugen_sparse.py next to your code (it imports only torch and, if present, triton).
pip install torch triton # versions as above
git clone https://huggingface.co/JakCwi/eugen-sparse
Quick start
import torch
import eugen_sparse as es
d_model, d_ff, G, K = 512, 4096, 32, 8 # 8 of 128 groups active = 6.25 % of the neurons
dev = "cuda"
W_gate = torch.randn(d_ff, d_model, device=dev, dtype=torch.bfloat16) * d_model ** -0.5
W_up = torch.randn(d_ff, d_model, device=dev, dtype=torch.bfloat16) * d_model ** -0.5
W_down = torch.randn(d_model, d_ff, device=dev, dtype=torch.bfloat16) * d_ff ** -0.5
sub, spg = es.make_subsample_index(d_ff, G, samples_per_group=8, seed=0) # once per layer
sub = sub.to(dev)
x = torch.randn(4096, d_model, device=dev, dtype=torch.bfloat16)
idx = es.grouped_topk_select(x, W_gate, G, K, subsample_idx=sub, samples_per_group=spg)
y = es.sparse_swiglu_ffn(x, W_gate, W_up, W_down, idx, G) # [4096, 512]
print(es.plan(x.shape[0], d_model, d_ff, G, K)) # kernel used? token chunks, tiles, determinism
A complete nn.Module (EugenSparseFFN: subsample and balancing buffers, update_balance() after the optimizer step, autocast, torch.compile) is in example.py; python example.py runs on CUDA or, slowly, on CPU.
Parameters and how to choose them
| parameter | valid values | how to choose |
|---|---|---|
density = num_active_groups · group_size / d_ff |
— | 5–10 %. Our training configuration: d_ff 4096, group_size 32, 8 active groups = 6.25 %. A wide FFN at low density is preferable to a narrow one at high density. |
group_size |
power of two, ≥ 16 | 32 (our value). Up to 64 the fused kernels run; above 64 an unfused pair (same result, more memory traffic). Larger groups use the GPU better but make the selection coarser. |
num_active_groups (K) |
1 … d_ff / group_size |
sets the density together with group_size. |
d_ff |
multiple of group_size, ≥ 2 groups |
need not be a power of two (1536, 3072, 3200 work). |
d_model |
multiple of 16 | multiples of 128 run at full tile width; smaller divisors use narrower tiles. |
samples_per_group |
1 … group_size |
8 of 32 (a quarter of the gate) in our training. More = more accurate, more expensive selection; = group_size (or no subsample) = exact selection. |
balancing: gamma, bias_max, momentum |
— | 1e-3, 0.5, 0.99 (our values); target_freq = num_active_groups / num_groups; call after every optimizer.step(). |
| tokens per micro-batch | tokens · K / num_groups well above 64 |
ideally hundreds of tokens per group; small micro-batches leave tiles half empty. |
| dtype | bf16 | under torch.autocast the function casts inputs and weights to the autocast dtype itself. fp32 works (tests) but is not the target. |
EUGEN_SPARSE_* environment variables |
integers, read at import | defaults = our training configuration. EUGEN_SPARSE_DW_DET=1 forces deterministic weight gradients (slower). Change the others only with your own measurement. |
Layout that worked better for us than several dense layers: first layer dense, the following layers sparse.
Pitfalls and limitations
- Silent fallback. For an unsupported shape, no Triton or an old PyTorch, the slow dense reference runs instead; on CUDA a one-time warning is printed. Check
kernel_supported(...)/plan(...)once. - The subsample must stay fixed: draw it once per layer, keep it in
state_dict. A new draw per step or after resuming changes the selection. - The balancing bias and its EMA belong to
state_dicttoo (persistent=True). - Non-determinism:
yanddXare accumulated with fp32 atomics, so bitwise differences between runs are possible; weight gradients too when there are several token chunks (unlessEUGEN_SPARSE_DW_DET=1). Selection and the CSR bucketing are deterministic. - Hard selection: only the selected groups get weight gradients in a step; the routing itself is not learned by gradient.
group_idxmust come fromgrouped_topk_select(distinct groups per token). Hand-made indices with duplicates or out-of-range values are not checked: the kernel would count a duplicated group twice, the reference once.- No input validation beyond the shape rules:
tokens · num_active_groupsmust stay below 2³¹ (int32 indices). - Block benchmarks do not predict training speed: measure your own training loop under
torch.compile. Random inputs spread tokens evenly; a trained model has popular groups (the balancing bias counters that). - Memory: compact activations
[tokens, K·group_size](gate and up) plus fp32 buffers[tokens, d_model]foryanddX. - Changing
group_sizemid-training changes the number of groups and the buffer shapes (bias, subsample): it is a model change, not a setting. - The example module counts group usage in its last forward call of the step; with gradient accumulation, accumulate
lb_freq_nowyourself.
How to test
python test_eugen_sparse.py # GPU: everything; CPU: selection, subsample, balancing, plan
TRITON_INTERPRET=1 python test_eugen_sparse.py # CPU: kernels vs reference in the Triton interpreter (fp32)
python speed_check.py --compile # GPU: time dense vs sparse FFN block on YOUR card (we publish no benchmark results)
On a GPU the tests compare the kernel with the reference (y, dX, dW_gate, dW_up, dW_down) in fp32 and bf16, with several token chunks, run torch.library.opcheck and check torch.compile without graph breaks. How the kernels work: TECHNICAL.md.
Files
eugen_sparse.py (library) · test_eugen_sparse.py · speed_check.py (a timing script to run on your own GPU; no results are published) · example.py · TECHNICAL.md · LICENSE
Licence
Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0), © 2026 Jakub Ćwirlej — free for non-commercial use with attribution. Full text in LICENSE. Dependencies (not included): PyTorch (BSD-3-Clause), Triton (MIT).
Citation and contact
Jakub Ćwirlej, eugen_sparse: sparse SwiGLU FFN kernel in Triton, 2026, https://huggingface.co/JakCwi/eugen-sparse. Contact and more about the project: https://jakubcwirlej.com.