File size: 18,278 Bytes
83894cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
# North Star OS addition (2026-07-14), style-matched to the Apple BSD-3 tree it lives in.
#
# LFM2 / LFM2.5 (LiquidAI) dense hybrid for macOS export: gated short-conv layers +
# GQA full-attention layers. Reference math: transformers models/lfm2/modeling_lfm2.py
# (slow_forward path) and the MLX-Swift LFM2 implementation. The short conv is expressed
# as L explicit taps (narrow/mul/add) instead of aten.conv1d so the Core AI converter
# sees only ops it already lowers, and the (L-1)-deep conv state rides a mutable state
# tensor exactly like the KV cache.

import torch
import torch.nn as nn
import torch.nn.functional as F
from typing_extensions import Self, override

from coreai_models.models.base import BaseForCausalLM
from coreai_models.primitives._ops import mutable_slice_update
from coreai_models.primitives.macos.cache import KVCache
from coreai_models.primitives.macos.rms_norm import RMSNorm
from coreai_models.primitives.macos.rope import initialize_rope
from coreai_models.primitives.macos.sdpa import SDPA

USE_FUSED_KV = True


def lfm2_resolve_rope_theta(config) -> float:
    """rope theta lives at config.rope_theta (lfm2) or config.rope_parameters (lfm2_moe)."""
    rope_params = getattr(config, "rope_parameters", None)
    if isinstance(rope_params, dict) and "rope_theta" in rope_params:
        return float(rope_params["rope_theta"])
    return float(getattr(config, "rope_theta", 1000000.0))


def lfm2_mlp_intermediate_size(config, intermediate_size: int) -> int:
    """LFM2 dense checkpoints store the pre-adjust ff dim; apply the 2/3 SwiGLU adjust."""
    if getattr(config, "block_auto_adjust_ff_dim", False):
        intermediate_size = int(2 * intermediate_size / 3)
        multiplier = getattr(config, "block_ffn_dim_multiplier", None)
        if multiplier is not None:
            intermediate_size = int(multiplier * intermediate_size)
            multiple_of = config.block_multiple_of
            intermediate_size = multiple_of * (
                (intermediate_size + multiple_of - 1) // multiple_of
            )
    return intermediate_size


class ConvState:
    """Per-conv-layer rolling window of the last (L_cache - 1) gated inputs.

    Layout: (n_conv_layers, 1, conv_dim, L_cache - 1). Zeros mean "sequence start",
    which reproduces the reference implementation's causal left-padding, so the same
    graph serves prefill and decode.
    """

    def __init__(self: Self, states: torch.Tensor) -> None:
        self._states = states

    def fetch(self: Self, conv_idx: int) -> torch.Tensor:
        torch._check_is_size(conv_idx)
        torch._check(conv_idx < self._states.size(0))
        return self._states.narrow(0, conv_idx, 1).squeeze(0)

    def update(self: Self, conv_idx: int, new_state: torch.Tensor) -> None:
        cache = self._states
        torch._check_is_size(conv_idx)
        torch._check(conv_idx < cache.size(0))
        begin_layer = torch.tensor((conv_idx,), dtype=torch.int32)
        end_layer = torch.tensor((conv_idx + 1,), dtype=torch.int32)
        zeros = [torch.tensor((0,), dtype=torch.int32) for _ in range(cache.dim() - 1)]
        ends = [
            torch.tensor((cache.size(i),), dtype=torch.int32) for i in range(1, cache.dim())
        ]
        mutable_slice_update(
            x=cache,
            update=new_state.unsqueeze(0),
            begin=torch.concatenate([begin_layer, *zeros]),
            end=torch.cat([end_layer, *ends]),
        )


class _ConvWeightHolder(nn.Module):
    """Holds the depthwise kernel under the HF key `<layer>.conv.conv.weight` without
    being an nn.Conv1d (keeps the tiny (D,1,L) kernel away from Linear-targeted quant)."""

    def __init__(self, conv_dim: int, l_cache: int) -> None:
        super().__init__()
        self.weight = nn.Parameter(torch.empty(conv_dim, 1, l_cache))


class ShortConv(nn.Module):
    """LFM2 gated short conv: BCx = in_proj(x); Bx = B*x; y = C * causal_dwconv(Bx)."""

    def __init__(self, config, conv_idx: int) -> None:
        super().__init__()
        self.conv_idx = conv_idx
        dim = getattr(config, "conv_dim", config.hidden_size)
        self.dim = dim
        self.l_cache = config.conv_L_cache
        bias = getattr(config, "conv_bias", False)
        assert not bias, "conv_bias=True not wired (both LFM2.5 checkpoints use False)"
        self.in_proj = nn.Linear(config.hidden_size, 3 * dim, bias=False)
        self.out_proj = nn.Linear(dim, config.hidden_size, bias=False)
        self.conv = _ConvWeightHolder(dim, self.l_cache)

    def forward(self, x: torch.Tensor, conv_state: ConvState | None = None) -> torch.Tensor:
        dim, l_cache = self.dim, self.l_cache
        query_len = x.shape[1]
        torch._check_is_size(query_len)

        bcx = self.in_proj(x).transpose(1, 2)  # (B, 3D, S)
        b = bcx.narrow(1, 0, dim)
        c = bcx.narrow(1, dim, dim)
        xg = bcx.narrow(1, 2 * dim, dim)
        bx = b * xg  # (B, D, S)

        if conv_state is not None:
            past = conv_state.fetch(self.conv_idx)  # (1, D, L-1)
            full = torch.cat([past, bx], dim=-1)  # (B, D, S + L - 1)
            conv_state.update(self.conv_idx, full.narrow(-1, query_len, l_cache - 1))
        else:
            full = F.pad(bx, (l_cache - 1, 0))

        # Depthwise causal conv as ONE native conv1d op (groups=dim). This is a single
        # op the CoreAI converter lowers to a fused conv — vs the unrolled per-tap
        # narrow/mul/add chain, which made the optimizer's IR explode (~18.5GB compile).
        # full is (B, D, S + L - 1); weight (D, 1, L); output (B, D, S).
        conv_out = F.conv1d(full, self.conv.weight, bias=None, groups=dim)

        y = c * conv_out  # (B, D, S)
        return self.out_proj(y.transpose(1, 2))


class Lfm2Attention(nn.Module):
    """GQA attention with per-head q/k RMSNorm and RoPE; KV cache indexed by the
    layer's ordinal among attention layers (attn_idx), not the global layer index."""

    def __init__(self, config, attn_idx: int) -> None:
        super().__init__()
        self.attn_idx = attn_idx

        dim = config.hidden_size
        self.n_heads = n_heads = config.num_attention_heads
        self.n_kv_heads = n_kv_heads = config.num_key_value_heads
        head_dim = getattr(config, "head_dim", None)
        self.head_dim = head_dim = head_dim if head_dim else dim // n_heads

        self.qkv_proj = nn.Linear(
            dim, (n_heads + 2 * n_kv_heads) * head_dim, bias=False
        )
        self.out_proj = nn.Linear(n_heads * head_dim, dim, bias=False)

        eps = getattr(config, "norm_eps", 1e-5)
        if USE_FUSED_KV:
            self.qk_norm = RMSNorm(head_dim, eps=eps, n_heads=n_heads + n_kv_heads)
        else:
            self.q_layernorm = RMSNorm(head_dim, eps=eps)
            self.k_layernorm = RMSNorm(head_dim, eps=eps)

        self.sdpa = SDPA(is_causal=True, scale=head_dim**-0.5)
        self.rope = initialize_rope(base=lfm2_resolve_rope_theta(config))

    def forward(
        self,
        x: torch.Tensor,
        position_ids: torch.IntTensor,
        cache: KVCache | None = None,
    ) -> torch.Tensor:
        batch_size, query_len, _ = x.shape
        n_heads, n_kv_heads = self.n_heads, self.n_kv_heads

        qkv = (
            self.qkv_proj(x)
            .reshape(batch_size, query_len, n_heads + 2 * n_kv_heads, self.head_dim)
            .permute(0, 2, 1, 3)
        )

        if USE_FUSED_KV:
            query_key = qkv.narrow(1, 0, n_heads + n_kv_heads)
        else:
            query = qkv.narrow(1, 0, n_heads)
            key = qkv.narrow(1, n_heads, n_kv_heads)
        value = qkv.narrow(1, n_heads + n_kv_heads, n_kv_heads)

        if USE_FUSED_KV:
            query_key = self.qk_norm(query_key)
        else:
            query = self.q_layernorm(query)
            key = self.k_layernorm(key)

        seq_len = position_ids.shape[-1]
        torch._check_is_size(query_len)
        torch._check_is_size(seq_len)
        offset = seq_len - query_len
        torch._check_is_size(offset)
        rope_positions = position_ids.narrow(-1, offset, query_len)

        if USE_FUSED_KV:
            query_key = self.rope(query_key, position_ids=rope_positions)
            query = query_key.narrow(1, 0, n_heads)
            key = query_key.narrow(1, n_heads, n_kv_heads)
        else:
            query = self.rope(query, position_ids=rope_positions)
            key = self.rope(key, position_ids=rope_positions)

        if cache is not None:
            key, value = cache.update_and_fetch(
                self.attn_idx, offset, key, value, seq_len=seq_len, query_len=query_len
            )

        output = (
            self.sdpa(query=query, key=key, value=value)
            .permute(0, 2, 1, 3)
            .reshape(batch_size, query_len, self.n_heads * self.head_dim)
        )
        return self.out_proj(output)


class Lfm2MLP(nn.Module):
    """SwiGLU MLP with LFM2's w1/w3/w2 naming (w1=gate, w3=up, w2=down)."""

    def __init__(self, config, intermediate_size: int | None = None, auto_adjust: bool = True) -> None:
        super().__init__()
        hidden_size = config.hidden_size
        inter = intermediate_size if intermediate_size else config.intermediate_size
        if auto_adjust:
            inter = lfm2_mlp_intermediate_size(config, inter)
        self.w1 = nn.Linear(hidden_size, inter, bias=False)
        self.w3 = nn.Linear(hidden_size, inter, bias=False)
        self.w2 = nn.Linear(inter, hidden_size, bias=False)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.w2(F.silu(self.w1(x)) * self.w3(x))


class Lfm2DecoderLayer(nn.Module):
    def __init__(self, config, layer_idx: int, attn_idx: int, conv_idx: int) -> None:
        super().__init__()
        self.is_attention_layer = config.layer_types[layer_idx] == "full_attention"
        if self.is_attention_layer:
            self.self_attn = Lfm2Attention(config, attn_idx=attn_idx)
        else:
            self.conv = ShortConv(config, conv_idx=conv_idx)
        self.feed_forward = self._build_feed_forward(config, layer_idx)
        eps = getattr(config, "norm_eps", 1e-5)
        self.operator_norm = RMSNorm(config.hidden_size, eps=eps)
        self.ffn_norm = RMSNorm(config.hidden_size, eps=eps)

    def _build_feed_forward(self, config, layer_idx: int) -> nn.Module:
        return Lfm2MLP(config)

    def forward(
        self,
        x: torch.Tensor,
        position_ids: torch.IntTensor,
        cache: KVCache | None = None,
        conv_state: ConvState | None = None,
    ) -> torch.Tensor:
        if self.is_attention_layer:
            r = self.self_attn(self.operator_norm(x), position_ids, cache)
        else:
            r = self.conv(self.operator_norm(x), conv_state)
        h = x + r
        return h + self.feed_forward(self.ffn_norm(h))


def _layer_ordinals(config) -> list[tuple[int, int]]:
    """Per global layer: (attn_idx, conv_idx) ordinals (the one not applicable = -1)."""
    ordinals, attn_i, conv_i = [], 0, 0
    for lt in config.layer_types:
        if lt == "full_attention":
            ordinals.append((attn_i, -1))
            attn_i += 1
        else:
            ordinals.append((-1, conv_i))
            conv_i += 1
    return ordinals


def num_attention_layers(config) -> int:
    return sum(1 for lt in config.layer_types if lt == "full_attention")


def num_conv_layers(config) -> int:
    return sum(1 for lt in config.layer_types if lt != "full_attention")


class Lfm2Model(nn.Module):
    layer_cls = Lfm2DecoderLayer

    def __init__(self, config) -> None:
        super().__init__()
        self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
        ordinals = _layer_ordinals(config)
        self.layers = nn.ModuleList(
            [
                self.layer_cls(config, layer_idx, attn_idx=a, conv_idx=c)
                for layer_idx, (a, c) in enumerate(ordinals)
            ]
        )
        eps = getattr(config, "norm_eps", 1e-5)
        self.embedding_norm = RMSNorm(config.hidden_size, eps=eps)

    def forward(
        self,
        input_ids: torch.Tensor,
        position_ids: torch.IntTensor,
        cache: KVCache | None = None,
        conv_state: ConvState | None = None,
    ) -> torch.Tensor:
        h = self.embed_tokens(input_ids)
        for layer in self.layers:
            h = layer(h, position_ids, cache, conv_state)
        return self.embedding_norm(h)


def _fuse_lfm2_attention_weights(model, state_dict: dict[str, torch.Tensor]) -> None:
    """Fuse q/k/v_proj -> qkv_proj and q/k_layernorm -> qk_norm, per attention layer."""
    for i, layer in enumerate(model.layers):
        if not getattr(layer, "is_attention_layer", False):
            continue
        prefix = f"model.layers.{i}.self_attn"
        combined = []
        for proj in ["q_proj", "k_proj", "v_proj"]:
            key = f"{prefix}.{proj}.weight"
            if key in state_dict:
                combined.append(state_dict.pop(key))
        if combined:
            state_dict[f"{prefix}.qkv_proj.weight"] = torch.concat(combined, axis=0)

        if USE_FUSED_KV:
            qn, kn = f"{prefix}.q_layernorm.weight", f"{prefix}.k_layernorm.weight"
            if qn in state_dict and kn in state_dict:
                attn = layer.self_attn
                qw = state_dict.pop(qn).unsqueeze(0).unsqueeze(0)
                kw = state_dict.pop(kn).unsqueeze(0).unsqueeze(0)
                fused = torch.cat(
                    [
                        qw.expand(attn.n_heads, 1, attn.head_dim),
                        kw.expand(attn.n_kv_heads, 1, attn.head_dim),
                    ],
                    dim=0,
                )
                state_dict[f"{prefix}.qk_norm.weight"] = fused


def build_lfm2_reference_inputs(config, target_dtype, max_context_length, trace_query_len, trace_offset, trace_kv_seq_len):
    """Reference inputs + dynamic shapes for the hybrid: KV cache sized to the number of
    ATTENTION layers only, plus a static-shape conv state for the conv layers."""
    batch_size = 1
    input_ids = torch.randint(
        1, config.vocab_size, (batch_size, trace_query_len), dtype=torch.int32
    )
    position_ids = (
        torch.arange(trace_query_len + trace_offset, dtype=torch.int32)
        .unsqueeze(0)
        .expand(batch_size, trace_query_len + trace_offset)
    )
    n_attn = num_attention_layers(config)
    head_dim = getattr(config, "head_dim", None) or (
        config.hidden_size // config.num_attention_heads
    )
    k_cache = torch.zeros(
        n_attn, 1, config.num_key_value_heads, trace_kv_seq_len, head_dim, dtype=target_dtype
    )
    v_cache = torch.zeros_like(k_cache)
    conv_dim = getattr(config, "conv_dim", config.hidden_size)
    conv_state = torch.zeros(
        num_conv_layers(config), 1, conv_dim, config.conv_L_cache - 1, dtype=target_dtype
    )

    reference_inputs = {
        "input_ids": input_ids,
        "position_ids": position_ids,
        "k_cache": k_cache,
        "v_cache": v_cache,
        "conv_state": conv_state,
    }
    dynamic_shapes = {
        "input_ids": {1: torch.export.Dim("seq_ids", max=max_context_length - 2)},
        "position_ids": {
            1: torch.export.Dim("seq_pos", min=trace_query_len, max=max_context_length - 1)
        },
        # The quantization trace calls this with max_context == trace_kv_seq_len; a Dim
        # with min == max is rejected, so the caches go static there (matching the default
        # quant path, which also uses static k/v). Only the real export pass (max > trace)
        # gets a dynamic seq-len Dim.
        "k_cache": (
            {
                KVCache.seq_len_dim(): torch.export.Dim(
                    "k_seq_len", min=trace_kv_seq_len, max=max_context_length
                )
            }
            if max_context_length > trace_kv_seq_len
            else None
        ),
        "v_cache": (
            {
                KVCache.seq_len_dim(): torch.export.Dim(
                    "v_seq_len", min=trace_kv_seq_len, max=max_context_length
                )
            }
            if max_context_length > trace_kv_seq_len
            else None
        ),
        "conv_state": None,
    }
    return reference_inputs, dynamic_shapes


class Lfm2ForCausalLM(BaseForCausalLM):
    _HF_MODEL_CLASS = None  # loaded straight from safetensors; no HF class needed

    @override
    def _init_model(self, config) -> None:
        self.model = Lfm2Model(config)
        self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
        if getattr(config, "tie_embedding", False) or getattr(
            config, "tie_word_embeddings", False
        ):
            self.lm_head.weight = self.model.embed_tokens.weight

    @BaseForCausalLM.cast_logits_bfloat16_to_float16
    def forward(
        self,
        input_ids: torch.Tensor,
        position_ids: torch.IntTensor,
        k_cache: torch.Tensor,
        v_cache: torch.Tensor,
        conv_state: torch.Tensor,
    ) -> torch.Tensor:
        cache = KVCache(k_cache, v_cache)
        conv = ConvState(conv_state)
        out = self.model(input_ids, position_ids, cache, conv)
        return self.lm_head(out)

    @override
    def _mutate_state_dict(self: Self, state_dict: dict[str, torch.Tensor]) -> None:
        _fuse_lfm2_attention_weights(self.model, state_dict)

    def load_state_dict(self, state_dict, strict: bool = True, assign: bool = False):
        result = super().load_state_dict(state_dict, strict=strict, assign=assign)
        if getattr(self.config, "tie_embedding", False) or getattr(
            self.config, "tie_word_embeddings", False
        ):
            self.lm_head.weight = self.model.embed_tokens.weight
        return result

    # ---- export hooks (consumed by export_macos_model when present) ----
    @staticmethod
    def state_names() -> tuple[str, ...]:
        return ("k_cache", "v_cache", "conv_state")

    @classmethod
    def build_reference_inputs(cls, config, target_dtype, max_context_length, trace_query_len, trace_offset, trace_kv_seq_len):
        return build_lfm2_reference_inputs(
            config, target_dtype, max_context_length, trace_query_len, trace_offset, trace_kv_seq_len
        )