flow: add H2O sparse attention (page-level and token-level, MHA + GQA) - #6
flow: add H2O sparse attention (page-level and token-level, MHA + GQA)#6GeoffreyWang1117 wants to merge 1 commit into
Conversation
Four new flows implementing the H2O heavy-hitter criterion
(arXiv:2306.14048), plus the one-line export needed by them.
Page-level (h2o_sparse_attention, gqa_h2o_sparse_attention)
-----------------------------------------------------------
forward_cache runs once per page and cannot accumulate across decode
steps, so these rank pages by two per-page statistics instead:
- average relevance q . mean(k), i.e. the page's total pre-softmax
attention mass up to the constant page size;
- peak relevance, an upper bound on max_k q.k derived from the
element-wise max/min envelope of the page:
max_k q.k <= sum_d max(q_d * max_d, q_d * min_d)
Both envelopes are needed: with a signed query, q_d * max_d alone is an
upper bound only where q_d >= 0 and is a lower bound everywhere else.
This is the same bound gqa_quest_sparse_attention already uses.
The two terms live on different scales -- the peak term is an upper bound
over the page and is systematically larger -- so they are combined with
exposed weights (w_avg, w_peak) rather than an unweighted sum.
Token-level (h2o_token_sparse_attention, gqa_h2o_token_sparse_attention)
------------------------------------------------------------------------
With page_size=1 a page is a token, and a Save inside forward_indexer
carries the running score across decode steps. Three things this needs
that are worth calling out:
1. The accumulated quantity is the *attention probability*, not the raw
logit. A Softmax(dim=0) over the request's tokens is applied before
accumulation. H2O accumulates normalised attention; a running sum of
raw logits is unbounded and sign-indefinite.
2. forward_cache zeroes cache["hh_score"] for the pages written in the
step. The KV pool is zeroed once at allocation and never again -- a
page released by a finished request keeps its contents and is handed
to the next request as-is, so without this reset a new request would
inherit the previous one's accumulated scores. This is what the new
Fill export is for; the op already existed in vortex_torch/cache but
was not reachable from the package.
3. The accumulator decays (default 0.984375 = 1 - 2**-6). The KV pool is
bfloat16, so hh_score accumulates in bfloat16. An undecayed sum grows
without bound and its increments are eventually lost to rounding,
freezing the ranking; a bounded steady state keeps them
representable. 0.984375 is exactly representable in bfloat16 and, in
simulation, the longest window that still reproduces the top-k of an
exact float64 accumulator. decay=1.0 restores the plain sum.
page_size=1 is asserted in create_cache. disable_radix_cache is required
but cannot be checked from inside a flow, so it is documented instead.
Verification
------------
- Profile mode (real op dispatch, shape/format validation, buffer
allocation) for all four flows at group sizes 1, 3, 4, 7, 8.
- Execute mode against the underlying Triton kernels on an RTX 3090:
Fill zeroes exactly the pages in loc and leaves the rest untouched;
the Add + Save recurrence over 128 tokens x 800 steps matches an exact
float64 reference to 8.4e-03 relative with 100% top-32 agreement,
against 4.2e-01 relative and 97% for the undecayed variant.
The token-level design -- Save inside forward_indexer for cross-step
accumulation, with page_size=1 and a disabled radix cache -- was
suggested by Zhuoming Chen, and is credited in the class docstrings.
|
Follow-up: I got the environment for the full stack working again, so the token-level flow is now verified on hardware against a reference, rather than in profile mode plus a kernel-level simulation as in the description above. The harness drives ResultThe line worth reading is the mass. Every step injects exactly 1.0 of probability mass, so a decayed sum has a closed-form steady state of The undecayed counterfactualSame harness, After 600 steps it has retained 445.8 of the 600.0 of mass the recurrence predicts — 26% rounded away — the error against the reference has grown to 3.2e-01, and top-k agreement has fallen to 62%. Two things I had to fix in the harness first, in case they are usefulThe scored window is not the whole sequence. My first reference softmaxed over all Top-k agreement is meaningless without heavy hitters. With i.i.d. keys and queries every token is attended about equally: the 116 scores landed in 0.49–0.62 and the gap at the top-32 boundary was 0.000079, which is 28× smaller than one bf16 ulp at that magnitude. No implementation can resolve that ordering. The harness now uses keys with a stable per-token affinity along a shared direction the queries lean toward — which is the situation H2O exists for — and prints the boundary width in ulps next to each overlap, so a low number is interpretable rather than alarming. The 97% row above sits at a 0.0-ulp boundary and is correctly not treated as a failure. Script
|
Four new flows implementing the H2O heavy-hitter criterion (arXiv:2306.14048), registered as
h2o_sparse_attention,gqa_h2o_sparse_attention,h2o_token_sparse_attention,gqa_h2o_token_sparse_attention. No existing flow is touched; the only change outsideflow/algorithms.pyis a one-line export.Page-level variants
forward_cacheruns once per page and cannot accumulate across decode steps, so these rank pages by two per-page statistics:average relevance —
q · mean(k), i.e. the page's total pre-softmax attention mass up to the constant page size;peak relevance — an upper bound on
max_k q·kfrom the element-wise max/min envelope:Both envelopes are required. With a signed query,
q_d · max_dalone is an upper bound only whereq_d ≥ 0, and is a lower bound everywhere else. This is the same boundgqa_quest_sparse_attentionalready uses, applied to the heavy-hitter score.The two terms are not on the same scale — the peak term is an upper bound over the page and is systematically larger than the mean term — so they are combined through
Add(alpha=w_avg, beta=w_peak)with the weights exposed on the constructor rather than fixed at 1.Token-level variants
With
page_size=1a page is a token, and aSaveinsideforward_indexercarries the running score across decode steps (forward_cachecannot). Three points worth review:1. The accumulated quantity is the attention probability, not the raw logit. A
Softmax(dim=0)over the request's tokens is applied before accumulation. H2O accumulates normalised attention mass; a running sum of raw logits is unbounded and sign-indefinite. The softmax scale defaults tohead_dim ** -0.5, resolved increate_cache(the only hook that receiveshead_dim) and overridable via the constructor.2.
forward_cachezeroescache["hh_score"]for the pages written in the step. The KV pool istorch.zerosonce at allocation and never again;KVCacheAllocator.free()only returns indices to the free list. A page released by a finished request keeps its contents and is handed to the next request as-is, so without this reset a new request inherits the previous one's accumulated heavy-hitter scores. The page-level flows are immune becauseCMean/CMax/CMinare overwriting reductions.This is what the new
Fillexport is for —vortex_torch/cache/fill.pyalready implements it (_impl_map = {FORMAT.PAGED: fill_p}), it just was not re-exported from the package.fill_p_kerneltriggers on end-of-page tokens, which withpage_size=1is every token, so it resets exactly the pages written this step.3. The accumulator decays (default
0.984375 = 1 − 2⁻⁶). The KV pool is bfloat16 (assert self.store_dtype == torch.bfloat16), sohh_scoreaccumulates in bfloat16 (~8 bits of mantissa). An undecayed sum grows without bound; once it does, each increment is small relative to the accumulated value and is lost to rounding, freezing the ranking at whatever it was. A decay keeps the accumulator at a bounded steady state of1/(1−decay)typical increments, which keeps them representable.0.984375is exactly representable in bfloat16 and, in simulation, the longest window that still reproduces the top-k of an exact float64 accumulator.decay=1.0restores the plain sum for anyone who wants it.page_size == 1is asserted increate_cache.disable_radix_cache=Trueis also required — prefix sharing would let one request's scores leak into another through shared pages — but that cannot be checked from inside a flow, so it is documented on the class instead.Verification
RTX 3090, torch 2.9.1, triton 3.5.1.
Profile mode — real op dispatch, shape/format validation and buffer allocation, for all four flows across group sizes 1, 3, 4, 7, 8, plus the
page_sizeguard and the resolved softmax scale:Execute mode — the actual Triton kernels behind
Fill,AddandSave(topKneeds the compiled CUDA extension and is not what these fixes touch):The undecayed row is the comparison that motivates the default: 42% relative error against the exact accumulator after 800 steps, versus 0.84% with the decay.
Verification scripts
Profile mode:
Execute mode:
Notes
The token-level design — using
Saveinsideforward_indexerfor cross-step accumulation, together withpage_size=1and a disabled radix cache — was suggested by Zhuoming Chen, and is credited in the class docstrings.Independent of this PR: the GQA variants use
GeMM/Softmaxover the query-group axis, which today requires the group size to be a power of two (#5 lifts that). Onv1as-is these flows work for power-of-two group sizes, same as every other flow in the file.