| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| import io |
| import json |
| import math |
| import os |
| import sys |
| import time |
| import traceback |
| from pathlib import Path |
| from typing import Any |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| from model_cpu_gpt2 import CPUGPT, CPUGPTConfig, get_config |
|
|
|
|
| def _load_ckpt(path: str, device: str = "cpu") -> dict: |
| if path.startswith("s3://"): |
| import boto3 |
|
|
| bucket, key = path[5:].split("/", 1) |
| buf = io.BytesIO() |
| print(f"[eval] Downloading {path} from S3 ...", flush=True) |
| boto3.client("s3").download_fileobj(bucket, key, buf) |
| buf.seek(0) |
| return torch.load(buf, map_location=device, weights_only=False) |
| return torch.load(path, map_location=device, weights_only=False) |
|
|
|
|
| def _build_model(config_name: str, ckpt_path: str, device: str) -> CPUGPT: |
| cfg = get_config(config_name) |
| cfg.seq_len = 65536 |
| model = CPUGPT(cfg) |
| if ckpt_path: |
| raw = _load_ckpt(ckpt_path, device="cpu") |
| state = raw.get("model", raw) |
| state = { |
| k.replace("module.", "").replace("_orig_mod.", ""): v |
| for k, v in state.items() |
| } |
| missing, unexpected = model.load_state_dict(state, strict=False) |
| if missing: |
| print(f" [warn] missing keys: {missing[:5]}", flush=True) |
| if unexpected: |
| print(f" [warn] unexpected keys: {unexpected[:5]}", flush=True) |
| print(f"[eval] Loaded {ckpt_path}", flush=True) |
| model = model.to(device) |
| model.eval() |
| total_params = sum(p.numel() for p in model.parameters()) |
| print(f"[eval] Model params: {total_params / 1e6:.1f}M", flush=True) |
| return model |
|
|
|
|
| def _tokenizer(): |
| import tiktoken |
|
|
| return tiktoken.get_encoding("r50k_base") |
|
|
|
|
| def _gpt2_baseline(device: str, size: str = "gpt2") -> tuple[Any, Any]: |
| from transformers import GPT2LMHeadModel, GPT2TokenizerFast |
|
|
| print(f"[eval] Loading GPT-2 baseline ({size}) ...", flush=True) |
| tok = GPT2TokenizerFast.from_pretrained(size) |
| mdl = GPT2LMHeadModel.from_pretrained(size).to(device).eval() |
| return mdl, tok |
|
|
|
|
| def eval_wikitext_bpb( |
| model: CPUGPT, device: str, seq_len: int = 1024, n_tokens: int = 5_000_000 |
| ) -> dict: |
| print("[eval] WikiText-103 BPB ...", flush=True) |
| import tiktoken |
| from datasets import load_dataset |
|
|
| enc = tiktoken.get_encoding("r50k_base") |
|
|
| ds = load_dataset("wikitext", "wikitext-103-raw-v1", split="test", streaming=True) |
| buf: list[int] = [] |
| for item in ds: |
| t = item.get("text", "") |
| if t.strip(): |
| buf.extend(enc.encode_ordinary(t)) |
| if len(buf) >= n_tokens: |
| break |
| buf = buf[:n_tokens] |
| print(f" WikiText-103 tokens: {len(buf):,}", flush=True) |
|
|
| tokens = torch.tensor(buf, dtype=torch.long) |
| total_loss = 0.0 |
| total_toks = 0 |
|
|
| with torch.no_grad(): |
| for i in range(0, len(tokens) - seq_len, seq_len): |
| chunk = tokens[i : i + seq_len + 1].to(device) |
| x, y = chunk[:-1].unsqueeze(0), chunk[1:].unsqueeze(0) |
| loss = model(x, y) |
| total_loss += loss.item() * seq_len |
| total_toks += seq_len |
|
|
| nats_per_token = total_loss / total_toks |
| bpb = nats_per_token / math.log(2) |
| perplexity = math.exp(nats_per_token) |
| print( |
| f" BPB={bpb:.4f} PPL={perplexity:.2f} loss={nats_per_token:.4f}", flush=True |
| ) |
| return { |
| "bpb": bpb, |
| "perplexity": perplexity, |
| "loss_nats": nats_per_token, |
| "tokens": total_toks, |
| "seq_len": seq_len, |
| } |
|
|
|
|
| def eval_wikitext_bpb_gpt2( |
| model: Any, |
| tokenizer: Any, |
| device: str, |
| seq_len: int = 1024, |
| n_tokens: int = 5_000_000, |
| ) -> dict: |
| from datasets import load_dataset |
|
|
| ds = load_dataset("wikitext", "wikitext-103-raw-v1", split="test", streaming=True) |
| buf: list[int] = [] |
| for item in ds: |
| t = item.get("text", "") |
| if t.strip(): |
| buf.extend(tokenizer.encode(t)) |
| if len(buf) >= n_tokens: |
| break |
| buf = buf[:n_tokens] |
|
|
| tokens = torch.tensor(buf, dtype=torch.long) |
| total_loss = 0.0 |
| total_toks = 0 |
| with torch.no_grad(): |
| for i in range(0, len(tokens) - seq_len, seq_len): |
| chunk = tokens[i : i + seq_len + 1].to(device) |
| x, y = chunk[:-1].unsqueeze(0), chunk[1:].unsqueeze(0) |
| out = model(x, labels=y) |
| total_loss += out.loss.item() * seq_len |
| total_toks += seq_len |
|
|
| nats = total_loss / total_toks |
| return { |
| "bpb": nats / math.log(2), |
| "perplexity": math.exp(nats), |
| "loss_nats": nats, |
| "tokens": total_toks, |
| } |
|
|
|
|
| class OurModelLM: |
| def __init__(self, model: CPUGPT, device: str): |
| import tiktoken |
|
|
| self.model = model |
| self.device = device |
| self._enc = tiktoken.get_encoding("r50k_base") |
| self._vocab = 50257 |
| self._eot = self._enc.eot_token |
|
|
| @property |
| def eot_token_id(self) -> int: |
| return self._eot |
|
|
| @property |
| def max_length(self) -> int: |
| return 1024 |
|
|
| @property |
| def max_gen_toks(self) -> int: |
| return 256 |
|
|
| @property |
| def batch_size(self) -> int: |
| return 1 |
|
|
| @property |
| def device(self): |
| return self._device |
|
|
| @device.setter |
| def device(self, v): |
| self._device = v |
|
|
| def tok_encode(self, text: str) -> list[int]: |
| return self._enc.encode_ordinary(text) |
|
|
| def tok_decode(self, tokens: list[int]) -> str: |
| return self._enc.decode(tokens) |
|
|
| def _model_call(self, inps: torch.Tensor) -> torch.Tensor: |
| with torch.no_grad(): |
| logits = self.model(inps.to(self._device)) |
| return logits.float() |
|
|
| def _model_generate( |
| self, context: torch.Tensor, max_length: int, eos_token_id: int |
| ) -> torch.Tensor: |
| return _greedy_generate( |
| self.model, context.to(self._device), max_length, eos_token_id, self._device |
| ) |
|
|
| def loglikelihood(self, requests): |
| results = [] |
| for ctx, cont in requests: |
| ctx_ids = self.tok_encode(ctx) |
| cont_ids = self.tok_encode(cont) |
| all_ids = ctx_ids + cont_ids |
| if len(all_ids) > self.max_length: |
| all_ids = all_ids[-self.max_length :] |
| inp = torch.tensor([all_ids], dtype=torch.long).to(self._device) |
| logits = self._model_call(inp) |
| log_probs = F.log_softmax(logits, dim=-1) |
| cont_start = len(ctx_ids) - max(0, len(all_ids) - self.max_length) |
| ll = 0.0 |
| for j, tok in enumerate(cont_ids): |
| pos = cont_start + j - 1 |
| if 0 <= pos < log_probs.size(1): |
| ll += log_probs[0, pos, tok].item() |
| is_greedy = all( |
| logits[0, cont_start + j - 1].argmax().item() == cont_ids[j] |
| for j in range(len(cont_ids)) |
| if 0 <= cont_start + j - 1 < logits.size(1) |
| ) |
| results.append((ll, is_greedy)) |
| return results |
|
|
| def loglikelihood_rolling(self, requests): |
| return [self.loglikelihood([("", t)])[0] for t in requests] |
|
|
| def generate_until(self, requests): |
| out = [] |
| for ctx, until in requests: |
| ids = self.tok_encode(ctx) |
| inp = torch.tensor([ids], dtype=torch.long).to(self._device) |
| gen = _greedy_generate( |
| self.model, inp, self.max_length, self._eot, self._device |
| ) |
| new_ids = gen[0, len(ids) :].tolist() |
| text = self.tok_decode(new_ids) |
| for stop in until if isinstance(until, list) else [until]: |
| if stop in text: |
| text = text[: text.index(stop)] |
| break |
| out.append(text) |
| return out |
|
|
|
|
| def _greedy_generate( |
| model: CPUGPT, inp: torch.Tensor, max_length: int, eos_id: int, device: str |
| ) -> torch.Tensor: |
| cur = inp |
| with torch.no_grad(): |
| for _ in range(max_length - cur.size(1)): |
| logits = model(cur) |
| next_tok = logits[:, -1, :].argmax(dim=-1, keepdim=True) |
| cur = torch.cat([cur, next_tok], dim=1) |
| if next_tok.item() == eos_id: |
| break |
| return cur |
|
|
|
|
| def run_lm_eval( |
| model: CPUGPT, device: str, tasks: list[str], limit: int | None = 500 |
| ) -> dict: |
| print(f"[eval] lm-eval tasks: {tasks} (limit={limit}) ...", flush=True) |
| try: |
| import lm_eval |
| from lm_eval import evaluator |
| from lm_eval import tasks as lm_tasks |
| except ImportError: |
| print(" lm-eval not installed — skipping", flush=True) |
| return {} |
|
|
| lm = OurModelLM(model, device) |
| results = {} |
| for task in tasks: |
| try: |
| res = evaluator.simple_evaluate( |
| model=lm, |
| tasks=[task], |
| num_fewshot=0, |
| limit=limit, |
| bootstrap_iters=100, |
| ) |
| results[task] = res["results"].get(task, {}) |
| acc = results[task].get("acc,none", results[task].get("acc", "?")) |
| print(f" {task}: acc={acc}", flush=True) |
| except Exception as e: |
| print(f" {task}: ERROR — {e}", flush=True) |
| results[task] = {"error": str(e)} |
| return results |
|
|
|
|
| def profile_gmacs(model: CPUGPT, device: str, seq_lens: list[int]) -> list[dict]: |
| print("[eval] GMACs profiling ...", flush=True) |
| rows = [] |
| cfg = model.cfg if hasattr(model, "cfg") else None |
|
|
| total_params = sum(p.numel() for p in model.parameters()) |
|
|
| for T in seq_lens: |
| if cfg and hasattr(cfg, "gla_chunk") and T % cfg.gla_chunk != 0: |
| continue |
| x = torch.randint(0, 50257, (1, T), device=device) |
| try: |
| with torch.no_grad(): |
| with torch.profiler.profile( |
| activities=[torch.profiler.ProfilerActivity.CPU] |
| + ( |
| [torch.profiler.ProfilerActivity.CUDA] |
| if device != "cpu" |
| else [] |
| ), |
| with_flops=True, |
| record_shapes=True, |
| ) as prof: |
| _ = model(x) |
|
|
| total_flops = sum(e.flops for e in prof.key_averages() if e.flops > 0) |
| gmacs = total_flops / 2 / 1e9 |
| rows.append( |
| { |
| "seq_len": T, |
| "gmacs": gmacs, |
| "params_m": total_params / 1e6, |
| "model": "FNO+GLA", |
| } |
| ) |
| print(f" seq_len={T}: {gmacs:.2f} GMACs", flush=True) |
| except Exception as e: |
| print(f" seq_len={T}: profiling error — {e}", flush=True) |
| rows.append( |
| {"seq_len": T, "gmacs": None, "error": str(e), "model": "FNO+GLA"} |
| ) |
|
|
| d_model = cfg.n_embd if cfg else 2048 |
| n_head = cfg.n_head if cfg else 16 |
| n_layer = cfg.n_layer if cfg else 24 |
| d_head = d_model // n_head |
| for T in seq_lens: |
| qkv_macs = 3 * T * d_model * d_model |
| attn_macs = T * T * d_head * n_head |
| out_macs = T * d_model * d_model |
| ffn_macs = 2 * T * d_model * (d_model * 4) |
| per_layer = qkv_macs + attn_macs + out_macs + ffn_macs |
| total_macs = per_layer * n_layer / 1e9 |
| rows.append({"seq_len": T, "gmacs": total_macs, "model": "SDPA (theoretical)"}) |
|
|
| return rows |
|
|
|
|
| def vram_benchmark(model: CPUGPT, device: str, seq_lens: list[int]) -> list[dict]: |
| if device == "cpu": |
| print("[eval] VRAM benchmark skipped (CPU)", flush=True) |
| return [] |
|
|
| print("[eval] VRAM benchmark ...", flush=True) |
| rows = [] |
| cfg = model.cfg if hasattr(model, "cfg") else None |
| chunk = cfg.gla_chunk if cfg else 256 |
|
|
| for T in seq_lens: |
| if T % chunk != 0: |
| continue |
| try: |
| x = torch.randint(0, 50257, (1, T), device=device) |
| torch.cuda.reset_peak_memory_stats(device) |
| torch.cuda.synchronize() |
| with torch.no_grad(): |
| _ = model(x) |
| torch.cuda.synchronize() |
| vram_mb = torch.cuda.max_memory_allocated(device) / 1024**2 |
| rows.append( |
| {"seq_len": T, "vram_mb": vram_mb, "oom": 0, "model": "FNO+GLA"} |
| ) |
| print(f" FNO+GLA seq_len={T}: {vram_mb:.0f} MB", flush=True) |
| except torch.cuda.OutOfMemoryError: |
| rows.append({"seq_len": T, "vram_mb": None, "oom": 1, "model": "FNO+GLA"}) |
| print(f" FNO+GLA seq_len={T}: OOM", flush=True) |
| finally: |
| torch.cuda.empty_cache() |
|
|
| try: |
| d = cfg.n_embd if cfg else 2048 |
| h = cfg.n_head if cfg else 16 |
| attn = nn.MultiheadAttention(d, h, batch_first=True).to(device).eval() |
| x = torch.randn(1, T, d, device=device) |
| torch.cuda.reset_peak_memory_stats(device) |
| torch.cuda.synchronize() |
| with torch.no_grad(): |
| _ = attn(x, x, x, need_weights=False) |
| torch.cuda.synchronize() |
| vram_mb_sdpa = torch.cuda.max_memory_allocated(device) / 1024**2 |
| rows.append( |
| { |
| "seq_len": T, |
| "vram_mb": vram_mb_sdpa, |
| "oom": 0, |
| "model": "Standard Attention (SDPA)", |
| } |
| ) |
| print(f" SDPA seq_len={T}: {vram_mb_sdpa:.0f} MB", flush=True) |
| del attn, x |
| except (torch.cuda.OutOfMemoryError, RuntimeError): |
| rows.append( |
| { |
| "seq_len": T, |
| "vram_mb": None, |
| "oom": 1, |
| "model": "Standard Attention (SDPA)", |
| } |
| ) |
| print(f" SDPA seq_len={T}: OOM", flush=True) |
| finally: |
| torch.cuda.empty_cache() |
|
|
| return rows |
|
|
|
|
| def throughput_benchmark( |
| model: CPUGPT, |
| device: str, |
| seq_lens: list[int], |
| n_warmup: int = 3, |
| n_steps: int = 10, |
| ) -> list[dict]: |
| print("[eval] Throughput benchmark ...", flush=True) |
| rows = [] |
| cfg = model.cfg if hasattr(model, "cfg") else None |
| chunk = cfg.gla_chunk if cfg else 256 |
|
|
| for T in seq_lens: |
| if T % chunk != 0: |
| continue |
| try: |
| x = torch.randint(0, 50257, (1, T), device=device) |
| for _ in range(n_warmup): |
| with torch.no_grad(): |
| _ = model(x) |
| if device != "cpu": |
| torch.cuda.synchronize() |
| t0 = time.perf_counter() |
| for _ in range(n_steps): |
| with torch.no_grad(): |
| _ = model(x) |
| if device != "cpu": |
| torch.cuda.synchronize() |
| elapsed = time.perf_counter() - t0 |
| tps = T * n_steps / elapsed |
| rows.append({"seq_len": T, "tok_per_sec": tps, "model": "FNO+GLA"}) |
| print(f" seq_len={T}: {tps:,.0f} tok/s", flush=True) |
| except Exception as e: |
| print(f" seq_len={T}: error — {e}", flush=True) |
| return rows |
|
|
|
|
| PROMPTS = [ |
| "The universe is approximately 13.8 billion years old. Scientists believe", |
| "Once upon a time in a small village near the mountains, there lived", |
| "The key difference between machine learning and traditional programming is", |
| "To make a perfect omelette, you will need the following ingredients:", |
| "The French Revolution began in 1789 when", |
| "In quantum mechanics, the uncertainty principle states that", |
| "The stock market crashed in 1929 because", |
| ] |
|
|
|
|
| def generate_samples( |
| model: CPUGPT, |
| device: str, |
| gpt2_model: Any, |
| gpt2_tokenizer: Any, |
| max_new_tokens: int = 150, |
| temperature: float = 0.8, |
| top_p: float = 0.9, |
| ) -> list[dict]: |
| import tiktoken |
|
|
| enc = tiktoken.get_encoding("r50k_base") |
|
|
| def _sample_our(prompt: str) -> str: |
| ids = enc.encode_ordinary(prompt) |
| inp = torch.tensor([ids], dtype=torch.long, device=device) |
| generated = list(ids) |
| with torch.no_grad(): |
| for _ in range(max_new_tokens): |
| logits = model(inp)[:, -1, :] |
| logits = logits / temperature |
| probs = F.softmax(logits, dim=-1) |
| sorted_probs, sorted_idx = torch.sort(probs, descending=True) |
| cumsum = sorted_probs.cumsum(dim=-1) |
| mask = (cumsum - sorted_probs) > top_p |
| sorted_probs[mask] = 0 |
| sorted_probs /= sorted_probs.sum() |
| next_tok = sorted_idx[torch.multinomial(sorted_probs, 1)].item() |
| if next_tok == enc.eot_token: |
| break |
| generated.append(next_tok) |
| inp = torch.cat([inp, torch.tensor([[next_tok]], device=device)], dim=1) |
| return enc.decode(generated[len(ids) :]) |
|
|
| def _sample_gpt2(prompt: str) -> str: |
| ids = gpt2_tokenizer.encode(prompt, return_tensors="pt").to(device) |
| with torch.no_grad(): |
| out = gpt2_model.generate( |
| ids, |
| max_new_tokens=max_new_tokens, |
| do_sample=True, |
| temperature=temperature, |
| top_p=top_p, |
| pad_token_id=gpt2_tokenizer.eos_token_id, |
| ) |
| return gpt2_tokenizer.decode(out[0, ids.size(1) :], skip_special_tokens=True) |
|
|
| results = [] |
| for prompt in PROMPTS: |
| print(f" Generating: '{prompt[:40]}...'", flush=True) |
| our_text = _sample_our(prompt) |
| gpt2_text = _sample_gpt2(prompt) |
| results.append({"prompt": prompt, "ours": our_text, "gpt2": gpt2_text}) |
| return results |
|
|
|
|
| def _write_samples_md(samples: list[dict], path: Path) -> None: |
| lines = ["# Qualitative Sample Comparison: FNO+GLA vs GPT-2\n"] |
| for i, s in enumerate(samples, 1): |
| lines.append(f"## Prompt {i}\n") |
| lines.append(f"**Prompt:** {s['prompt']}\n") |
| lines.append(f"**FNO+GLA (ours):**\n{s['ours']}\n") |
| lines.append(f"**GPT-2 1.5B:**\n{s['gpt2']}\n") |
| lines.append("---\n") |
| path.write_text("\n".join(lines)) |
|
|
|
|
| def parse_training_log(log_path: str) -> list[dict]: |
| rows = [] |
| try: |
| with open(log_path) as f: |
| for line in f: |
| if "step=" not in line: |
| continue |
| try: |
| parts = dict(p.split("=") for p in line.split() if "=" in p) |
| step_str = parts.get("step", "") |
| if "/" in step_str: |
| step = int(step_str.split("/")[0]) |
| else: |
| step = int(step_str) |
| loss = float(parts.get("loss", 0)) |
| tps = float(parts.get("tok/s", "0").replace(",", "")) |
| rows.append( |
| { |
| "step": step, |
| "loss": loss, |
| "tok_per_sec": tps, |
| "bpb": loss / math.log(2), |
| } |
| ) |
| except Exception: |
| pass |
| except FileNotFoundError: |
| print(f" [warn] training log not found: {log_path}", flush=True) |
| return rows |
|
|
|
|
| def make_figures(results: dict, output_dir: Path) -> None: |
| figs_dir = output_dir / "figures" |
| figs_dir.mkdir(parents=True, exist_ok=True) |
|
|
| try: |
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import numpy as np |
| except ImportError: |
| print("[eval] matplotlib not available — skipping figures", flush=True) |
| return |
|
|
| plt.rcParams.update({"font.size": 12, "figure.dpi": 150}) |
|
|
| vram = results.get("vram_benchmark", []) |
| if vram: |
| fig, ax = plt.subplots(figsize=(7, 4.5)) |
| ours = [ |
| (r["seq_len"], r["vram_mb"]) |
| for r in vram |
| if "FNO" in r["model"] and r.get("vram_mb") |
| ] |
| sdpa = [ |
| (r["seq_len"], r["vram_mb"]) |
| for r in vram |
| if "SDPA" in r["model"] and r.get("vram_mb") |
| ] |
| oom_s = [r["seq_len"] for r in vram if "SDPA" in r["model"] and r.get("oom")] |
| if ours: |
| xs, ys = zip(*sorted(ours)) |
| ax.plot(xs, ys, "b-o", label="FNO+GLA (ours, O(N))", linewidth=2) |
| if sdpa: |
| xs, ys = zip(*sorted(sdpa)) |
| ax.plot(xs, ys, "r--s", label="Standard Attn (O(N²))", linewidth=2) |
| for x in oom_s: |
| ax.axvline(x, color="red", alpha=0.3, linestyle=":") |
| if oom_s: |
| ax.annotate( |
| "SDPA OOM →", |
| xy=(oom_s[0], ax.get_ylim()[1] * 0.9), |
| color="red", |
| fontsize=10, |
| ) |
| ax.set_xlabel("Sequence Length") |
| ax.set_ylabel("Peak VRAM (MB)") |
| ax.set_title("Fig 1 — Memory Scaling: FNO+GLA vs Standard Attention") |
| ax.legend() |
| ax.set_xscale("log", base=2) |
| fig.tight_layout() |
| fig.savefig(figs_dir / "fig1_vram_scaling.png") |
| plt.close(fig) |
| print(" fig1_vram_scaling.png saved", flush=True) |
|
|
| gmacs = results.get("gmacs_profile", []) |
| if gmacs: |
| fig, ax = plt.subplots(figsize=(7, 4.5)) |
| ours = [ |
| (r["seq_len"], r["gmacs"]) |
| for r in gmacs |
| if r["model"] == "FNO+GLA" and r.get("gmacs") |
| ] |
| sdpa = [ |
| (r["seq_len"], r["gmacs"]) |
| for r in gmacs |
| if "SDPA" in r["model"] and r.get("gmacs") |
| ] |
| if ours: |
| xs, ys = zip(*sorted(ours)) |
| ax.plot(xs, ys, "b-o", label="FNO+GLA (ours)", linewidth=2) |
| if sdpa: |
| xs, ys = zip(*sorted(sdpa)) |
| ax.plot(xs, ys, "r--s", label="SDPA (theoretical)", linewidth=2) |
| ax.set_xlabel("Sequence Length") |
| ax.set_ylabel("GMACs") |
| ax.set_title("Fig 2 — Compute: FNO+GLA vs SDPA") |
| ax.legend() |
| ax.set_xscale("log", base=2) |
| fig.tight_layout() |
| fig.savefig(figs_dir / "fig2_gmacs_scaling.png") |
| plt.close(fig) |
| print(" fig2_gmacs_scaling.png saved", flush=True) |
|
|
| lm_res = results.get("lm_eval", {}) |
| wt = results.get("wikitext", {}) |
| if lm_res or wt: |
| tasks_show = [ |
| "arc_easy", |
| "arc_challenge", |
| "hellaswag", |
| "piqa", |
| "winogrande", |
| "lambada_openai", |
| "boolq", |
| ] |
| ours_acc = [] |
| task_names = [] |
| for t in tasks_show: |
| if t in lm_res: |
| a = lm_res[t].get("acc,none", lm_res[t].get("acc")) |
| if a is not None: |
| ours_acc.append(float(a) * 100) |
| task_names.append(t.replace("_openai", "").replace("_", "\n")) |
|
|
| if task_names: |
| fig, ax = plt.subplots(figsize=(9, 5)) |
| x = np.arange(len(task_names)) |
| bars = ax.bar( |
| x, ours_acc, 0.5, label="FNO+GLA 1.13B (ours)", color="#2196F3" |
| ) |
| gpt2_124m = { |
| "arc_easy": 44.5, |
| "arc_challenge": 22.3, |
| "hellaswag": 31.6, |
| "piqa": 64.6, |
| "winogrande": 51.7, |
| "lambada\nopenai": 35.6, |
| "boolq": 58.5, |
| } |
| ref_acc = [gpt2_124m.get(n.replace("\n", "\n"), 0) for n in task_names] |
| ax.bar( |
| x + 0.25, |
| ref_acc, |
| 0.25, |
| label="GPT-2 124M (published)", |
| color="#FF9800", |
| alpha=0.7, |
| ) |
| ax.set_xticks(x + 0.125) |
| ax.set_xticklabels(task_names, fontsize=10) |
| ax.set_ylabel("Accuracy (%)") |
| ax.set_title("Fig 3 — Zero-Shot Benchmarks: FNO+GLA vs GPT-2") |
| ax.legend() |
| ax.set_ylim(0, 100) |
| for bar in bars: |
| ax.text( |
| bar.get_x() + bar.get_width() / 2, |
| bar.get_height() + 0.5, |
| f"{bar.get_height():.1f}", |
| ha="center", |
| fontsize=9, |
| ) |
| fig.tight_layout() |
| fig.savefig(figs_dir / "fig3_benchmark_bars.png") |
| plt.close(fig) |
| print(" fig3_benchmark_bars.png saved", flush=True) |
|
|
| curve = results.get("training_curve", []) |
| if curve: |
| fig, ax1 = plt.subplots(figsize=(8, 4.5)) |
| steps = [r["step"] for r in curve] |
| losses = [r["loss"] for r in curve] |
| bpbs = [r["bpb"] for r in curve] |
| ax1.plot(steps, losses, "b-", linewidth=1.5, alpha=0.8, label="Training loss") |
| ax1.set_xlabel("Training Step") |
| ax1.set_ylabel("Loss (nats)", color="blue") |
| ax1.tick_params(axis="y", labelcolor="blue") |
| ax2 = ax1.twinx() |
| ax2.plot(steps, bpbs, "r-", linewidth=1, alpha=0.5, label="BPB") |
| ax2.set_ylabel("BPB", color="red") |
| ax2.tick_params(axis="y", labelcolor="red") |
| if bpbs: |
| ax2.annotate( |
| f"Final BPB: {bpbs[-1]:.3f}", |
| xy=(steps[-1], bpbs[-1]), |
| xytext=(steps[-1] * 0.7, bpbs[-1] * 1.05), |
| arrowprops=dict(arrowstyle="->"), |
| ) |
| ax1.set_title("Fig 4 — Training Curve (FNO+GLA 1.13B, 5B tokens, 8×H200)") |
| lines1, labels1 = ax1.get_legend_handles_labels() |
| lines2, labels2 = ax2.get_legend_handles_labels() |
| ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper right") |
| fig.tight_layout() |
| fig.savefig(figs_dir / "fig4_training_curve.png") |
| plt.close(fig) |
| print(" fig4_training_curve.png saved", flush=True) |
|
|
| wt_our = results.get("wikitext_ours", {}) |
| wt_gpt2s = results.get("wikitext_gpt2_small", {}) |
| wt_gpt2l = results.get("wikitext_gpt2_large", {}) |
| if wt_our: |
| fig, ax = plt.subplots(figsize=(7, 4)) |
| models = ["GPT-2 124M", "GPT-2 1.5B", "FNO+GLA 1.13B\n(ours, 5B tokens)"] |
| bpbs_list = [ |
| wt_gpt2s.get("bpb", 4.87), |
| wt_gpt2l.get("bpb", 3.92), |
| wt_our.get("bpb", 0), |
| ] |
| colors = ["#FF9800", "#FF5722", "#2196F3"] |
| bars = ax.barh(models, bpbs_list, color=colors, edgecolor="white") |
| ax.set_xlabel("WikiText-103 BPB (lower = better)") |
| ax.set_title("Fig 5 — WikiText-103 Bits-per-Byte Comparison") |
| for bar, bpb in zip(bars, bpbs_list): |
| ax.text( |
| bpb + 0.02, |
| bar.get_y() + bar.get_height() / 2, |
| f"{bpb:.3f}", |
| va="center", |
| fontsize=11, |
| ) |
| fig.tight_layout() |
| fig.savefig(figs_dir / "fig5_wikitext_bpb.png") |
| plt.close(fig) |
| print(" fig5_wikitext_bpb.png saved", flush=True) |
|
|
| tput = results.get("throughput", []) |
| if tput: |
| fig, ax = plt.subplots(figsize=(7, 4.5)) |
| xs = [r["seq_len"] for r in tput] |
| ys = [r["tok_per_sec"] for r in tput] |
| ax.plot(xs, ys, "b-o", linewidth=2) |
| ax.set_xlabel("Sequence Length") |
| ax.set_ylabel("Throughput (tokens/sec)") |
| ax.set_title("Fig 6 — Inference Throughput vs Sequence Length (A10G)") |
| ax.set_xscale("log", base=2) |
| fig.tight_layout() |
| fig.savefig(figs_dir / "fig6_throughput.png") |
| plt.close(fig) |
| print(" fig6_throughput.png saved", flush=True) |
|
|
| print("[eval] All figures saved to", figs_dir, flush=True) |
|
|
|
|
| MODEL_CARD = """\ |
| --- |
| license: apache-2.0 |
| language: |
| - en |
| tags: |
| - causal-lm |
| - fno |
| - gated-linear-attention |
| - efficient-transformers |
| - long-context |
| datasets: |
| - Skylion007/openwebtext |
| --- |
| |
| # FNO+GLA: Fourier Neural Operator + Gated Linear Attention LLM |
| |
| **Architecture**: FNO sequence mixer (O(N log N)) + GLA recurrent mixer (O(N)) — |
| no quadratic attention, runs 32K context on a single A10G GPU. |
| |
| **Model size**: 1.13B parameters |
| **Training**: 5B tokens (OpenWebText), 8×H200 SXM, seq_len=32,768 |
| **Pattern**: SSSL (3 FNO + 1 GLA per 4-layer group) |
| |
| ## Results |
| |
| | Benchmark | FNO+GLA 1.13B | GPT-2 1.5B | |
| |----------------|---------------|------------| |
| | WikiText-103 BPB | see eval | ~3.92 | |
| | ARC-Easy | see eval | ~50.4 | |
| | HellaSwag | see eval | ~41.4 | |
| | PIQA | see eval | ~70.8 | |
| |
| ## Memory Efficiency |
| |
| FNO+GLA uses O(N log N) memory for the FNO path and O(N) for GLA, |
| vs O(N²) for standard attention. Fits 32K context in 24GB VRAM where |
| standard attention OOMs at ~16K. |
| |
| ## Usage |
| |
| ```python |
| # Load weights and run inference — see scripts/paper_eval.py |
| ``` |
| |
| ## Citation |
| ``` |
| @misc{fela-acml2026, |
| title={FNO+GLA: Efficient Long-Context Language Modeling}, |
| year={2026} |
| } |
| ``` |
| """ |
|
|
|
|
| def push_to_hf( |
| ckpt_path: str, |
| results: dict, |
| output_dir: Path, |
| hf_repo: str, |
| hf_token: str | None = None, |
| ) -> None: |
| print(f"[eval] Pushing to HuggingFace: {hf_repo} ...", flush=True) |
| try: |
| from huggingface_hub import HfApi |
|
|
| api = HfApi(token=hf_token) |
|
|
| readme_path = output_dir / "README.md" |
| readme_path.write_text(MODEL_CARD) |
|
|
| results_path = output_dir / "paper_eval.json" |
| results_path.write_text(json.dumps(results, indent=2, default=str)) |
|
|
| for f in output_dir.rglob("*"): |
| if not f.is_file(): |
| continue |
| if f.suffix in (".pt",): |
| continue |
| repo_path = str(f.relative_to(output_dir)) |
| try: |
| api.upload_file( |
| path_or_fileobj=str(f), |
| path_in_repo=f"results/{repo_path}", |
| repo_id=hf_repo, |
| repo_type="model", |
| ) |
| print(f" uploaded: results/{repo_path}", flush=True) |
| except Exception as e: |
| print(f" WARN: {repo_path} failed: {e}", flush=True) |
|
|
| api.upload_file( |
| path_or_fileobj=str(readme_path), |
| path_in_repo="README.md", |
| repo_id=hf_repo, |
| repo_type="model", |
| ) |
|
|
| if ckpt_path and not ckpt_path.startswith("s3://"): |
| ckpt_file = Path(ckpt_path) |
| if ckpt_file.exists(): |
| print( |
| f" uploading checkpoint ({ckpt_file.stat().st_size / 1e9:.1f}GB)...", |
| flush=True, |
| ) |
| api.upload_file( |
| path_or_fileobj=str(ckpt_file), |
| path_in_repo=f"checkpoints/{ckpt_file.name}", |
| repo_id=hf_repo, |
| repo_type="model", |
| ) |
|
|
| print( |
| f"[eval] HuggingFace push complete: https://huggingface.co/{hf_repo}", |
| flush=True, |
| ) |
| except Exception as e: |
| print(f"[eval] HuggingFace push failed: {e}", flush=True) |
| traceback.print_exc() |
|
|
|
|
| def main() -> None: |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--ckpt", required=True) |
| ap.add_argument("--config", default="gpt2-1b") |
| ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") |
| ap.add_argument("--output-dir", default="results/paper_eval_v2") |
| ap.add_argument("--train-log", default="/workspace/train_out.log") |
| ap.add_argument("--hf-repo", default="itstheraj/fela-acml2026") |
| ap.add_argument("--hf-token", default=None) |
| ap.add_argument("--skip-lm-eval", action="store_true") |
| ap.add_argument("--skip-hf", action="store_true") |
| ap.add_argument("--lm-eval-limit", type=int, default=500) |
| args = ap.parse_args() |
|
|
| out = Path(args.output_dir) |
| out.mkdir(parents=True, exist_ok=True) |
| (out / "figures").mkdir(exist_ok=True) |
|
|
| print(f"\n{'=' * 60}", flush=True) |
| print(f"FNO+GLA Paper Evaluation — {args.config} on {args.device}", flush=True) |
| print(f"Checkpoint: {args.ckpt}", flush=True) |
| print(f"Output: {out}", flush=True) |
| print(f"{'=' * 60}\n", flush=True) |
|
|
| results: dict = {} |
|
|
| model = _build_model(args.config, args.ckpt, args.device) |
|
|
| gpt2_sm_mdl, gpt2_sm_tok = _gpt2_baseline(args.device, "gpt2") |
| gpt2_lg_mdl, gpt2_lg_tok = _gpt2_baseline(args.device, "gpt2-large") |
|
|
| print("\n[1/9] Training curve ...", flush=True) |
| results["training_curve"] = parse_training_log(args.train_log) |
| print(f" {len(results['training_curve'])} steps parsed", flush=True) |
|
|
| print("\n[2/9] WikiText-103 BPB ...", flush=True) |
| results["wikitext_ours"] = eval_wikitext_bpb(model, args.device) |
| results["wikitext_gpt2_small"] = eval_wikitext_bpb_gpt2( |
| gpt2_sm_mdl, gpt2_sm_tok, args.device |
| ) |
| results["wikitext_gpt2_large"] = eval_wikitext_bpb_gpt2( |
| gpt2_lg_mdl, gpt2_lg_tok, args.device |
| ) |
| results["wikitext"] = results["wikitext_ours"] |
|
|
| print("\n[3/9] Open LLM Leaderboard benchmarks ...", flush=True) |
| if not args.skip_lm_eval: |
| tasks = [ |
| "arc_easy", |
| "arc_challenge", |
| "hellaswag", |
| "piqa", |
| "winogrande", |
| "lambada_openai", |
| "boolq", |
| ] |
| results["lm_eval"] = run_lm_eval( |
| model, args.device, tasks, limit=args.lm_eval_limit |
| ) |
| else: |
| print(" [skipped]", flush=True) |
| results["lm_eval"] = {} |
|
|
| print("\n[4/9] GMACs profiling ...", flush=True) |
| seq_lens_bench = [512, 1024, 2048, 4096, 8192, 16384, 32768, 65536] |
| results["gmacs_profile"] = profile_gmacs(model, args.device, seq_lens_bench) |
|
|
| print("\n[5/9] VRAM benchmark ...", flush=True) |
| results["vram_benchmark"] = vram_benchmark(model, args.device, seq_lens_bench) |
|
|
| print("\n[6/9] Throughput benchmark ...", flush=True) |
| results["throughput"] = throughput_benchmark(model, args.device, seq_lens_bench) |
|
|
| print("\n[7/9] Qualitative samples ...", flush=True) |
| results["samples"] = generate_samples(model, args.device, gpt2_lg_mdl, gpt2_lg_tok) |
| _write_samples_md(results["samples"], out / "samples_v2.md") |
|
|
| def _save_csv(rows: list[dict], path: Path) -> None: |
| if not rows: |
| return |
| with open(path, "w", newline="") as f: |
| w = csv.DictWriter(f, fieldnames=rows[0].keys()) |
| w.writeheader() |
| w.writerows(rows) |
|
|
| _save_csv(results["gmacs_profile"], out / "gmacs_profile.csv") |
| _save_csv(results["vram_benchmark"], out / "vram_benchmark_v2.csv") |
| _save_csv(results["throughput"], out / "throughput_v2.csv") |
|
|
| (out / "paper_eval.json").write_text(json.dumps(results, indent=2, default=str)) |
| print(f"\n[eval] Results saved to {out / 'paper_eval.json'}", flush=True) |
|
|
| print("\n" + "=" * 60, flush=True) |
| print("RESULTS SUMMARY", flush=True) |
| print("=" * 60, flush=True) |
| wt = results.get("wikitext_ours", {}) |
| if wt: |
| print(f"WikiText-103 BPB (ours): {wt.get('bpb', '?'):.4f}", flush=True) |
| print( |
| f"WikiText-103 PPL (ours): {wt.get('perplexity', '?'):.2f}", |
| flush=True, |
| ) |
| wt_s = results.get("wikitext_gpt2_small", {}) |
| wt_l = results.get("wikitext_gpt2_large", {}) |
| if wt_s: |
| print(f"WikiText-103 BPB (GPT-2 124M): {wt_s.get('bpb', '?'):.4f}", flush=True) |
| if wt_l: |
| print(f"WikiText-103 BPB (GPT-2 1.5B): {wt_l.get('bpb', '?'):.4f}", flush=True) |
| for task, res in results.get("lm_eval", {}).items(): |
| acc = res.get("acc,none", res.get("acc", "?")) |
| print( |
| f" {task:<25} {float(acc) * 100:.1f}%" |
| if isinstance(acc, (int, float)) |
| else f" {task:<25} ?", |
| flush=True, |
| ) |
| print("=" * 60, flush=True) |
|
|
| print("\n[8/9] Generating paper figures ...", flush=True) |
| make_figures(results, out) |
|
|
| print("\n[9/9] HuggingFace push ...", flush=True) |
| if not args.skip_hf: |
| hf_token = args.hf_token or os.environ.get("HF_TOKEN") |
| push_to_hf(args.ckpt, results, out, args.hf_repo, hf_token) |
| else: |
| print(" [skipped]", flush=True) |
|
|
| print(f"\n[eval] COMPLETE. Results at: {out}", flush=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|