Benchmarks

June 10, 2026 · View on GitHub

Configuration

Every number on this page comes from this stack unless a table says otherwise:

GPU1× H100 80 GB SXM
Base imageNGC 25.06
CUDA12.8
PyTorch2.8.0+cu128
Triton3.4
Inputsbf16 (fp16 for LateOn / ModernColBERT shapes)
Accumulatorfp32 throughout
Timing50-iter median over CUDA events, after 5 warmup

Baseline package versions

All baselines are pinned PyPI releases (no git checkouts), installed into the stack above by the SkyPilot jobs in scripts/ (see ../benchmarks/README.md).

packageversionrole on this page
flash-maxsim0.2.1forward head-to-head and the chunking "vs flash" column
pylate1.5.0vanilla-PyLate end-to-end baselines (older tables on this page were produced against 1.3.3; patch_pylate() supports both layouts)
fast-plaid1.4.6.280PLAID rerank vs engine.search() (the .280 suffix is the torch-2.8 wheel)
colpali-engine0.3.16ColQwen2 / ColPali end-to-end (ColbertPairwiseCE) and loss isolation

The ColQwen2 / ColPali section is the one exception to the stack above: its SkyPilot job pins torch==2.11.0+cu128 / torchvision==0.26.0+cu128 to satisfy colpali-engine's dependency floor (0.3.16 requires transformers>=5.3), so treat its absolute step times as same-row vanilla-vs-LIK ratios rather than cross-comparable with the other tables.

Fair-comparison protocol

Every speedup on this page is measured at matched numerics: each baseline runs the inner einsum / matmul with an fp32 accumulator just like the fused kernel, and parity vs the eager reference is asserted at atol=1e-2, rtol=1e-2 before timing. The forward and cached-contrastive sections also report a torch.compile column on the same body so you can see what Inductor alone closes.

Reproducing

Running individual scripts, the full local sweep, the SkyPilot cluster drivers, and the output format all live in one place: ../benchmarks/README.md. This page is the headline numbers and the analysis behind them.

Forward (reranking / inference)

The eager reference each shape is checked against is one line:

def eager_fp32(Q, D):
    S = torch.einsum("ild,jtd->ijlt", Q.float(), D.float())
    return S.max(-1).values.sum(-1)

We also report a torch.compile(dynamic=False, mode="reduce-overhead") column on the same body. Inductor fuses the surrounding ops but still has to materialise the [Nq · Nd · Lq · Ld] similarity tile in HBM before the max(-1) reduction — that materialisation is what the fused Triton kernel exists to skip. Empirically torch.compile lands within ±10% of eager on every shape; on the small train-batch (Nq=Nd=32) case it's actually slower than eager because the per-call dispatch overhead dominates such tiny work.

Full table:

shapeLIKeager (fp32 acc)torch.compile (fp32 acc)LIK vs eagerLIK vs compilenaive scratch
text-short Nq=1, Nd=1k, Lq=32, Ld=3000.094 ms0.263 ms0.273 ms2.8×2.9×183 MB → 0
text-long Nq=1, Nd=1k, Lq=32, Ld=10240.096 ms0.793 ms0.880 ms8.3×9.2×626 MB → 0
colpali Nq=1, Nd=1k, Lq=128, Ld=10240.097 ms1.538 ms1.603 ms15.9×16.5×1.0 GB → 0
corpus-5k Nq=1, Nd=5k, Lq=32, Ld=3000.129 ms1.195 ms1.196 ms9.3×9.3×916 MB → 0
corpus-10k Nq=1, Nd=10k, Lq=32, Ld=3000.247 ms2.354 ms2.352 ms9.5×9.5×1.8 GB → 0
train-batch Nq=Nd=32, Lq=32, Ld=3000.097 ms0.127 ms0.169 ms1.3×1.7×43 MB → 0
train-batch-128 Nq=Nd=128, Lq=32, Ld=3000.226 ms1.539 ms1.540 ms6.8×6.8×621 MB → 0
large-d-512 Nq=1, Nd=1k, Lq=32, Ld=300, d=5120.108 ms0.837 ms0.837 ms7.8×7.8×623 MB → 0
large-d-1024 Nq=1, Nd=500, Lq=32, Ld=300, d=10240.112 ms0.821 ms0.821 ms7.3×7.3×604 MB → 0
lateon-code-edge-rerank Nd=1k, Ld=2048, d=480.095 ms0.767 ms0.768 ms8.1×8.1×626 MB → 0
lateon-code-edge-big Nd=4k, Ld=2048, d=480.260 ms2.961 ms2.961 ms11.4×11.4×2.5 GB → 0
mxbai-edge-rerank Nd=1k, Ld=300, d=640.096 ms0.168 ms0.166 ms1.7×1.7×110 MB → 0
mxbai-edge-corpus-10k Nd=10k, Ld=300, d=640.130 ms1.398 ms1.399 ms10.8×10.8×1.1 GB → 0

On wide shapes (Lq · Ld large) LIK beats both baselines by 7-16×; the HBM round-trip on the similarity tile dominates. On tiny shapes the kernel-launch + autotune overhead caps the win at ~1.3-2.9×.

The naive scratch column shows naive peak → LIK peak; LIK never materialises the [Nq · Nd · Lq · Ld] similarity tile, so its peak working set is bounded by the output and ranges from a few KB to ~64 KB across the table — effectively zero next to the naive scratch.

vs flash-maxsim (same Triton MaxSim math)

flash-maxsim (Roi Pony / IBM) was the first public Triton MaxSim kernel and the direct inspiration for this library. The numbers below come from benchmarks/kernels/bench_flash_maxsim.py. flash-maxsim 0.2.1 has no normalize=True knob and no autograd-aware backward, so we report plain forward only.

shapeoursflash-maxsimspeedup
rerank-short (Nq=1, Nd=1k, Lq=32, Ld=300)0.1060.1111.05×
rerank-long (Nq=1, Nd=1k, Lq=32, Ld=1024)0.1670.1731.04×
rerank-very-long (Nq=1, Nd=500, Lq=32, Ld=4096)0.2270.2551.12×
rerank-10k (Nq=1, Nd=10k, Lq=32, Ld=300)0.3220.3291.02×
train-in-batch-32 (Nq=Nd=32, Lq=32, Ld=200)0.0980.1021.05×
train-in-batch-128 (Nq=Nd=128, Lq=32, Ld=200)0.2630.2981.14×
train-long-doc (Nq=Nd=16, Lq=32, Ld=2048)0.0890.0921.04×
edge-d48 (Nq=1, Nd=4k, Lq=32, Ld=2048, d=48)0.3400.3511.03×
edge-d64 (Nq=1, Nd=10k, Lq=32, Ld=300, d=64)0.2200.2201.00×

LIK meets or beats flash-maxsim on every cross-product forward shape: a dead heat on the tightest ones (edge-d64, rerank-10k) and 1.12–1.14× ahead on the wide regimes (rerank-very-long, train-in-batch-128). Both kernels are fused (neither materialises the score tile), so peak working set is sub-100 KB on every shape and there's no memory column to compare. The real differentiators are elsewhere: a fused normalize=True (no extra HBM round-trip), a real autograd-aware backward (unified / lowmem), packed/varlen, PLAID residual decompression, and a fused D-side projection head — none of which flash-maxsim ships. bench_flash_maxsim.py also covers KD-layout (Q[B, Lq, d] × D[B, K, Ld, d] → [B, K], LIK +4 to +17%) and pairwise (Q[B, Lq, d] × D[B, Ld, d] → [B], LIK within a few percent except the smallest pair shape, where flash's lighter launch wins).

Long-query chunking (Lq > 512)

For long query sequences (Lq > 512, such as image or document queries used on the query side) the un-chunked kernel serialises a long static_range loop inside each program. Since MaxSim is a sum over query tokens of a per-token max, splitting the query axis into fixed 128-token chunks and summing the per-chunk scores is numerically exact, and launches more, shorter programs that keep the H100 busy. It fires only above a measured crossover (Lq > 512); shorter queries fall through to the existing core unchanged. From benchmarks/kernels/bench_chunking.py:

shapeun-chunkedchunkedspeedupvs flash
in-batch Nq=Nd=16, Lq=768, Ld=5120.260 ms0.147 ms1.77×1.38×
in-batch Nq=Nd=64, Lq=768, Ld=5121.358 ms0.912 ms1.49×1.52×
in-batch Nq=Nd=32, Lq=1024, Ld=10240.731 ms0.588 ms1.24×1.53×
tail Nq=Nd=16, Lq=1030, Ld=10240.472 ms0.327 ms1.44×1.24×
rerank Nq=1, Nd=500, Lq=1024, Ld=10240.436 ms0.440 ms0.99×1.18×
boundary Nq=Nd=128, Lq=512 (≤ cutoff)2.278 ms2.288 ms1.00×1.49×

The split is implemented as a thin dispatch around the shared forward core (no kernel change) and autograd flows through the reshape + sum, so training is covered too — neutral-to-better there (large batches +19–32%, small batches sit in the launch-bound noise floor). It also collapses the per-Lq autotune sweep onto a small bounded number of cache entries. Chunking is cross-product-only: the KD/pairs path (4-D D) already saturates its Nq·K grid, so it stays un-chunked.

Fused L2-normalize

F.normalize(Q) → maxsim becomes a single kernel (normalize=True). The fused path normalizes in SRAM, eliminating two HBM round-trips, and the backward correctly applies the L2-norm Jacobian.

shapeF.normalize + maxsimfusedspeedupexplicit peakfused peakmem ratio
text-short (Nq=1, Nd=1k, Ld=300)0.673 ms0.100 ms6.7×368 MB74 MB5.0×
text-long (Nq=1, Nd=1k, Ld=1024)1.623 ms0.099 ms16.4×1254 MB250 MB5.0×
bigbatch-300 (Nq=32, Nd=32, Ld=300)0.441 ms0.098 ms4.5×12 MB3 MB4.7×
bigbatch-2k (Nq=8, Nd=16, Ld=2048)0.421 ms0.075 ms5.6×40 MB8 MB5.0×
bigbatch-8k (Nq=8, Nd=16, Ld=8192)0.590 ms0.131 ms4.5×161 MB32 MB5.0×
corpus-10k (Nq=1, Nd=10k, Ld=300)4.357 ms0.284 ms15.3×3674 MB733 MB5.0×

The explicit path writes a normalized copy of D to HBM and re-reads it during the matmul; the fused path streams the same data through threadgroup memory and never writes the intermediate, so peak working set drops by a steady ~5× across the whole sweep.

PLAID / ColBERTv2

End-to-end vs fast_plaid.engine.search(): build the index with fast-plaid, time engine.search(), then load the same compressed tensors and call maxsim_residual_varlen on the same compressed bytes. To keep the comparison apples-to-apples both pipelines end on a torch.topk(scores, k=10)engine.search() returns the top-10, so the LIK timer now folds the same final argmax in.

We report two LIK variants:

  • lik_full + top-k — score the whole corpus, then top-k. Upper bound on the rerank cost; corresponds to "no IVF probe".
  • lik_partial + top-k (4 096 cands) — score the same number of candidates fast-plaid keeps after its IVF probe (n_full_scores=4096), then top-k. Closest apples-to-apples vs engine.search() because it pays the same rerank workload, minus IVF probing — which fast-plaid currently does in Rust and we don't.
corpus shape (nbits)engine.search()lik_full + top-klik_partial + top-kfull speeduppartial speeduplik_full peaklik_partial peak
5 000 docs × 200, nb=223.50 ms1.48 ms1.28 ms15.9×18.3×72 MB95 MB
10 000 docs × 300, nb=247.27 ms3.78 ms1.73 ms12.5×27.3×145 MB180 MB
10 000 docs × 512, nb=279.73 ms5.61 ms2.49 ms14.2×32.0×217 MB277 MB
10 000 docs × 512, nb=4138.43 ms6.04 ms2.71 ms22.9×51.0×334 MB442 MB
25 000 docs × 300, nb=277.24 ms9.25 ms1.71 ms8.3×45.1×306 MB341 MB

Reading: even with the top-k argmax folded into the LIK side, the fused kernel is 8-22× faster than the full fast-plaid pipeline on "full" (scoring every doc) and 18-47× on "partial" (matching fast-plaid's post-IVF rerank workload). At 25k docs the lik_full gap closes because we now do every doc's residual decompress while fast-plaid's IVF probe scales sub-linearly; the partial column tells the more honest matched-workload story. Against a PyTorch transliteration of fast-plaid's exact decompress → pad → matmul → reduce slice, the fused varlen kernel is 3.0-4.0× faster at 10-34× less GPU memory — no [Ntop, max_Ld, packed_dim] padded scratch is allocated.

Fused D-side head (training)

Replaces F.linear → F.normalize → maxsim for the hidden-state → embedding → MaxSim path. Forward saves an argmax; backward gathers H_d only at winning positions and is closed-form. Both columns use the same LIK MaxSim — the only difference is whether the projection / normalize step is folded in:

shapeunfused (F.linear+normalize then LIK MaxSim)fusedspeedupunfused peakfused peak
LateOn Nd=16, Lq=32, Ld=300, d_model=7680.93 ms0.98 ms0.95×84 MB86 MB
LateOn Nd=32, Lq=32, Ld=10240.93 ms0.99 ms0.94×201 MB189 MB
LateOn-Code Nd=128, Ld=10241.47 ms1.04 ms1.42×608 MB505 MB
LateOn-Code Nd=256, Ld=10242.34 ms1.09 ms2.15×1028 MB889 MB
LateOn-Code Nd=512, Ld=20487.31 ms1.71 ms4.28×3524 MB3193 MB
LateOn-Code Nd=1024, Ld=204814.08 ms3.15 ms4.47×6852 MB6321 MB
LateOn-Code-edge Nd=256, Ld=4096, d_model=384, d=965.23 ms1.33 ms3.93×1988 MB1631 MB

The win grows with Nd · Ld (more positions for the fused linear to batch over) and shrinks as Nq · Lq / Ld → 1. Memory and derivation in design.md.

FP8 inference (Hopper)

This row compares two LIK kernels against each other, not LIK vs naive PyTorch. maxsim_inference_fp8 runs the inner matmul in e4m3 (fp8) with an fp32 accumulator and bf16 output; maxsim (bf16) is the same Triton kernel one precision step up. No requantization of Q / D is needed if they were stored fp8 to begin with (PyLate's PLAID index can ship fp8 directly), so the speedup below isolates the "swap bf16 tensor cores for fp8 tensor cores" win.

From benchmarks/kernels/bench_fp8.py:

shapebf16fp8speeduplabel
Nd=1k, Lq=32, Ld=128, d=1280.124 ms0.133 ms0.94×pylate-rerank-1k
Nd=4k, Lq=32, Ld=128, d=1280.154 ms0.158 ms0.98×pylate-rerank-4k
Nd=8k, Lq=32, Ld=256, d=1280.278 ms0.234 ms1.19×colbert-rerank-8k
Nd=4k, Lq=32, Ld=256, d=1280.442 ms0.346 ms1.28×batched-rerank-16k
Nd=2k, Lq=32, Ld=512, d=1280.194 ms0.173 ms1.13×long-docs-2k
Nd=1k, Lq=32, Ld=128, d=960.123 ms0.137 ms0.90×lateon-edge-1k
Nd=4k, Lq=32, Ld=128, d=960.148 ms0.155 ms0.96×lateon-edge-4k

On rerank-sized shapes the bf16 kernel got fast enough in 0.3.0 that short-context fp8 (Ld=128) is now within ±5% of bf16 or slightly behind — kernel-launch and the e4m3 packing pay back only once Lq · Ld is large enough to dominate. Useful regime is now Ld ≥ 256 and corpus ≥ 4k, where fp8 lands 1.12-1.29×. Tolerance vs bf16 stays inside 5e-3 absolute on every shape we tested — fine for top-k retrieval where ordering, not exact scores, is what matters.

Peak working set is identical between the two columns (same Q/D tensors live in HBM either way; both kernels are fused so neither allocates score-tile scratch). The memory win of fp8 is upstream of this bench — if the index is stored fp8 (PyLate's PLAID index can ship that way), D itself is 2× smaller in HBM and the corpus fits in half the RAM.

End-to-end LateOn-Code-edge training (17 M encoder)

losses.Contrastive, in-batch negatives, bf16, grad-checkpointing, 1×H100.

setupvanillafusedspeeduppeak (v → f)
bs=256, Lq=32, Ld=256116.2 ms90.8 ms1.28×11.5 → 9.6 GB
bs=192, Lq=32, Ld=512152.2 ms127.9 ms1.19×16.6 → 14.7 GB
bs=128, Lq=32, Ld=1024231.7 ms208.3 ms1.11×22.6 → 21.4 GB
bs=64, Lq=32, Ld=2048322.5 ms318.5 ms1.01×25.8 GB

Smaller encoder + bigger effective batch ⇒ bigger MaxSim slice ⇒ bigger end-to-end speedup. These numbers use patch_pylate(); maxsim_from_hidden (autograd path) adds another ~1–3 ms / step but isn't wired into PyLate's loss path because PyLate's Dense projection runs inside the encoder forward.

Backward paths

There are two paths. unified is the fastest single-pass backward: it scatters grad_D with fp32 atomics, accumulating in a full-size fp32 buffer that is cast to the input dtype at the end. lowmem is the memory-optimal one: each output row is destination-owned, so it accumulates in fp32 registers and writes grad_Q / grad_D straight in the input dtype — no full-size fp32 buffer, no fp32→bf16 transient, no atomics (hence deterministic).

auto sends the gradient-heavy shapes — knowledge-distillation / hard-negative layouts (where grad_D is n_neg-inflated) and large high-contention in-batch squares — to lowmem, and everything else to unified. The split is exact: lowmem's grad_D is a one-hot matmul whose wasted FLOPs scale with Lq, so it ties or beats unified for short queries but loses on long-query×long-doc cross-products, which stay on unified.

Realistic shapes (H100, bf16, fwd+bwd peak memory / step time):

shapeunifiedlowmem
pylate-text B256 Lq32 Ld30096 MB / 2.23 ms52 MB / 2.10 ms
colpali B128 Lq32 Ld1030141 MB / 1.38 ms72 MB / 1.57 ms
colpali-neg B128×n81086 MB / 0.86 ms543 MB / 0.71 ms
colpali-neg B256×n164329 MB / 2.36 ms2165 MB / 1.37 ms
longq B64 Lq1030 Ld1030179 MB / 5.6 ms107 MB / 9.0 ms

So on the hard-negative path lowmem roughly halves peak memory and runs ~1.7× faster; on long-query cross-products unified keeps the speed edge. lowmem is deterministic across runs; unified drifts within fp32 ULP (reduction order depends on scheduling).

# Per-call override (auto is recommended):
maxsim(Q, D, normalize=True, backward="lowmem")  # | "unified" | "auto"

End-to-end PyLate Contrastive training

batch × negsvanilla PyLatefusedspeedup
64 × 11.36 ms1.16 ms1.17×
128 × 24.75 ms1.85 ms2.57×
256 × 324.28 ms5.91 ms4.11×

LateOn / ModernColBERT (long documents)

At 2k-4k the naive einsum still fits, so you see speedup and memory ratios. At 8k+ naive OOMs on 80 GB at sane training batch sizes — that's the story this section tells. Numbers apply equally to lightonai/LateOn, lightonai/GTE-ModernColBERT-v1 and lightonai/LateOn-Code (same backbone, d=128).

MaxSim only (one colbert_scores call, fp16 inputs + fp32 accumulator, auto backward), from benchmarks/kernels/bench_longdoc.py (formerly bench_lateon.py). Parity vs the fp32-acc naive reference is asserted before timing on every shape that fits in HBM:

shapefwd LIKfwd naivebwd LIKbwd naivepeak LIKpeak naive
Nq=8, Nd=16, Lq=32, Ld=2048 train-2k0.08 ms0.15 ms0.65 ms0.64 ms96 MB152 MB
Nq=8, Nd=16, Lq=32, Ld=4096 train-4k0.09 ms0.21 ms0.61 ms0.64 ms128 MB240 MB
Nq=16,Nd=32, Lq=32, Ld=4096 bigbatch-4k0.120.650.541.82193 MB672 MB
Nq=1, Nd=64, Lq=32, Ld=4096 rerank-4k0.09 ms0.24 ms0.53 ms0.78 ms320 MB416 MB
Nq=8, Nd=16, Lq=32, Ld=8192 train-8k0.12 msOOM0.43 msOOM192 MBOOM
Nq=16,Nd=32, Lq=32, Ld=8192 bigbatch-8k0.18 msOOM0.44 msOOM321 MBOOM
Nq=1, Nd=256,Lq=32, Ld=8192 rerank-8k0.18 msOOM1.36 msOOM2.1 GBOOM
Nq=1, Nd=32, Lq=32, Ld=16384 huge-doc0.13 msOOM0.55 msOOM576 MBOOM

At Ld ≥ 8k naive OOMs on the [Nq, Nd, Lq, Ld] similarity scratch (8 · 16 · 32 · 8192 · 4 bytes ≈ 128 MB *per query position* — and Nq queries multiply that). The fused kernel never writes that tensor out, so HBM stays flat with Ld and the same shapes that OOM in the naive column run in ~0.2 ms here. The bigbatch-4k column shows what this buys you when both paths fit: ~5× fwd and ~3.5× bwd wall-clock advantage and ~3.5× less HBM, which is what lets bs ≥ 16 at Ld = 4k survive at all.

LightOn cached-contrastive (MaxSim isolation)

pylate.losses.CachedContrastive chunks MaxSim into (bs / mini)**2 Python-level calls. The fused kernel collapses that double loop into one call that never materializes S. We compare three implementations on the same chunked tiling (Lq=128, d=128, mini=32, fwd + bwd):

  • vanillapylate.scores.colbert_scores per tile (current PyLate default, fp32 accumulator).
  • torch.compile — local re-implementation of the colbert_scores body wrapped in torch.compile(dynamic=False, mode="reduce-overhead"), same numerics as vanilla.
  • LIKlate_interaction_kernels.maxsim over the same chunking.

Parity is checked on an 8-row probe before timing. torch.compile lands below vanilla here because Dynamo recompiles on every fresh tile shape and the cuda-graph fast path trips on the pending-backward state pylate's loss leaves behind:

shapetilesvanilla fwd+bwdtorch.compile fwd+bwdLIK fwd+bwdLIK vs vanillaLIK vs compile
bs=64, Ld=204847.64 ms25.99 ms1.27 ms6.02×20.46×
bs=64, Ld=4096414.67 ms51.55 ms2.43 ms6.04×21.21×
bs=64, Ld=8192431.72 ms101.89 ms4.59 ms6.91×22.20×
bs=128, Ld=20481631.32 ms104.63 ms5.17 ms6.06×20.24×
bs=128, Ld=40961660.15 ms207.57 ms11.04 ms5.45×18.80×
bs=128, Ld=819216129.85 ms410.47 ms21.18 ms6.13×19.38×
bs=256, Ld=204864130.86 ms424.27 ms25.87 ms5.06×16.40×
bs=256, Ld=409664252.76 ms841.58 ms47.92 ms5.27×17.56×
bs=256, Ld=8192 (real recipe)64544.52 ms1668.55 ms91.51 ms5.95×18.23×

LIK is a steady 5.0-6.9× over vanilla and 16-22× over the compiled tile across the whole range, with the win growing with Ld. The vs-torch.compile gap widened in 0.3.0: compile now recompiles every fresh tile shape and trips the cuda-graph fast path on the pending-backward state pylate's loss leaves behind, so the compiled column lands well above vanilla. The vs-vanilla numbers are tighter than 0.1.0's headline 13.8× because that figure was measured against an older PyLate that ran colbert_scores in plain Python; the loss path got faster, the kernel got a fair fight, and we still win by ~5×.

End-to-end on LateOn-Code-edge (17 M, real MS MARCO triplets)

Real pylate.models.ColBERT("lightonai/LateOn-Code-edge") (d=48, 2 047-token context), AdamW + bf16 autocast, MS MARCO triplet split loaded through datasets. We swap patch_pylate() on/off and time full optimizer steps (encoder forward + loss + backward + step). The kernel is a drop-in — no other code changes between the two columns.

recipesetupvanilla PyLate+ LIKspeedup
Contrastivebs=16, Lq=32, Ld=25664.3 ms60.9 ms1.06×
CachedContrastivebs=64, mini=16, Ld=300, grad-ckpt317.2 ms318.5 ms1.00×
CachedContrastivebs=128, mini=16, Ld=512, grad-ckpt692.9 ms687.5 ms1.01×

Reading: on a 17 M encoder the transformer forward already swallows most of the step, so LIK lands in the 1.00–1.06× band across all three shapes. bs=16, Ld=256 buys ~6 %; the two larger batches sit flat at ~1×. Same bottleneck story as the LateOn 149 M numbers from v0.1.0 (measured with the since-removed bench_pylate_lateon.py harness), just shifted up the batch axis because the encoder is 9× smaller. The bs=64, Ld=300 shape used to be the headline win (1.15× in 0.3.0); it regressed to ~1.00× in this sweep and is queued for investigation in 0.3.1 (see CHANGELOG).

End-to-end PyLate training (GTE-ModernColBERT-v1)

The PyLate sibling of the ColQwen2 harness below (benchmarks/pylate/bench_pylate_e2e.py, same instrumentation/replay/OOM-as-outcome design): real SentenceTransformerTrainer steps with lightonai/GTE-ModernColBERT-v1 (ModernBERT 149 M, d=128, Lq=48, Ld=300 fixed-pad) + Contrastive on real sentence-transformers/msmarco-bm25 triplets (1 explicit negative, so N=2 document slots), bf16 autocast, PyLate 1.5.0. lik is one patch_pylate() call. Two regimes per (batch, variant) cell:

Canonical recipe (no grad-checkpointing): the encoder binds first. Without checkpointing, ModernBERT's activations for 3 × B sequences fill the 80 GB card at B=256 — vanilla, LIK, and PyLate's own score_mini_batch_size chunking all OOM identically on a sub-GiB allocation with ~78 GiB already used. Both variants train B=128 (step peak ~51–53 GiB) at the same speed. On this recipe the MaxSim term never becomes the constraint, so LIK cannot move the ceiling — it only trims the op's transient (1.8 GiB → 56 MiB at B=128).

--grad-checkpoint (the regime matching the ColQwen2 bench): the B² score transient binds, and LIK moves both the ceiling and the step time. Vanilla PyLate's grid is transient rather than held — ColBERTScores materializes one document-slot at a time and .max(dim) saves int64 indices, not the grid — but the transient still scales B² and is one contiguous fp32 allocation per slot:

batch sizevanilla: fwd transientvanilla: bwd spikeLIK: fwd transientLIK: heldLIK: bwd spike
1281.80 GiB1.77 GiB54 MiB54 MiB62 MiB
2567.13 GiB7.03 GiB124 MiB124 MiB110 MiB
51228.37 GiB28.03 GiB314 MiB312 MiB157 MiB
1024OOM893 MiB885 MiB186 MiB
batch sizevanillavanilla + score_mini_batch_size=64LIK
1289.55 GiB8.75 GiB
25620.92 GiB15.79 GiB
51254.10 GiB30.60 GiB29.72 GiB
1024OOM (56.25 GiB grid alloc)62.18 GiB · 6.02 s/step57.67 GiB · 4.81 s/step

Reading: at B=1024 vanilla dies on a single 56.25 GiB request — exactly the [1024, 1024, 48, 300] fp32 per-slot grid (the embeddings promote to fp32 through F.normalize under autocast), recorded by the harness as an OOM inside the score call. LIK's footprint stays sub-GiB (per-slot argmax buffers + outputs, linear in B). Because the grid transient overlaps the step's peak here, whole-step memory splits before the OOM — 29.7 vs 54.1 GiB at B=512 — and, with a 149 M encoder too small to hide the op, LIK is also 1.07–1.12× faster per step (B=256: 1.23 vs 1.31 s; B=512: 2.40 vs 2.69 s), holding ~210 samples/s flat from B=64 to B=1024.

The honest comparison the harness was built for: PyLate's own mitigation (score_mini_batch_size=64) also reaches B=1024 by serializing the score computation into query chunks — at 6.02 s/step and 62.2 GiB vs LIK's 4.81 s/step and 57.7 GiB. Chunking buys the ceiling with recompute; the fused kernel buys it while speeding the step up, with no knob to tune.

Reproduce: sky launch scripts/sky_pylate_e2e.yaml, then benchmarks/pylate/summarize_pylate_e2e.py --regime plain|ckpt.

End-to-end ColQwen2 / ColPali training

Real colpali-engine training recipe, instrumented at the MaxSim op (benchmarks/colpali/bench_colpali_e2e.py, adapted from the harness in colpali PR #412): vidore/colqwen2-base (Qwen2-VL 2 B backbone) + LoRA r=32 via the release's own ColModelTrainingConfig, ColbertPairwiseCELoss, grad-checkpointing, bf16, real vidore/colpali_train_set pages (≤768 visual tokens each), colpali-engine 0.3.16 / torch 2.11.0+cu128. Each (batch size, variant) cell runs 4 optimizer steps in a fresh process; lik is one patch_colpali_engine() call, no other change. The wrapped loss records every MaxSim call's shapes and in-train footprint, then each recorded shape is replayed on an isolated graph where the op's forward/saved/backward brackets are exact (the replayed "held" numbers match the in-train ones to within ~1 MiB).

Step time is parity — steady-state steps agree within noise at every batch that fits both (B=16: 1.47 vs 1.48 s, B=32: 2.43 vs 2.48 s, B=64: 4.72 vs 4.47 s; LIK's first step pays the one-time Triton autotune, which amortizes over a run). A ColQwen2 step is dominated by the 2 B-param doc/query towers, so the op-level speedup dilutes to ~1×, same story as the per-loss-head step-time table this harness replaced (0.91–1.02× across five loss heads in v0.4.x). What does not dilute is memory:

VRAM attributable to the MaxSim op (isolated replay)

batch sizevanilla: heldvanilla: bwd spikevanilla: totalLIK: heldLIK: bwd spikeLIK: total
1632 MiB73 MiB106 MiB1 MiB6 MiB7 MiB
32151 MiB339 MiB489 MiB1 MiB13 MiB14 MiB
64615 MiB1.35 GiB1.95 GiB3 MiB26 MiB29 MiB
1282.40 GiB5.41 GiB7.81 GiB8 MiB53 MiB61 MiB

Reading: the embeddings reaching the loss are fp32 in practice (autocast computes the L2-norm in fp32 and the division promotes), so vanilla holds the fp32 [B, B, Lq, Ld] score grid from the op's forward until its backward — 2.40 GiB at B=128 — and the backward spikes another ~2.25× that (the grid's gradient plus the amax scatter). LIK holds only the [B, B] output and its backward allocates only the input gradients (dominated by grad_D), so its footprint grows linearly in B instead of quadratically: 61 MiB total at B=128, a ~130× reduction, ×2 per batch doubling vs vanilla's ×4.

Batch-size ceiling (whole-step peak alloc, 80 GB H100)

batch sizevanillaLIK
1610.87 GiB10.87 GiB
3217.09 GiB17.09 GiB
6429.54 GiB29.54 GiB
128OOM (53.95 GiB pre-OOM)54.38 GiB

Reading: the two columns are identical while both fit because the score tensors are freed before the step's peak (the op's backward runs first; the peak lives in the model backward). At B=128 vanilla dies not from peak usage but from fragmentation: the OOM is a 1.81 GiB contiguous request failing while 25.0 GiB sit reserved-but-unallocated — the multi-GiB score grid needs blocks the allocator can no longer produce, where LIK's 61 MiB fits in whatever scraps remain. Net: vanilla maxes out at B=64, LIK trains B=128 (8.5 s/step) — 2× batch headroom from removing the op's B² term.

Reproduce: sky launch scripts/sky_colpali_e2e.yaml, then benchmarks/colpali/summarize_colpali_e2e.py renders both tables and the log-log plot from the per-cell JSONs.

Explicit-negative MaxSim isolation (no encoder)

Like the cached-contrastive isolation above, this strips the encoder and times just the explicit-negative loss head's forward+backward on synthetic embeddings (benchmarks/colpali/bench_colpali_loss.py, H100, bf16, Lq=32, Ld=1030 ≈ ColPali visual tokens, in_batch_term_weight=0 to isolate the pos/neg path this fuses). Vanilla runs the einsum → amax → sum; LIK runs maxsim_pairs (positives) + 4-D maxsim (negatives, which auto-routes the KD backward to lowmem).

shape (Lq=32, Ld=1030, d=128)vanillaLIKspeedupvanilla peakLIK peak
colbert-neg B64 × n41.41 ms1.63 ms0.87×278 MB242 MB
colbert-neg B128 × n41.66 ms1.47 ms1.13×493 MB420 MB
colbert-neg B128 × n82.48 ms1.65 ms1.50×880 MB679 MB
colbert-neg B256 × n84.41 ms1.76 ms2.50×1697 MB1293 MB
colbert-neg B256 × n167.73 ms1.79 ms4.31×3239 MB2321 MB
pairwise-neg B128 × n82.48 ms1.48 ms1.67×880 MB679 MB
pairwise-neg B256 × n84.38 ms1.56 ms2.81×1697 MB1293 MB

Reading — speed: the win scales with B × n_neg. It's launch-bound and slightly behind at B=64 (0.87×), then climbs 1.13× → 4.31× as the pos/neg slabs grow enough to amortise the Triton launches. This is the throughput the encoder hides in the e2e table above.

Memory: peak is lower for LIK too — ~13% at B64×n4, widening to ~28% at B256×n16. The forward never materializes the [B, n_neg, Lq, Ld] similarity tensor, and the 4-D negative backward auto-routes to lowmem, which owns each output row and accumulates grad_D in fp32 registers, writing bf16 straight out — no full-size fp32 grad buffer, no fp32→bf16 transient. So on the hard-negative path the fused heads are both a throughput and a memory win in training, and the gap widens with B × n_neg. (The win is largest in inference, where there's no grad_D at all.)

Edge models (d ∈ {48, 64})

Edge ColBERT models (d ∈ {48, 64}) are more memory-bound, so the fused kernel widens its lead. From benchmarks/kernels/bench_inference_edge.py:

shapefusednaive (fp32)speedupfused memnaive mem
LateOn-Code-edge Nd=1 000, Ld=1 024, d=480.106 ms0.397 ms3.8×0.0 MB314 MB
LateOn-Code-edge Nd=1 000, Ld=4 096, d=480.135 ms1.490 ms11.0×0.0 MB1.2 GB
LateOn-Code-edge Nd=1 000, Ld=8 192, d=480.262 ms3.039 ms11.6×0.0 MB2.5 GB
LateOn-Code-edge Nd=16 000, Ld=512, d=480.254 ms3.036 ms12.0×0.1 MB2.5 GB
mxbai-edge Nd=1 000, Ld=4 096, d=640.172 ms1.754 ms10.2×0.0 MB1.5 GB
mxbai-edge Nd=16 000, Ld=512, d=640.331 ms3.565 ms10.8×0.1 MB3.0 GB

Where this kernel actually moves the e2e needle

  1. Inference / reranking — no encoder backward → MaxSim is the step. 1.7–15× at matched numerics, biggest wins on wide Lq · Ld shapes.
  2. Small-encoder training — encoder small enough that MaxSim is material; LateOn-Code-edge moves 1.00–1.06× end-to-end on real MS MARCO triplets in this sweep (was 1.04–1.15× pre-0.3.0; the bs=64, Ld=300 headline is queued for 0.3.1 — see CHANGELOG).
  3. Long-context regimes (Ld ≥ 8k) — fused kernels run, naive doesn't.
  4. Compressed indices — PLAID rerank vs engine.search() is 8–23× on full corpus scoring and 18–51× on the partial rerank workload that matches fast-plaid's IVF probe.
  5. KD / offline scoring — teacher + student both do MaxSim, no per-step encoder cost to hide behind.

For plain LateOn fine-tuning the kernel is essentially free: same step time, same VRAM, same numerics, one patch_pylate() call.

Memory

The naive column is the measured allocator peak. Its floor is the Nq · Nd · Lq · Ld · 4-byte fp32 similarity tensor, but the measured peak runs above that formula because the fp32 casts and broadcasts of the operands coexist with the tensor at the peak. Fused keeps the Nq · Nd result plus — only if autograd is on — a Nq · Nd · Lq int32 argmax buffer.

scenarionaive scratchfused fwdfused fwd + argmax
Nq=1, Nd=1000, Lq=32, Ld=300183 MB4 KB128 KB
Nq=128, Nd=128, Lq=32, Ld=300621 MB64 KB2 MB
Nq=1, Nd=1000, Lq=128, Ld=10241.0 GB4 KB512 KB
Nq=16, Nd=32, Lq=32, Ld=81922.1 GB2 KB64 KB

This is what unlocks large in-batch negatives: at the same HBM budget you fit ~5–10× more of them than vanilla PyLate.

Apple Silicon (MPS)

MaxSimScorer and retrieve work on mps:0 tensors. Two implementations land on Apple Silicon and the dispatch picks per call:

  • metal — fused simdgroup_matrix kernels for both forward (maxsim_inference_metal) and forward + backward (maxsim_train_metal / maxsim_backward_metal), JIT-compiled via torch.mps.compile_shader. Never materialises the [Nq · Nd · Lq · Ld] similarity tensor and now also handles autograd-tracking training calls (via _MaxSimFnMetal) when the dtype / shape suit the kernel.
  • compiletorch.compile-fused dense reference. Autograd-aware fallback for fp32 inputs, d > 128, and tiny shapes the Metal launch overhead doesn't amortise on.

MacBook Air Apple M4 (2025, 16 GB unified memory), fp16, 30-iter median (benchmarks/kernels/bench_mps.py). metal needs d ≤ 128 and d % 8 == 0; outside that the dispatch transparently falls back to compile.

shapemetalcompileeagermetal vs eagermetal vs compilecompile vs eager
rerank-short (Nq=1, Nd=1000, Lq=32, Ld=300)8.45 ms18.33 ms18.04 ms2.14×2.17×0.98×
rerank-mid (Nq=1, Nd=500, Lq=32, Ld=1024)15.86 ms65.40 ms31.28 ms1.97×4.12×0.48×
rerank-10k (Nq=1, Nd=10k, Lq=32, Ld=300)57.33 ms184.88 ms181.66 ms3.17×3.22×0.98×
colpali (Nq=1, Nd=100, Lq=32, Ld=1024)2.94 ms13.51 ms6.45 ms2.19×4.59×0.48×
colpali-big (Nq=1, Nd=500, Lq=32, Ld=1024)16.11 ms66.91 ms30.15 ms1.87×4.15×0.45×
edge-d48 (Nq=1, Nd=4k, Lq=32, Ld=1024, d=48)31.98 ms457.1 ms111.7 ms3.49×14.29×0.24×
edge-d64 (Nq=1, Nd=1k, Lq=32, Ld=300, d=64)4.67 ms14.09 ms10.55 ms2.26×3.02×0.75×
train-batch (Nq=Nd=32, Lq=32, Ld=200)5.44 ms9.76 ms2.36 ms0.43×1.80× (compile)0.24×

For someone moving from plain PyTorch to this library, the headline is metal vs eager: 1.9–3.5× on every realistic inference shape. On train-batch the Metal kernel's launch overhead dominates and eager actually wins (2.36 ms vs metal's 5.44 ms) — the dispatch heuristic (Nq * Nd ≥ 64 ∧ Ld ≥ 192) is tuned to route this regime away from metal. Override with LIK_FORCE_MPS_BACKEND={metal,compile,reference} or LIK_DISABLE_COMPILE=1 if you need explicit control.

Memory is the second story: because metal streams D in 32-row tiles through threadgroup memory, peak working-set is one output tensor (8 MB) regardless of the corpus size. On rerank-10k the compile / eager paths materialise 2.5–4.0 GB intermediates and on edge-d48 0.75–1.5 GB — ~95–500× memory reduction.

The Metal kernel uses Apple's 8×8 simdgroup_matrix MMA (the Metal analogue of CUDA tensor cores) on top of a persistent threadgroup design: each launch serves 8 consecutive j values, loading Q once into a register-resident simdgroup_matrix cache that's reused across every (j, d-chunk) pair (collapsing 8·Ld_chunks redundant LDS reads into a single load pass). The cooperative D load stages each row through per-thread registers so the L2-normalize fold pays one LDS write instead of three. Tolerances stay inside 5e-3 relative for fp16 and 3e-2 for bf16.

The same kernel family also covers training on Apple Silicon: an argmax-saving forward (maxsim_train_metal) plus a single-pass backward (maxsim_backward_metal) that mirrors Triton's maxsim_backward_unifiedgrad_Q is row-owned, grad_D scatters via atomic_uint CAS (M1-compatible), and the L2-normalize Jacobian is folded into the kernel writes. KD / pairs layouts (4-D D of shape [Nq, K, Ld, d]) route to the same launch via a runtime flag bit, matching PyLate's colbert_kd_scores / colbert_scores_pairwise.

Training (forward + backward) on the same MacBook Air M4, fp16, 30-iter median:

shapemetalcompileeagermetal vs eagermetal vs compile
train-bs8 (Nq=Nd=8, Lq=32, Ld=64)0.52 ms0.52 ms1.34 ms2.58×1.00×
train-bs16 (Nq=Nd=16, Lq=32, Ld=128)2.35 ms3.37 ms1.93 ms0.82×1.44×
train-bs32 (Nq=Nd=32, Lq=32, Ld=200)6.19 ms24.69 ms7.43 ms1.20×3.99×
train-bs8-long (Nq=Nd=8, Lq=32, Ld=512)1.44 ms4.84 ms2.29 ms1.60×3.36×
train-d64 (Nq=Nd=16, Lq=32, Ld=256, d=64)1.65 ms4.96 ms2.83 ms1.71×3.00×

Same launch-overhead story as forward train-batch: on train-bs16 (Nq · Nd · Ld = 32 768, matmul work ≈ 17 M FMAs split across the two backward passes) Metal's per-launch fixed cost doesn't amortise against eager's already-fast native ops and the kernel runs 0.82× of eager — reproducibly across runs. From Ld ≥ 200 or d = 64 (which halves matmul cost relative to the scatter) Metal pulls ahead 1.2–1.7×, and the gap vs torch.compile$ \text{opens} \text{to} 3–4 \times \text{because} \text{Inductor} \text{still} \text{materialises} \text{the} $[Nq, Nd, Lq, Ld] score tile for the backward.