File size: 14,417 Bytes
b901794
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
"""CUDA-graph single-token decoding for Yunmo-Next.

Cached decoding through the regular modules is launch-bound: a token costs
about 5,200 kernel launches across 60 layers while the GPU is busy for roughly
a tenth of the step.  This module replays the whole step as one CUDA graph.

What changes relative to ``YunmoNextForCausalLM.forward_cached``:

- GQA keys/values live in preallocated buffers.  Global layers use a buffer of
  the bucket length, local layers a 2,048-slot ring (keys are stored after
  RoPE, so slot order does not matter); a mask computed from the position
  tensor selects valid slots.
- KDA convolution caches are FLA's own ``[N, D, W]`` buffers, updated in place
  by ``ShortConvolution``; the recurrent state is updated in place by FLA's
  fused recurrent kernel (``inplace_final_state=True``).
- Depth routing is the model's own cached Attention Residual code, unchanged:
  only each layer's mixer call is redirected to the static versions while the
  graph is captured.

Prefill still runs through ``forward_cached``; ``load`` copies its caches into
the static buffers.  One graph is captured per length bucket and reused.

With ``batch > 1`` each row is an independent sequence with its own position
(continuous batching): ``load(cache, row)`` fills a row from a batch-1
prefill, ``release(row)`` parks it, and ``step(tokens)`` advances every
active row.  A 1B model's decode step is bound by reading the weights and by
per-kernel gaps, both nearly independent of the batch, so rows are almost
free until the GPU saturates.
"""

from __future__ import annotations

from dataclasses import dataclass

import torch
import torch.nn.functional as F

from .attention import GatedGroupedQueryAttention
from .protocol import CONTROL_BASE

# Per-row greedy summary columns: lexical max value, its index, any-NaN flag,
# then the logits of the 256 protocol IDs.
SUMMARY_COLUMNS = 3 + 256

BUCKETS = (4096, 16384, 65536, 262144)


@dataclass
class _GQABuffers:
    keys: torch.Tensor
    values: torch.Tensor
    is_global: bool


@dataclass
class _KDABuffers:
    state: torch.Tensor
    conv: tuple[torch.Tensor, torch.Tensor, torch.Tensor]


class StaticDecoder:
    """Captured one-token decode step for one length bucket and batch size."""

    def __init__(self, model, length: int, batch: int = 1) -> None:
        if model.training:
            raise RuntimeError("static decoding requires model.eval()")
        self.model = model
        self.length = length
        self.batch = batch
        config = model.config
        self.window = config.attention_window
        param = next(model.parameters())
        device, dtype = param.device, param.dtype
        self.device = device
        self.token = torch.zeros((batch, 1), dtype=torch.long, device=device)
        self.position = torch.zeros((batch, 1), dtype=torch.long, device=device)
        # Parked rows keep rewriting one slot instead of advancing.
        self.active = torch.zeros((batch, 1), dtype=torch.long, device=device)
        self.buffers: dict[int, _GQABuffers | _KDABuffers] = {}
        for index, layer in enumerate(model.layers):
            mixer = layer.mixer
            if isinstance(mixer, GatedGroupedQueryAttention):
                slots = length if mixer.is_global else self.window
                shape = (batch, config.num_key_value_heads, slots, config.attention_head_dim)
                self.buffers[index] = _GQABuffers(
                    torch.zeros(shape, dtype=dtype, device=device),
                    torch.zeros(shape, dtype=dtype, device=device),
                    bool(mixer.is_global),
                )
            else:
                width = mixer.q_conv1d.weight.shape[-1]
                self.buffers[index] = _KDABuffers(
                    torch.zeros((batch, mixer.num_heads, mixer.head_v_dim, mixer.head_k_dim),
                                dtype=torch.float32, device=device),
                    tuple(
                        torch.zeros((batch, conv.weight.shape[0], width), dtype=dtype, device=device)
                        for conv in (mixer.q_conv1d, mixer.k_conv1d, mixer.v_conv1d)
                    ),
                )
        self.graph: torch.cuda.CUDAGraph | None = None
        self._host_position = [0] * batch
        self._host_active = [False] * batch
        self.logits: torch.Tensor | None = None
        self.summary: torch.Tensor | None = None

    # ---- per-layer static mixers -------------------------------------------------

    def _gqa(self, layer, buffers: _GQABuffers, hidden: torch.Tensor) -> torch.Tensor:
        mixer = layer.mixer
        normalized = layer.input_norm(hidden)
        raw_q, raw_k, raw_v, gate = mixer._linear_project(normalized)
        query, key, value = mixer._shape_projected(raw_q, raw_k, raw_v, self.position)
        batch, heads, slots, width = buffers.keys.shape
        position = self.position.view(batch, 1)
        slot = position if buffers.is_global else torch.remainder(position, slots)
        index = slot.view(batch, 1, 1, 1).expand(batch, heads, 1, width)
        buffers.keys.scatter_(2, index, key)
        buffers.values.scatter_(2, index, value)
        # Ring slots fill in order from 0, so the first min(position + 1, slots)
        # slots hold exactly the keys this query may see.
        valid = torch.arange(slots, device=hidden.device).view(1, slots) <= position
        # One query token: fold each KV head's query heads (SDPA's GQA maps
        # query head h to KV head h // groups) into the query-length axis and
        # attend with two batched matmuls, reading each KV buffer once.  SDPA
        # with ``enable_gqa`` expands KV to every query head (~2 ms per layer
        # at batch 16) and SDPA on the folded shape picks a slow FP32 kernel;
        # measured per layer at 4,096 slots: 0.17-0.32 ms here vs 0.27-3.9 ms.
        # Same scale as the model (SDPA default 1/sqrt(head_dim)).
        query_heads = query.shape[1]
        grouped = query.reshape(batch, heads, query_heads // heads, width)
        scores = torch.matmul(grouped, buffers.keys.transpose(-1, -2)) * width**-0.5
        scores = scores.masked_fill(~valid.view(batch, 1, 1, slots), float("-inf"))
        output = torch.matmul(torch.softmax(scores, dim=-1), buffers.values)
        output = output.reshape(batch, query_heads, 1, width)
        return mixer._apply_output_gate(output, gate)

    def _kda(self, layer, buffers: _KDABuffers, hidden: torch.Tensor) -> torch.Tensor:
        from einops import rearrange
        from fla.ops.kda.fused_recurrent import fused_recurrent_kda_fwd

        mixer = layer.mixer
        x = layer.input_norm(hidden)
        conv_q, conv_k, conv_v = buffers.conv
        q, _ = mixer.q_conv1d(x=mixer.q_proj(x), cache=conv_q, output_final_state=True)
        k, _ = mixer.k_conv1d(x=mixer.k_proj(x), cache=conv_k, output_final_state=True)
        v, _ = mixer.v_conv1d(x=mixer.v_proj(x), cache=conv_v, output_final_state=True)
        g = rearrange(mixer.f_proj(x), "... (h d) -> ... h d", d=mixer.head_k_dim)
        beta = mixer.b_proj(x)
        q, k = (rearrange(t, "... (h d) -> ... h d", d=mixer.head_k_dim) for t in (q, k))
        v = rearrange(v, "... (h d) -> ... h d", d=mixer.head_v_dim)
        output, _ = fused_recurrent_kda_fwd(
            q=q, k=k, v=v, g=g, beta=beta, A_log=mixer.A_log, dt_bias=mixer.dt_bias,
            scale=k.shape[-1] ** -0.5, initial_state=buffers.state, inplace_final_state=True,
            output_final_state=True, use_qk_l2norm_in_kernel=True, use_gate_in_kernel=True,
            use_beta_sigmoid_in_kernel=True, allow_neg_eigval=mixer.allow_neg_eigval,
            lower_bound=mixer.lower_bound, cu_seqlens=None, state_v_first=True,
        )
        gate = rearrange(mixer.g_proj(x), "... (h d) -> ... h d", d=mixer.head_v_dim)
        output = rearrange(mixer.o_norm(output, gate), "b t h d -> b t (h d)")
        return mixer.o_proj(output)

    # ---- step and capture --------------------------------------------------------

    def _step(self) -> torch.Tensor:
        model = self.model
        originals = {}
        for index, layer in enumerate(model.layers):
            buffers = self.buffers[index]
            static = self._gqa if isinstance(buffers, _GQABuffers) else self._kda

            def mixer_output_cached(hidden, position_ids, *, fla_cache, attention_cache,
                                    _layer=layer, _buffers=buffers, _static=static):
                return _static(_layer, _buffers, hidden), None

            originals[index] = layer.__dict__.get("mixer_output_cached")
            layer.mixer_output_cached = mixer_output_cached
        try:
            state = _NullState()
            hidden = model._forward_hidden_two_phase_attention_residual_cached(
                model.embed_tokens(self.token), self.position, state, None, None,
            )
            return F.linear(hidden, model.embed_tokens.weight)[:, -1]
        finally:
            for index, layer in enumerate(model.layers):
                if originals[index] is None:
                    del layer.mixer_output_cached
                else:
                    layer.mixer_output_cached = originals[index]

    def _tensors(self):
        for buffers in self.buffers.values():
            if isinstance(buffers, _GQABuffers):
                yield buffers.keys
                yield buffers.values
            else:
                yield buffers.state
                yield from buffers.conv

    @torch.inference_mode()
    def capture(self, warmup: int = 3) -> None:
        stream = torch.cuda.Stream()
        stream.wait_stream(torch.cuda.current_stream())
        with torch.cuda.stream(stream):
            for _ in range(warmup):
                _greedy_summary(self._step())
        torch.cuda.current_stream().wait_stream(stream)
        graph = torch.cuda.CUDAGraph()
        with torch.cuda.graph(graph):
            self.logits = self._step()
            self.summary = _greedy_summary(self.logits)
        # Warm-up and capture ran real steps on the buffers.  ``load`` rewrites
        # every buffer of the row it fills, so clearing (instead of keeping a
        # snapshot copy, which doubled peak memory) is enough.
        for tensor in self._tensors():
            tensor.zero_()
        self.position.zero_()
        self.active.zero_()
        self.graph = graph

    # ---- runtime -----------------------------------------------------------------

    @torch.inference_mode()
    def load(self, cache, row: int = 0) -> int:
        """Copy a batch-1 ``forward_cached`` prefill cache into ``row``."""

        position = int(cache.seen_tokens)
        if position >= self.length:
            raise ValueError("prompt does not fit this decode bucket")
        for index, buffers in self.buffers.items():
            if isinstance(buffers, _GQABuffers):
                buffers.keys[row].zero_()
                buffers.values[row].zero_()
                entry = cache.attention.get(index)
                if entry is None:
                    continue
                count = entry.keys.shape[2]
                positions = torch.arange(position - count, position, device=self.device)
                slots = positions if buffers.is_global else torch.remainder(positions, self.window)
                buffers.keys[row].index_copy_(1, slots, entry.keys[0])
                buffers.values[row].index_copy_(1, slots, entry.values[0])
            else:
                layer_state = cache.fla_cache[index]
                buffers.state[row].copy_(layer_state["recurrent_state"][0])
                for target, source in zip(buffers.conv, layer_state["conv_state"], strict=True):
                    target[row].copy_(source[0])
        self.position[row].fill_(position)
        self.active[row].fill_(1)
        self._host_position[row] = position
        self._host_active[row] = True
        return position

    def release(self, row: int) -> None:
        """Park ``row``; its outputs are ignored until the next ``load``."""

        self.active[row].fill_(0)
        self.position[row].fill_(0)
        self._host_active[row] = False

    @torch.inference_mode()
    def step(self, tokens) -> torch.Tensor:
        """``advance`` and return a copy of the ``[batch, vocab]`` logits."""

        self.advance(tokens)
        return self.logits.clone()

    @torch.inference_mode()
    def advance(self, tokens) -> None:
        """Advance every active row by one token.

        ``tokens`` is an int (batch 1) or one int per row; parked rows' entries
        are ignored.  Afterwards ``logits`` and ``summary`` hold the new rows
        until the next call.
        """

        if self.graph is None:
            raise RuntimeError("capture() must run before step()")
        if isinstance(tokens, int):
            tokens = [tokens]
        if len(tokens) != self.batch:
            raise ValueError("step needs one token per row")
        # A global-layer write past the buffer would be an out-of-bounds
        # scatter inside the graph; refuse on the host instead.
        for row in range(self.batch):
            if self._host_active[row]:
                if self._host_position[row] >= self.length:
                    raise RuntimeError("decode exceeded the static decoder's length bucket")
                self._host_position[row] += 1
        self.token.copy_(torch.tensor(tokens, dtype=torch.long).view(self.batch, 1), non_blocking=False)
        self.graph.replay()
        self.position.add_(self.active)


def _greedy_summary(logits: torch.Tensor) -> torch.Tensor:
    # Float32 throughout: the index (< 2**24) is exact in float32 but not in
    # float16, and float16 logits widen exactly.
    lexical = logits[:, :CONTROL_BASE]
    index = lexical.argmax(dim=-1, keepdim=True)
    return torch.cat(
        (
            lexical.gather(-1, index).float(),
            index.float(),
            torch.isnan(logits).any(dim=-1, keepdim=True).float(),
            logits[:, CONTROL_BASE:].float(),
        ),
        dim=-1,
    )


class _NullState:
    """Stands in for ``YunmoNextCUDAState``: static mixers ignore it."""

    fla_cache = None
    attention: dict = {}


def bucket_for(length: int) -> int:
    for bucket in BUCKETS:
        if length <= bucket:
            return bucket
    raise ValueError("requested length exceeds the largest decode bucket")