Appearance
K3's KDA Precision Problem: Why Lower-Bounded Decay Is Necessary
In the Kimi K3 paper, the implementation of KDA adds a lower bound to the log-decay
Based on the K3 report, the FlashKDA repository, and a set of mechanism experiments on an H200, I reached two preliminary conclusions about how the lower bound works:
- It bounds the absolute range of
, preventing in diagonal-tile matrices from exploding or disappearing and destabilizing the matrix multiplication. - It controls the decay rate of intermediate values such as
, , and while inverting the lower-triangular system, preventing numerical underflow.
The first point determines whether a diagonal tile can use dense Tensor Core operations. The second determines whether the FP16 Neumann expansion remains controlled, and helps explain why the final chunk size is 16.
How FlashKDA computes tiles
Let us temporarily ignore decay and begin with the most basic query-key multiplication inside a chunk. Suppose the chunk length is
The first dimension indexes token positions, and the second contains the 128 features of each token. A Tensor Core does not process the entire causal matrix as one indivisible matrix. Instead,
Multiplying them produces one score tile:
Thus, multiplying two 16×128 panels computes only one 16×16 block of the 128×128 score matrix. When

The causal mask divides these tiles into three categories:
- When
, all 16 positions in the query tile occur after all 16 positions in the key tile, so the entire 16×16 block is valid. This is an off-diagonal dense tile. - When
, every key comes from the future, so the whole block is masked. - When
, the queries and keys come from the same group of 16 positions, and only the lower triangle satisfying is valid. This is a diagonal tile.
The 128-token chunk above illustrates the computation in which a large chunk is subdivided into 16-token secondary tiles. FlashKDA v1 currently uses a 16-token kernel chunk directly, so one kernel chunk is exactly one 16×16 numerical tile.
How the per-channel decay factor enters tile computation
KDA computes more than an ordinary
After collecting the
For a particular position pair
This explains how decay can be folded into a GEMM. First scale row
For tile
When
In other words, the relative decay in the result matrix cannot explode. The numerical risk is not the final
If
Forming
directly is numerically safe, but it requires a separate calculation for each position pair. Splitting it into and lets the pairwise work collapse into a GEMM, but requires both factors to be individually representable at low precision.
Why a diagonal tile needs lower-bounded decay
As described above, matrix computation splits the target
The relative decay can then be split around this boundary:
Both exponents are non-positive, so both factors lie in
The diagonal tile is different:
This is where lower-bounded decay matters. With
the difference between any
Matrix inversion in FlashKDA
The delta updates inside a KDA chunk form a strictly lower-triangular matrix
For a general dense
A Neumann series can be understood as the matrix version of a geometric series. For a scalar
Replacing the scalar
This expansion is called a Neumann series. In general, it requires the spectral radius of
Therefore, every term of degree at least
This finite sequence can be simplified further. FlashKDA does not add the terms one by one; it uses a repeated-squaring factorization. For
The kernel only needs to compute
The role of lower-bounded decay in matrix inversion
The previous section showed that the depth of the repeated-squaring expansion is determined by the chunk length
K3 restricts each step's log-decay to
From the exponent range of BF16/FP32,
Meanwhile, FlashKDA's inverse constructs
Thus, increasing the chunk length from 16 to 17 does not yet exceed the exponent range, but it requires the low-precision kernel to form
By bounding the worst-case cumulative decay, lower-bounded decay and the Neumann repeated-squaring expansion jointly determine a sensible chunk boundary: 16 is the largest power of two inside the exponent-safe region, and it lets the inverse stop after
Numerical experiments on H200
To separate the split-exponent problem from the Neumann-inverse problem, the experiment first constructs a floor_e-5 input: every position uses the strongest decay
The experimental procedure is:
- Convert
and to BF16. - Construct
with BF16 operands and FP32 accumulation. - Store
in FP16. - Use FP16 accumulation on the H200 to simulate the Neumann inverse.
- Use FP64 construction and direct inversion as the gold result.
Each configuration is run with three seeds. The errors below are medians, while counts of non-finite scales use the worst observed value.
Exponent boundary
| Chunk C | inverse finite | decay = 0 | reciprocal = inf | INV total | INV power |
|---|---|---|---|---|---|
| 8 | 100% | 0 | 0 | ||
| 16 | 100% | 0 | 0 | ||
| 17 | 100% | 0 | 0 | ||
| 18 | 100% | 1 | 1 | ||
| 19 | 0% | 2 | 2 | ||
| 32 | 0% | 15 | 15 |
Under the worst-case decay
High-power stress input
To exclude exponent overflow and underflow, the experiment constructs weak_decay_correlated inputs:
- All keys are identical and normalized, producing highly correlated update directions.
, keeping finite throughout. , preventing multi-step propagation from disappearing too quickly.
| Chunk C | Highest power formed | Max power magnitude | INV total | INV power | Finite |
|---|---|---|---|---|---|
| 8 | 100% | ||||
| 16 | 100% | ||||
| 17 | 100% | ||||
| 24 | 100% | ||||
| 32 | 100% | ||||
| 48 | 0% |
The error gradually increases with the power.
Ordinary random input
The stress experiment exposes a mechanism boundary; it should not be treated as a typical model distribution. Random model_like inputs provide an important counterexample:
| Chunk C | Finite | INV total | INV power | Max power magnitude |
|---|---|---|---|---|
| 8 | 100% | |||
| 16 | 100% | |||
| 32 | 100% | |||
| 48 | 0% |
In more realistic conditions, the extreme behavior above is less likely to occur, and error does not necessarily or monotonically worsen as the chunk grows.
Choosing a chunk size of 16 is therefore a conservative design point that covers extreme decay, highly correlated keys, low-precision inversion, and the hardware layout.
Experiment code
The excerpt below retains the three input families, the stable reference, the kernel's numerical path, FP16 accumulation, and single-run metrics. Result aggregation, plotting, table generation, and file output are omitted.
Show experiment code
python
#!/usr/bin/env python3
"""Measure FlashKDA (I + L)^-1 accuracy as chunk size grows."""
import ctypes
import math
from pathlib import Path
import torch
LOG2E = math.log2(math.e)
_CUBLAS = None
_CUBLAS_HANDLE = ctypes.c_void_p()
_FP16_ONE = ctypes.c_ushort(0x3C00)
_FP16_ZERO = ctypes.c_ushort(0x0000)
def exp2_ftz_f32(x):
y = torch.exp2(x.float())
return torch.where(y < torch.finfo(torch.float32).tiny, 0.0, y)
def cublas_fp16_accum_mm(a, b):
"""CUDA FP16-accumulator GEMM, matching the kernel precision path."""
global _CUBLAS
if _CUBLAS is None:
try:
_CUBLAS = ctypes.CDLL("libcublas.so")
except OSError:
import nvidia.cublas
lib_dir = Path(nvidia.cublas.__file__).parent / "lib"
candidates = sorted(lib_dir.glob("libcublas.so.*"))
if not candidates:
raise
_CUBLAS = ctypes.CDLL(str(candidates[0]))
assert _CUBLAS.cublasCreate_v2(ctypes.byref(_CUBLAS_HANDLE)) == 0
m, k = a.shape
k2, n = b.shape
assert k == k2 and a.is_contiguous() and b.is_contiguous()
out = torch.zeros(m, n, dtype=torch.float16, device=a.device)
torch.cuda.synchronize(a.device)
status = _CUBLAS.cublasGemmEx(
_CUBLAS_HANDLE,
0,
0,
ctypes.c_int(n),
ctypes.c_int(m),
ctypes.c_int(k),
ctypes.byref(_FP16_ONE),
ctypes.c_void_p(b.data_ptr()),
ctypes.c_int(2),
ctypes.c_int(n),
ctypes.c_void_p(a.data_ptr()),
ctypes.c_int(2),
ctypes.c_int(k),
ctypes.byref(_FP16_ZERO),
ctypes.c_void_p(out.data_ptr()),
ctypes.c_int(2),
ctypes.c_int(n),
ctypes.c_int(64), # CUBLAS_COMPUTE_16F
ctypes.c_int(0), # CUBLAS_GEMM_DEFAULT
)
assert status == 0, f"cublasGemmEx failed: {status}"
torch.cuda.synchronize(a.device)
return out
def fp16_accum_mm(a, b):
"""Use CUDA FP16 accumulation, with a deterministic CPU fallback."""
if a.is_cuda:
return cublas_fp16_accum_mm(a, b)
out = torch.zeros(
a.shape[0], b.shape[1], dtype=torch.float16, device=a.device
)
for k in range(a.shape[1]):
product = a[:, k:k + 1].float() * b[k:k + 1].float()
out = (out.float() + product).half()
return out
def neumann_inverse_fp16(l):
"""Compute (I-L)(I+L^2)(I+L^4)... with FP16 accumulation."""
n = l.shape[0]
inv = (torch.eye(n, dtype=torch.float16, device=l.device) - l).half()
power = l
exponent = 1
max_power = float(l.abs().max())
while exponent * 2 < n:
power = fp16_accum_mm(power, power)
exponent *= 2
max_power = max(max_power, float(power.abs().max()))
inv = (inv + fp16_accum_mm(inv, power)).half()
return inv, exponent, max_power
def make_inputs(case, chunk, dim, seed, device):
generator = torch.Generator(device="cpu").manual_seed(seed)
if case == "weak_decay_correlated":
key = torch.randn(1, dim, dtype=torch.float64, generator=generator)
key = key / key.norm(dim=-1, keepdim=True)
keys = key.expand(chunk, -1)
log_decay = torch.full((chunk,), -0.01, dtype=torch.float64)
beta = torch.full((chunk,), 0.5, dtype=torch.float64)
else:
keys = torch.randn(
chunk, dim, dtype=torch.float64, generator=generator
)
keys = keys / keys.norm(dim=-1, keepdim=True)
if case == "floor_e-5":
log_decay = torch.full((chunk,), -5.0, dtype=torch.float64)
else:
random_gate = torch.randn(
chunk, dtype=torch.float64, generator=generator
)
log_decay = -5.0 * torch.sigmoid(random_gate)
beta = torch.sigmoid(
torch.randn(chunk, dtype=torch.float64, generator=generator)
)
return (
keys.to(device=device, dtype=torch.bfloat16),
log_decay.to(device),
beta.to(device),
)
def stable_l(keys, log_decay, beta):
keys64 = keys.double()
cumsum = log_decay.cumsum(0)
dots = keys64 @ keys64.t()
decay_ratio = torch.exp(
(cumsum[:, None] - cumsum[None, :]).clamp(max=0.0)
)
return torch.tril(
dots * decay_ratio * beta[:, None], diagonal=-1
)
def kernel_l(keys, log_decay, beta):
cumsum_log2 = log_decay.cumsum(0) * LOG2E
decay = exp2_ftz_f32(cumsum_log2).bfloat16()
inv_decay = exp2_ftz_f32(-cumsum_log2).bfloat16()
k_decayed = keys * decay[:, None]
k_inverse = keys * inv_decay[:, None]
l = (k_decayed.float() @ k_inverse.float().t()).half()
l = torch.tril(l, diagonal=-1)
l = (l * beta.half()[:, None]).half()
return l, decay, inv_decay
def rel_rmse(actual, expected):
if not torch.isfinite(actual).all():
return math.inf
error = (actual.double() - expected.double()).square().mean().sqrt()
scale = expected.double().square().mean().sqrt()
return float(error / (scale + 1e-30))
def run_one(case, chunk, dim, seed, device):
keys, log_decay, beta = make_inputs(
case, chunk, dim, seed, device
)
l_ref = stable_l(keys, log_decay, beta)
identity = torch.eye(chunk, dtype=torch.float64, device=device)
inv_ref = torch.linalg.inv(identity + l_ref)
l_kernel, decay, inv_decay = kernel_l(keys, log_decay, beta)
stable_inv, stable_power, stable_power_max = (
neumann_inverse_fp16(l_ref.half())
)
kernel_inv, kernel_power, kernel_power_max = (
neumann_inverse_fp16(l_kernel)
)
residual = rel_rmse(
(identity + l_ref) @ kernel_inv.double(), identity
)
if torch.isfinite(l_kernel).all():
inv_of_kernel_l = torch.linalg.inv(identity + l_kernel.double())
neumann_only = rel_rmse(kernel_inv, inv_of_kernel_l)
else:
neumann_only = math.inf
return {
"case": case,
"chunk": chunk,
"seed": seed,
"decay_zeros": int((decay == 0).sum()),
"inv_decay_nonfinite": int((~torch.isfinite(inv_decay)).sum()),
"l_finite": int(torch.isfinite(l_kernel).all()),
"inv_finite": int(torch.isfinite(kernel_inv).all()),
"l_rel_rmse": rel_rmse(l_kernel, l_ref),
"inv_total_rel_rmse": rel_rmse(kernel_inv, inv_ref),
"inv_neumann_only_rel_rmse": neumann_only,
"inv_stable_l_rel_rmse": rel_rmse(stable_inv, inv_ref),
"inverse_residual": residual,
"highest_power": kernel_power,
"kernel_power_max": kernel_power_max,
"stable_l_highest_power": stable_power,
"stable_l_power_max": stable_power_max,
}
if __name__ == "__main__":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
result = run_one(
case="floor_e-5",
chunk=16,
dim=128,
seed=0,
device=device,
)
print(result)References
- Kimi Team, Kimi K3: Open Frontier Intelligence, 2026. See Section 2.1.1 and Figure 3 in particular.
- MoonshotAI, FlashKDA. See the chunk-size selection discussion,
fwd_kernel1.cuh, andutils.cuhin particular.