summerMC commited on
Commit
e7f9fbc
·
verified ·
1 Parent(s): 51dddb3

Add dqwen loader code

Browse files
Files changed (6) hide show
  1. dqwen/__init__.py +1 -0
  2. dqwen/bart_fusion.py +201 -0
  3. dqwen/core.py +314 -0
  4. dqwen/data.py +93 -0
  5. dqwen/evalutil.py +57 -0
  6. dqwen/train.py +242 -0
dqwen/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .core import * # noqa
dqwen/bart_fusion.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BART (facebook/bart-large, frozen FP32) -> Qwen fusion.
2
+
3
+ Pipeline
4
+ --------
5
+ 1. Qwen masked-diffusion generates a DRAFT
6
+ 2. BART encodes the draft text -> `bart_states` [B, S, 1024]
7
+ 3. Qwen remasks its lowest-confidence draft tokens and re-denoises them while `BartFusion`
8
+ injects the BART context into the response-region input embeddings
9
+ -> generate(prompt) returns (draft, final)
10
+
11
+ dtype contract (this is the fix for the earlier BF16/FP32 mismatch, built in):
12
+ * BART stays FP32 and frozen.
13
+ * BartFusion casts `bart_states` and `qwen_embeds` to *its own weight dtype/device* inside forward(),
14
+ and casts the result back to the dtype of `qwen_embeds`. Works whether fusion is FP32 or BF16.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from typing import Optional
20
+
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+
25
+ from .core import DiffusionLM, diffusion_generate, masked_diffusion_loss
26
+
27
+
28
+ def fourier_pos(frac: torch.Tensor, dim: int = 64) -> torch.Tensor:
29
+ """frac: [...,] in roughly [0, 2] -> [..., dim] sin/cos features."""
30
+ n = dim // 2
31
+ freqs = (2.0 ** torch.linspace(0, 6, n, device=frac.device)) * math.pi
32
+ ang = frac[..., None].float() * freqs
33
+ return torch.cat([ang.sin(), ang.cos()], dim=-1)
34
+
35
+
36
+ class BartFusion(nn.Module):
37
+ def __init__(self, qwen_dim: int = 5120, bart_dim: int = 1024, d_inner: int = 1024, n_heads: int = 16):
38
+ super().__init__()
39
+ assert d_inner % n_heads == 0
40
+ self.n_heads, self.d_inner = n_heads, d_inner
41
+ # NOTE: proj[0] is a Linear -> `fusion.proj[0].weight` is valid (used for dtype/device lookups)
42
+ self.proj = nn.Sequential(nn.Linear(bart_dim, qwen_dim), nn.GELU(), nn.Linear(qwen_dim, d_inner))
43
+ self.ln_q = nn.LayerNorm(qwen_dim)
44
+ self.q = nn.Linear(qwen_dim, d_inner)
45
+ self.k = nn.Linear(d_inner, d_inner)
46
+ self.v = nn.Linear(d_inner, d_inner)
47
+ self.o = nn.Linear(d_inner, qwen_dim)
48
+ self.pos_q = nn.Linear(64, d_inner)
49
+ self.pos_k = nn.Linear(64, d_inner)
50
+ self.gate = nn.Parameter(torch.zeros(1)) # tanh-gated, 0 at init => identical to plain Qwen
51
+
52
+ def forward(self, qwen_embeds, bart_states, prefix_len, bart_mask=None, resp_len=None):
53
+ """qwen_embeds [B,L,H]; bart_states [B,S,bart_dim]; prefix_len: int or LongTensor[B] (prompt length);
54
+ resp_len: int or LongTensor[B] = real (pre-EOS) response length, used for position alignment."""
55
+ w = self.proj[0].weight
56
+ tgt_dtype, tgt_dev = w.dtype, w.device
57
+ out_dtype = qwen_embeds.dtype
58
+ bart_states = bart_states.to(device=tgt_dev, dtype=tgt_dtype) # <- dtype fix
59
+ x = qwen_embeds.to(device=tgt_dev, dtype=tgt_dtype)
60
+
61
+ B, L, _ = x.shape
62
+ S = bart_states.shape[1]
63
+ prefix = torch.as_tensor(prefix_len, device=tgt_dev).reshape(-1).expand(B).long()
64
+ rel = torch.arange(L, device=tgt_dev)[None, :] - prefix[:, None] # [B,L] index inside the response
65
+ is_resp = rel >= 0
66
+ n_real = torch.as_tensor(resp_len if resp_len is not None else (L - prefix),
67
+ device=tgt_dev).reshape(-1).expand(B).float().clamp(min=1)
68
+ fq = rel.clamp(min=0).float() / n_real[:, None]
69
+ if bart_mask is not None:
70
+ s_len = bart_mask.sum(1).clamp(min=1).float().to(tgt_dev)
71
+ else:
72
+ s_len = torch.full((B,), float(S), device=tgt_dev)
73
+ fk = torch.arange(S, device=tgt_dev, dtype=torch.float32)[None, :] / s_len[:, None]
74
+
75
+ ctx = self.proj(bart_states) # [B,S,d_inner]
76
+ q = self.q(self.ln_q(x)) + self.pos_q(fourier_pos(fq).to(tgt_dtype))
77
+ k = self.k(ctx) + self.pos_k(fourier_pos(fk).to(tgt_dtype))
78
+ v = self.v(ctx)
79
+ h = self.n_heads
80
+ sh = lambda t: t.view(B, -1, h, self.d_inner // h).transpose(1, 2)
81
+ am = bart_mask.to(tgt_dev).bool()[:, None, None, :] if bart_mask is not None else None
82
+ a = F.scaled_dot_product_attention(sh(q), sh(k), sh(v), attn_mask=am)
83
+ delta = self.o(a.transpose(1, 2).reshape(B, L, self.d_inner))
84
+
85
+ # keep the injected signal on the same scale as the embeddings it is added to
86
+ d_rms = delta.float().pow(2).mean(-1, keepdim=True).add(1e-6).rsqrt()
87
+ e_rms = x.float().pow(2).mean(-1, keepdim=True).sqrt().detach()
88
+ delta = (delta.float() * d_rms * e_rms) * torch.tanh(self.gate.float())
89
+ delta = delta * is_resp[..., None].float() # prompt positions untouched
90
+ return qwen_embeds + delta.to(device=qwen_embeds.device, dtype=out_dtype)
91
+
92
+
93
+ # ------------------------------------------------------------------------------------------
94
+ @torch.no_grad()
95
+ def bart_encode(bart, btok, texts, device, max_len: int = 512):
96
+ enc = btok(list(texts), return_tensors="pt", padding=True, truncation=True, max_length=max_len)
97
+ ids = enc["input_ids"].to(device)
98
+ mask = enc["attention_mask"].to(device)
99
+ enc_dtype = next(bart.parameters()).dtype
100
+ states = bart.get_encoder()(input_ids=ids, attention_mask=mask).last_hidden_state
101
+ return states.to(enc_dtype), mask
102
+
103
+
104
+ def _cut_at_eos(ids_1d: torch.Tensor, eos_id: int) -> int:
105
+ hit = (ids_1d == eos_id).nonzero(as_tuple=True)[0]
106
+ return int(hit[0]) if hit.numel() else int(ids_1d.numel())
107
+
108
+
109
+ class Pipeline:
110
+ """Holds everything needed for inference: draft (Qwen diffusion) -> BART -> refine (Qwen + fusion)."""
111
+
112
+ def __init__(self, dlm: DiffusionLM, tok, bart, btok, fusion: BartFusion, eos_id: int, device,
113
+ chat_kwargs: Optional[dict] = None):
114
+ self.dlm, self.tok, self.bart, self.btok, self.fusion = dlm, tok, bart, btok, fusion
115
+ self.eos_id, self.device = eos_id, device
116
+ self.chat_kwargs = chat_kwargs or {"enable_thinking": False}
117
+
118
+ def encode_prompt(self, prompt: str) -> torch.Tensor:
119
+ text = self.tok.apply_chat_template(
120
+ [{"role": "user", "content": prompt}], tokenize=False, add_generation_prompt=True, **self.chat_kwargs
121
+ )
122
+ return torch.tensor([self.tok(text, add_special_tokens=False)["input_ids"]], device=self.device)
123
+
124
+ def text_of(self, ids_1d: torch.Tensor) -> str:
125
+ n = _cut_at_eos(ids_1d, self.eos_id)
126
+ return self.tok.decode(ids_1d[:n].tolist(), skip_special_tokens=True)
127
+
128
+ @torch.no_grad()
129
+ def generate(self, prompt: str, gen_len=256, steps=64, block_len=32, temperature=0.0,
130
+ refine_ratio=0.3, refine_steps=16, use_bart=True):
131
+ self.dlm.eval(); self.fusion.eval()
132
+ p = self.encode_prompt(prompt)
133
+ draft_ids, conf = diffusion_generate(self.dlm, p, gen_len=gen_len, steps=steps, block_len=block_len,
134
+ temperature=temperature, eos_id=self.eos_id, return_conf=True)
135
+ draft = self.text_of(draft_ids[0])
136
+ if not use_bart:
137
+ return draft, draft
138
+ n_real = _cut_at_eos(draft_ids[0], self.eos_id)
139
+ if n_real == 0:
140
+ return draft, draft
141
+ states, bmask = bart_encode(self.bart, self.btok, [draft if draft.strip() else " "], self.device)
142
+ fusion_fn = lambda emb, plen: self.fusion(emb, states, plen, bart_mask=bmask, resp_len=n_real)
143
+ k = max(1, int(refine_ratio * n_real))
144
+ low = conf[0, :n_real].topk(k, largest=False).indices
145
+ init = draft_ids.clone()
146
+ init[0, low] = self.dlm.mask_id
147
+ # tail after EOS stays EOS (not re-opened)
148
+ final_ids = diffusion_generate(self.dlm, p, steps=refine_steps, block_len=draft_ids.shape[1],
149
+ temperature=temperature, eos_id=self.eos_id, fusion=fusion_fn,
150
+ init_response=init)
151
+ return draft, self.text_of(final_ids[0])
152
+
153
+
154
+ # ------------------------------------------------------------------------------------------
155
+ @torch.no_grad()
156
+ def make_drafts(dlm: DiffusionLM, ids, resp, t_lo=0.3, t_hi=0.9):
157
+ """One-shot noisy drafts that look like real diffusion errors: mask at rate t, fill every masked
158
+ position with argmax (+ record the commit-time confidence). Returns (draft_ids, conf)."""
159
+ B, L = ids.shape
160
+ resp = resp.clone(); resp[:, 0] = False
161
+ t = torch.rand(B, device=ids.device) * (t_hi - t_lo) + t_lo
162
+ masked = (torch.rand(B, L, device=ids.device) < t[:, None]) & resp
163
+ noisy = torch.where(masked, torch.full_like(ids, dlm.mask_id), ids)
164
+ h = dlm.hidden(noisy)
165
+ b, j = masked.nonzero(as_tuple=True)
166
+ lg = dlm.head(h[b, j - 1]).float()
167
+ pr = lg.softmax(-1)
168
+ cand = pr.argmax(-1)
169
+ draft = ids.clone()
170
+ conf = torch.ones(B, L, device=ids.device)
171
+ draft[b, j] = cand
172
+ conf[b, j] = pr.gather(-1, cand[:, None]).squeeze(-1)
173
+ return draft, conf
174
+
175
+
176
+ def refine_loss(dlm, fusion, bart, btok, tok, ids, resp, plen, eos_id, device, ratio_lo=0.15, ratio_hi=0.6):
177
+ """Stage-2 loss: remask the lowest-confidence draft tokens, predict ground truth with BART context."""
178
+ draft, conf = make_drafts(dlm, ids, resp)
179
+ B, L = ids.shape
180
+ texts, n_real = [], []
181
+ for i in range(B):
182
+ r = draft[i, plen[i]:]
183
+ n = _cut_at_eos(r, eos_id)
184
+ n_real.append(max(n, 1))
185
+ t = tok.decode(r[:n].tolist(), skip_special_tokens=True)
186
+ texts.append(t if t.strip() else " ")
187
+ states, bmask = bart_encode(bart, btok, texts, device)
188
+
189
+ rho = torch.rand(B, device=device) * (ratio_hi - ratio_lo) + ratio_lo
190
+ masked = torch.zeros(B, L, dtype=torch.bool, device=device)
191
+ for i in range(B):
192
+ lo, hi = int(plen[i]), L
193
+ k = max(1, int(rho[i] * n_real[i]))
194
+ c = conf[i, lo:lo + n_real[i]]
195
+ idx = c.topk(min(k, c.numel()), largest=False).indices + lo
196
+ masked[i, idx] = True
197
+ nr = torch.tensor(n_real, device=device)
198
+ pl = torch.as_tensor(plen, device=device)
199
+ fusion_fn = lambda emb, plen_: fusion(emb, states, plen_, bart_mask=bmask, resp_len=nr)
200
+ return masked_diffusion_loss(dlm, ids, resp, weighting="uniform", fusion=fusion_fn, prefix_len=pl,
201
+ forced_masked=masked, context_ids=draft)
dqwen/core.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Masked-diffusion adaptation of Qwen3.5 (hybrid Gated-DeltaNet + full attention).
2
+
3
+ Pieces
4
+ ------
5
+ * patch_bidirectional : full-attention layers -> bidirectional, DeltaNet layers -> forward + reverse scan
6
+ (reverse path gated by a zero-initialised per-channel gate, so step 0 == original DeltaNet)
7
+ * DiffusionLM : wrapper with a trainable [MASK] embedding, 4D/2D mask dict builder, hidden-state API
8
+ * masked_diffusion_loss : LLaDA-style masked-token CE with "shift" (position j is predicted from hidden j-1,
9
+ which keeps the AR-pretrained lm_head alignment, as in Dream)
10
+ * diffusion_generate : block-wise (semi-AR) confidence-based parallel unmasking
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from typing import Callable, Optional
16
+
17
+ import torch
18
+ import torch.nn as nn
19
+ import torch.nn.functional as F
20
+
21
+ try: # class lookup is done lazily so the module imports even on older transformers
22
+ from transformers.models.qwen3_5.modeling_qwen3_5 import (
23
+ Qwen3_5Attention,
24
+ Qwen3_5GatedDeltaNet,
25
+ Qwen3_5TextModel,
26
+ )
27
+ except Exception as e: # pragma: no cover
28
+ raise ImportError(
29
+ "Qwen3.5 classes not found. Install a recent transformers: pip install -U transformers"
30
+ ) from e
31
+
32
+
33
+ # --------------------------------------------------------------------------------------
34
+ # 1. architecture surgery
35
+ # --------------------------------------------------------------------------------------
36
+ def _patch_deltanet(mod: Qwen3_5GatedDeltaNet, hidden_size: int, bidirectional: bool = True):
37
+ """Make one GatedDeltaNet layer bidirectional.
38
+
39
+ out = orig(h) + g * flip(orig(flip(h))) (out_proj is linear & bias-free, so this equals
40
+ out_proj(core_f + g * core_r) exactly)
41
+ g is a per-channel fp32 parameter initialised to 0.
42
+ """
43
+ if getattr(mod, "_dqwen_patched", False):
44
+ return
45
+ orig_forward = mod.forward
46
+ # The 4-bit model has already been dispatched to CUDA before patching.
47
+ # New parameters otherwise default to CPU and cause device mismatch.
48
+ reference = next(mod.parameters())
49
+ mod.rev_gate = nn.Parameter(torch.zeros(hidden_size, device=reference.device, dtype=torch.float32))
50
+ mod._dqwen_patched = True
51
+
52
+ def forward(hidden_states, cache_params=None, attention_mask=None, **kwargs):
53
+ y = orig_forward(hidden_states, cache_params=None, attention_mask=attention_mask, **dict(kwargs))
54
+ if not bidirectional:
55
+ return y
56
+ h_r = hidden_states.flip(1)
57
+ m_r = attention_mask.flip(1) if attention_mask is not None else None
58
+ y_r = orig_forward(h_r, cache_params=None, attention_mask=m_r, **dict(kwargs)).flip(1)
59
+ # The first device-fix only moved rev_gate; y_r may still differ.
60
+ # Move the reverse output to the forward output's device with autograd intact.
61
+ if not isinstance(y, torch.Tensor) or not isinstance(y_r, torch.Tensor):
62
+ raise TypeError(f"DeltaNet outputs: forward={type(y)!r}, reverse={type(y_r)!r}")
63
+ if y.device != y_r.device:
64
+ print(f"[DeltaNet device] forward={y.device}, reverse={y_r.device}, "
65
+ f"gate={mod.rev_gate.device}; aligning reverse output")
66
+ y_r = y_r.to(device=y.device, dtype=y.dtype)
67
+ gate = mod.rev_gate.to(device=y.device, dtype=y.dtype)
68
+ try:
69
+ return y + gate * y_r
70
+ except RuntimeError as exc:
71
+ raise RuntimeError(
72
+ f"DeltaNet fusion failed: forward={y.device}/{y.dtype}/{tuple(y.shape)}, "
73
+ f"reverse={y_r.device}/{y_r.dtype}/{tuple(y_r.shape)}, "
74
+ f"gate={gate.device}/{gate.dtype}/{tuple(gate.shape)}; "
75
+ f"source_gate={mod.rev_gate.device}. Original error: {exc}"
76
+ ) from exc
77
+
78
+ mod.forward = forward
79
+
80
+
81
+ def patch_bidirectional(model: nn.Module, deltanet_bidirectional: bool = True) -> dict:
82
+ """In-place surgery. Returns simple stats."""
83
+ n_full = n_delta = 0
84
+ text = find_text_model(model)
85
+ hidden = text.config.hidden_size
86
+ for m in text.modules():
87
+ if isinstance(m, Qwen3_5Attention):
88
+ m.is_causal = False
89
+ n_full += 1
90
+ elif isinstance(m, Qwen3_5GatedDeltaNet):
91
+ _patch_deltanet(m, hidden, deltanet_bidirectional)
92
+ n_delta += 1
93
+ return {"full_attention_layers": n_full, "deltanet_layers": n_delta}
94
+
95
+
96
+ def find_text_model(model: nn.Module) -> Qwen3_5TextModel:
97
+ for m in model.modules():
98
+ if isinstance(m, Qwen3_5TextModel):
99
+ return m
100
+ raise ValueError("Qwen3_5TextModel not found inside the given module")
101
+
102
+
103
+ def find_lm_head(model: nn.Module) -> nn.Module:
104
+ for name, m in model.named_modules():
105
+ if name.endswith("lm_head"):
106
+ return m
107
+ raise ValueError("lm_head not found")
108
+
109
+
110
+ def rev_gate_state(model: nn.Module) -> dict:
111
+ return {n: p.detach().cpu() for n, p in model.named_parameters() if n.endswith("rev_gate")}
112
+
113
+
114
+ def load_rev_gates(model: nn.Module, state: dict):
115
+ params = dict(model.named_parameters())
116
+ missing = []
117
+ for n, v in state.items():
118
+ # tolerate different peft prefixes by matching on suffix after 'layers.'
119
+ key = n if n in params else next((k for k in params if k.endswith(n.split("layers.", 1)[-1]) and "rev_gate" in k), None)
120
+ if key is None:
121
+ missing.append(n)
122
+ continue
123
+ params[key].data.copy_(v.to(params[key].dtype))
124
+ return missing
125
+
126
+
127
+ # --------------------------------------------------------------------------------------
128
+ # 2. wrapper
129
+ # --------------------------------------------------------------------------------------
130
+ class DiffusionLM(nn.Module):
131
+ """Wraps a (possibly PEFT-wrapped) Qwen3_5ForCausalLM.
132
+
133
+ `fusion` (optional) is a callable (embeds, prefix_len) -> embeds that injects extra context
134
+ (e.g. BART states) into the input embeddings of the response region.
135
+ """
136
+
137
+ def __init__(self, lm: nn.Module, mask_id: int, init_mask_from_token: Optional[int] = None):
138
+ super().__init__()
139
+ self.lm = lm
140
+ self.mask_id = int(mask_id)
141
+ self.text = find_text_model(lm)
142
+ self.head = find_lm_head(lm)
143
+ hidden = self.text.config.hidden_size
144
+ emb = self.text.embed_tokens.weight
145
+ init = emb[init_mask_from_token if init_mask_from_token is not None else mask_id].detach().float().clone()
146
+ if init.abs().sum() == 0: # untrained row -> fall back to the mean embedding
147
+ init = emb.detach().float().mean(0)
148
+ self.mask_embed = nn.Parameter(init.to(emb.device))
149
+ self.hidden_size = hidden
150
+
151
+ # ---- masks ----------------------------------------------------------------------
152
+ def build_masks(self, attn2d: Optional[torch.Tensor], dtype=torch.bfloat16):
153
+ if attn2d is None:
154
+ return {"full_attention": None, "linear_attention": None}
155
+ a = attn2d.bool()
156
+ full = (a[:, None, :, None] & a[:, None, None, :]) # [B,1,L,L] True = attend
157
+ # a padded query row would be all False -> NaN in sdpa; let pads attend to themselves
158
+ eye = torch.eye(a.shape[1], dtype=torch.bool, device=a.device)[None, None]
159
+ full = full | (~a[:, None, :, None] & eye)
160
+ if getattr(self.text.config, "_attn_implementation", "sdpa") == "eager":
161
+ neg = torch.finfo(dtype).min
162
+ full = torch.zeros(full.shape, dtype=dtype, device=a.device).masked_fill(~full, neg)
163
+ return {"full_attention": full, "linear_attention": a.long()}
164
+
165
+ # ---- embeddings -----------------------------------------------------------------
166
+ def embed(self, input_ids: torch.Tensor) -> torch.Tensor:
167
+ e = self.text.embed_tokens(input_ids)
168
+ is_mask = (input_ids == self.mask_id)[..., None]
169
+ return torch.where(is_mask, self.mask_embed.to(e.dtype)[None, None, :], e)
170
+
171
+ def hidden(self, input_ids, attn2d=None, fusion: Optional[Callable] = None, prefix_len=None):
172
+ emb = self.embed(input_ids)
173
+ if fusion is not None:
174
+ emb = fusion(emb, prefix_len)
175
+ out = self.text(
176
+ inputs_embeds=emb,
177
+ attention_mask=self.build_masks(attn2d, emb.dtype),
178
+ use_cache=False,
179
+ )
180
+ return out.last_hidden_state
181
+
182
+ def logits_at(self, h: torch.Tensor) -> torch.Tensor:
183
+ return self.head(h)
184
+
185
+
186
+ # --------------------------------------------------------------------------------------
187
+ # 3. loss
188
+ # --------------------------------------------------------------------------------------
189
+ def _chunked_ce(head, hs: torch.Tensor, targets: torch.Tensor, chunk: int = 1024):
190
+ """CE per token without materialising [N, V] logits for all N at once."""
191
+ outs = []
192
+ for i in range(0, hs.shape[0], chunk):
193
+ lg = head(hs[i : i + chunk]).float()
194
+ outs.append(F.cross_entropy(lg, targets[i : i + chunk], reduction="none"))
195
+ return torch.cat(outs) if outs else hs.new_zeros(0)
196
+
197
+
198
+ def masked_diffusion_loss(
199
+ dlm: DiffusionLM,
200
+ input_ids: torch.Tensor, # [B, L] clean tokens (prompt + response + EOS padding)
201
+ resp_mask: torch.Tensor, # [B, L] bool, True where the token belongs to the response (may be masked)
202
+ attn2d: Optional[torch.Tensor] = None,
203
+ t_min: float = 0.05,
204
+ weighting: str = "elbo", # "elbo" -> 1/t (LLaDA/Dream), "uniform" -> 1
205
+ fusion: Optional[Callable] = None,
206
+ prefix_len=None,
207
+ forced_masked: Optional[torch.Tensor] = None, # if given, mask exactly this set (used for refine-stage training)
208
+ context_ids: Optional[torch.Tensor] = None, # tokens shown at un-masked positions (draft); targets stay `input_ids`
209
+ ):
210
+ B, L = input_ids.shape
211
+ dev = input_ids.device
212
+ resp_mask = resp_mask.clone()
213
+ resp_mask[:, 0] = False # position 0 has no left neighbour to predict it (shift)
214
+
215
+ if forced_masked is None:
216
+ t = torch.rand(B, device=dev) * (1 - t_min) + t_min
217
+ masked = (torch.rand(B, L, device=dev) < t[:, None]) & resp_mask
218
+ # guarantee >=1 masked token per row
219
+ none = masked.sum(1) == 0
220
+ if none.any():
221
+ idx = torch.multinomial(resp_mask[none].float() + 1e-9, 1).squeeze(1)
222
+ rows = none.nonzero(as_tuple=True)[0]
223
+ masked[rows, idx] = True
224
+ else:
225
+ masked = forced_masked & resp_mask
226
+ t = masked.sum(1).float() / resp_mask.sum(1).clamp(min=1)
227
+ t = t.clamp(min=t_min)
228
+
229
+ base = input_ids if context_ids is None else context_ids
230
+ noisy = torch.where(masked, torch.full_like(input_ids, dlm.mask_id), base)
231
+ h = dlm.hidden(noisy, attn2d, fusion=fusion, prefix_len=prefix_len)
232
+
233
+ b_idx, j_idx = masked.nonzero(as_tuple=True)
234
+ hs = h[b_idx, j_idx - 1]
235
+ tgt = input_ids[b_idx, j_idx]
236
+ ce = _chunked_ce(dlm.head, hs, tgt)
237
+
238
+ if weighting == "elbo":
239
+ w = 1.0 / t[b_idx]
240
+ else:
241
+ w = torch.ones_like(ce)
242
+ n_resp = resp_mask.sum(1).clamp(min=1).float()
243
+ loss = (w * ce / n_resp[b_idx]).sum() / B
244
+
245
+ return loss, {"ce": ce.mean().detach(), "n_masked": int(masked.sum()), "t": t.mean().detach()}
246
+
247
+
248
+ # --------------------------------------------------------------------------------------
249
+ # 4. sampler
250
+ # --------------------------------------------------------------------------------------
251
+ @torch.no_grad()
252
+ def diffusion_generate(
253
+ dlm: DiffusionLM,
254
+ prompt_ids: torch.Tensor, # [1, P]
255
+ gen_len: int = 256,
256
+ steps: int = 64,
257
+ block_len: int = 32,
258
+ temperature: float = 0.0,
259
+ eos_id: Optional[int] = None,
260
+ fusion: Optional[Callable] = None,
261
+ init_response: Optional[torch.Tensor] = None, # [1, gen_len] tokens; MASK id => to be generated
262
+ return_conf: bool = False,
263
+ ):
264
+ """Block-wise low-confidence remasking (LLaDA). Returns [1, gen_len] response tokens."""
265
+ assert prompt_ids.shape[0] == 1
266
+ dev = prompt_ids.device
267
+ P = prompt_ids.shape[1]
268
+ if init_response is None:
269
+ resp = torch.full((1, gen_len), dlm.mask_id, dtype=torch.long, device=dev)
270
+ else:
271
+ resp = init_response.clone().to(dev)
272
+ gen_len = resp.shape[1]
273
+ x = torch.cat([prompt_ids, resp], dim=1)
274
+ conf_all = torch.zeros(1, gen_len, device=dev)
275
+
276
+ n_blocks = math.ceil(gen_len / block_len)
277
+ steps_per_block = max(1, steps // n_blocks)
278
+
279
+ for b in range(n_blocks):
280
+ lo, hi = P + b * block_len, min(P + (b + 1) * block_len, P + gen_len)
281
+ blk_masked0 = (x[0, lo:hi] == dlm.mask_id).sum().item()
282
+ if blk_masked0 == 0:
283
+ continue
284
+ # tokens to commit per step (spread evenly)
285
+ base, rem = divmod(blk_masked0, steps_per_block)
286
+ schedule = [base + (1 if i < rem else 0) for i in range(steps_per_block)]
287
+ for k in schedule:
288
+ if k == 0:
289
+ continue
290
+ h = dlm.hidden(x, None, fusion=fusion, prefix_len=P)
291
+ pos = torch.arange(lo, hi, device=dev)
292
+ still = x[0, lo:hi] == dlm.mask_id
293
+ pos = pos[still]
294
+ if pos.numel() == 0:
295
+ break
296
+ lg = dlm.head(h[0, pos - 1]).float() # shift: predict j from j-1
297
+ if temperature > 0:
298
+ g = -torch.log(-torch.log(torch.rand_like(lg).clamp_min(1e-10)).clamp_min(1e-10))
299
+ cand = (lg / temperature + g).argmax(-1)
300
+ else:
301
+ cand = lg.argmax(-1)
302
+ prob = F.softmax(lg, -1).gather(-1, cand[:, None]).squeeze(-1)
303
+ kk = min(k, pos.numel())
304
+ top = prob.topk(kk).indices
305
+ x[0, pos[top]] = cand[top]
306
+ conf_all[0, pos[top] - P] = prob[top]
307
+ if eos_id is not None and (x[0, P : hi] == eos_id).any() and not (x[0, P:hi] == dlm.mask_id).any():
308
+ break
309
+ out = x[:, P:].clone()
310
+ if eos_id is not None:
311
+ hit = (out[0] == eos_id).nonzero(as_tuple=True)[0]
312
+ if hit.numel() > 0:
313
+ out[0, hit[0]:] = eos_id # everything after the first EOS is padding
314
+ return (out, conf_all) if return_conf else out
dqwen/data.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat-SFT data -> fixed-length (prompt | response | EOS filler) tensors for masked diffusion.
2
+
3
+ Following LLaDA, the response region is padded with EOS up to the fixed length and those filler tokens are
4
+ part of the response (so the model learns *when to stop* by predicting EOS at the tail).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import random
10
+ from typing import Iterable, Optional
11
+
12
+ import torch
13
+
14
+
15
+ def to_messages(row: dict) -> Optional[list]:
16
+ """Normalise common SFT row layouts to [{'role','content'}, ...] ending with an assistant turn."""
17
+ if "messages" in row and row["messages"]:
18
+ msgs = [{"role": m["role"], "content": m["content"]} for m in row["messages"]]
19
+ elif "conversations" in row and row["conversations"]:
20
+ rm = {"human": "user", "user": "user", "gpt": "assistant", "assistant": "assistant", "system": "system"}
21
+ msgs = [{"role": rm.get(m.get("from", m.get("role")), "user"), "content": m.get("value", m.get("content"))}
22
+ for m in row["conversations"]]
23
+ elif "instruction" in row and ("output" in row or "response" in row):
24
+ q = row["instruction"] + (("\n" + row["input"]) if row.get("input") else "")
25
+ msgs = [{"role": "user", "content": q}, {"role": "assistant", "content": row.get("output", row.get("response"))}]
26
+ elif "question" in row and "answer" in row:
27
+ msgs = [{"role": "user", "content": row["question"]}, {"role": "assistant", "content": row["answer"]}]
28
+ else:
29
+ return None
30
+ if len(msgs) < 2 or msgs[-1]["role"] != "assistant" or not msgs[-1]["content"]:
31
+ return None
32
+ return msgs
33
+
34
+
35
+ def iter_rows(source: str, config: Optional[str] = None, split: str = "train", n: int = 20000, seed: int = 0) -> Iterable[dict]:
36
+ """`source` = path to .jsonl, or a Hugging Face dataset id (streamed, so nothing huge is downloaded)."""
37
+ if source.endswith(".jsonl") or source.endswith(".json"):
38
+ with open(source, encoding="utf-8") as f:
39
+ rows = [json.loads(l) for l in f if l.strip()]
40
+ random.Random(seed).shuffle(rows)
41
+ yield from rows[:n]
42
+ return
43
+ from datasets import load_dataset
44
+ ds = load_dataset(source, config, split=split, streaming=True).shuffle(seed=seed, buffer_size=10_000)
45
+ for i, r in enumerate(ds):
46
+ if i >= n:
47
+ break
48
+ yield r
49
+
50
+
51
+ def build_dataset(tok, rows: Iterable[dict], max_len: int = 512, max_prompt: int = 256,
52
+ eos_token: str = "<|im_end|>", chat_kwargs: Optional[dict] = None, verbose: bool = True):
53
+ chat_kwargs = chat_kwargs or {"enable_thinking": False}
54
+ eos_id = tok.convert_tokens_to_ids(eos_token)
55
+ ids_l, resp_l, plen_l = [], [], []
56
+ seen = kept = 0
57
+ for row in rows:
58
+ seen += 1
59
+ msgs = to_messages(row)
60
+ if msgs is None:
61
+ continue
62
+ ptxt = tok.apply_chat_template(msgs[:-1], tokenize=False, add_generation_prompt=True, **chat_kwargs)
63
+ p = tok(ptxt, add_special_tokens=False)["input_ids"]
64
+ r = tok(msgs[-1]["content"], add_special_tokens=False)["input_ids"] + [eos_id]
65
+ if len(p) > max_prompt or len(p) + len(r) > max_len:
66
+ continue # drop (never truncate: a cut response would lose its EOS)
67
+ pad = max_len - len(p) - len(r)
68
+ seq = p + r + [eos_id] * pad
69
+ rm = [False] * len(p) + [True] * (len(r) + pad)
70
+ ids_l.append(seq); resp_l.append(rm); plen_l.append(len(p)); kept += 1
71
+ if verbose:
72
+ print(f"[data] kept {kept}/{seen} examples (max_len={max_len}, max_prompt={max_prompt})")
73
+ if kept == 0:
74
+ raise ValueError(
75
+ f"No example survived filtering (seen {seen}). The chat template alone may exceed max_prompt="
76
+ f"{max_prompt}; raise max_prompt/max_len or check the dataset columns.")
77
+ return {
78
+ "ids": torch.tensor(ids_l, dtype=torch.long),
79
+ "resp": torch.tensor(resp_l, dtype=torch.bool),
80
+ "plen": torch.tensor(plen_l, dtype=torch.long),
81
+ }
82
+
83
+
84
+ def batches(ds: dict, batch_size: int, device, seed: int = 0, epochs: int = 10**9):
85
+ g = torch.Generator().manual_seed(seed)
86
+ N = ds["ids"].shape[0]
87
+ if N < batch_size:
88
+ raise ValueError(f"Dataset has {N} examples but batch_size={batch_size}; lower batch_size or add data.")
89
+ for _ in range(epochs):
90
+ perm = torch.randperm(N, generator=g)
91
+ for i in range(0, N - batch_size + 1, batch_size):
92
+ ix = perm[i:i + batch_size]
93
+ yield ds["ids"][ix].to(device), ds["resp"][ix].to(device), ds["plen"][ix].to(device)
dqwen/evalutil.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation: held-out masked-token CE, GSM8K accuracy (draft vs BART-refined)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Optional
6
+
7
+ import torch
8
+
9
+ from .core import DiffusionLM, masked_diffusion_loss
10
+ from .data import batches
11
+
12
+
13
+ @torch.no_grad()
14
+ def heldout_ce(dlm: DiffusionLM, ds: dict, device, n_batches: int = 10, batch_size: int = 2, rate: float = 0.5, seed: int = 123):
15
+ """Mean CE (nats/token) on masked response tokens at a fixed masking rate -> lower is better."""
16
+ was = dlm.training
17
+ dlm.eval()
18
+ g = torch.Generator(device="cpu").manual_seed(seed)
19
+ tot, n = 0.0, 0
20
+ for i, (ids, resp, _) in enumerate(batches(ds, batch_size, device, seed=seed, epochs=1)):
21
+ if i >= n_batches:
22
+ break
23
+ r = resp.clone(); r[:, 0] = False
24
+ forced = (torch.rand(ids.shape, generator=g).to(device) < rate) & r
25
+ _, info = masked_diffusion_loss(dlm, ids, resp, forced_masked=forced, weighting="uniform")
26
+ tot += float(info["ce"]); n += 1
27
+ dlm.train(was)
28
+ return tot / max(n, 1)
29
+
30
+
31
+ _num = re.compile(r"-?\d[\d,]*\.?\d*")
32
+
33
+
34
+ def _last_number(text: str) -> Optional[str]:
35
+ m = _num.findall(text.replace("$", ""))
36
+ return m[-1].replace(",", "").rstrip(".") if m else None
37
+
38
+
39
+ def extract_answer(text: str) -> Optional[str]:
40
+ if "####" in text:
41
+ return _last_number(text.split("####")[-1])
42
+ return _last_number(text)
43
+
44
+
45
+ def gsm8k_eval(pipe, n: int = 50, gen_len: int = 256, steps: int = 128, block_len: int = 32, use_bart: bool = True, seed: int = 0):
46
+ from datasets import load_dataset
47
+ ds = load_dataset("openai/gsm8k", "main", split="test").shuffle(seed=seed).select(range(n))
48
+ ok_draft = ok_final = 0
49
+ for i, row in enumerate(ds):
50
+ gold = row["answer"].split("####")[-1].strip().replace(",", "")
51
+ q = row["question"] + "\nSolve step by step, then write the final number after '####'."
52
+ draft, final = pipe.generate(q, gen_len=gen_len, steps=steps, block_len=block_len, use_bart=use_bart)
53
+ ok_draft += extract_answer(draft) == gold
54
+ ok_final += extract_answer(final) == gold
55
+ if (i + 1) % 10 == 0:
56
+ print(f" {i+1}/{n} draft acc {ok_draft/(i+1):.3f} | refined acc {ok_final/(i+1):.3f}")
57
+ return {"n": n, "draft_acc": ok_draft / n, "refined_acc": ok_final / n}
dqwen/train.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loading, LoRA setup, stage-1 (diffusion adaptation) and stage-2 (BART fusion) training loops."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional
9
+
10
+ import torch
11
+
12
+ from .core import (DiffusionLM, diffusion_generate, find_text_model, load_rev_gates, masked_diffusion_loss,
13
+ patch_bidirectional, rev_gate_state)
14
+ from .data import batches
15
+
16
+ LORA_TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", # full-attention layers
17
+ "in_proj_qkv", "in_proj_z", "out_proj", # Gated-DeltaNet layers
18
+ "gate_proj", "up_proj", "down_proj"] # MLP
19
+
20
+
21
+ @dataclass
22
+ class Stage1Cfg:
23
+ max_steps: int = 300
24
+ batch_size: int = 2 # micro batch (sequences of max_len tokens)
25
+ grad_accum: int = 8
26
+ lr: float = 2e-4 # LoRA
27
+ gate_lr: float = 2e-3 # DeltaNet reverse gates
28
+ mask_lr: float = 1e-3 # [MASK] embedding
29
+ warmup: int = 20
30
+ min_lr_ratio: float = 0.1
31
+ weight_decay: float = 0.0
32
+ clip: float = 1.0
33
+ t_min: float = 0.05
34
+ weighting: str = "elbo" # or "uniform"
35
+ log_every: int = 5
36
+ sample_every: int = 100
37
+ save_every: int = 100
38
+ save_dir: str = "/content/diffusion_qwen_bart"
39
+ seed: int = 0
40
+
41
+
42
+ def load_dlm(model_id: str = "Qwen/Qwen3.5-9B", device: str = "cuda", dtype=torch.bfloat16,
43
+ load_in_4bit: bool = False, lora_r: int = 32, lora_alpha: int = 64, lora_dropout: float = 0.0,
44
+ mask_token: str = "<|fim_pad|>", grad_ckpt: bool = True, deltanet_bidirectional: bool = True,
45
+ lm: Optional[torch.nn.Module] = None, tok=None):
46
+ """Returns (dlm, tok). `lm`/`tok` can be passed in for tests (skips from_pretrained)."""
47
+ from peft import LoraConfig, get_peft_model
48
+ from transformers import AutoModelForCausalLM, AutoTokenizer
49
+
50
+ if tok is None:
51
+ tok = AutoTokenizer.from_pretrained(model_id)
52
+ if lm is None:
53
+ kw = dict(dtype=dtype, attn_implementation="sdpa")
54
+ if load_in_4bit:
55
+ from transformers import BitsAndBytesConfig
56
+ kw["quantization_config"] = BitsAndBytesConfig(
57
+ load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True,
58
+ bnb_4bit_compute_dtype=dtype)
59
+ kw["device_map"] = {"": 0} if device == "cuda" else None
60
+ lm = AutoModelForCausalLM.from_pretrained(model_id, **kw)
61
+ lm.config.use_cache = False
62
+
63
+ stats = patch_bidirectional(lm, deltanet_bidirectional)
64
+ print("[patch]", stats)
65
+ if grad_ckpt:
66
+ lm.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
67
+
68
+ cfg = LoraConfig(r=lora_r, lora_alpha=lora_alpha, lora_dropout=lora_dropout, target_modules=LORA_TARGETS,
69
+ bias="none")
70
+ lm = get_peft_model(lm, cfg)
71
+ # PEFT freezes non-LoRA weights. Reactivate and verify reverse gates.
72
+ for n, p in lm.named_parameters():
73
+ if n.endswith("rev_gate"):
74
+ p.requires_grad_(True)
75
+ if p.device.type != torch.device(device).type:
76
+ raise RuntimeError(f"{n} is on {p.device}, expected {device}; rerun model loading with patched core.py")
77
+
78
+ mask_id = tok.convert_tokens_to_ids(mask_token)
79
+ dlm = DiffusionLM(lm, mask_id=mask_id)
80
+ dlm.mask_embed.requires_grad_(True)
81
+ if device == "cuda":
82
+ dlm.mask_embed.data = dlm.mask_embed.data.cuda()
83
+ n_tr = sum(p.numel() for p in dlm.parameters() if p.requires_grad)
84
+ print(f"[model] trainable params: {n_tr/1e6:.1f}M | mask token {mask_token!r} id={mask_id}")
85
+ return dlm, tok
86
+
87
+
88
+ def _groups(dlm, cfg: Stage1Cfg):
89
+ lora, gates, other = [], [], []
90
+ for n, p in dlm.named_parameters():
91
+ if not p.requires_grad:
92
+ continue
93
+ (gates if n.endswith("rev_gate") else other if n.endswith("mask_embed") else lora).append(p)
94
+ return [
95
+ {"params": lora, "lr": cfg.lr, "base": cfg.lr},
96
+ {"params": gates, "lr": cfg.gate_lr, "base": cfg.gate_lr},
97
+ {"params": other, "lr": cfg.mask_lr, "base": cfg.mask_lr},
98
+ ]
99
+
100
+
101
+ def _lr_at(step, cfg: Stage1Cfg):
102
+ if step < cfg.warmup:
103
+ return (step + 1) / cfg.warmup
104
+ pr = (step - cfg.warmup) / max(1, cfg.max_steps - cfg.warmup)
105
+ return cfg.min_lr_ratio + (1 - cfg.min_lr_ratio) * 0.5 * (1 + math.cos(math.pi * min(1.0, pr)))
106
+
107
+
108
+ def save_checkpoint(dlm: DiffusionLM, tok, save_dir: str, step: int, fusion=None):
109
+ os.makedirs(save_dir, exist_ok=True)
110
+ dlm.lm.save_pretrained(os.path.join(save_dir, "qwen_lora"))
111
+ tok.save_pretrained(os.path.join(save_dir, "qwen_lora"))
112
+ torch.save({"mask_embed": dlm.mask_embed.detach().cpu(), "rev_gates": rev_gate_state(dlm.lm),
113
+ "mask_id": dlm.mask_id, "step": step}, os.path.join(save_dir, "extras.pt"))
114
+ if fusion is not None:
115
+ torch.save(fusion.state_dict(), os.path.join(save_dir, "fusion.pt"))
116
+ print(f"[ckpt] saved step {step} -> {save_dir}")
117
+
118
+
119
+ def load_checkpoint(dlm: DiffusionLM, save_dir: str, fusion=None):
120
+ from peft import set_peft_model_state_dict
121
+ from safetensors.torch import load_file
122
+ sd = load_file(os.path.join(save_dir, "qwen_lora", "adapter_model.safetensors"))
123
+ set_peft_model_state_dict(dlm.lm, sd)
124
+ ex = torch.load(os.path.join(save_dir, "extras.pt"), map_location="cpu")
125
+ dlm.mask_embed.data.copy_(ex["mask_embed"].to(dlm.mask_embed.device))
126
+ missing = load_rev_gates(dlm.lm, ex["rev_gates"])
127
+ if missing:
128
+ print("[ckpt] WARNING rev_gate keys not matched:", missing[:3])
129
+ fp = os.path.join(save_dir, "fusion.pt")
130
+ if fusion is not None and os.path.exists(fp):
131
+ fusion.load_state_dict(torch.load(fp, map_location="cpu"))
132
+ return ex.get("step", 0)
133
+
134
+
135
+ @torch.no_grad()
136
+ def quick_sample(dlm, tok, prompt: str, eos_id: int, device, gen_len=128, steps=48, block_len=32, chat_kwargs=None):
137
+ was = dlm.training
138
+ dlm.eval()
139
+ text = tok.apply_chat_template([{"role": "user", "content": prompt}], tokenize=False,
140
+ add_generation_prompt=True, **(chat_kwargs or {"enable_thinking": False}))
141
+ p = torch.tensor([tok(text, add_special_tokens=False)["input_ids"]], device=device)
142
+ out = diffusion_generate(dlm, p, gen_len=gen_len, steps=steps, block_len=block_len, eos_id=eos_id)
143
+ ids = out[0].tolist()
144
+ if eos_id in ids:
145
+ ids = ids[:ids.index(eos_id)]
146
+ dlm.train(was)
147
+ return tok.decode(ids, skip_special_tokens=True)
148
+
149
+
150
+ def train_stage1(dlm: DiffusionLM, tok, ds: dict, cfg: Stage1Cfg, device="cuda", sample_prompts=None,
151
+ eos_token="<|im_end|>", resume: bool = False):
152
+ torch.manual_seed(cfg.seed)
153
+ eos_id = tok.convert_tokens_to_ids(eos_token)
154
+ opt = torch.optim.AdamW(_groups(dlm, cfg), weight_decay=cfg.weight_decay, betas=(0.9, 0.99))
155
+ start = 0
156
+ if resume and os.path.exists(os.path.join(cfg.save_dir, "extras.pt")):
157
+ start = load_checkpoint(dlm, cfg.save_dir)
158
+ print(f"[resume] from step {start}")
159
+ dlm.train()
160
+ it = batches(ds, cfg.batch_size, device, seed=cfg.seed + start)
161
+ t0, tok_count, ema = time.time(), 0, None
162
+ for step in range(start, cfg.max_steps):
163
+ for g in opt.param_groups:
164
+ g["lr"] = g["base"] * _lr_at(step, cfg)
165
+ tot = 0.0
166
+ for _ in range(cfg.grad_accum):
167
+ ids, resp, _plen = next(it)
168
+ loss, info = masked_diffusion_loss(dlm, ids, resp, t_min=cfg.t_min, weighting=cfg.weighting)
169
+ (loss / cfg.grad_accum).backward()
170
+ tot += float(loss.detach()) / cfg.grad_accum
171
+ tok_count += ids.numel()
172
+ gn = torch.nn.utils.clip_grad_norm_([p for g in opt.param_groups for p in g["params"]], cfg.clip)
173
+ opt.step(); opt.zero_grad(set_to_none=True)
174
+ ema = tot if ema is None else 0.9 * ema + 0.1 * tot
175
+ if (step + 1) % cfg.log_every == 0 or step == start:
176
+ dt = time.time() - t0
177
+ print(f"step {step+1}/{cfg.max_steps} loss {tot:.4f} (ema {ema:.4f}) gnorm {float(gn):.2f} "
178
+ f"lr {opt.param_groups[0]['lr']:.2e} | {tok_count/dt:,.0f} tok/s")
179
+ if sample_prompts and (step + 1) % cfg.sample_every == 0:
180
+ for sp in sample_prompts[:2]:
181
+ print(f" [sample] {sp!r} -> {quick_sample(dlm, tok, sp, eos_id, device)!r}")
182
+ if (step + 1) % cfg.save_every == 0 or step + 1 == cfg.max_steps:
183
+ save_checkpoint(dlm, tok, cfg.save_dir, step + 1)
184
+ return dlm
185
+
186
+
187
+ # ------------------------------------------------------------------------------------------
188
+ @dataclass
189
+ class Stage2Cfg:
190
+ max_steps: int = 200
191
+ batch_size: int = 2
192
+ grad_accum: int = 4
193
+ lr: float = 5e-4
194
+ gate_lr: float = 3e-3
195
+ warmup: int = 10
196
+ clip: float = 1.0
197
+ log_every: int = 5
198
+ save_every: int = 100
199
+ save_dir: str = "/content/diffusion_qwen_bart"
200
+ seed: int = 0
201
+
202
+
203
+ def train_stage2_fusion(dlm: DiffusionLM, tok, bart, btok, fusion, ds: dict, cfg: Stage2Cfg, device="cuda",
204
+ eos_token="<|im_end|>"):
205
+ """Only the fusion module is trained; Qwen (LoRA) and BART are frozen."""
206
+ from .bart_fusion import refine_loss
207
+ torch.manual_seed(cfg.seed)
208
+ eos_id = tok.convert_tokens_to_ids(eos_token)
209
+ for p in dlm.parameters():
210
+ p.requires_grad_(False)
211
+ for p in bart.parameters():
212
+ p.requires_grad_(False)
213
+ bart.eval()
214
+ fusion.train()
215
+ for p in fusion.parameters():
216
+ p.requires_grad_(True)
217
+ gate = [fusion.gate]
218
+ rest = [p for n, p in fusion.named_parameters() if n != "gate"]
219
+ opt = torch.optim.AdamW([{"params": rest, "lr": cfg.lr, "base": cfg.lr},
220
+ {"params": gate, "lr": cfg.gate_lr, "base": cfg.gate_lr}], weight_decay=0.0)
221
+ dlm.train()
222
+ it = batches(ds, cfg.batch_size, device, seed=cfg.seed)
223
+ t0 = time.time()
224
+ for step in range(cfg.max_steps):
225
+ sc = min(1.0, (step + 1) / cfg.warmup) * (0.5 * (1 + math.cos(math.pi * step / cfg.max_steps)) * 0.9 + 0.1)
226
+ for g in opt.param_groups:
227
+ g["lr"] = g["base"] * sc
228
+ tot = 0.0
229
+ for _ in range(cfg.grad_accum):
230
+ ids, resp, plen = next(it)
231
+ loss, info = refine_loss(dlm, fusion, bart, btok, tok, ids, resp, plen, eos_id, device)
232
+ (loss / cfg.grad_accum).backward()
233
+ tot += float(loss.detach()) / cfg.grad_accum
234
+ gn = torch.nn.utils.clip_grad_norm_(fusion.parameters(), cfg.clip)
235
+ opt.step(); opt.zero_grad(set_to_none=True)
236
+ if (step + 1) % cfg.log_every == 0 or step == 0:
237
+ print(f"[fusion] step {step+1}/{cfg.max_steps} loss {tot:.4f} gnorm {float(gn):.2f} "
238
+ f"gate {float(torch.tanh(fusion.gate)):+.4f} | {time.time()-t0:.0f}s")
239
+ if (step + 1) % cfg.save_every == 0 or step + 1 == cfg.max_steps:
240
+ os.makedirs(cfg.save_dir, exist_ok=True)
241
+ torch.save(fusion.state_dict(), os.path.join(cfg.save_dir, "fusion.pt"))
242
+ return fusion