CoolFace
Apppublic

LiquidAI/masked-diffusion

sourceHugging Faceupdated 2mo agoView on Hugging Face
18likes
lfm2_diffusion.py429 linesDownload Raw Back to root
1"""lfm2_diffusion.py — CPU (PyTorch) masked-diffusion inference for LFM2.5 Encoder.2 3Server-side replacement for the old in-browser WebGPU path. The model, decode4loop, and numerics are a 1:1 port of the reference JS (lfm2_webgpu_forward.js /5lfm2_bidir_mdlm.js): a *bidirectional* LFM2 forward, then the block-scheduled,6confidence-driven MDLM unmasking loop.7 8Speed comes from three things:9  * fast-dLLM "DualCache" (as in the WebGPU build): the first step of each block10    runs a full-canvas forward that fills a per-layer K/V cache (attention) and Bx11    cache (shortconv); every later step of that block only recomputes the active12    ~block-size slice, attending to / convolving against the frozen cache.13  * smoothed int8 dynamic quantization of every matmul (see `quantize_int8`).14  * one worker thread per available core (cgroup-aware, no oversubscription).15 16The checkpoint is pulled from the Hub model repo at boot (no local copy);17everything runs on CPU so nothing is downloaded to the client.18"""19 20import os21import math22import re23 24import torch25import torch.nn as nn26import torch.nn.functional as F27from huggingface_hub import hf_hub_download28from safetensors.torch import load_file29 30# The perfectblend diffusion-SFT checkpoint (f32 safetensors). It is the encoder31# turned into an MDLM chatbot — the *base* LFM2.5-Encoder-350M is a plain MLM and32# echoes instead of answering, so it is not interchangeable here.33MODEL_ID = os.environ.get("DIFFUSION_MODEL", "LiquidAI/LFM2.5-Encoder-350M-Diffusion")34WEIGHTS_FILE = os.environ.get("DIFFUSION_WEIGHTS", "model.safetensors")35 36# --- architecture constants (LFM2.5-Encoder-350M), mirrored from the JS ------37H = 1024            # hidden size38NL = 16             # layers39NH = 16             # attention heads40NKV = 8             # kv heads (GQA group size 2)41HD = 64             # head dim42FF = 4608           # ffn inner size43EPS = 1e-544ROPE_THETA = 1e645ATTN_LAYERS = frozenset({2, 5, 8, 10, 12, 14})   # the rest are shortconv layers46 47# --- MDLM / tokenizer constants (see the model card) -------------------------48MASK_ID = 16        # <|mask|>49REAL_VOCAB = 64402  # ignore logit ids >= REAL_VOCAB (padded vocab is 65536)50 51# --- int8 smoothing (see Lfm2Diffusion.quantize_int8) ------------------------52SMOOTH_ALPHA = 0.553# Fixed calibration set, only used to measure per-channel activation ranges (one54# forward each, ~1s at boot). app.py tokenizes these.55CALIB_PROMPTS = (56    "What is the capital of France?",57    "Write a haiku about the ocean.",58    "Give me two tips for better sleep.",59    "What is 12 times 8?",60)61 62 63def effective_cpus():64    """Cores actually available to this process (cgroup quota, not the host)."""65    try:  # cgroup v266        raw = open("/sys/fs/cgroup/cpu.max").read().split()67        if raw and raw[0] != "max":68            return max(1, round(int(raw[0]) / int(raw[1])))69    except (OSError, ValueError):70        pass71    try:  # cgroup v172        q = int(open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").read())73        p = int(open("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read())74        if q > 0:75            return max(1, q // p)76    except (OSError, ValueError):77        pass78    try:79        return max(1, len(os.sched_getaffinity(0)))80    except AttributeError:81        return os.cpu_count() or 182 83 84def render_chat_prompt(user_text, system_text=None):85    """LFM2 chat template for a single user turn + add_generation_prompt."""86    s = ""87    if system_text:88        s += f"[SYS]\n{system_text.strip()}\n[/SYS]\n\n"89    s += f"[Question]\n{user_text.strip()}\n[/Question]\n\n[Answer]\n"90    return s91 92 93def trim(text):94    """Cut generated text at the answer terminator / role markers (app.py trim)."""95    for stop in ("[/Answer]", "[Question]", "[SYS]"):96        i = text.find(stop)97        if i != -1:98            text = text[:i]99    text = text.strip()100    text = re.sub(r"^(?:\[Answer\]|Answer\b)\s*:?\s*", "", text, flags=re.I)101    text = re.sub(r"\s*(?:\.{3}|…)\s*$", "", text)102    return text.strip()103 104 105def load_hf_tensors(path):106    """Load the safetensors checkpoint — weights are already f32 and [out, in], so107    there is nothing to dequantize or transpose. The dimension check makes a108    checkpoint that doesn't match the constants above fail loudly at boot rather109    than as silent garbage."""110    t = load_file(path)111    attn = frozenset(int(m.group(1)) for k in t112                     if (m := re.match(r"lfm2\.layers\.(\d+)\.self_attn\.", k)))113    a = min(attn)114    got = (t["lfm2.embed_tokens.weight"].shape[1],115           1 + max(int(m.group(1)) for k in t if (m := re.match(r"lfm2\.layers\.(\d+)\.", k))),116           attn,117           t["lfm2.layers.0.feed_forward.w1.weight"].shape[0],118           t[f"lfm2.layers.{a}.self_attn.q_proj.weight"].shape[0],119           t[f"lfm2.layers.{a}.self_attn.k_proj.weight"].shape[0])120    want = (H, NL, ATTN_LAYERS, FF, NH * HD, NKV * HD)121    if got != want:122        raise ValueError(f"{path}: (hidden, layers, attn layers, ffn, q, kv) is {got}, expected {want}")123    return t124 125 126class RowScaledLinear(nn.Module):127    """Per-token activations around a dynamically quantized Linear.128 129    The int8 kernel derives ONE activation scale per call, so a single loud token130    coarsens every other row in the batch. Normalising each row to unit max first131    (and putting the scale back on the output) makes that one scale exactly right132    for every row, which is per-token quantization at the cost of two elementwise133    passes."""134 135    def __init__(self, qlin):136        super().__init__()137        self.q = qlin138 139    def forward(self, x):140        r = x.abs().amax(-1, keepdim=True).clamp_min(1e-6)141        return self.q(x / r) * r142 143 144def rms_norm(x, w, eps=EPS):145    inv = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps)146    return x * inv * w147 148 149def _linear(in_f, out_f, weight):150    m = nn.Linear(in_f, out_f, bias=False)151    m.weight.data = weight.contiguous()152    return m153 154 155class Lfm2Layer(nn.Module):156    def __init__(self, t, li):157        super().__init__()158        p = f"lfm2.layers.{li}."159        self.attn = li in ATTN_LAYERS160        self.register_buffer("op_norm", t[p + "operator_norm.weight"], persistent=False)161        self.register_buffer("ffn_norm", t[p + "ffn_norm.weight"], persistent=False)162        # fused gate+up: one GEMM instead of two163        self.w13 = _linear(H, 2 * FF, torch.cat(164            [t[p + "feed_forward.w1.weight"], t[p + "feed_forward.w3.weight"]], 0))165        self.w2 = _linear(FF, H, t[p + "feed_forward.w2.weight"])166        if self.attn:167            self.qkv = _linear(H, H + 2 * NKV * HD, torch.cat(168                [t[p + "self_attn.q_proj.weight"], t[p + "self_attn.k_proj.weight"],169                 t[p + "self_attn.v_proj.weight"]], 0))170            self.o = _linear(H, H, t[p + "self_attn.out_proj.weight"])171            self.register_buffer("q_norm", t[p + "self_attn.q_layernorm.weight"], persistent=False)172            self.register_buffer("k_norm", t[p + "self_attn.k_layernorm.weight"], persistent=False)173        else:174            self.register_buffer("conv", t[p + "conv.conv.weight"].reshape(H, 1, -1), persistent=False)175            self.in_proj = _linear(H, 3 * H, t[p + "conv.in_proj.weight"])176            self.out_proj = _linear(H, H, t[p + "conv.out_proj.weight"])177 178 179class Lfm2Diffusion(nn.Module):180    """Takes ownership of `tensors`: most weights are used without copying, and181    quantize_int8 rescales them in place."""182 183    def __init__(self, tensors):184        super().__init__()185        t = tensors186        self.register_buffer("emb", t["lfm2.embed_tokens.weight"].contiguous(), persistent=False)187        self.register_buffer("emb_norm", t["lfm2.embedding_norm.weight"], persistent=False)188        # tied logits, trimmed to the real vocab (ids >= REAL_VOCAB are suppressed189        # anyway, so there's no point computing the padded tail every step). Cloned,190        # not shared with `emb`: quantize_int8 rescales the head's columns in place.191        self.lm_head = _linear(H, REAL_VOCAB, t["lfm2.embed_tokens.weight"][:REAL_VOCAB].clone())192        self.layers = nn.ModuleList([Lfm2Layer(t, li) for li in range(NL)])193        self.eval()194 195        i = torch.arange(HD // 2, dtype=torch.float32)196        ang = torch.arange(1024, dtype=torch.float32)[:, None] * torch.pow(ROPE_THETA, -2.0 * i / HD)[None, :]197        self.rope_cos, self.rope_sin = torch.cos(ang), torch.sin(ang)198 199    @classmethod200    def from_hub(cls, token=None):201        path = hf_hub_download(MODEL_ID, WEIGHTS_FILE, token=token)202        return cls(load_hf_tensors(path))203 204    # --- int8 -----------------------------------------------------------------205    def _calibrate(self, calib_ids):206        """Per-channel |activation| maxima at every Linear input, from one forward207        per calibration prompt over the canvas a first decode step actually sees208        (prompt + an all-masked answer). Running whole generations instead is 3x209        slower at boot and no more accurate."""210        act = {}211 212        def hook(name):213            def f(_mod, inp, _out):214                a = inp[0].detach().abs().amax(0)215                act[name] = a if name not in act else torch.maximum(act[name], a)216            return f217 218        handles = [m.register_forward_hook(hook(n))219                   for n, m in self.named_modules() if isinstance(m, nn.Linear)]220        try:221            for ids in calib_ids:222                P, T = len(ids), len(ids) + 64223                x = torch.full((T,), MASK_ID, dtype=torch.long)224                x[:P] = torch.tensor(ids, dtype=torch.long)225                self.forward(x, list(range(P, T)), self.new_state(T))226        finally:227            for h in handles:228                h.remove()229        return act230 231    def quantize_int8(self, calib_ids):232        """Smooth per-channel activation outliers into the weights, then quantize.233 234        Dynamic int8 (onednn/fbgemm) is the only fast int8 path on CPU, but it235        scales activations per *tensor*, and this model's post-norm activations have236        per-channel outliers that hijack that single scale — plain int8 costs ~35%237        of the argmaxes, which iterative unmasking then compounds into visibly worse238        answers. So do SmoothQuant first: pull a per-channel factor s out of each239        Linear's input and into whatever produced it, which is exact algebra, and240        the activations quantize cleanly at no runtime cost."""241        act = self._calibrate(calib_ids)242 243        def smooth(lin, act_max, absorb, group=1):244            """s = max|x|^a / max|w|^(1-a), folded into lin's columns and, inverted,245            into the producer. `group` shares one factor across neighbouring input246            channels (GQA: several query heads read the same kv channel)."""247            w_max = lin.weight.abs().amax(0)248            if group > 1:249                act_max = act_max.view(-1, group, HD).amax(1)250                w_max = w_max.view(-1, group, HD).amax(1)251            s = (act_max.clamp_min(1e-5) ** SMOOTH_ALPHA /252                 w_max.clamp_min(1e-5) ** (1 - SMOOTH_ALPHA)).clamp(1e-2, 1e2)253            cols = s[:, None, :].expand(-1, group, HD).reshape(-1) if group > 1 else s254            lin.weight.data *= cols255            absorb(s)256 257        for li, layer in enumerate(self.layers):258            n = f"layers.{li}."259            smooth(layer.w13, act[n + "w13"], lambda s: layer.ffn_norm.div_(s))260            # w2 reads silu(gate) * up, so its factor folds into the up rows.261            smooth(layer.w2, act[n + "w2"], lambda s: layer.w13.weight.data[FF:].div_(s[:, None]))262            if layer.attn:263                smooth(layer.qkv, act[n + "qkv"], lambda s: layer.op_norm.div_(s))264                # o reads the attention output, i.e. the v channels repeated across265                # each GQA group, so one factor per kv channel folds into the v rows.266                smooth(layer.o, act[n + "o"], group=NH // NKV,267                       absorb=lambda s: layer.qkv.weight.data[H + NKV * HD:].div_(s.reshape(-1, 1)))268            else:269                smooth(layer.in_proj, act[n + "in_proj"], lambda s: layer.op_norm.div_(s))270                # out_proj reads C * conv(Bx); the conv is depthwise, so its271                # per-channel kernel absorbs the factor (the Bx cache is untouched).272                smooth(layer.out_proj, act[n + "out_proj"], lambda s: layer.conv.div_(s[:, None, None]))273        smooth(self.lm_head, act["lm_head"], lambda s: self.emb_norm.div_(s))274 275        for eng in ("fbgemm", "onednn", "qnnpack"):276            if eng in torch.backends.quantized.supported_engines:277                torch.backends.quantized.engine = eng278                break279        torch.ao.quantization.quantize_dynamic(280            self, {nn.Linear: torch.ao.quantization.per_channel_dynamic_qconfig},281            dtype=torch.qint8, inplace=True)282        # collect first: wrapping while walking would descend into the wrappers283        todo = [(mod, name, child) for mod in self.modules()284                for name, child in mod.named_children()285                if isinstance(child, torch.ao.nn.quantized.dynamic.Linear)]286        for mod, name, child in todo:287            setattr(mod, name, RowScaledLinear(child))288        return self289 290    def _rope(self, x, pos):291        cos = self.rope_cos[pos][:, None, :]292        sin = self.rope_sin[pos][:, None, :]293        x1, x2 = x[..., :HD // 2], x[..., HD // 2:]294        return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1)295 296    def new_state(self, T):297        """Per-generation DualCache: frozen K/V (attn) and Bx (conv) per layer."""298        st = []299        for L in self.layers:300            if L.attn:301                st.append({"k": torch.zeros(T, NKV, HD), "v": torch.zeros(T, NKV, HD)})302            else:303                st.append({"bx": torch.zeros(T, H)})304        return st305 306    def _conv_slice(self, bx_cache, conv_w, s0, length):307        T = bx_cache.shape[0]308        left = bx_cache[s0 - 1] if s0 - 1 >= 0 else torch.zeros(H)309        right = bx_cache[s0 + length] if s0 + length < T else torch.zeros(H)310        padded = torch.cat([left[None], bx_cache[s0:s0 + length], right[None]], 0)311        out = F.conv1d(padded.t().unsqueeze(0), conv_w, padding=0, groups=H)312        return out.squeeze(0).t()313 314    @torch.inference_mode()315    def forward(self, ids, rows, st, s0=None, length=None):316        """Full refresh (s0 is None) fills the caches over all T; a slice step317        (s0, length) recomputes only [s0, s0+length) against the frozen cache.318        rows: absolute positions to return logits for -> (len(rows), REAL_VOCAB)."""319        T = ids.shape[0]320        if s0 is None:321            s0, length = 0, T322        pos = torch.arange(s0, s0 + length)323        h = self.emb[ids[s0:s0 + length]]324 325        for L, cache in zip(self.layers, st):326            xn = rms_norm(h, L.op_norm)327            if L.attn:328                qkv = L.qkv(xn)329                q = qkv[:, :H].view(length, NH, HD)330                k = qkv[:, H:H + NKV * HD].view(length, NKV, HD)331                v = qkv[:, H + NKV * HD:].view(length, NKV, HD)332                q = self._rope(rms_norm(q, L.q_norm), pos)333                k = self._rope(rms_norm(k, L.k_norm), pos)334                cache["k"][s0:s0 + length] = k335                cache["v"][s0:s0 + length] = v336                # GQA handled inside sdpa (query head h -> kv head h // GQA); no337                # need to materialize an expanded [T, NH, HD] copy of the cache.338                attn = F.scaled_dot_product_attention(339                    q.transpose(0, 1), cache["k"].transpose(0, 1), cache["v"].transpose(0, 1),340                    enable_gqa=True)341                h = h + L.o(attn.transpose(0, 1).reshape(length, H))342            else:343                bcx = L.in_proj(xn)344                B, C, x = bcx[:, :H], bcx[:, H:2 * H], bcx[:, 2 * H:]345                cache["bx"][s0:s0 + length] = B * x346                conv = self._conv_slice(cache["bx"], L.conv, s0, length)347                h = h + L.out_proj(C * conv)348            fn = rms_norm(h, L.ffn_norm)349            gu = L.w13(fn)350            h = h + L.w2(F.silu(gu[:, :FF]) * gu[:, FF:])351 352        h = rms_norm(h, self.emb_norm)353        if rows is not None:354            h = h[torch.as_tensor([r - s0 for r in rows], dtype=torch.long)]355        return self.lm_head(h)[:, :REAL_VOCAB]356 357    @staticmethod358    def _select(logits, temperature):359        if temperature and temperature > 0:360            u = torch.rand_like(logits).clamp_min_(1e-20)361            ids = (logits + temperature * (-torch.log(-torch.log(u)))).argmax(-1)362        else:363            ids = logits.argmax(-1)364        conf = torch.softmax(logits, dim=-1).gather(1, ids.unsqueeze(1)).squeeze(1)365        return ids, conf366 367 368# --- MDLM decode loop (port of diffuseGPU: block-scheduled adaptive reveal) ---369def diffuse(model, prompt_ids, max_new=64, steps=32, block_size=16,370            temperature=0.0, tau=0.9):371    """Generator yielding {step, gen_ids, revealed} frames. gen_ids is the372    generated region only (length max_new); revealed is the set of indices (into373    gen_ids) freshly committed this step."""374    P = len(prompt_ids)375    T = P + max_new376    x = torch.full((T,), MASK_ID, dtype=torch.long)377    x[:P] = torch.tensor(prompt_ids, dtype=torch.long)378 379    yield {"step": 0, "gen_ids": x[P:].tolist(), "revealed": set()}380 381    st = model.new_state(T)382    nblk = max(1, math.ceil(max_new / block_size))383    per_block = max(1, math.ceil(steps / nblk))384    step = 0385    for b in range(nblk):386        s0 = P + b * block_size387        s1 = min(P + (b + 1) * block_size, T)388        if not (x[s0:s1] == MASK_ID).any():389            continue390        block_steps_left = per_block391        fresh = True   # first step of each block = full refresh (rebuild caches)392        while True:393            rows = [i for i in range(s0, s1) if x[i].item() == MASK_ID]394            if not rows:395                break396            if fresh:397                logits = model.forward(x, rows, st)398                fresh = False399            else:400                logits = model.forward(x, rows, st, s0=s0, length=s1 - s0)401            ids, conf = model._select(logits, temperature)402            cand = sorted(403                ({"pos": rows[i], "id": int(ids[i]), "conf": float(conf[i])}404                 for i in range(len(rows))),405                key=lambda c: c["conf"], reverse=True)406            min_k = len(rows) if block_steps_left <= 1 else math.ceil(len(rows) / block_steps_left)407            revealed, taken, committed = set(), set(), 0408 409            def adj(pos):410                return (pos - 1) in taken or (pos + 1) in taken411 412            for idx, c in enumerate(cand):413                if idx == 0 or ((c["conf"] >= tau or committed < min_k) and not adj(c["pos"])):414                    x[c["pos"]] = c["id"]415                    revealed.add(c["pos"] - P)416                    taken.add(c["pos"])417                    committed += 1418            for c in cand:419                if committed >= min_k:420                    break421                if c["pos"] not in taken:422                    x[c["pos"]] = c["id"]423                    revealed.add(c["pos"] - P)424                    taken.add(c["pos"])425                    committed += 1426            block_steps_left = max(1, block_steps_left - 1)427            step += 1428            yield {"step": step, "gen_ids": x[P:].tolist(), "revealed": revealed}429