File size: 9,592 Bytes
aef8188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Qwen3 forward in MLX with explicit per-layer attention masks.

Attention modules adapted from mlx-lm (MIT License, Copyright Apple Inc.),
https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/models/qwen3.py, reduced to the
inference paths this artifact needs: every call takes an explicit mask, so the stock
causal-mask construction is deliberately absent. ``Denoiser`` is the numpy-facing wrapper
the sampler talks to; masks cross the boundary in the blocked convention (``True`` =
blocked) and are inverted exactly once, here.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path

import mlx.core as mx
import mlx.nn as nn
import numpy as np

ARCHITECTURE_KEYS = (
    'vocab_size', 'hidden_size', 'num_hidden_layers', 'num_attention_heads',
    'num_key_value_heads', 'head_dim', 'intermediate_size', 'rms_norm_eps', 'rope_theta',
)


@dataclass
class ModelArgs:
    vocab_size: int
    hidden_size: int
    num_hidden_layers: int
    num_attention_heads: int
    num_key_value_heads: int
    head_dim: int
    intermediate_size: int
    rms_norm_eps: float
    rope_theta: float


class KVCache:
    """Append-only key/value buffer with logical truncation via ``trim``.

    ``offset`` is the logical length; buffers grow in 256-token steps and are overwritten
    past ``offset`` after a trim, which is what lets the denoising loop re-feed a changing
    block against a fixed prefix.
    """

    step = 256

    def __init__(self) -> None:
        self.keys: mx.array | None = None
        self.values: mx.array | None = None
        self.offset = 0

    def update_and_fetch(self, keys: mx.array, values: mx.array) -> tuple[mx.array, mx.array]:
        end = self.offset + keys.shape[2]
        if self.keys is None or end > self.keys.shape[2]:
            batch, n_kv_heads, _, head_dim = keys.shape
            size = ((end + self.step - 1) // self.step) * self.step
            grown_keys = mx.zeros((batch, n_kv_heads, size, head_dim), keys.dtype)
            grown_values = mx.zeros((batch, n_kv_heads, size, head_dim), values.dtype)
            if self.keys is not None:
                grown_keys[..., : self.offset, :] = self.keys[..., : self.offset, :]
                grown_values[..., : self.offset, :] = self.values[..., : self.offset, :]
            self.keys, self.values = grown_keys, grown_values
        self.keys[..., self.offset : end, :] = keys
        self.values[..., self.offset : end, :] = values
        self.offset = end
        return self.keys[..., :end, :], self.values[..., :end, :]

    def trim(self, count: int) -> int:
        count = min(self.offset, count)
        self.offset -= count
        return count


class Attention(nn.Module):
    def __init__(self, args: ModelArgs) -> None:
        super().__init__()
        self.n_heads = args.num_attention_heads
        self.n_kv_heads = args.num_key_value_heads
        self.scale = args.head_dim**-0.5
        dim = args.hidden_size
        self.q_proj = nn.Linear(dim, self.n_heads * args.head_dim, bias=False)
        self.k_proj = nn.Linear(dim, self.n_kv_heads * args.head_dim, bias=False)
        self.v_proj = nn.Linear(dim, self.n_kv_heads * args.head_dim, bias=False)
        self.o_proj = nn.Linear(self.n_heads * args.head_dim, dim, bias=False)
        self.q_norm = nn.RMSNorm(args.head_dim, eps=args.rms_norm_eps)
        self.k_norm = nn.RMSNorm(args.head_dim, eps=args.rms_norm_eps)
        self.rope = nn.RoPE(args.head_dim, traditional=False, base=args.rope_theta)

    def __call__(self, x: mx.array, mask: mx.array, cache: KVCache | None = None) -> mx.array:
        batch, length, _ = x.shape
        queries = self.q_proj(x).reshape(batch, length, self.n_heads, -1)
        keys = self.k_proj(x).reshape(batch, length, self.n_kv_heads, -1)
        values = self.v_proj(x).reshape(batch, length, self.n_kv_heads, -1)
        queries = self.q_norm(queries).transpose(0, 2, 1, 3)
        keys = self.k_norm(keys).transpose(0, 2, 1, 3)
        values = values.transpose(0, 2, 1, 3)
        offset = cache.offset if cache is not None else 0
        queries = self.rope(queries, offset=offset)
        keys = self.rope(keys, offset=offset)
        if cache is not None:
            keys, values = cache.update_and_fetch(keys, values)
        output = mx.fast.scaled_dot_product_attention(
            queries, keys, values, scale=self.scale, mask=mask
        )
        output = output.transpose(0, 2, 1, 3).reshape(batch, length, -1)
        return self.o_proj(output)


class MLP(nn.Module):
    def __init__(self, args: ModelArgs) -> None:
        super().__init__()
        self.gate_proj = nn.Linear(args.hidden_size, args.intermediate_size, bias=False)
        self.up_proj = nn.Linear(args.hidden_size, args.intermediate_size, bias=False)
        self.down_proj = nn.Linear(args.intermediate_size, args.hidden_size, bias=False)

    def __call__(self, x: mx.array) -> mx.array:
        return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))


class TransformerBlock(nn.Module):
    def __init__(self, args: ModelArgs) -> None:
        super().__init__()
        self.self_attn = Attention(args)
        self.mlp = MLP(args)
        self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
        self.post_attention_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)

    def __call__(self, x: mx.array, mask: mx.array, cache: KVCache | None = None) -> mx.array:
        x = x + self.self_attn(self.input_layernorm(x), mask, cache)
        return x + self.mlp(self.post_attention_layernorm(x))


class Qwen3Model(nn.Module):
    def __init__(self, args: ModelArgs) -> None:
        super().__init__()
        self.args = args
        self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
        self.layers = [TransformerBlock(args) for _ in range(args.num_hidden_layers)]
        self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)

    def __call__(
        self, input_ids: mx.array, mask: mx.array, caches: list[KVCache] | None = None
    ) -> mx.array:
        """Hidden states under an explicit mask; ``mask`` broadcasts to ``[B, H, Q, K]``."""

        hidden = self.embed_tokens(input_ids)
        caches = caches or [None] * len(self.layers)
        for layer, cache in zip(self.layers, caches):
            hidden = layer(hidden, mask, cache)
        return self.norm(hidden)


class Denoiser:
    """Single-sequence forward with position gathering and forbidden-logit masking.

    Mirrors the torch ``Qwen3Denoiser`` contract on numpy tensors: token ids in, fp32
    logits out, gathered at the requested row positions before the tied head so the full
    vocabulary is never materialized for unneeded rows.
    """

    def __init__(self, model: Qwen3Model, config: dict) -> None:
        self.model = model
        self.config = config
        mdlm = config['mdlm']
        forbidden = np.zeros(config['vocab_size'], dtype=bool)
        forbidden[np.asarray(mdlm['forbidden_output_token_ids'])] = True
        forbidden[mdlm['forbidden_output_from'] :] = True
        self._forbidden = forbidden

    def new_caches(self) -> list[KVCache]:
        return [KVCache() for _ in range(len(self.model.layers))]

    @staticmethod
    def trim_caches(caches: list[KVCache], keep: int) -> None:
        excess = caches[0].offset - keep
        if excess > 0:
            for cache in caches:
                cache.trim(excess)

    def logits(
        self,
        token_ids,
        blocked: np.ndarray,
        positions,
        caches: list[KVCache] | None = None,
    ) -> np.ndarray | None:
        """Logits ``[len(positions), vocab]`` for the fed rows; ``None`` primes a cache only.

        ``blocked`` is ``[Q, K]`` with ``True`` marking a blocked key; with ``caches`` the
        key axis must cover ``offset + Q`` exactly, matching the cached forward contract of
        the torch backend.
        """

        ids = np.asarray(token_ids, dtype=np.int64)
        query_len = ids.shape[0]
        expected_keys = (caches[0].offset if caches else 0) + query_len
        if blocked.shape != (query_len, expected_keys):
            raise ValueError(
                f'blocked must be [{query_len}, {expected_keys}], got {blocked.shape}'
            )
        mask = mx.array(~blocked)[None, None, :, :]
        hidden = self.model(mx.array(ids[None, :]), mask, caches)
        if positions is None:
            mx.eval(hidden)
            return None
        taken = mx.take(hidden[0], mx.array(np.asarray(positions, dtype=np.int32)), axis=0)
        logits = self.model.embed_tokens.as_linear(taken).astype(mx.float32)
        out = np.array(logits)
        out[:, self._forbidden] = np.finfo(np.float32).min
        return out


def load_denoiser(directory: str | Path, *, dtype: str | None = None) -> tuple[Denoiser, dict]:
    """Build the model from ``config.json`` + ``model.safetensors`` in ``directory``.

    ``dtype`` recasts the stored weights on load (e.g. ``'float32'`` for parity checks);
    the shipped file is fp16.
    """

    directory = Path(directory)
    config = json.loads((directory / 'config.json').read_text())
    model = Qwen3Model(ModelArgs(**{key: config[key] for key in ARCHITECTURE_KEYS}))
    weights = mx.load(str(directory / 'model.safetensors'))
    if dtype is not None:
        weights = {name: array.astype(getattr(mx, dtype)) for name, array in weights.items()}
    model.load_weights(list(weights.items()))
    mx.eval(model.parameters())
    return Denoiser(model, config), config