Flash-MaxSim

June 11, 2026 · View on GitHub

Fused Triton GPU kernel for ColBERT/ColPali MaxSim scoring. 2.5–2.9x faster than FP16 eager PyTorch, 3.9x / 4.7x (A100 / H100) at matched FP32-accumulation precision, up to 4.6x on variable-length corpora, and 2.6–5.1x faster than torch.compile(max-autotune) — the strongest PyTorch configuration, which most public kernels never benchmark against. The B × Lq × Ld similarity matrix is never materialized. Drop-in replacement — same API, no configuration.

pip install flash-maxsim
from flash_maxsim import flash_maxsim

scores = flash_maxsim(Q, D)  # that's it

Why Flash-MaxSim?

Every existing MaxSim implementation computes and stores the full similarity matrix in GPU memory. Flash-MaxSim eliminates it — the matrix never exists outside the chip.

1. Memory: the similarity matrix is gone

The standard einsum / bmm approach allocates B × Lq × Ld × 2 bytes for the similarity matrix. For ColPali at 10K docs, that's 21 GB — instant OOM.

ColPali (Lq=1024, Ld=1024)Naive sim matrixFlash-MaxSim
B=1,0002,097 MB0 MB
B=5,00010,486 MB0 MB
B=10,00020,972 MB0 MB

That's 21 GB of temporary memory just for scoring — on top of model weights, KV cache, and the embeddings themselves. On a 40 GB GPU, this OOMs. On 80 GB, it eats a quarter of your memory for a tensor that gets immediately reduced and thrown away.

Flash-MaxSim uses zero extra HBM. The similarity is computed tile-by-tile in SRAM and reduced on the fly.

2. Speed: 2.5–2.9x vs FP16 eager, 3.9–4.7x at matched precision

All timings cast-hoisted and CUDA-event-measured (medians); raw JSON in benchmarks/bench_fwd_fair_*.json and benchmarks/bench_chunked_fp16_eager_*.json.

Per-shape at B=1K, vs naive einsum at matched precision (FP32 accumulation, TF32 tensor cores, cast hoisted out of the timed region):

Shape (Lq, Ld)A100H100
textual (32, 300)1.4x1.2x
long-doc (32, 1024)2.0x1.8x
medium (128, 1024)3.0x3.3x
visual (512, 1024)3.5x4.2x
ColPali (1024, 1024)3.9x4.7x

Vs torch.compile(mode="max-autotune") of the same expression — the strongest PyTorch baseline, with CUDA graphs and Inductor autotuning — Flash is 2.6–5.1x across the five shapes (peak 5.1x at medium Lq=128; 3.8x at ColPali; benchmarks/bench_compile_ma_*.json). Most published kernel comparisons stop at eager; we report both because compile narrows the latency gap but cannot remove the materialized [B, Lq, Ld] intermediate — the memory profile and OOM cliffs are compile-invariant.

Vs the fastest (but less precise) baseline — plain FP16 eager einsum, including its production chunked variant — Flash is 2.5–2.9x faster with 5–9x lower peak memory (A100, ColPali):

BFP16 eagerChunked FP16 eager (best chunk)FlashFlash peakEager peak
1,0004.3 ms (2.1x)4.3 ms (2.1x)2.0 ms0.3 GB2.4 GB
10,00045.9 ms (2.8x)43.0 ms (2.6x)16.4 ms2.6 GB23.9 GB
20,00092.9 ms (2.8x)86.3 ms (2.6x)32.6 ms5.3 GB47.7 GB

3. Zero parameters — no chunk size to tune

Production systems (vLLM, etc.) chunk documents into mini-batches to avoid OOM. Too large → OOM. Too small → launch overhead. Flash-MaxSim has zero configuration — same code on a 16 GB GPU and an 80 GB GPU.

4. Variable-length documents — zero padding waste

Real collections have variable doc lengths, and this is where the gap is widest: padding wastes compute proportional to the fill ratio. At matched precision vs padded naive einsum (B=1K, Ld_max=512, A100): 3.2x on uniform lengths, 4.3x at a HotpotQA-like length distribution, 4.6x on highly ragged collections (benchmarks/bench_varlen_buckets_*.json). Flash-MaxSim supports packed variable-length documents:

from flash_maxsim import flash_maxsim_packed, pack_docs

D_packed, cu_seqlens, max_ld = pack_docs(doc_embeddings)
scores = flash_maxsim(Q, D_packed, doc_lengths=cu_seqlens)
RegimeNSpeedupPadding saved
ColBERT skewed (avg_Ld≈49)100K5.1x39%
ColBERT uniform100K2.7x42%
ColPali uniform5004.2x37%
ColPali skewed5K3.9x19%

5. INT8 index — half storage, faster, more precise

Store embeddings as INT8 (2x compression). The kernel uses INT8 tensor cores (624 TOPS on A100 — 2x FP16 throughput). No dequantization in HBM.

from flash_maxsim import flash_maxsim_int8x8, quantize_int8_symmetric

# Index time: quantize once (50% storage savings)
D_int8, scales = quantize_int8_symmetric(D)

# Query time: drop-in
scores = flash_maxsim_int8x8(Q, D_int8, scales)
Method (ColPali B=5K)LatencyD StorageExtra HBMPrecision
Naive dequant+einsum30.9 ms1 byte/dimD_fp16 copy + sim matrix0.065
Flash FP168.0 ms2 bytes/dim~00.00008
Flash INT8×INT86.6 ms1 byte/dim~00.023

Flash INT8×INT8 is 4.7x faster than naive dequant, uses half the storage, and is 3x more precise (FP32 accumulation vs FP16 einsum).

6. Training — autograd backward pass

Full gradient support via saved argmax indices. Sparse backward — no full matrix in either direction:

# Single query (e.g. cross-encoder rerank training)
from flash_maxsim import flash_maxsim_train
scores = flash_maxsim_train(Q, D)              # Q: [Lq, d], D: [B, Ld, d]
scores.sum().backward()                         # gradients to both Q and D

# Batched (contrastive / in-batch negatives — new in v0.2.1)
from flash_maxsim import flash_maxsim_batched_train
scores = flash_maxsim_batched_train(            # Q: [Nq, Lq, d], D: [B, Ld, d]
    Q_batch, D, shared_docs=True,               #   shared_docs=True for contrastive
    doc_lengths=d_lens, query_lengths=q_lens,   #   varlen — masks padded tokens
)                                                # → scores [Nq, B]
scores.diagonal().sum().backward()              # gradients to Q_batch and D

# Knowledge distillation (each query has its own doc set)
scores = flash_maxsim_batched_train(            # Q: [Nq, Lq, d], D: [Nq, B, Ld, d]
    Q_batch, D_per_query, shared_docs=False,    # → scores [Nq, B]
)

The batched path uses an inverse-grid CSR backward (atomic-free, runs on tensor cores) when work is non-trivial, falling back to FP32-atomic scatter otherwise. Saved activations are O(Nq × B × Lq) argmax indices instead of the full O(Nq × B × Lq × Ld) similarity matrix that vanilla autograd would materialize — 95–205× less scoring memory at typical contrastive shapes, 1.4–3.8× faster full training step than colbert_scores-style baselines on A100, and lifts the OOM ceiling 2× (e.g. ColPali contrastive B=128 becomes feasible on a single 80 GB A100).

Verified bit-exact for grad_Q vs FP32 reference at fixed-length shapes; cosine similarity > 0.999 across all tested batched shapes; correct under variable-length inputs even when padded query positions hold non-zero values.

7. 800x more precise

Flash-MaxSim uses FP32 accumulation for the running max and score sum. The standard FP16 einsum has compounding rounding errors:

MethodMean error vs FP32Top-20 overlapSpearman
FP16 naive (einsum)6.2×10⁻²95%0.993
Flash FP167.6×10⁻⁵100%1.000
Flash INT8×INT82.3×10⁻²100%0.999

Quick Start

import torch
from flash_maxsim import flash_maxsim

# Score one query against 1000 documents
Q = torch.randn(32, 128, device="cuda", dtype=torch.float16)   # query: 32 tokens
D = torch.randn(1000, 300, 128, device="cuda", dtype=torch.float16)  # 1000 docs, 300 tokens each
scores = flash_maxsim(Q, D)  # [1000]

# ColPali (long query) — automatic chunking, no configuration needed
Q_colpali = torch.randn(1024, 128, device="cuda", dtype=torch.float16)
D_colpali = torch.randn(1000, 1024, 128, device="cuda", dtype=torch.float16)
scores = flash_maxsim(Q_colpali, D_colpali)  # [1000]

# Batched: 16 queries vs same corpus (up to 15x faster than serial loop)
Q_batch = torch.randn(16, 32, 128, device="cuda", dtype=torch.float16)
scores = flash_maxsim_batched(Q_batch, D, shared_docs=True)  # [16, 1000]

Variable-Length Documents

from flash_maxsim import flash_maxsim_varlen, pack_pairs

# Each pair has different lengths — zero padding waste
q_embs = [torch.randn(32, 128, ...), torch.randn(48, 128, ...)]
d_embs = [torch.randn(180, 128, ...), torch.randn(250, 128, ...)]

Q_packed, D_packed, cu_q, cu_d, max_lq, max_ld = pack_pairs(q_embs, d_embs)
scores = flash_maxsim_varlen(Q_packed, D_packed, cu_q, cu_d, max_lq, max_ld)

INT8 Index

from flash_maxsim import flash_maxsim_int8x8, quantize_int8_symmetric

# Index time (once): 50% smaller storage
D_int8, scales = quantize_int8_symmetric(D)

# Query time: INT8 tensor cores, zero overhead
scores = flash_maxsim_int8x8(Q, D_int8, scales)

Training

# Single query
from flash_maxsim import flash_maxsim_train

Q = torch.randn(32, 128, device="cuda", dtype=torch.float16, requires_grad=True)
D = torch.randn(100, 300, 128, device="cuda", dtype=torch.float16, requires_grad=True)
scores = flash_maxsim_train(Q, D)
scores.sum().backward()                   # Q.grad and D.grad

# Batched contrastive training (new in v0.2.1)
from flash_maxsim import flash_maxsim_batched_train

Q = torch.randn(64, 32, 128, device="cuda", dtype=torch.float16, requires_grad=True)
D = torch.randn(64, 300, 128, device="cuda", dtype=torch.float16, requires_grad=True)
scores = flash_maxsim_batched_train(Q, D, shared_docs=True)   # [64, 64] scores
scores.diagonal().sum().backward()        # contrastive loss → grads

Zero-Copy Reranking

Score documents directly from a model's output tensor — zero additional memory:

from flash_maxsim import flash_maxsim_rerank_direct

scores = flash_maxsim_rerank_direct(
    Q, batch_tensor, doc_offsets, doc_lengths, max_ld
)  # 0 bytes allocated for scoring

How It Works

Q_block = load(Q)                      # SRAM (small — one query)
m = [-inf] * Lq                        # registers (running max per query token)

for tile in D.tiles(BLOCK_D):
    D_tile = load(tile)                # SRAM
    S = tl.dot(Q_block, D_tile.T)     # tensor cores — stays in SRAM
    m = max(m, S.max(axis=1))         # online max reduction
    # S dies here — never written to HBM

score = sum(m)                          # one scalar per doc → HBM

Same principle as Flash Attention, but simpler: max is trivially composable across tiles (no log-sum-exp rescaling needed).

API Reference

Core Scoring

FunctionSignatureDescription
flash_maxsim[Lq,d] × [B,Ld,d] → [B]Single query, auto-chunking for long queries
flash_maxsim_batched[Nq,Lq,d] × [B,Ld,d] → [Nq,B]Multi-query (shared or per-query docs)
flash_maxsim_varlenpacked Q,D + cu_seqlens → [N]Variable-length pairs, zero padding
flash_maxsim_packed[Lq,d] × packed [T,d] + cu_seqlens → [B]Shared Q + variable-length packed D

INT8 Quantization

FunctionDescription
`flash_maxsim_int8x8$\text{True} \text{INT8} \times \text{INT8} \text{tensor} \text{core} \text{scoring} (\text{recommended})
$quantize_int8_symmetric`Per-token symmetric INT8 quantization for D
quantize_query_int8Per-token INT8 quantization for Q
flash_maxsim_int8Legacy: fused affine INT8 dequant+scoring

Training & Utilities

FunctionSignatureDescription
flash_maxsim_train[Lq,d] × [B,Ld,d] → [B]Single-query MaxSim with autograd backward (sparse argmax)
flash_maxsim_batched_train[Nq,Lq,d] × [B,Ld,d] → [Nq,B]Batched MaxSim with autograd — for contrastive in-batch negatives or KD; supports shared_docs, doc_lengths, query_lengths
flash_maxsim_int8_batched_trainsame signatureSaves D as INT8 in the autograd context. Drop-in for the niche case where the caller releases the FP16 D between forward and backward; see CHANGELOG.md for the honest peak-memory measurement
flash_maxsim_rerank_directscattered batch tensor → [B]Zero-copy scoring from a serving model's output
pack_pairslist of (q, d) → packedVariable-length (Q, D) pair packing into cu_seqlens format
pack_docslist of D → packedVariable-length doc packing for flash_maxsim_packed
maxsim_naive[Lq,d] × [B,Ld,d] → [B]Pure PyTorch reference (FP16 einsum)

Serving / production utilities

FunctionDescription
warmup()Pre-compile every kernel specialization the dispatcher can pick at runtime. Call once at server startup so the first request doesn't pay Triton JIT cost. CLI: python -m flash_maxsim.warmup.

What's new in v0.3.0

See CHANGELOG.md for the full list. Highlights:

  • Auto-routes to a split-d forward at d > 512 so fat-embedding encoders (Jina v2 d=512, Granite-Embedding d=384/768, Voyage v2 d=1024, NV-Embed d=4096) run without the SRAM-spill latency cliff the standard kernel hits at those dims.
  • Backward unified kernel (atomic-mode default for small shapes): fused dQ+dD in one launch with Q register hoisting — a single D load serves both gradients.
  • Per-arch heuristic launch-config table replaces Triton's autotune — deterministic launch every call (CUDA-graph friendly), no first-call trial overhead.
  • int32 CSR build for the invgrid backward at large shapes: 25-35% lower CSR transient memory at ColPali B=128.

Requirements

  • NVIDIA GPU (Ampere or newer recommended)
  • PyTorch >= 2.0
  • Triton >= 3.4
  • CUDA

Tested on: H100 80GB, A100 80GB/40GB, V100.

Authors

IBM Research Israel

  • Roi Pony
  • Adi Raz Goldfarb
  • Idan Friedman
  • Udi Barzelay

License

Apache 2.0