~/satyajit

Halo's 2.8× over TRL is not a kernel, and its own benchmark proves it

mdjsonmcp

2026-09-22 · 17 min · explainer · training · mixture-of-experts · distributed · benchmarks · mlops

Halo is White Circle's open-source post-training framework, released 20 August 2026 under Apache-2.0. The claim on the README is specific:

On 8× B300, Halo delivers up to ~2.8× the training throughput of stock TRL (2.7× at 25% less peak memory when both sides shard ZeRO-3), with larger margins over the other frameworks benchmarked.

whitecircle/halo@4c1e6c6 · snapshot 2026-09-22
tracked files
1,735
license
Apache-2.0
branch
main
tests
909 files
source
13.3 MB
commit date
2026-09-21
source by language
Python13.2 MB(1330)Shell32.5 kB(6)Dockerfile19.6 kB(1)Makefile13.4 kB(1)C++4.0 kB(1)JavaScript0.3 kB(1)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-09-22 at 4c1e6c6 branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, testFileCount

Two things about that sentence before anything else. It states its hardware. And it volunteers, in parentheses, the matched-configuration number — which is lower — without being asked. That is not how a framework comparison usually opens.

The reflex when reading "2.8× stock TRL" is that TRL's defaults are not tuned, and beating an untuned baseline is a weaker claim than beating a tuned one. That reflex is right often enough to be worth applying every time. So: what was stock TRL configured as, where does the gap actually come from, and does the training loop do what the docs say?

I cloned the repository at 4c1e6c6 — 83,866 lines of Python across src/ — read the EP layer and the benchmark runner, and checked the one claim Halo makes about somebody else's code against that code.

What stock TRL was configured as

This turns out to be the easiest question in the piece, because the answer is a committed file and a table headed "What differs between the two sides".

stock TRL baselineHalo
Trainertrl.SFTTrainer on transformers v5DistributedSFTTrainer
Precisionbf16 + FA4 bf16 computeidentical
Optimizeradamw_torch_fused — fp32 moments, 12 B/paramAdamWBF16 + stochastic rounding — 6 B/param
ShardingFSDP2 full_shard — ZeRO-3FSDP2 ZeRO-2 default + Expert Parallelism
Expert kernelgrouped_mm — transformers v5 defaultgrouped_mm + EP token distribution
Liger / lossLiger on → fused-linear CE (the MoE applier's default)Liger on, plain Liger CE

Held constant on both sides: bf16, FlashAttention-4, Liger, learning_rate=2e-5, seed 42, 10 steps with 3 warmup, gradient_accumulation_steps=1, drop_last, global batch = 8 × per-device, gradient checkpointing on, 8× B300 SXM6. The dataset is deliberately synthetic — create_benchmark_dataset builds 64 filler examples tokenised to exactly seq tokens — which removes packing as a variable, and the doc says so: "It is a shape fixture, not real data."

The baseline runner is in the tree, with a docstring that pre-empts the complaint:

# tests/gpu/profiling/benchmark_trl_baseline.py
# Expert compute is transformers' OWN grouped GEMM (torch.nn.functional.grouped_mm,
# the default experts_implementation for gpt-oss) — the same grouped-GEMM CLASS as
# Halo, NOT an eager loop. So this is a grouped-vs-grouped baseline on the expert
# kernel; Halo's lead comes from EP token distribution + AdamWBF16 + FSDP2, not
# from "grouped GEMM vs loop".

And the doc states outright that the baseline gets the stronger option where the two differ: "The baseline gets the strongest stock options — ZeRO-3 and Liger's FLCE, which is why TRL fits 128k/256k." Halo's default is plain Liger CE, which materialises logits and is worse at long context; on gpt-oss's 201k vocab that costs real memory, and the doc benchmarks it separately rather than quietly enabling it.

So the honest verdict on the baseline is: tuned, current, documented, and runnable. That is a higher bar than most framework comparisons clear. One cell is missing, and it is the one that matters.

The asymmetry: ZeRO-2 against ZeRO-3

Halo ÷ stock TRL, tokens/s/GPU · gpt-oss-20b on 8× B300 · peak GB underneath
Halo EP1ZeRO-2Halo EP2ZeRO-2Halo EP8ZeRO-2Halo EP1ZeRO-3 — matchedshapeTRL ZeRO-34k·b13,885 · 47.6 GB2.32×9,009 · 60 GB2.70×10,479 · 77 GB2.14×8,320 · 26 GB (−45% mem)1.43×5,560 · 28.7 GB (−40% mem)4k·b25,519 · 48.2 GB2.80×15,429 · 67 GB2.77×15,314 · 77 GB1.69×9,352 · 37 GB (−23% mem)1.25×6,874 · 29.3 GB (−39% mem)4k·b46,759 · 50.6 GB2.78×18,823 · 81 GB2.66×17,949 · 91 GB1.50×10,128 · 53 GB1.49×10,082 · 41.6 GB (−18% mem)16k·b16,513 · 50.6 GB2.81×18,304 · 76 GB2.52×16,407 · 85 GB1.50×9,747 · 49 GB (−3% mem)2.68×17,464 · 37.9 GB (−25% mem)16k·b27,466 · 55.6 GB2.78×20,730 · 107 GB2.31×17,219 · 124 GB1.28×9,552 · 92 GB2.51×18,742 · 68.4 GBthe headline 2.8× is the left three columns — Halo's ZeRO-2 default against TRL's ZeRO-3 default, which re-gathers 20.7B params every micro-stepmatched at ZeRO-3 the gap is 1.25–1.49× at 4k and 2.51–2.68× at 16k, at less memory in every row — and that is the number Halo's own README quotes

TRL runs FSDP2 full_shard, which is ZeRO-3 — it re-gathers all 20.7B parameters every micro-step. Halo's default is ZeRO-2, reshard_after_forward=False, which keeps them resident. That is a memory-for-speed trade, and it is a large one on a short step: the doc measures Halo's own EP1 at ZeRO-3 as −38% against its ZeRO-2 at 4k·b1, falling to −5% at 16k·b1, because a fixed re-gather cost is a bigger share of a shorter step.

Which means the headline 2.8× is comparing two different sharding strategies as well as two frameworks. Halo handles this the right way: it publishes an EP1 ZeRO-3 column expressly to isolate the framework gap — "both sides shard every param 8-way with the same kernel" — and that column reads 1.43×, 1.25×, 1.49× at 4k and 2.68×, 2.51× at 16k, at lower peak memory in every row.

The README's own sentence reproduces exactly from the 16k·b1 row: 17,464 ÷ 6,513 = 2.68, at 37.9 GB against 50.6 GB, which is 25.1% less. "2.7× at 25% less peak memory when both sides shard ZeRO-3." Four for four.

So there are two true statements and the shorter one travelled:

The cell I would still like is the third: TRL at ZeRO-2. The benchmark script already takes --fsdp_sharding shard_grad_op for exactly that, and no ZeRO-2 TRL row is published. It would probably narrow the 4k gap and barely touch the 16k one — the doc's own reasoning about re-gather cost predicts that — but "probably" is doing work there, and one torchrun would replace it.

It is not the kernel, and Halo's own numbers prove it

The natural hypothesis for a 2.8× is fused kernels. Halo is loaded with them — FlashAttention-4, Liger, DeepGEMM, grouped GEMM, a bf16 Adam in Triton — so it would be an easy story to tell. The repository declines to tell it.

the expert kernel is not the gap · tokens/s/GPU at 4k·b1, gpt-oss-20b on 8× B300
both frameworks default to grouped GEMMthe per-expert loop is opt-in on both sides: --experts_impl eager for TRL, --no_grouped_gemm for Haloper-expert loopgrouped GEMM02.5k5k7.5k10kstock TRLZeRO-3 · transformers v5 EP pathkernel uplift +100%1,9403,885Halo EP132 experts per rank · dense DP=8kernel uplift +62%5,545 (1.43× TRL's fast kernel)9,009Halo EP216 per rankkernel uplift +79%5,853 (1.51× TRL's fast kernel)10,479Halo EP84 per rankkernel uplift +14%7,282 (1.87× TRL's fast kernel)8,320the dashed line is TRL with its best expert kernel. Halo with its worst one is already 1.43× past it at EP1.so the gap is token routing, the optimiser and the sharding default — not tiling. Halo's own docs say so: "structural rather than kernel-level".

Both frameworks default to a grouped-GEMM expert kernel, and both expose the slow per-expert loop as an opt-in, so the comparison is a clean 2×2. The decisive cell is bottom-left: Halo running the loop at EP1 does 5,545 tok/s/GPU, against TRL's 3,885 with the fast kernel. Handicap Halo with the worse kernel and it is still 1.43× ahead. Whatever the gap is, it survives the kernel being removed.

The kernel uplift itself is interesting for a separate reason. TRL gains +100% from grouped GEMM, Halo EP1 +62%, EP2 +79% — and EP8 only +14%, because at four experts per rank each expert sees a larger M and the loop's per-shape tile already fits. The more you shard experts, the less a grouped kernel buys you. That is a useful rule of thumb and it is not in any paper I know of.

Where the gap actually is

Halo names it: "Most of the gap is structural rather than kernel-level." Three things, in descending order.

1. Token routing. This is the one Halo makes a claim about somebody else's code, so it is the one worth verifying. The assertion is that transformers v5's expert-parallel path never moves tokens: every rank holds the whole batch, zeroes the routing scores of experts it does not own, and all-reduces the full [tokens, hidden] MoE output.

That is exactly what transformers says about itself, in the docstring of the class in question:

# transformers/distributed/tensor_parallel.py — EpRouterParallel
"""Expert-parallel router: forward-only slicing of router outputs to local experts.
...
- zeroes scores for non-local experts
- remaps surviving global indices to local indices (`fmod` after masking non-local slots)
- sets dropped slots to sentinel `num_local_experts` (skipped by grouped_gemm experts forward)
 
Downstream `moe_tp_experts` allreduce-sums partial per-rank expert outputs.
"""

and MoeExpertsParallel.transform_output_post_forward ends in _AllReduceForward.apply(output, process_group) over the whole output tensor. Halo's characterisation of the baseline is accurate, checked against the baseline's own source rather than taken on trust.

A diagram titled EP token routing, labelled ep 2, 32 experts to 16 per rank, dp 2. Two rank boxes on the left each hold a flat batch of hidden states with a per-token top-k router. Purple arrows marked DeepEP dispatch all-to-all cross between them into two expert boxes, one holding experts 0 to 15 and one experts 16 to 31, each running a grouped matmul over tokens sorted by expert. A second set of crossing arrows marked DeepEP combine all-to-all returns the rows to two output boxes, same rows in the same order, weighted by the top-k probabilities. A dashed footer reads: EP is orthogonal to DP — every token returns to the rank it came from, so rank 0 still trains on batch A.
What Halo does instead: each token is sent once to the rank owning its expert and comes back to where it started, so the wire carries top_k/num_experts of the batch per layer rather than a full-tensor reduction on every rank. For gpt-oss-20b that fraction is 4/32. (Halo, agent-docs/assets/diagrams/ep_token_routing.png, Apache-2.0 — licence committed at /articles/halo/HALO-LICENSE.txt.)

The forward path in src/distributed/expert_parallel/base_layer.py is the diagram, almost line for line — fp32 router logits, route_tokens_to_experts, then a single _dispatch_compute_combine_shared that hands the flattened tokens to a DeepEP V2 dispatcher, runs the local experts, and combines back.

2. The optimiser. AdamWBF16 keeps weights and both Adam moments in bf16 with stochastic rounding — 6 bytes per parameter against adamw_torch_fused's 12 — and Halo prices it at "a 17% shorter step". The stochastic rounding is not decoration: the kernel's docstring gives the reason, that nearest rounding would truncate a sub-ULP lr*step update to zero and inflate the non-negative second moment. Its RNG is seeded identically on every rank so replicated parameters round the same way, which is the sort of detail that only gets written down after it has gone wrong once.

3. The sharding default, discussed above, which is a choice rather than an advantage.

The check that makes the rest credible

A throughput benchmark that does not also show convergence is measuring how fast you can compute the wrong thing.

A line chart of training loss over 200 steps for four configurations — stock TRL at ZeRO-3, Halo dense at expert-parallel size one, Halo at EP2 and Halo at EP8 — on the same seeded data at the same global batch and a constant learning rate. All four curves fall together and are visually indistinguishable after about step 100, converging on the same value near 0.002.
Two hundred steps, same seeded data, same global batch of 16, constant learning rate. All four land at about 0.00205 and are within ~1% of each other by step 100 — so expert parallelism, grouped GEMM and bf16 Adam with stochastic rounding preserve the optimisation dynamics. This is the figure that licenses reading the throughput chart as a speed result. (Halo, agent-docs/assets/benchmarks/convergence_loss.png, Apache-2.0.)
A grouped bar chart of throughput in tokens per second per GPU for gpt-oss-20b with gradient checkpointing on, across five shapes from 4k times batch 1 to 16k times batch 2. Five series: stock TRL at ZeRO-3 in grey, always the shortest bar between 3,885 and 7,466; Halo EP1 at ZeRO-2 in orange, the tallest or near-tallest between 9,009 and 20,730; Halo EP1 at ZeRO-3 in red, between 5,560 and 18,742; Halo EP2 at ZeRO-2 in green, between 10,479 and 17,219; and Halo EP8 at ZeRO-2 in blue, between 8,320 and 10,128.
The throughput table drawn. Note the red series — Halo's EP1 at ZeRO-3, the only one sharing TRL's sharding — nearly meeting the orange at 16k and falling well short of it at 4k. That gap between red and orange is the part of the headline that is a memory strategy rather than a framework. (Halo, agent-docs/assets/benchmarks/throughput_4k16k.png, Apache-2.0.)

The small-hardware end

The headline is a datacentre number, and the framing that Halo is about small hardware is not quite right — 8× B300 SXM6 is 2.3 TB of HBM. But the repository ships an explicit small end, and it is the more interesting part for anyone who is not White Circle.

LiquidAI/LFM2.5-8B-A1B@5dd2260 · snapshot 2026-09-22
parameters
8.47B
repo size
16.97 GB
architecture
Lfm2MoeForCausalLM
task
text-generation
library
transformers
license
other
safetensors
1 shard
largest file
16.94 GB
files
10
downloads
58.7K
likes
770
languages
en, ar, zh, fr, de, ja
parameters by dtype
BF168.47BF32704
liquidlfm2.5edge

The first of the two shipped MoE fine-tuning recipes. 32 routed experts, 4 active — a 1B-active model in an 8B body, which is the shape that makes EP2 on two GPUs a sensible starting point rather than a research project.

repo last modified 2026-08-24

LiquidAI/LFM2-24B-A2B@a3bbacd · snapshot 2026-09-22
parameters
23.84B
repo size
47.69 GB
architecture
Lfm2MoeForCausalLM
task
text-generation
library
transformers
license
other
safetensors
1 shard
largest file
47.69 GB
files
9
downloads
3.8K
likes
351
languages
en, ar, zh, fr, de, ja
parameters by dtype
BF1623.84BF322.4K
liquidlfm2edge

The second, and the same recipe: 64 routed experts, 4 active. The cookbook says change the checkpoint and the EP size, and the repository's own GPU test exercises this one in both plain FSDP and EP modes.

repo last modified 2026-08-05

The runnable config is 42 lines and reads like a normal TRL YAML, which is the whole design argument:

# examples/sft/lfm2/lfm2.5-8b-a1b-ultrachat-ep2.yaml
model_name_or_path: LiquidAI/LFM2.5-8B-A1B
expert_parallel_size: 2              # 32 routed experts → 16/rank
moe_balancing: bias_update           # LFM2 has no router auxiliary loss
save_sharded_ep: false               # gather a standard HF checkpoint on save
use_grouped_gemm: true
fp32_router: true
attn_implementation: flash_attention_2
packing: true
max_length: 8192
per_device_train_batch_size: 1
gradient_accumulation_steps: 8
optim: adamw_torch_fused
learning_rate: 5.0e-06

Three lines in there are the entire product. expert_parallel_size: 2 wraps the MoE blocks in place rather than swapping in a separate distributed model. save_sharded_ep: false gathers a standard SafeTensors checkpoint on save, so from_pretrained still works afterwards. And moe_balancing: bias_update handles a family-specific fact — LFM2 has no router auxiliary loss, so balancing has to go through the expert-selection bias instead — which is the kind of thing that decides whether a framework actually supports a model or merely lists it.

The cookbook is also honest about what does not work: CP is unsupported for LFM2, because the short-convolution layers operate across the sequence axis and cannot take a Ulysses split. A support matrix with a "No" in it is worth more than one without.

What I would actually ship

What would change my mind

6 claims above, and what would falsify each

  1. The 2.8x compares Halo at ZeRO-2 against TRL at ZeRO-3, and matched at ZeRO-3 the gap is 1.25x to 2.68x.

    Divide the published columns row by row: Halo EP1 ZeRO-2 over stock TRL gives 2.32, 2.80, 2.78, 2.81, 2.78; Halo EP1 ZeRO-3 over the same gives 1.43, 1.25, 1.49, 2.68, 2.51. If the EP1 ZeRO-3 column is not what I think it is — if it carries some other difference the table does not name — the isolation argument fails. The run that settles it in the other direction is the missing one: benchmark_trl_baseline.py --fsdp_sharding shard_grad_op, TRL at ZeRO-2, against Halo at ZeRO-2. If that closes most of the gap at 4k, the headline is mostly a sharding default.

  2. The expert kernel is not where the gap comes from.

    Halo's per-expert loop at EP1 is 5,545 tok/s/GPU and stock TRL's grouped GEMM is 3,885, both at 4k batch 1 on 8 B300s. Reproduce with --no_grouped_gemm on the Halo side and the default on the TRL side. If Halo's loop path falls below TRL's grouped path on other hardware or other shapes — plausible at EP8, where the uplift is only +14% and the margins are thinner — the claim is shape-specific rather than general.

  3. transformers v5's expert-parallel path all-reduces the full MoE output on every rank.

    EpRouterParallel's own docstring in transformers/distributed/tensor_parallel.py says it zeroes scores for non-local experts and that moe_tp_experts allreduce-sums the partial outputs; MoeExpertsParallel.transform_output_post_forward ends in _AllReduceForward.apply(output, process_group). If a later transformers release adds a token-dispatch path — and there is no reason it will not — the structural half of Halo's advantage evaporates and this section dates badly. Profiling both with NCCL counters is the direct check: bytes on the wire per MoE layer, full tensor versus top_k/num_experts of the batch.

  4. The baseline is current, tuned TRL rather than a straw man.

    tests/gpu/profiling/benchmark_trl_baseline.py runs trl.SFTTrainer on transformers v5 with FSDP2 full_shard, adamw_torch_fused, bf16, FA4, Liger with the gpt-oss applier's fused-linear cross-entropy, and experts_implementation=grouped_mm. If a stock TRL option worth more than a few percent is off — packing is the obvious candidate, though the fixture makes every row exactly seq long so there is nothing to pack — then the baseline is weaker than it looks. Turning that option on and re-running is the whole test.

  5. Throughput costs nothing in convergence.

    200 steps, same seeded data, global batch 16, constant LR: TRL, Halo dense, EP2 and EP8 all reach ~0.00205 and are within ~1% by step 100. Two hundred steps on a shape fixture is a short run and a loss of 0.002 on filler data is nearly a memorisation check, so the honest version is that nothing diverges early. A real SFT run to convergence on real data, comparing final eval loss and a downstream score, is the measurement that would actually establish it — and it is the one nobody publishes, here or anywhere.

  6. The comparison against Axolotl, MS-SWIFT and Unsloth is not matched the way the TRL one is.

    The six committed receipts under agent-docs/assets/benchmarks/runs/ carry a dataset_group field on the Halo run and not on the others, a decaying learning rate against Axolotl's constant 2e-5, and loss traces that do not track. If tokens per second at a fixed sequence length and batch is genuinely invariant to all of that — which it largely is — then the comparison stands on its own terms and my caution is over-cautious. The direct fix is to state the fixture for every framework in the receipt, as the TRL doc does.


No B300s were involved. Every throughput and memory figure is Reported: read out of whitecircle/halo at commit 4c1e6c6, cloned rather than summarised, Apache-2.0 — principally agent-docs/optimization/halo-vs-stock-trl.md, human-docs/performance.md and the six run receipts under agent-docs/assets/benchmarks/runs/. The three figures are the repository's own. What I read rather than reported: src/distributed/expert_parallel/base_layer.py and dispatcher.py for the forward path, src/optimizers/adamw_bf16.py for the optimiser, tests/gpu/profiling/benchmark_trl_baseline.py for the baseline's configuration, and examples/sft/lfm2/lfm2.5-8b-a1b-ultrachat-ep2.yaml with human-docs/cookbooks/halo-lfm2-moe-cookbook.md for the MoE recipes. The claim about the baseline's routing was checked against huggingface/transformers on main, in src/transformers/distributed/tensor_parallel.py. For what the expert-parallel mechanism is from first principles: Mixture of Experts, from scratch.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Halo's 2.8× over TRL is not a kernel, and its own benchmark proves it", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026halo,
  author = {Satyajit Ghana},
  title  = {Halo's 2.8× over TRL is not a kernel, and its own benchmark proves it},
  url    = {https://ai.thesatyajit.com/articles/halo},
  year   = {2026}
}
share