Buckets:
| #!/usr/bin/env python3 | |
| """Clean-room scaled reproduction of BP-checkpointing vs FmAD vs ZO. | |
| The experiment uses the paper's AG News task but a small, from-scratch residual | |
| text model with frozen random features and trainable rank-1 adapters. This makes | |
| all three gradient estimators practical on a single 24 GB GPU while preserving | |
| the paper's core comparison: exact reverse-mode gradients with activation | |
| checkpointing, one-direction forward-mode AD, and one-direction two-sided ZO. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import collections | |
| import csv | |
| import gc | |
| import json | |
| import math | |
| import os | |
| import random | |
| import re | |
| import time | |
| from pathlib import Path | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torch.func import functional_call, jvp | |
| from torch.utils.checkpoint import checkpoint | |
| TOKEN_RE = re.compile(r"[A-Za-z0-9']+") | |
| def seed_everything(seed: int) -> None: | |
| random.seed(seed) | |
| torch.manual_seed(seed) | |
| if torch.cuda.is_available(): | |
| torch.cuda.manual_seed_all(seed) | |
| def tokenize(text: str) -> list[str]: | |
| return TOKEN_RE.findall(text.lower()) | |
| def load_ag_news(train_size: int, eval_size: int, vocab_size: int, seq_len: int, seed: int): | |
| from datasets import load_dataset | |
| dataset = load_dataset("fancyzhx/ag_news") | |
| train_rows = dataset["train"].shuffle(seed=seed).select(range(train_size)) | |
| eval_rows = dataset["test"].shuffle(seed=seed).select(range(eval_size)) | |
| counts: collections.Counter[str] = collections.Counter() | |
| for text in train_rows["text"]: | |
| counts.update(tokenize(text)) | |
| vocab = {word: idx + 2 for idx, (word, _) in enumerate(counts.most_common(vocab_size - 2))} | |
| def encode(texts): | |
| ids = torch.zeros((len(texts), seq_len), dtype=torch.long) | |
| mask = torch.zeros((len(texts), seq_len), dtype=torch.float32) | |
| for row, text in enumerate(texts): | |
| tokens = tokenize(text)[:seq_len] | |
| if not tokens: | |
| tokens = ["<unk>"] | |
| token_ids = [vocab.get(token, 1) for token in tokens] | |
| ids[row, : len(token_ids)] = torch.tensor(token_ids) | |
| mask[row, : len(token_ids)] = 1.0 | |
| return ids, mask | |
| train_x, train_mask = encode(train_rows["text"]) | |
| eval_x, eval_mask = encode(eval_rows["text"]) | |
| return ( | |
| train_x, | |
| train_mask, | |
| torch.tensor(train_rows["label"], dtype=torch.long), | |
| eval_x, | |
| eval_mask, | |
| torch.tensor(eval_rows["label"], dtype=torch.long), | |
| len(vocab) + 2, | |
| ) | |
| def load_synthetic(train_size: int, eval_size: int, vocab_size: int, seq_len: int, seed: int): | |
| generator = torch.Generator().manual_seed(seed) | |
| def make(n): | |
| y = torch.randint(0, 4, (n,), generator=generator) | |
| x = torch.randint(6, vocab_size, (n, seq_len), generator=generator) | |
| x[:, :4] = y[:, None] + 2 | |
| mask = torch.ones((n, seq_len), dtype=torch.float32) | |
| return x, mask, y | |
| return (*make(train_size), *make(eval_size), vocab_size) | |
| class AdapterBlock(nn.Module): | |
| def __init__(self, width: int, rank: int, generator: torch.Generator): | |
| super().__init__() | |
| base = torch.empty(width, width).normal_(generator=generator) | |
| base = torch.linalg.qr(base).Q | |
| self.register_buffer("base", base) | |
| self.lora_a = nn.Parameter(torch.empty(rank, width).normal_(std=0.02, generator=generator)) | |
| self.lora_b = nn.Parameter(torch.empty(width, rank).normal_(std=0.02, generator=generator)) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| base = F.linear(x, self.base) | |
| adapter = F.linear(F.linear(x, self.lora_a), self.lora_b) | |
| return x + 0.25 * F.gelu(base + adapter) | |
| class AdapterTextModel(nn.Module): | |
| def __init__(self, vocab_size: int, width: int, depth: int, rank: int, seed: int): | |
| super().__init__() | |
| generator = torch.Generator().manual_seed(seed) | |
| self.embedding = nn.Embedding(vocab_size, width, padding_idx=0) | |
| with torch.no_grad(): | |
| self.embedding.weight.normal_(std=1.0 / math.sqrt(width), generator=generator) | |
| self.embedding.weight[0].zero_() | |
| self.embedding.weight.requires_grad_(False) | |
| self.blocks = nn.ModuleList([AdapterBlock(width, rank, generator) for _ in range(depth)]) | |
| self.classifier = nn.Linear(width, 4) | |
| def forward(self, token_ids: torch.Tensor, mask: torch.Tensor, use_checkpoint: bool = False): | |
| hidden = self.embedding(token_ids) | |
| for block in self.blocks: | |
| if use_checkpoint and self.training: | |
| hidden = checkpoint(block, hidden, use_reentrant=False) | |
| else: | |
| hidden = block(hidden) | |
| pooled = (hidden * mask.unsqueeze(-1)).sum(dim=1) / mask.sum(dim=1, keepdim=True).clamp_min(1) | |
| return self.classifier(pooled) | |
| def trainable_parameters(model: nn.Module): | |
| return {name: param for name, param in model.named_parameters() if param.requires_grad} | |
| def evaluate(model, x, mask, y, batch_size=256): | |
| model.eval() | |
| correct = 0 | |
| loss_sum = 0.0 | |
| for start in range(0, len(y), batch_size): | |
| stop = min(start + batch_size, len(y)) | |
| logits = model(x[start:stop], mask[start:stop], use_checkpoint=False) | |
| loss_sum += F.cross_entropy(logits, y[start:stop], reduction="sum").item() | |
| correct += (logits.argmax(dim=-1) == y[start:stop]).sum().item() | |
| model.train() | |
| return loss_sum / len(y), correct / len(y) | |
| def sync(device: torch.device) -> None: | |
| if device.type == "cuda": | |
| torch.cuda.synchronize() | |
| def make_batches(n: int, batch_size: int, steps: int, seed: int): | |
| generator = torch.Generator().manual_seed(seed) | |
| batches = [] | |
| while len(batches) < steps: | |
| permutation = torch.randperm(n, generator=generator) | |
| for start in range(0, n - batch_size + 1, batch_size): | |
| batches.append(permutation[start : start + batch_size]) | |
| if len(batches) == steps: | |
| break | |
| return batches | |
| def one_step(model, optimizer, method, x, mask, y, perturbations, zo_eps, grad_clip): | |
| optimizer.zero_grad(set_to_none=True) | |
| if method == "bp_checkpoint": | |
| logits = model(x, mask, use_checkpoint=True) | |
| loss = F.cross_entropy(logits, y) | |
| loss.backward() | |
| else: | |
| params = trainable_parameters(model) | |
| estimated_grads = {name: torch.zeros_like(param) for name, param in params.items()} | |
| losses = [] | |
| for _ in range(perturbations): | |
| directions = {name: torch.randn_like(param) for name, param in params.items()} | |
| if method == "fmad": | |
| def loss_fn(candidate_params): | |
| logits = functional_call( | |
| model, | |
| candidate_params, | |
| (x, mask), | |
| {"use_checkpoint": False}, | |
| strict=False, | |
| ) | |
| return F.cross_entropy(logits, y) | |
| primal_loss, directional_derivative = jvp(loss_fn, (params,), (directions,)) | |
| losses.append(primal_loss.detach()) | |
| scalar = directional_derivative.detach() | |
| elif method == "zo": | |
| positive = {name: param + zo_eps * directions[name] for name, param in params.items()} | |
| negative = {name: param - zo_eps * directions[name] for name, param in params.items()} | |
| with torch.no_grad(): | |
| positive_loss = F.cross_entropy( | |
| functional_call(model, positive, (x, mask), {"use_checkpoint": False}, strict=False), y | |
| ) | |
| negative_loss = F.cross_entropy( | |
| functional_call(model, negative, (x, mask), {"use_checkpoint": False}, strict=False), y | |
| ) | |
| losses.append((positive_loss + negative_loss) / 2) | |
| scalar = (positive_loss - negative_loss) / (2 * zo_eps) | |
| else: | |
| raise ValueError(method) | |
| for name in estimated_grads: | |
| estimated_grads[name].add_(directions[name], alpha=float(scalar) / perturbations) | |
| for name, param in params.items(): | |
| param.grad = estimated_grads[name] | |
| loss = torch.stack(losses).mean() | |
| grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) | |
| optimizer.step() | |
| return float(loss.detach()), float(grad_norm.detach()) | |
| def run_method(method, args, data, device): | |
| train_x, train_mask, train_y, eval_x, eval_mask, eval_y, vocab_size = data | |
| seed_everything(args.seed) | |
| model = AdapterTextModel(vocab_size, args.width, args.depth, args.rank, args.seed).to(device) | |
| optimizer = torch.optim.AdamW( | |
| [parameter for parameter in model.parameters() if parameter.requires_grad], | |
| lr=getattr(args, f"lr_{'bp' if method == 'bp_checkpoint' else method}"), | |
| weight_decay=0.0, | |
| ) | |
| batch_override = getattr(args, f"batch_size_{'bp' if method == 'bp_checkpoint' else method}") | |
| method_batch_size = batch_override if batch_override is not None else args.batch_size | |
| batches = make_batches(len(train_y), method_batch_size, args.steps, args.seed + 17) | |
| eval_x = eval_x.to(device) | |
| eval_mask = eval_mask.to(device) | |
| eval_y = eval_y.to(device) | |
| model.train() | |
| initial_loss, initial_accuracy = evaluate(model, eval_x, eval_mask, eval_y) | |
| rows = [{ | |
| "method": method, | |
| "step": 0, | |
| "train_loss": None, | |
| "val_loss": initial_loss, | |
| "val_accuracy": initial_accuracy, | |
| "elapsed_train_s": 0.0, | |
| "peak_memory_mb": None, | |
| "step_flops": None, | |
| "grad_norm": None, | |
| }] | |
| print(json.dumps({"event": "eval", **rows[-1]}), flush=True) | |
| train_elapsed = 0.0 | |
| peak_memory_mb = 0.0 | |
| step_flops = None | |
| first_threshold_step = None | |
| first_threshold_time = None | |
| last_loss = None | |
| last_grad_norm = None | |
| for step, indices in enumerate(batches, start=1): | |
| bx = train_x[indices].to(device) | |
| bm = train_mask[indices].to(device) | |
| by = train_y[indices].to(device) | |
| if device.type == "cuda": | |
| torch.cuda.reset_peak_memory_stats(device) | |
| sync(device) | |
| start = time.perf_counter() | |
| if step == 1: | |
| try: | |
| from torch.utils.flop_counter import FlopCounterMode | |
| with FlopCounterMode(display=False) as flop_counter: | |
| last_loss, last_grad_norm = one_step( | |
| model, optimizer, method, bx, bm, by, | |
| args.perturbations, args.zo_eps, args.grad_clip, | |
| ) | |
| step_flops = int(flop_counter.get_total_flops()) | |
| except Exception as error: | |
| print(json.dumps({"event": "flop_counter_warning", "method": method, "error": repr(error)}), flush=True) | |
| last_loss, last_grad_norm = one_step( | |
| model, optimizer, method, bx, bm, by, | |
| args.perturbations, args.zo_eps, args.grad_clip, | |
| ) | |
| else: | |
| last_loss, last_grad_norm = one_step( | |
| model, optimizer, method, bx, bm, by, | |
| args.perturbations, args.zo_eps, args.grad_clip, | |
| ) | |
| sync(device) | |
| train_elapsed += time.perf_counter() - start | |
| if device.type == "cuda": | |
| peak_memory_mb = max(peak_memory_mb, torch.cuda.max_memory_allocated(device) / (1024 ** 2)) | |
| if step % args.eval_every == 0 or step == args.steps: | |
| val_loss, val_accuracy = evaluate(model, eval_x, eval_mask, eval_y) | |
| row = { | |
| "method": method, | |
| "step": step, | |
| "train_loss": last_loss, | |
| "val_loss": val_loss, | |
| "val_accuracy": val_accuracy, | |
| "elapsed_train_s": train_elapsed, | |
| "peak_memory_mb": peak_memory_mb, | |
| "step_flops": step_flops, | |
| "grad_norm": last_grad_norm, | |
| } | |
| rows.append(row) | |
| print(json.dumps({"event": "eval", **row}), flush=True) | |
| if first_threshold_step is None and val_accuracy >= args.threshold: | |
| first_threshold_step = step | |
| first_threshold_time = train_elapsed | |
| summary = { | |
| "method": method, | |
| "batch_size": method_batch_size, | |
| "initial_accuracy": initial_accuracy, | |
| "final_accuracy": rows[-1]["val_accuracy"], | |
| "best_accuracy": max(row["val_accuracy"] for row in rows), | |
| "total_train_s": train_elapsed, | |
| "peak_memory_mb": peak_memory_mb, | |
| "step_flops": step_flops, | |
| "threshold": args.threshold, | |
| "threshold_step": first_threshold_step, | |
| "threshold_train_s": first_threshold_time, | |
| "threshold_flops": None if first_threshold_step is None or step_flops is None else first_threshold_step * step_flops, | |
| "threshold_censored": first_threshold_step is None, | |
| } | |
| del model, optimizer, eval_x, eval_mask, eval_y | |
| gc.collect() | |
| if device.type == "cuda": | |
| torch.cuda.empty_cache() | |
| return rows, summary | |
| def write_outputs(output_dir: Path, rows, summaries, args, hardware): | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| with (output_dir / "metrics.csv").open("w", newline="") as handle: | |
| writer = csv.DictWriter(handle, fieldnames=list(rows[0].keys())) | |
| writer.writeheader() | |
| writer.writerows(rows) | |
| by_method = {entry["method"]: entry for entry in summaries} | |
| bp = by_method["bp_checkpoint"] | |
| comparisons = {} | |
| for competitor in ("fmad", "zo"): | |
| other = by_method[competitor] | |
| comparison = { | |
| "final_accuracy_gap_percentage_points": 100 * (bp["final_accuracy"] - other["final_accuracy"]), | |
| "final_accuracy_relative_gain_percent": 100 * (bp["final_accuracy"] / other["final_accuracy"] - 1), | |
| "peak_memory_ratio_bp_over_other": ( | |
| bp["peak_memory_mb"] / other["peak_memory_mb"] | |
| if other["peak_memory_mb"] > 0 else None | |
| ), | |
| "total_wall_time_ratio_other_over_bp": other["total_train_s"] / bp["total_train_s"], | |
| } | |
| if bp["threshold_train_s"] is not None and other["threshold_train_s"] is not None: | |
| comparison["bp_faster_to_threshold_percent"] = 100 * ( | |
| 1 - bp["threshold_train_s"] / other["threshold_train_s"] | |
| ) | |
| comparison["threshold_compute_ratio_other_over_bp"] = ( | |
| other["threshold_flops"] / bp["threshold_flops"] | |
| ) | |
| comparisons[f"bp_vs_{competitor}"] = comparison | |
| payload = { | |
| "scope": "scaled AG News proxy; not an exact full-paper replication", | |
| "dataset": "https://huggingface.co/datasets/fancyzhx/ag_news", | |
| "official_code": "https://github.com/Astuary/Gradient_Estimation_Methods/tree/bc5798add2339d1317664462648167fc6b987671", | |
| "hardware": hardware, | |
| "configuration": vars(args), | |
| "methods": summaries, | |
| "comparisons": comparisons, | |
| } | |
| with (output_dir / "summary.json").open("w") as handle: | |
| json.dump(payload, handle, indent=2) | |
| print("FINAL_SUMMARY=" + json.dumps(payload, sort_keys=True), flush=True) | |
| def parse_args(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--output-dir", default="results") | |
| parser.add_argument("--train-size", type=int, default=8192) | |
| parser.add_argument("--eval-size", type=int, default=1024) | |
| parser.add_argument("--vocab-size", type=int, default=12000) | |
| parser.add_argument("--seq-len", type=int, default=64) | |
| parser.add_argument("--width", type=int, default=192) | |
| parser.add_argument("--depth", type=int, default=8) | |
| parser.add_argument("--rank", type=int, default=1) | |
| parser.add_argument("--batch-size", type=int, default=128) | |
| parser.add_argument("--batch-size-bp", type=int) | |
| parser.add_argument("--batch-size-fmad", type=int) | |
| parser.add_argument("--batch-size-zo", type=int) | |
| parser.add_argument("--steps", type=int, default=300) | |
| parser.add_argument("--eval-every", type=int, default=25) | |
| parser.add_argument("--threshold", type=float, default=0.55) | |
| parser.add_argument("--perturbations", type=int, default=1) | |
| parser.add_argument("--zo-eps", type=float, default=1e-3) | |
| parser.add_argument("--grad-clip", type=float, default=5.0) | |
| parser.add_argument("--lr-bp", type=float, default=3e-3) | |
| parser.add_argument("--lr-fmad", type=float, default=1e-3) | |
| parser.add_argument("--lr-zo", type=float, default=1e-3) | |
| parser.add_argument("--seed", type=int, default=0) | |
| parser.add_argument("--synthetic", action="store_true") | |
| parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") | |
| return parser.parse_args() | |
| def main(): | |
| args = parse_args() | |
| seed_everything(args.seed) | |
| device = torch.device(args.device) | |
| if device.type == "cuda" and not torch.cuda.is_available(): | |
| raise RuntimeError("CUDA was requested but is not available") | |
| hardware = { | |
| "device": str(device), | |
| "gpu": torch.cuda.get_device_name(0) if device.type == "cuda" else None, | |
| "torch": torch.__version__, | |
| "cuda": torch.version.cuda, | |
| "job_id": os.environ.get("HF_JOB_ID"), | |
| } | |
| print(json.dumps({"event": "hardware", **hardware}), flush=True) | |
| if args.synthetic: | |
| data = load_synthetic(args.train_size, args.eval_size, args.vocab_size, args.seq_len, args.seed) | |
| else: | |
| data = load_ag_news(args.train_size, args.eval_size, args.vocab_size, args.seq_len, args.seed) | |
| trainable_probe = AdapterTextModel(data[-1], args.width, args.depth, args.rank, args.seed) | |
| counts = { | |
| "total_parameters": sum(p.numel() for p in trainable_probe.parameters()), | |
| "trainable_parameters": sum(p.numel() for p in trainable_probe.parameters() if p.requires_grad), | |
| } | |
| print(json.dumps({"event": "model", **counts}), flush=True) | |
| del trainable_probe | |
| all_rows = [] | |
| summaries = [] | |
| for method in ("bp_checkpoint", "fmad", "zo"): | |
| method_rows, summary = run_method(method, args, data, device) | |
| all_rows.extend(method_rows) | |
| summaries.append(summary) | |
| write_outputs(Path(args.output_dir), all_rows, summaries, args, hardware) | |
| if __name__ == "__main__": | |
| main() | |
Xet Storage Details
- Size:
- 18.5 kB
- Xet hash:
- 2c8f7f84f1140a103c5a80fb05a86b180a481816c5c6dc3f3ac12176f9d23cad
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.