| """ |
| Head-to-head benchmark: MDLM v3 vs AR Control Model. |
| |
| Compares: |
| 1. Perplexity (held-out, same data) |
| 2. Forward-pass throughput (TPS at batch 1/8/32) |
| 3. Generation speed (tokens/sec, latency) |
| 4. Text quality (oracle log-prob via Qwen3, repetition score) |
| 5. Sample outputs (side-by-side) |
| |
| Both models use the same tokenizer, same data, same parameter budget (~201M). |
| Only architecture differs: masked diffusion (bidirectional) vs autoregressive (causal). |
| """ |
| import json |
| import sys |
| import time |
| import math |
| import argparse |
| import numpy as np |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn.functional as F |
|
|
| REPO = Path(__file__).resolve().parent.parent |
| sys.path.insert(0, str(REPO / "src")) |
|
|
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" |
| CHECKPOINT_DIR = REPO / "checkpoints" |
| RESULTS_DIR = REPO / "results" |
| DATA_DIR = REPO / "data" |
|
|
| from mdlm_bpe_v3 import ( |
| MDLMConfig, MDLMBPEV3, BPETokenizer as MDLMTokenizer, |
| forward_mask_bpe, mdlm_loss, sample_semi_ar, |
| ) |
| from ar_control import ( |
| ARConfig, ARControlModel, BPETokenizer as ARTokenizer, |
| ar_loss, sample_ar, measure_perplexity as ar_measure_ppl, |
| ) |
|
|
|
|
| def load_mdlm(): |
| """Load trained MDLM v3.""" |
| tok = MDLMTokenizer() |
| config = MDLMConfig( |
| vocab_size=tok.vocab_size, |
| d_model=1024, n_heads=16, n_layers=10, max_seq_len=256, |
| ) |
| model = MDLMBPEV3(config, pad_id=tok.pad_id, mask_id=tok.mask_id).to(DEVICE) |
| ckpt = torch.load(CHECKPOINT_DIR / "mdlm_bpe_v3_best.pt", map_location=DEVICE, weights_only=False) |
| model.load_state_dict(ckpt["model_state"]) |
| model.eval() |
| return model, tok, ckpt |
|
|
|
|
| def load_ar(): |
| """Load trained AR Control.""" |
| tok = ARTokenizer() |
| config = ARConfig( |
| vocab_size=tok.vocab_size, |
| d_model=1024, n_heads=16, n_layers=15, max_seq_len=256, |
| ) |
| model = ARControlModel(config, pad_id=tok.pad_id).to(DEVICE) |
| ckpt = torch.load(CHECKPOINT_DIR / "ar_control_best.pt", map_location=DEVICE, weights_only=False) |
| model.load_state_dict(ckpt["model_state"]) |
| model.eval() |
| return model, tok, ckpt |
|
|
|
|
| |
| |
| |
|
|
| def eval_mdlm_perplexity(model, tokens, tokenizer, batch_size=32): |
| """MDLM perplexity via masked CE loss. |
| |
| MDLM's loss is computed only on masked positions. For fair comparison, |
| we use the same eval procedure as the original MDLM training: sample |
| random timesteps, mask tokens, compute CE on masked positions only. |
| """ |
| model.eval() |
| total_loss = 0.0 |
| total_masked = 0 |
| mask_id = tokenizer.mask_id |
|
|
| n = tokens.shape[0] |
| with torch.no_grad(): |
| for i in range(0, n, batch_size): |
| batch = tokens[i:i+batch_size].to(DEVICE) |
| bsz = batch.shape[0] |
| t = torch.rand(bsz, device=DEVICE) |
| masked, mask_pos = forward_mask_bpe(batch, t, mask_id) |
|
|
| with torch.amp.autocast('cuda', dtype=torch.bfloat16): |
| logits = model(masked, t) |
|
|
| mask_flat = mask_pos.reshape(-1) |
| if mask_flat.sum() == 0: |
| continue |
| logits_flat = logits.reshape(-1, logits.shape[-1]) |
| tokens_flat = batch.reshape(-1) |
|
|
| loss = F.cross_entropy( |
| logits_flat[mask_flat], tokens_flat[mask_flat], |
| reduction='sum', |
| ) |
| total_loss += loss.item() |
| total_masked += mask_flat.sum().item() |
|
|
| avg_loss = total_loss / max(total_masked, 1) |
| ppl = math.exp(min(avg_loss, 15)) |
| return avg_loss, ppl |
|
|
|
|
| |
| |
| |
|
|
| def benchmark_throughput_mdlm(model, vocab_size, seq_len=128, batches=[1, 8, 32]): |
| """Measure MDLM forward-pass TPS.""" |
| model.eval() |
| results = {} |
| mask_id = 1 |
|
|
| with torch.no_grad(): |
| for bs in batches: |
| tokens = torch.randint(0, vocab_size, (bs, seq_len), device=DEVICE) |
| t = torch.rand(bs, device=DEVICE) |
|
|
| |
| for _ in range(3): |
| with torch.amp.autocast('cuda', dtype=torch.bfloat16): |
| _ = model(tokens, t) |
| torch.cuda.synchronize() |
|
|
| n_iters = 20 |
| start = time.time() |
| for _ in range(n_iters): |
| with torch.amp.autocast('cuda', dtype=torch.bfloat16): |
| _ = model(tokens, t) |
| torch.cuda.synchronize() |
| elapsed = time.time() - start |
|
|
| tps = bs * seq_len * n_iters / elapsed |
| latency_ms = elapsed / n_iters * 1000 |
| results[f"batch_{bs}"] = {"tps": tps, "latency_ms": latency_ms, "steps": n_iters} |
|
|
| return results |
|
|
|
|
| def benchmark_throughput_ar(model, vocab_size, seq_len=128, batches=[1, 8, 32]): |
| """Measure AR forward-pass TPS (full sequence, teacher forcing).""" |
| model.eval() |
| results = {} |
|
|
| with torch.no_grad(): |
| for bs in batches: |
| tokens = torch.randint(0, vocab_size, (bs, seq_len), device=DEVICE) |
| inp = tokens[:, :-1] |
|
|
| |
| for _ in range(3): |
| with torch.amp.autocast('cuda', dtype=torch.bfloat16): |
| _ = model(inp) |
| torch.cuda.synchronize() |
|
|
| n_iters = 20 |
| start = time.time() |
| for _ in range(n_iters): |
| with torch.amp.autocast('cuda', dtype=torch.bfloat16): |
| _ = model(inp) |
| torch.cuda.synchronize() |
| elapsed = time.time() - start |
|
|
| tps = bs * (seq_len - 1) * n_iters / elapsed |
| latency_ms = elapsed / n_iters * 1000 |
| results[f"batch_{bs}"] = {"tps": tps, "latency_ms": latency_ms, "steps": n_iters} |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
| def benchmark_generation_mdlm(model, tokenizer, n_samples=5, seq_len=128): |
| """Measure MDLM generation speed (full-parallel + semi-AR).""" |
| model.eval() |
| results = {"full_parallel": [], "semi_ar": []} |
|
|
| prompts = [ |
| "Climate change is one of the biggest challenges", |
| "The future of artificial intelligence depends on", |
| "Education systems around the world need to", |
| " Renewable energy sources such as solar and wind", |
| "The economic impact of the pandemic has", |
| ] |
|
|
| for i in range(n_samples): |
| prompt = prompts[i % len(prompts)] |
| prompt_ids = tokenizer.encode(prompt, add_special=False) |
|
|
| |
| torch.cuda.synchronize() |
| start = time.time() |
| text = sample_semi_ar(model, tokenizer, prompt_ids=prompt_ids, |
| seq_len=seq_len, n_samples=1, |
| block_size=seq_len, temperature=0.7) |
| torch.cuda.synchronize() |
| elapsed = time.time() - start |
|
|
| n_tokens = len(tokenizer.encode(text[0], add_special=False)) |
| results["full_parallel"].append({ |
| "text": text[0][:200], |
| "tokens": n_tokens, |
| "time_s": elapsed, |
| "tps": n_tokens / max(elapsed, 0.001), |
| }) |
|
|
| |
| torch.cuda.synchronize() |
| start = time.time() |
| text = sample_semi_ar(model, tokenizer, prompt_ids=prompt_ids, |
| seq_len=seq_len, n_samples=1, |
| block_size=4, temperature=0.7) |
| torch.cuda.synchronize() |
| elapsed = time.time() - start |
|
|
| n_tokens = len(tokenizer.encode(text[0], add_special=False)) |
| results["semi_ar"].append({ |
| "text": text[0][:200], |
| "tokens": n_tokens, |
| "time_s": elapsed, |
| "tps": n_tokens / max(elapsed, 0.001), |
| }) |
|
|
| return results |
|
|
|
|
| def benchmark_generation_ar(model, tokenizer, n_samples=5, max_new_tokens=64): |
| """Measure AR generation speed (one token at a time with KV cache).""" |
| model.eval() |
| results = [] |
|
|
| prompts = [ |
| "Climate change is one of the biggest challenges", |
| "The future of artificial intelligence depends on", |
| "Education systems around the world need to", |
| " Renewable energy sources such as solar and wind", |
| "The economic impact of the pandemic has", |
| ] |
|
|
| for i in range(n_samples): |
| prompt = prompts[i % len(prompts)] |
| prompt_ids = tokenizer.encode(prompt, add_special=False) |
|
|
| torch.cuda.synchronize() |
| start = time.time() |
| text = sample_ar(model, tokenizer, prompt_ids=prompt_ids, |
| max_new_tokens=max_new_tokens, |
| temperature=0.7, top_p=0.95) |
| torch.cuda.synchronize() |
| elapsed = time.time() - start |
|
|
| n_tokens = len(tokenizer.encode(text, add_special=False)) |
| results.append({ |
| "text": text[:200], |
| "tokens": n_tokens, |
| "time_s": elapsed, |
| "tps": n_tokens / max(elapsed, 0.001), |
| }) |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
| def repetition_score(text): |
| """1.0 = no repetition, 0.0 = heavy repetition. |
| |
| Computes ratio of unique bigrams to total bigrams. |
| """ |
| words = text.split() |
| if len(words) < 2: |
| return 1.0 |
| bigrams = list(zip(words[:-1], words[1:])) |
| unique = len(set(bigrams)) |
| total = len(bigrams) |
| return unique / total if total > 0 else 1.0 |
|
|
|
|
| def distinct_n(text, n=1): |
| """Distinct-N metric: unique n-grams / total n-grams.""" |
| words = text.split() |
| if len(words) < n: |
| return 1.0 |
| ngrams = list(zip(*[words[i:] for i in range(n)])) |
| unique = len(set(ngrams)) |
| total = len(ngrams) |
| return unique / total if total > 0 else 1.0 |
|
|
|
|
| def compute_quality_metrics(text): |
| """Compute text quality metrics.""" |
| return { |
| "repetition_score": round(repetition_score(text), 3), |
| "distinct_1": round(distinct_n(text, 1), 3), |
| "distinct_2": round(distinct_n(text, 2), 3), |
| "n_words": len(text.split()), |
| "n_chars": len(text), |
| } |
|
|
|
|
| |
| |
| |
|
|
| def oracle_log_prob(text, qwen_model, qwen_tokenizer, device=DEVICE): |
| """Compute mean per-token log-probability under Qwen3-0.6B. |
| |
| Higher (closer to 0) = more coherent text. |
| """ |
| inputs = qwen_tokenizer(text, return_tensors="pt", truncation=True, |
| max_length=256).to(device) |
|
|
| with torch.no_grad(): |
| outputs = qwen_model(**inputs) |
| logits = outputs.logits[:, :-1, :] |
| target = inputs.input_ids[:, 1:] |
|
|
| log_probs = F.log_softmax(logits, dim=-1) |
| token_log_probs = log_probs.gather(2, target.unsqueeze(-1)).squeeze(-1) |
| return token_log_probs.mean().item() |
|
|
|
|
| |
| |
| |
|
|
| def run_benchmark(include_oracle=False): |
| print("=" * 70) |
| print("HEAD-TO-HEAD BENCHMARK: MDLM v3 vs AR CONTROL") |
| print("=" * 70) |
|
|
| |
| print("\nLoading MDLM v3...") |
| mdlm_model, mdlm_tok, mdlm_ckpt = load_mdlm() |
| mdlm_params = sum(p.numel() for p in mdlm_model.parameters()) |
| print(f" {mdlm_params:,} params ({mdlm_params/1e6:.1f}M)") |
|
|
| print("Loading AR Control...") |
| ar_model, ar_tok, ar_ckpt = load_ar() |
| ar_params = sum(p.numel() for p in ar_model.parameters()) |
| print(f" {ar_params:,} params ({ar_params/1e6:.1f}M)") |
|
|
| |
| print("\nLoading holdout data...") |
| tokens = np.load(DATA_DIR / "train_tokens_v3_128.npy", mmap_mode='r') |
| holdout = torch.from_numpy(np.array(tokens[:1000], dtype=np.int16)).long() |
| print(f" {len(holdout)} sequences for perplexity eval") |
|
|
| |
| print("\n--- 1. PERPLEXITY ---") |
| print(" MDLM (masked CE)...") |
| mdlm_loss_val, mdlm_ppl = eval_mdlm_perplexity(mdlm_model, holdout, mdlm_tok) |
| print(f" MDLM: loss={mdlm_loss_val:.4f} PPL={mdlm_ppl:.1f}") |
|
|
| print(" AR (next-token CE)...") |
| ar_loss_val, ar_ppl = ar_measure_ppl(ar_model, holdout) |
| print(f" AR: loss={ar_loss_val:.4f} PPL={ar_ppl:.1f}") |
|
|
| |
| print("\n--- 2. FORWARD-PASS THROUGHPUT ---") |
| print(" MDLM...") |
| mdlm_tps = benchmark_throughput_mdlm(mdlm_model, mdlm_tok.vocab_size) |
| for k, v in mdlm_tps.items(): |
| print(f" {k}: {v['tps']:,.0f} TPS ({v['latency_ms']:.1f}ms)") |
|
|
| print(" AR...") |
| ar_tps = benchmark_throughput_ar(ar_model, ar_tok.vocab_size) |
| for k, v in ar_tps.items(): |
| print(f" {k}: {v['tps']:,.0f} TPS ({v['latency_ms']:.1f}ms)") |
|
|
| |
| print("\n--- 3. GENERATION SPEED ---") |
| print(" MDLM (full-parallel + semi-AR)...") |
| mdlm_gen = benchmark_generation_mdlm(mdlm_model, mdlm_tok, n_samples=5) |
|
|
| fp_tps = [r["tps"] for r in mdlm_gen["full_parallel"]] |
| sar_tps = [r["tps"] for r in mdlm_gen["semi_ar"]] |
| print(f" Full-parallel: {np.mean(fp_tps):.1f} tok/s avg") |
| print(f" Semi-AR: {np.mean(sar_tps):.1f} tok/s avg") |
|
|
| print(" AR (sequential with KV cache)...") |
| ar_gen = benchmark_generation_ar(ar_model, ar_tok, n_samples=5) |
| ar_gen_tps = [r["tps"] for r in ar_gen] |
| print(f" AR: {np.mean(ar_gen_tps):.1f} tok/s avg") |
|
|
| |
| print("\n--- 4. TEXT QUALITY ---") |
| prompts = [ |
| "Climate change is one of the biggest challenges", |
| "The future of artificial intelligence depends on", |
| "Education systems around the world need to", |
| ] |
|
|
| mdlm_samples = [] |
| ar_samples = [] |
|
|
| for prompt in prompts: |
| prompt_ids = mdlm_tok.encode(prompt, add_special=False) |
|
|
| mdlm_text = sample_semi_ar(mdlm_model, mdlm_tok, prompt_ids=prompt_ids, |
| seq_len=128, n_samples=1, |
| block_size=128, temperature=0.7)[0] |
| ar_text = sample_ar(ar_model, ar_tok, prompt_ids=prompt_ids, |
| max_new_tokens=64, temperature=0.7, top_p=0.95) |
|
|
| mdlm_q = compute_quality_metrics(mdlm_text) |
| ar_q = compute_quality_metrics(ar_text) |
|
|
| mdlm_samples.append({"prompt": prompt, "text": mdlm_text[:300], "quality": mdlm_q}) |
| ar_samples.append({"prompt": prompt, "text": ar_text[:300], "quality": ar_q}) |
|
|
| print(f"\n Prompt: {prompt}") |
| print(f" MDLM: {mdlm_text[:150]}") |
| print(f" rep={mdlm_q['repetition_score']} d1={mdlm_q['distinct_1']} d2={mdlm_q['distinct_2']}") |
| print(f" AR: {ar_text[:150]}") |
| print(f" rep={ar_q['repetition_score']} d1={ar_q['distinct_1']} d2={ar_q['distinct_2']}") |
|
|
| |
| oracle_results = None |
| if include_oracle: |
| print("\n--- 5. ORACLE LOG-PROB (Qwen3-0.6B) ---") |
| try: |
| from transformers import AutoModelForCausalLM, AutoTokenizer as HFTokenizer |
| print(" Loading Qwen3-0.6B...") |
| qwen_tok = HFTokenizer.from_pretrained("Qwen/Qwen3-0.6B") |
| qwen_model = AutoModelForCausalLM.from_pretrained( |
| "Qwen/Qwen3-0.6B", torch_dtype=torch.bfloat16 |
| ).to(DEVICE).eval() |
|
|
| mdlm_oracle = [] |
| ar_oracle = [] |
|
|
| for s in mdlm_samples: |
| lp = oracle_log_prob(s["text"], qwen_model, qwen_tok) |
| mdlm_oracle.append(lp) |
| for s in ar_samples: |
| lp = oracle_log_prob(s["text"], qwen_model, qwen_tok) |
| ar_oracle.append(lp) |
|
|
| print(f" MDLM oracle LP: {np.mean(mdlm_oracle):.3f}") |
| print(f" AR oracle LP: {np.mean(ar_oracle):.3f}") |
| oracle_results = { |
| "mdlm": mdlm_oracle, "ar": ar_oracle, |
| "mdlm_mean": float(np.mean(mdlm_oracle)), |
| "ar_mean": float(np.mean(ar_oracle)), |
| } |
| except Exception as e: |
| print(f" Oracle skipped: {e}") |
|
|
| |
| results = { |
| "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), |
| "gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU", |
| "mdlm": { |
| "params": mdlm_params, |
| "config": mdlm_ckpt.get("config", {}), |
| "perplexity": mdlm_ppl, |
| "eval_loss": mdlm_loss_val, |
| "throughput": mdlm_tps, |
| "generation": { |
| "full_parallel_tps": float(np.mean(fp_tps)), |
| "semi_ar_tps": float(np.mean(sar_tps)), |
| }, |
| "samples": mdlm_samples, |
| }, |
| "ar": { |
| "params": ar_params, |
| "config": ar_ckpt.get("config", {}), |
| "perplexity": ar_ppl, |
| "eval_loss": ar_loss_val, |
| "throughput": ar_tps, |
| "generation": { |
| "tps": float(np.mean(ar_gen_tps)), |
| }, |
| "samples": ar_samples, |
| }, |
| "oracle": oracle_results, |
| } |
|
|
| |
| print("\n" + "=" * 70) |
| print("SUMMARY") |
| print("=" * 70) |
| print(f"{'Metric':<30} {'MDLM v3':>15} {'AR Control':>15}") |
| print("-" * 62) |
| print(f"{'Parameters':<30} {mdlm_params/1e6:>14.1f}M {ar_params/1e6:>14.1f}M") |
| print(f"{'Layers':<30} {'10 (+AdaLN)':>15} {'15':>15}") |
| print(f"{'Perplexity':<30} {mdlm_ppl:>15.1f} {ar_ppl:>15.1f}") |
| print(f"{'Eval loss':<30} {mdlm_loss_val:>15.4f} {ar_loss_val:>15.4f}") |
| for bs in [1, 8, 32]: |
| k = f"batch_{bs}" |
| m_tps = mdlm_tps[k]["tps"] |
| a_tps = ar_tps[k]["tps"] |
| speedup = m_tps / a_tps if a_tps > 0 else 0 |
| print(f"{'Fwd TPS (batch=' + str(bs) + ')':<30} {m_tps:>15,.0f} {a_tps:>15,.0f} ({speedup:.1f}x)") |
| print(f"{'Gen TPS (best mode)':<30} {max(np.mean(fp_tps), np.mean(sar_tps)):>15.1f} {np.mean(ar_gen_tps):>15.1f}") |
| if oracle_results: |
| print(f"{'Oracle log-prob':<30} {oracle_results['mdlm_mean']:>15.3f} {oracle_results['ar_mean']:>15.3f}") |
|
|
| |
| output_path = RESULTS_DIR / "comparison_benchmark.json" |
| with open(output_path, "w") as f: |
| json.dump(results, f, indent=2, default=str) |
| print(f"\nResults saved to {output_path}") |
|
|
| return results |
|
|
|
|
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--oracle", action="store_true", help="Include Qwen3 oracle scoring") |
| args = parser.parse_args() |
| run_benchmark(include_oracle=args.oracle) |
|
|