From Attention to KV Cache Compression: Notes from a KernelGen Optimization
Published:
Official challenge page
Challenge 8: https://kernelgen.flagos.io/challenge/8?lang=zh&tab=readme
This post records one optimization pass for KernelGen Challenge 8. It is a set of notes written after doing the problem and filling in the missing background along the way. The statement itself is short, and the core function has only one name; once we actually start implementing it, however, it pulls in Attention, KV Cache, Triton DSL, GPU memory access, quantized byte layouts, and cross-backend differences.
Let us not begin with code. A natural first question is: why is a KV Cache compression operator that finally writes only a few hundred bytes worth optimizing with a custom kernel?
The rough answer is that in long-context decoding, the KV Cache is already close to the inference state itself. It occupies memory, consumes bandwidth, and every compression, movement, and writeback detail can show up in real decoding latency. This post starts there and slowly walks toward the concrete Triton implementation.
The implementation notes and experiments below come from our submissions, local microbenchmarks, and several rounds of independent checks during the competition. I only keep the parts that help explain the implementation choices.
The Thread
The first half clarifies Attention, KV Cache, and the byte layout in the problem. The middle section derives how one output slot is produced. The second half returns to implementation and discusses the two optimization routes: GPGPU and Ascend.
Starting from Attention
Standard dot-product attention can be written as:
\[\boldsymbol{o}_i=\sum_j a_{i,j}\boldsymbol{v}_j,\qquad a_{i,j}=\frac{\exp(s_{i,j})}{\sum_k \exp(s_{i,k})},\qquad s_{i,j}=\frac{\boldsymbol{q}_i^{\top}\boldsymbol{k}_j}{\sqrt{d}}.\]Here $\boldsymbol{q}_i$ is the query at the current position, and $\boldsymbol{k}_j,\boldsymbol{v}_j$ are the key and value at attended positions. For a decoder-only model, token $i$ can only see the past, so $j\leq i$.
The prefill phase is usually regular: the $Q,K,V$ of the whole input segment can be computed in one pass and then handed to a matrix-shaped attention kernel. Decoding is less tidy. The model generates only one new token each step, but that token still attends to all previous tokens. At step $t$ we roughly have:
\[\boldsymbol{q}_t \quad\text{attends to}\quad \{\boldsymbol{k}_1,\ldots,\boldsymbol{k}_t\},\quad \{\boldsymbol{v}_1,\ldots,\boldsymbol{v}_t\}.\]If every step recomputed $K,V$ from all historical hidden states, the repeated work would be substantial. Practical inference therefore stores the historical tokens’ $K,V$ and reads them directly later. That stored state is the KV Cache.
In this sense, the KV Cache is a state variable of decoding. It removes repeated computation, while moving pressure onto memory capacity and memory bandwidth.
The KV Cache Bill
If the context length is $L$, the number of KV heads is $h_{kv}$, and the key/value dimensions per head are $d_k,d_v$, then the KV Cache size is roughly:
\[O\big(L\cdot h_{kv}\cdot(d_k+d_v)\big).\]That is the capacity bill. In decoding we also pay a read-bandwidth bill: every generated token has to read historical Key/Value states for attention. As $L$ grows, the bottleneck often shifts from arithmetic toward the memory system.
Many attention variants can be understood through this line. MHA, MQA, GQA, MLA, and related designs differ in form, but they repeatedly ask the same practical question: how many bytes should we spend to preserve historical information?
KernelGen does not ask us to redesign the attention architecture. It gives us a DeepSeek-style compression rule. The engineering task is more concrete:
a historical state window
-> one 512-dimensional compressed vector
-> a packed KV-cache byte region
This is the core of the post: make that compression fast while keeping the byte-level semantics correct.
Competition Background and Problem Statement
The problem comes from the FlagOS KernelGen 48-hour operator bounty challenge in Beijing. Its function is named c128_256_512_compress, and the statement describes KV Cache compression for DeepSeek V4 long-context inference. The submission is a Python file. The function name and parameters must match the statement, the file must be UTF-8, and the platform expects Python 3 and Triton 3.5 compatibility. The same solution.py is tested on multiple backends, so the problem is both a kernel optimization task and a multi-backend engineering task.
The platform roughly covers:
| Category | Platforms |
|---|---|
| GPGPU route | NVIDIA, MetaX, Hygon, TianShu, Moore Threads, T-Head |
| DSA/NPU route | Huawei Ascend |
The benchmark shapes are also quite fixed. compress_ratio is only 128, 256, or 512, and num_reqs and total_tokens form 12 cases:
num_reqs | total_tokens | compress_ratio |
|---|---|---|
| 1 | 8192 | 128 |
| 4 | 32768 | 128 |
| 8 | 65536 | 128 |
| 8 | 131072 | 128 |
| 1 | 8192 | 256 |
| 4 | 32768 | 256 |
| 8 | 65536 | 256 |
| 8 | 131072 | 256 |
| 1 | 8192 | 512 |
| 4 | 32768 | 512 |
| 8 | 65536 | 512 |
| 8 | 131072 | 512 |
These numbers matter. They mean we are working on a highly structured compression task, rather than a general operator over arbitrary shapes. A fixed structure gives constraints, but it also leaves useful openings. The later block_size=8 optimization came from exactly this fixed structure.
The Compression Operator
The target function is:
def c128_256_512_compress(
state_cache,
token_to_req,
positions,
boundary_token_indices,
block_table,
rms_norm_weight,
cos_sin_cache,
kv_slot_mapping,
kv_cache,
block_size,
compress_ratio,
rms_norm_eps=1.0e-6,
):
...
One can first view it as a “window compressor”: for every token listed in boundary_token_indices, take the previous contiguous window of length compress_ratio, compress those state rows into a new KV Cache slot, and write the packed result back.
Gather and Scatter
In operator implementation, gather means reading a set of positions from a source tensor according to indices and forming the working set needed for computation. In this problem, gather uses request id, position, and block table to locate the physical state-cache rows in the compression window. Scatter is the reverse write path: the computed payload and scale bytes are written back to the paged KV cache according to the target KV slot.
Some constants appear repeatedly:
| Name | Value | Meaning |
|---|---|---|
HEAD_DIM | 512 | Width of the compressed vector |
ROPE_HEAD_DIM | 64 | Last 64 dimensions use RoPE and are stored as BF16 bytes |
NOPE_HEAD_DIM | 448 | First 448 dimensions are INT8-quantized |
KV_BLOCK_SIZE | 64 | One KV page contains 64 slots |
TOKEN_STRIDE | 576 | Payload bytes per slot |
SCALE_DIM | 8 | Scale-byte region per slot |
The payload of one output slot is:
[448 bytes INT8 NOPE][128 bytes BF16 RoPE]
The scale region is:
[7 active scale bytes][1 padding byte]
Byte-Level Correctness
The final comparison is against the KV Cache byte layout. Close intermediate floating-point values are not enough: INT8 value bytes, scale bytes, RoPE BF16 bytes, and page/slot scatter positions all have to match. Many attempts that looked “almost right” eventually failed on these byte details.
How One Output Is Produced
Let us expand one output. The formulas look long, but they answer three questions: where is the window, how is it compressed into 512 dimensions, and how is the result packed back into KV Cache?
Let the current boundary token be $b$, the window length be $C=\mathtt{compress_ratio}$, and the head dimension be $D=512$. Its request id and position are:
\[r=\mathtt{token\_to\_req}[b],\qquad p=\mathtt{positions}[b].\]The $i$-th historical token position in the window is:
\[t_i=p-C+1+i,\qquad 0\le i<C.\]Each $t_i$ is mapped through the paged state-cache layout to a physical row. Let $B=\mathtt{block_size}$, which is 8 in the problem:
\[\ell_i=\left\lfloor\frac{t_i}{B}\right\rfloor,\qquad o_i=t_i\bmod B,\] \[g_i=\mathtt{block\_table}[r,\ell_i],\qquad \mathtt{row}_i=g_iB+o_i.\]The first 512 dimensions of state_cache[row_i] are values, and the next 512 dimensions are scores:
For every dimension $d$, we apply softmax along the window and then form a weighted sum:
\[\alpha_{i,d}=\frac{\exp(s_{i,d})}{\sum_{j=0}^{C-1}\exp(s_{j,d})}, \qquad c_d=\sum_{i=0}^{C-1}\alpha_{i,d}v_{i,d}.\]One detail is easy to miss: the softmax is per dimension. In other words, the 512 dimensions each have their own length-$C$ weight vector, and each dimension normalizes over its own window.
After obtaining the 512-dimensional compressed vector $\boldsymbol{c}$, we apply RMSNorm. With weight $w_d$:
\[\rho=\left(\frac{1}{D}\sum_{d=0}^{D-1}c_d^2+\varepsilon\right)^{-1/2}, \qquad y_d=c_d\rho w_d.\]The first 448 dimensions go through NOPE quantization. The reference implementation first does a BF16 roundtrip. For the $g$-th group of 64 dimensions:
\[z_d=\operatorname{fp32}(\operatorname{bf16}(y_d)), \qquad G_g=\{64g,\ldots,64g+63\},\] \[a_g=\max\left(\max_{d\in G_g}|z_d|,10^{-4}\right), \qquad e_g=\left\lceil\log_2\frac{a_g}{127}\right\rceil.\]The quantized value and scale byte are:
\[q_d=\operatorname{int8}\left(\operatorname{clip}(z_d\,2^{-e_g},-127,127)\right), \qquad \mathtt{scale}_g=\operatorname{uint8}(e_g+127).\]The final 64 dimensions use GPT-J interleaved RoPE. For $j=0,\ldots,31$:
\[u_j=y_{448+2j},\qquad w_j=y_{448+2j+1}, \qquad p_c=\left\lfloor\frac{p}{C}\right\rfloor C.\]After reading $\cos_j,\sin_j$ from cos_sin_cache[p_c]:
The writeback position comes from kv_slot_mapping[b]:
slot = kv_slot_mapping[b]
page = slot // 64
slot_offset = slot % 64
payload_col = slot_offset * 576
scale_col = 64 * 576 + slot_offset * 8
Putting it together, the operator is roughly:
window rows
-> per-dim softmax weighted sum
-> RMSNorm
-> NOPE INT8 bytes + scale bytes
-> RoPE BF16 bytes
-> paged KV-cache scatter
The Baseline
The official baseline is a good semantic reference. It first gathers all rows needed by the window:
flat_idx = (block_numbers * block_size + block_offsets).reshape(-1)
all_rows = state_cache.reshape(-1, 2 * HEAD_DIM)[flat_idx].reshape(
num_outputs, compress_ratio, 2 * HEAD_DIM
)
kv_vals = all_rows[:, :, :HEAD_DIM]
scores = all_rows[:, :, HEAD_DIM:]
compressed = (kv_vals * F.softmax(scores, dim=1)).sum(dim=1)
The code is clean. The cost is also clear: it constructs a large logical intermediate tensor:
[num_outputs, compress_ratio, 1024]
When compress_ratio=512, the source traffic for a single output is roughly:
512 * 1024 * sizeof(float) ~= 2 MiB
The final payload plus scale is under 600 bytes. This contrast points to the main pressure points:
| Bill | Main pressure |
|---|---|
state_cache reads | Larger windows mean heavier source traffic |
| Intermediate tensor | all_rows amplifies memory traffic |
| Paged layout | block_table and slot math add integer addressing work |
| Finalizer | Small data volume, but BF16, INT8, scale, and RoPE byte semantics are sharp |
This observation is not subtle, but it gives the optimization direction: move fewer large tensors, avoid repeated address work, and stream whenever possible.
A Bit of Triton and GPU Background
Triton is a Python DSL for GPU kernels. Its abstraction level sits roughly between PyTorch and CUDA C: we write programs, each program handles a tile, and the compiler maps these tiles to the underlying GPU execution model.
Here a tile can be read as “a small implementation work block.” It is an implementation-level unit. We cut a large problem into smaller rectangles so memory access, register use, and parallel granularity become more controllable.
Implementation Granularity: Tile
A tile is a fixed chunk of work inside the kernel implementation. In matrix multiplication it often corresponds to a small rectangle of the matrix. In this problem it is closer to “some historical tokens times some head dimensions.” After the large window is split into tiles, the kernel can read and reduce by blocks, trying to consume the data near registers or cache.
For this problem, the full computation surface can be imagined as:
outputs x compress_window x head_dim
A Triton program usually does not process the whole surface at once. More often, one program handles a segment of head dimensions for one output and reads the window dimension in chunks:
BLOCK_T: how many source tokens to read at a time
BLOCK_D: how many head dimensions to process at a time
So the core tile read by one tl.load is usually:
[BLOCK_T, BLOCK_D]
For example, with BLOCK_T=128, BLOCK_D=64, one program processes 64 dimensions for one boundary token and scans 128 historical tokens at a time. For compress_ratio=512, it scans four such token tiles. The 512 head dimensions are usually covered by eight dimension tiles.
Tile size affects many details: a larger BLOCK_T reduces loop count but makes each load and mask heavier; a larger BLOCK_D exposes more dimension parallelism but increases accumulator and register pressure. Tuning tiles is therefore not simply “make them larger”; it is a balance among backend registers, cache, memory transactions, and compiler lowering.
In this problem, a program can correspond to:
one output slot
one segment of head dimensions
one segment of the compression window
Typical code looks like:
out_pid = tl.program_id(0)
group_pid = tl.program_id(1)
dims = group_pid * BLOCK_D + tl.arange(0, BLOCK_D)
lanes = tl.arange(0, BLOCK_T)
tl.program_id chooses which output block this program owns, while tl.arange creates vectorized lanes. Then tl.load reads a two-dimensional tile:
values = tl.load(
state_cache
+ block[:, None] * state_s0
+ block_offset[:, None] * state_s1
+ dims[None, :] * state_s2
)
Logically, values is [BLOCK_T, BLOCK_D]. We write tensorized expressions in Python; the compiler lowers them to the GPU backend.
When writing kernels like this, the main ledger usually contains:
| Item | Meaning in this problem |
|---|---|
| Global memory reads | state_cache is large; excessive reads easily become bandwidth-bound |
| Address generation | block_table lookup and integer indexing consume instructions and registers |
| Register pressure | Larger BLOCK_D means more accumulators and heavier programs |
| Parallel granularity | Too-small BLOCK_T adds loops; too-large BLOCK_T may stress scheduling |
| Temporary tensors | PyTorch baseline’s all_rows is clear, but it increases memory traffic |
The two main optimizations later, online softmax and block8 physical-block arithmetic, both fit this ledger: the former reduces intermediate tensors, while the latter reduces address work in the hot loop.
Our Decomposition
The current implementation is roughly split into two stages:
Triton gather/reduce -> compressed[outputs, 512]
Triton/PyTorch-safe finalize -> packed kv_cache bytes
The first stage streams the window from the paged state cache and performs the softmax weighted sum, producing FP32 compressed. The second stage applies RMSNorm, NOPE quantization, RoPE, and scatter.
The main entry is roughly:
if _should_use_ascend_split_finalize(state_cache):
return _c128_256_512_compress_ascend_block_gather(...)
backend = _backend_kind(device_type=state_cache.device.type)
out = kv_cache if _should_reuse_zero_kv_cache(backend, num_outputs) else _zero_like_with_triton(kv_cache)
compressed = _mapped_gather_compressed(
state_cache,
token_to_req,
positions,
boundary_token_indices,
block_table,
block_size,
compress_ratio,
backend=backend,
)
_finalize_kernel[(num_outputs,)](
compressed,
boundary_token_indices,
positions,
rms_norm_weight,
cos_sin_cache,
kv_slot_mapping,
out.view(torch.bfloat16),
out,
...
)
In a multi-platform problem, dispatch itself is part of the optimization. NVIDIA, MetaX, Hygon, T-Head, TianShu, Moore, and Ascend do not behave identically. A tile choice that works on one platform is hard to apply unconditionally to another. We encountered this lesson several times.
Two Routes: GPGPU and Ascend
Looking across the experiment records, a clear split appears: GPGPU backends and Ascend should be reasoned about separately.
On the GPGPU side, the main tension is memory traffic, temporary tensors, and address generation in hot loops. NVIDIA, MetaX, Hygon, T-Head, TianShu, and Moore have different details, but they all roughly fit the model of “a bandwidth-hungry GPU kernel written in Triton.” The route that became stable was:
block8 direct gather
+ online softmax
+ fewer block_table reads in the hot loop
+ backend-isolated tile/route choices
+ byte-exact finalizer
The first reliable 10x result came from simplifying address arithmetic with block_size=8:
sub_b1823a55c086 / fede9cf / 7 passed / avg 10.10
After reconnecting the Ascend path, we also had a more protected all-platform version:
sub_4c00b8a5fb5f / 30efa74e... / 7 passed / avg 10.37
Ascend felt like a different problem. The early pure-Triton version left random gather inside the kernel, and Ascend scores once stayed around 0.8x. That is not surprising. Ascend 910B is a DSA architecture; the boundaries among data movement, Vector compute, and Cube compute are more explicit than on GPGPU. On a GPGPU, an indirect tl.load may still be rescued by L1/L2 cache and coalescing. On Ascend, the same random access more easily becomes scalar Vector-side loads with poor bandwidth utilization.
The Structural Turn in Ascend Optimization
The route from below 1x to roughly 2x came from changing how data enters computation. First, CANN was used to turn random reads into a contiguous tensor. Later, the route moved to Triton scanning physical blocks directly. The former solved the “get past 1x” problem; the latter reduced the large intermediate tensor and its extra movement.
The first breakthrough came from changing the movement path. At that point, the real blocker was random gather movement; parameters like BLOCK_T or num_warps came later:
CANN index_select pre-gather
-> gathered_rows [N, C, 1024]
-> Triton linear scan softmax/RMSNorm
-> PyTorch quant/RoPE/scatter
This step moved Ascend from below 1x to around 1.15x. Its meaning was simple: on Ascend, CANN/torch-npu already has a more mature path for movement operations such as index_select, possibly using DMA/burst reads; Triton is better used for the later linear scan and online softmax. The downside is equally clear: gathered_rows is a large intermediate tensor, and the largest case expands to [N, C, 1024], which must be written and then read again.
The second step was the key jump from 1.15x to 2x: replace pre-gather with block-centric gather. The problem uses block_size=8, and the compression window is contiguous, so most of the window can be viewed as a sequence of 8-token physical blocks. The kernel removes the preconstruction of gathered_rows; each program handles one output and reads state_cache by physical block:
start_logical = first_pos // block_size
end_logical = (first_pos + compress_ratio - 1) // block_size
for log_block in range(start_logical, end_logical + 1):
phys_block = tl.load(block_table + req * s0 + log_block * s1)
vals = tl.load(state_cache + phys_block * state_s0 + slot[:, None] * state_s1 + dims[None, :] * state_s2)
scores = tl.load(state_cache + phys_block * state_s0 + slot[:, None] * state_s1 + (512 + dims)[None, :] * state_s2)
# online softmax update
This step pays off twice. First, it removes the writeback and reread of a huge intermediate tensor. Second, it changes access granularity from “compute many indirect addresses per token” to “scan a short sequence of consecutive slots in a physical block,” which is a shape Ascend can handle more comfortably. On the platform, the first block-gather version lifted Ascend from about 1.22x to 1.85x.
Several later changes were small, but they followed the same line. The first and last physical blocks of a compression window may only be partially valid, while the middle blocks are usually full 8-token blocks. So the boundary blocks keep masks, and the middle blocks scan without masks:
cr=128: 16 logical blocks, about 14 full middle blocks, roughly 87.5% of masks avoided
cr=512: 64 logical blocks, about 62 full middle blocks, roughly 96.9% of masks avoided
This moved Ascend to the 1.93x-1.94x range. Changing num_warps from 2 to 4 reached about 1.97x. Increasing BLOCK_D from 64 to 128 reduced the number of dimension groups from 8 to 4, and submission sub_b240c845e12e reached 2.02x.
The later 2.1x-2.3x range came mostly from two directions: increasing BLOCK_D further, and reducing scatter/finalizer overhead. With BLOCK_D=256, only two dimension groups remain. With BLOCK_D=512, one program covers the full 512 dimensions, eliminating much of the group loop and cross-group RMSNorm handling. For scatter, direct PyTorch index_put_ still leaves noticeable overhead. A more stable route is to obtain payload and scale_bytes on the PyTorch/CANN side, then use a pure uint8 Triton kernel for byte copy:
payload = [448 bytes NOPE INT8][128 bytes RoPE BF16]
scale_bytes = [7 bytes scale][1 byte padding]
pure uint8 scatter -> paged kv_cache
“Pure uint8” matters. Ascend/Bisheng is sensitive to a mix of BF16, FP32, int32, uint8, log2/exp2, and stride-2 RoPE stores. Keeping quantization and RoPE on a conservative path, and letting the scatter kernel only move bytes, turned out to be more robust. This line later pushed Ascend to roughly 2.28x-2.35x.
Summarized as a table:
| Stage | Ascend score | Main change | Source of gain |
|---|---|---|---|
| Pure Triton random gather | ~0.8x | Indirect paged-state tl.load | Random Vector loads are weak on DSA |
| CANN pre-gather | ~1.15x | Gather into contiguous [N,C,1024], then Triton linear scan | Movement via CANN, compute via Triton |
| Block-centric gather | ~1.85x | Triton scans physical blocks directly | Remove large intermediate, improve access shape |
| Skip middle-block masks | ~1.93x | Full middle blocks avoid tl.where | Fewer masks and branch-shaped operations |
num_warps=4 | ~1.97x | Higher parallel granularity | Better match to per-program work |
BLOCK_D=128 | ~2.02x | Dimension groups 8 -> 4 | Fewer group loops, higher compute density |
BLOCK_D=256/512 + byte scatter | ~2.28x-2.35x | Fewer dimension groups, pure uint8 scatter | Lower finalize/scatter overhead, avoid mixed-type pitfalls |
The lesson is direct: Ascend optimization starts by deciding which hardware path each part belongs on. Gather should be made as block-contiguous as possible, softmax/RMSNorm can live in Triton, quant/RoPE should respect byte correctness first, and scatter is easier to trust as a pure byte-copy kernel.
The two routes can be summarized as:
| Route | Main bottleneck | Useful direction |
|---|---|---|
| GPGPU | Large-window reads, temporary tensors, integer addressing, backend tile differences | Online softmax, block8 address simplification, backend-specific routes |
| Ascend | Random gather and fragile byte finalizer lowering on DSA | CANN or block-centric movement, larger 1D programs, conservative quant/RoPE boundaries |
This is also the engineering boundary we eventually adopted: push GPGPU optimizations actively, but do not casually touch Ascend’s fragile quantization path; probe Ascend separately and merge a stage back only after it is reliable.
Online Softmax: Streaming Away the Window
The baseline can be summarized as:
first gather [N, C, 1024]
then softmax
then reduce
The Triton version is closer to:
read source window by tiles
maintain online softmax state while reading
finally write compressed[N, 512]
Online softmax maintains three quantities:
m : current maximum score
den : softmax denominator
num : weighted numerator of value * softmax_weight
For each incoming tile:
m_next = max(m, max(scores))
old_w = exp(m - m_next)
tile_w = exp(scores - m_next)
num_next = num * old_w + sum(values * tile_w)
den_next = den * old_w + sum(tile_w)
The final output is:
compressed = num / den
The implementation in _gather_softmax_sum_block8_direct_online_kernel looks like:
score_max = tl.full((BLOCK_D,), -float("inf"), tl.float32)
denom = tl.zeros((BLOCK_D,), tl.float32)
numer = tl.zeros((BLOCK_D,), tl.float32)
for start in range(0, compress_ratio, BLOCK_T):
values = tl.load(...)
scores = tl.load(...)
tile_max = tl.max(scores, axis=0)
new_max = tl.maximum(score_max, tile_max)
old_scale = tl.exp(score_max - new_max)
weights = tl.exp(scores - new_max[None, :])
denom = denom * old_scale + tl.sum(weights, axis=0)
numer = numer * old_scale + tl.sum(weights * values, axis=0)
score_max = new_max
tl.store(compressed + out_pid * out_stride + dims, numer / denom)
The gain is straightforward: avoid constructing a large intermediate surface. Once the window is read, reduce it near the kernel instead of materializing it.
The Small Opening from block_size=8
The key step that made the result stable around 10x came from a small clue in the layout.
The official block_size is 8, and the compression window is contiguous. Therefore adjacent groups of eight tokens correspond to consecutive physical state blocks. A direct implementation repeatedly reads block_table in the hot loop:
for start in range(0, C, BLOCK_T):
logical_block = first_logical_block + start // 8 + rel_block
physical_block = block_table[req, logical_block]
block_table itself is not large, but this logic sits in the hot loop. Its cost is more than a few integer reads: it adds address generation, masks, register use, and backend codegen pressure.
So we changed the loop to read only the starting block:
first_logical_block = (boundary_pos - compress_ratio + 1) // 8
first_physical_block = tl.load(
block_table + req * block_table_s0 + first_logical_block * block_table_s1,
).to(tl.int64)
for start in range(0, compress_ratio, BLOCK_T):
block = first_physical_block + start // 8 + rel_block
values = tl.load(
state_cache
+ block[:, None] * state_s0
+ block_offset[:, None] * state_s1
+ dims[None, :] * state_s2
)
scores = tl.load(
state_cache
+ block[:, None] * state_s0
+ block_offset[:, None] * state_s1
+ (512 + dims)[None, :] * state_s2
)
In words:
- read the physical block at the window start once;
- derive later blocks as
first_physical_block + start // 8 + rel_block; - keep the mathematical semantics unchanged;
- reduce
block_tablereads and integer addressing in the hot loop.
This optimization corresponded to the first reproducible 10x submission:
sub_b1823a55c086 / fede9cf / 7 passed / avg 10.10
The lesson is plain: when the statement gives a fixed structure, try to turn it into simpler address arithmetic first. That is often more explanatory than trying a few more tile parameters.
The Finalizer’s Sharp Edges
The finalizer writes only a few hundred bytes per output, so bandwidth is not the main issue. Correctness is.
The Triton finalizer first applies RMSNorm:
mean_sq = tl.sum(vals * vals, axis=0) / 512
rrms = tl.rsqrt(mean_sq + rms_norm_eps)
normed = vals * rrms * weights
Then NOPE quantization:
q_normed = (q_vals * rrms * q_weights).to(tl.bfloat16).to(tl.float32)
amax = tl.maximum(tl.max(tl.abs(q_normed), axis=0), 1.0e-4)
exponent = tl.ceil(tl.log2(amax * (1.0 / 127.0)))
inv_scale = tl.exp2(-exponent)
q_scaled = q_normed * inv_scale
q = tl.where(q_scaled >= 0.0, tl.floor(q_scaled), tl.ceil(q_scaled)).to(tl.int32)
q = tl.minimum(tl.maximum(q, -127), 127)
q_bytes = tl.where(q < 0, q + 256, q)
The RoPE section is roughly:
cos_v = tl.load(cos_sin_cache + compressed_pos * cos_s0 + pair_dims * cos_s1)
sin_v = tl.load(cos_sin_cache + compressed_pos * cos_s0 + (64 // 2 + pair_dims) * cos_s1)
rot_even = even_normed * cos_v - odd_normed * sin_v
rot_odd = odd_normed * cos_v + even_normed * sin_v
rotated = tl.where(even_mask, rot_even, rot_odd)
tl.store(..., rotated.to(tl.bfloat16))
Several details are sensitive:
- the BF16 roundtrip must align with the reference implementation;
- negative INT8 values must be written as bytes correctly;
- scale bytes come from per-64-group power-of-two exponents;
- RoPE interleaved pairs and BF16 byte layout must match.
Ascend taught us a useful lesson here: Triton routes that generate FP32 quant_tmp may produce INT8 byte mismatches, while preserving a real BF16 memory boundary is more reliable. The current Ascend route is conservative for that reason: correctness comes first.
Routing Across Chips
The competition covers multiple backends. A route that works on one platform often cannot be copied to another. We eventually preferred making backend differences explicit in dispatch, rather than hoping one configuration would run everywhere.
Current experience roughly looks like:
| Platform | Current experience |
|---|---|
| NVIDIA | D32 is useful, but best kept inside an NVIDIA-like route |
| MetaX | D64 direct-online is more stable; avoid borrowing the NVIDIA D32 conclusion blindly |
| Hygon | D64 direct-online; CR512 T256 is a useful local lever |
| T-Head | After the platform recovered, direct-online plus zeroed-cache reuse helped |
| TianShu | Sensitive to tile/warp choices; safer to move conservatively |
| Moore | Cache hints showed signal, but stability still needs careful validation |
| Ascend | Quant/RoPE finalization is fragile and should be advanced separately |
The code also tries to keep platform-specific entries separate:
def _nvidia_gather(...):
# NVIDIA: D=32 route
...
def _hygon_gather(...):
# Hygon: D=64 direct_online
...
def _metax_gather(...):
# MetaX: D=64 direct_online
...
This design is a bit boring, but it reduces the chance that a small win on one platform causes a large regression on another. For a multi-backend competition, this plain engineering separation is valuable.
Lessons Worth Keeping
Looking back, the most useful directions are:
| Direction | Meaning |
|---|---|
| Avoid large intermediate surfaces | Avoid temporary tensors such as [N, C, 1024] whenever possible |
| Stream reductions | Use online softmax to compress the window while reading it |
| Exploit fixed structure | block_size=8 simplifies physical-block addressing |
| Tune with a ledger | Before tuning a tile, ask which cost it reduces |
| Isolate backends | Separate backend routes to avoid one platform’s experience hurting another |
| Preserve byte semantics | Treat byte-exact finalization as a boundary, especially BF16, INT8, scale, and RoPE |
Some directions deserve caution:
| Caution | Reason |
|---|---|
| Tile roulette without a hypothesis | Small parameter wins may be accidental and not transferable |
| Spreading NVIDIA D32 to MetaX/Hygon | Register, cache, and codegen behavior differ |
| Aggressively fusing quant/RoPE on Ascend | This area easily triggers byte mismatches |
| Relying on checker/cache outlier scores | Without an explainable kernel mechanism, it should not be the final route |
| Looking only at local CUDA timing | Remote multi-platform results are the actual competition constraint |
Code Snapshot
I have also put the stable non-outlier source snapshot in a small GitHub repository:
Stable source repository
sub_64c96b412ccc, submitted on June 13, 2026 Beijing time, passed 7/7 with an average speedup of about 10.21x.
This is not the later 200x-style MetaX outlier. I am sharing it mainly as a record of the optimization ideas in this post: backend-isolated routes, block_size=8 address simplification, and a conservative Ascend path that puts correctness ahead of a more aggressive Triton finalizer.
It should be read with some humility. The code was tuned for the competition environment and the official test surface at that time. In particular, it assumes the official block_size=8 setting, the block-table regularity observed in the benchmark data, and an output cache surface compatible with the platform checker. A small local review found that more general inputs, such as non-contiguous physical block tables, non-zero initial kv_cache, or non-official block_size values, can break byte-level equivalence with the reference implementation. The snapshot is therefore a useful competition artifact, not a general-purpose KV cache compression library.
About Outlier Scores
MetaX once produced outlier signals such as 200x/277x. We later treated them as diagnostic signals of the checker/timing surface rather than a robust kernel mechanism. They reminded us that platform measurement can be complicated, but they were not suitable as the foundation of the final implementation.
Closing
If this optimization is compressed into a few lines, it would be:
First understand the byte contract.
Then find the largest intermediate tensor and the hottest inner loop.
Stream when possible; reduce address work when possible.
Separate multi-platform routes; retest outlier scores.
From this angle, the 10x result is not mysterious. It is more like a sequence of ordinary but important cleanups: understand the baseline computation surface, account for memory in Triton programs, use the fixed block_size=8, and handle different chips separately.
This route may not be elegant, but for us it was relatively reliable and easier to keep pushing forward.

Leave a Comment