Files
2026-07-30 06:39:29 +08:00

718 lines
26 KiB
Python

#!/usr/bin/env python3
"""Train one frozen residual variant for the reduced Attention Residuals study."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import platform
import statistics
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
PROTOCOL_ID = "llm-atlas-k3-attnres-reduced-v1"
ARCHITECTURES = ("baseline", "full", "block")
EXPECTED_SEEDS = (2026073001, 2026073002, 2026073003)
EVAL_STEPS = (0, 100, 250, 500, 1000, 1500, 2000)
VOCABULARY = 256
CONTEXT = 256
LAYERS = 16
SUBLAYERS = LAYERS * 2
BLOCKS = 8
SUBLAYERS_PER_BLOCK = SUBLAYERS // BLOCKS
D_MODEL = 192
HEADS = 6
D_HEAD = D_MODEL // HEADS
D_FF = 768
RMS_EPS = 1e-6
PEAK_LR = 3e-4
MIN_LR = 3e-5
WARMUP_STEPS = 100
WEIGHT_DECAY = 0.1
BETAS = (0.9, 0.95)
ADAM_EPS = 1e-8
GRAD_CLIP = 1.0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--architecture", choices=ARCHITECTURES, required=True)
parser.add_argument("--seed", type=int, required=True)
parser.add_argument("--steps", type=int, default=2000)
parser.add_argument("--batch-size", type=int, default=32)
parser.add_argument("--cache-dir", type=Path, required=True)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--validation-windows", type=int, default=64)
parser.add_argument("--diagnostic-windows", type=int, default=16)
parser.add_argument("--eval-batch-size", type=int, default=8)
parser.add_argument("--timing-warmup", type=int, default=20)
parser.add_argument("--run-kind", choices=("smoke", "formal", "replay"), default="formal")
return parser.parse_args()
def configure_determinism(seed: int) -> None:
if os.environ.get("CUBLAS_WORKSPACE_CONFIG") != ":4096:8":
raise RuntimeError("CUBLAS_WORKSPACE_CONFIG must be :4096:8 before Python starts")
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.use_deterministic_algorithms(True)
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False
torch.set_float32_matmul_precision("highest")
def canonical_json_sha256(value: Any) -> str:
payload = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode()
return hashlib.sha256(payload).hexdigest()
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def tensor_bytes(tensor: torch.Tensor) -> bytes:
value = tensor.detach().cpu().contiguous()
header = f"{value.dtype}|{tuple(value.shape)}|".encode()
return header + value.view(torch.uint8).numpy().tobytes()
def state_hash(
model: nn.Module,
*,
include_mixers: bool | None,
) -> str:
digest = hashlib.sha256()
for name, tensor in sorted(model.state_dict().items()):
is_mixer = name.startswith("mixers.") or name.startswith("output_mixer.")
if include_mixers is not None and is_mixer != include_mixers:
continue
digest.update(name.encode())
digest.update(b"\0")
digest.update(tensor_bytes(tensor))
return digest.hexdigest()
def window_start(seed: int, step: int, row: int, corpus_length: int) -> int:
payload = "\0".join(
[PROTOCOL_ID, "train-window", str(seed), str(step), str(row)]
).encode()
value = int.from_bytes(hashlib.sha256(payload).digest()[:8], "big")
return value % (corpus_length - (CONTEXT + 1))
class ByteCorpus:
def __init__(self, cache_dir: Path, manifest: dict[str, Any], device: torch.device):
self.device = device
self.train = np.memmap(cache_dir / "train.bin", dtype=np.uint8, mode="r")
self.validation = np.memmap(
cache_dir / "validation.bin", dtype=np.uint8, mode="r"
)
self.validation_starts = manifest["windows"]["validation_starts"]
self.diagnostic_starts = manifest["windows"]["diagnostic_starts"]
def training_batch(
self, seed: int, step: int, batch_size: int
) -> tuple[torch.Tensor, torch.Tensor]:
rows = np.empty((batch_size, CONTEXT + 1), dtype=np.int64)
for row in range(batch_size):
start = window_start(seed, step, row, len(self.train))
rows[row] = self.train[start : start + CONTEXT + 1]
tensor = torch.from_numpy(rows).to(self.device, non_blocking=False)
return tensor[:, :-1], tensor[:, 1:]
def fixed_batch(
self, starts: list[int], begin: int, end: int
) -> tuple[torch.Tensor, torch.Tensor]:
chosen = starts[begin:end]
rows = np.empty((len(chosen), CONTEXT + 1), dtype=np.int64)
for row, start in enumerate(chosen):
rows[row] = self.validation[start : start + CONTEXT + 1]
tensor = torch.from_numpy(rows).to(self.device, non_blocking=False)
return tensor[:, :-1], tensor[:, 1:]
class RMSNorm(nn.Module):
def __init__(self, dimension: int):
super().__init__()
self.weight = nn.Parameter(torch.ones(dimension))
def forward(self, value: torch.Tensor) -> torch.Tensor:
normalized = value.float() * torch.rsqrt(
value.float().square().mean(dim=-1, keepdim=True) + RMS_EPS
)
return normalized.to(value.dtype) * self.weight
class CausalAttention(nn.Module):
def __init__(self):
super().__init__()
self.qkv = nn.Linear(D_MODEL, 3 * D_MODEL, bias=False)
self.o_proj = nn.Linear(D_MODEL, D_MODEL, bias=False)
mask = torch.triu(torch.ones(CONTEXT, CONTEXT, dtype=torch.bool), diagonal=1)
self.register_buffer("causal_mask", mask, persistent=False)
def forward(self, value: torch.Tensor) -> torch.Tensor:
batch, sequence, _ = value.shape
qkv = self.qkv(value).view(batch, sequence, 3, HEADS, D_HEAD)
query, key, content = qkv.unbind(dim=2)
query = query.transpose(1, 2)
key = key.transpose(1, 2)
content = content.transpose(1, 2)
scores = torch.matmul(query, key.transpose(-1, -2)).float() / math.sqrt(D_HEAD)
scores = scores.masked_fill(
self.causal_mask[:sequence, :sequence], float("-inf")
)
probabilities = torch.softmax(scores, dim=-1).to(query.dtype)
mixed = torch.matmul(probabilities, content)
mixed = mixed.transpose(1, 2).contiguous().view(batch, sequence, D_MODEL)
return self.o_proj(mixed)
class SwiGLU(nn.Module):
def __init__(self):
super().__init__()
self.gate = nn.Linear(D_MODEL, D_FF, bias=False)
self.up = nn.Linear(D_MODEL, D_FF, bias=False)
self.down = nn.Linear(D_FF, D_MODEL, bias=False)
def forward(self, value: torch.Tensor) -> torch.Tensor:
return self.down(F.silu(self.gate(value)) * self.up(value))
class TransformerBlock(nn.Module):
def __init__(self):
super().__init__()
self.attention_norm = RMSNorm(D_MODEL)
self.attention = CausalAttention()
self.mlp_norm = RMSNorm(D_MODEL)
self.mlp = SwiGLU()
class DepthMixer(nn.Module):
def __init__(self):
super().__init__()
self.query = nn.Parameter(torch.zeros(D_MODEL))
self.key_norm = RMSNorm(D_MODEL)
def forward(
self, sources: list[torch.Tensor], capture: bool = False
) -> tuple[torch.Tensor, dict[str, Any] | None]:
values = torch.stack(sources, dim=0)
keys = self.key_norm(values)
logits = torch.einsum("d,nbtd->nbt", self.query, keys.float())
weights = torch.softmax(logits, dim=0)
output = torch.einsum("nbt,nbtd->btd", weights, values.float()).to(
values.dtype
)
if not capture:
return output, None
entropy = -(weights * torch.log(weights.clamp_min(1e-30))).sum(dim=0)
return output, {
"mean_weights": weights.mean(dim=(1, 2)).detach().cpu().tolist(),
"entropy_mean": entropy.mean().detach().cpu().item(),
"sources": len(sources),
}
@dataclass
class TraceAccumulator:
layer_input_rms: list[float]
branch_output_rms: list[float]
stream_state_rms: list[float]
depth_weights: list[dict[str, Any]]
output_weights: dict[str, Any] | None = None
def rms(value: torch.Tensor) -> float:
return value.float().square().mean().sqrt().detach().cpu().item()
class ReducedLanguageModel(nn.Module):
def __init__(self, architecture: str):
super().__init__()
self.architecture = architecture
self.token_embedding = nn.Embedding(VOCABULARY, D_MODEL)
self.position_embedding = nn.Embedding(CONTEXT, D_MODEL)
self.blocks = nn.ModuleList([TransformerBlock() for _ in range(LAYERS)])
self.final_norm = RMSNorm(D_MODEL)
if architecture == "baseline":
self.mixers = nn.ModuleList()
self.output_mixer = None
else:
self.mixers = nn.ModuleList([DepthMixer() for _ in range(SUBLAYERS)])
self.output_mixer = DepthMixer()
self.reset_parameters()
def reset_parameters(self) -> None:
for module in self.modules():
if isinstance(module, nn.Embedding):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
elif isinstance(module, nn.Linear):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
elif isinstance(module, RMSNorm):
nn.init.ones_(module.weight)
scaled = 0.02 / math.sqrt(2 * LAYERS)
for block in self.blocks:
nn.init.normal_(block.attention.o_proj.weight, mean=0.0, std=scaled)
nn.init.normal_(block.mlp.down.weight, mean=0.0, std=scaled)
for mixer in self.mixers:
nn.init.zeros_(mixer.query)
nn.init.ones_(mixer.key_norm.weight)
if self.output_mixer is not None:
nn.init.zeros_(self.output_mixer.query)
nn.init.ones_(self.output_mixer.key_norm.weight)
def embed(self, input_ids: torch.Tensor) -> torch.Tensor:
positions = torch.arange(input_ids.shape[1], device=input_ids.device)
return self.token_embedding(input_ids) + self.position_embedding(positions)[None]
def forward(
self, input_ids: torch.Tensor, capture: bool = False
) -> tuple[torch.Tensor, TraceAccumulator | None]:
embedded = self.embed(input_ids)
trace = (
TraceAccumulator([], [], [], [])
if capture
else None
)
if self.architecture == "baseline":
hidden = embedded
for block in self.blocks:
attention_input = hidden
attention_output = block.attention(block.attention_norm(attention_input))
hidden = hidden + attention_output
if trace is not None:
trace.layer_input_rms.append(rms(attention_input))
trace.branch_output_rms.append(rms(attention_output))
trace.stream_state_rms.append(rms(hidden))
mlp_input = hidden
mlp_output = block.mlp(block.mlp_norm(mlp_input))
hidden = hidden + mlp_output
if trace is not None:
trace.layer_input_rms.append(rms(mlp_input))
trace.branch_output_rms.append(rms(mlp_output))
trace.stream_state_rms.append(rms(hidden))
elif self.architecture == "full":
sources = [embedded]
mixer_index = 0
for block in self.blocks:
attention_input, weights = self.mixers[mixer_index](sources, capture)
mixer_index += 1
attention_output = block.attention(block.attention_norm(attention_input))
sources.append(attention_output)
if trace is not None:
trace.layer_input_rms.append(rms(attention_input))
trace.branch_output_rms.append(rms(attention_output))
trace.stream_state_rms.append(
rms(torch.stack(sources, dim=0))
)
trace.depth_weights.append(weights or {})
mlp_input, weights = self.mixers[mixer_index](sources, capture)
mixer_index += 1
mlp_output = block.mlp(block.mlp_norm(mlp_input))
sources.append(mlp_output)
if trace is not None:
trace.layer_input_rms.append(rms(mlp_input))
trace.branch_output_rms.append(rms(mlp_output))
trace.stream_state_rms.append(
rms(torch.stack(sources, dim=0))
)
trace.depth_weights.append(weights or {})
assert self.output_mixer is not None
hidden, output_weights = self.output_mixer(sources, capture)
if trace is not None:
trace.output_weights = output_weights
else:
completed = [embedded]
partial: torch.Tensor | None = None
mixer_index = 0
for block in self.blocks:
for branch_index in range(2):
sources = completed + ([] if partial is None else [partial])
branch_input, weights = self.mixers[mixer_index](sources, capture)
mixer_index += 1
if branch_index == 0:
branch_output = block.attention(
block.attention_norm(branch_input)
)
else:
branch_output = block.mlp(block.mlp_norm(branch_input))
partial = (
branch_output if partial is None else partial + branch_output
)
if trace is not None:
trace.layer_input_rms.append(rms(branch_input))
trace.branch_output_rms.append(rms(branch_output))
trace.stream_state_rms.append(rms(partial))
trace.depth_weights.append(weights or {})
if mixer_index % SUBLAYERS_PER_BLOCK == 0:
completed.append(partial)
partial = None
assert partial is None
assert len(completed) == BLOCKS + 1
assert self.output_mixer is not None
hidden, output_weights = self.output_mixer(completed, capture)
if trace is not None:
trace.output_weights = output_weights
normalized = self.final_norm(hidden)
logits = F.linear(normalized, self.token_embedding.weight)
return logits, trace
def learning_rate(step: int, total_steps: int) -> float:
if step <= WARMUP_STEPS:
return PEAK_LR * step / WARMUP_STEPS
progress = (step - WARMUP_STEPS) / max(1, total_steps - WARMUP_STEPS)
cosine = 0.5 * (1 + math.cos(math.pi * progress))
return MIN_LR + (PEAK_LR - MIN_LR) * cosine
def cross_entropy(logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
return F.cross_entropy(
logits.float().reshape(-1, VOCABULARY), targets.reshape(-1)
)
@torch.no_grad()
def evaluate(
model: ReducedLanguageModel,
corpus: ByteCorpus,
starts: list[int],
window_count: int,
eval_batch_size: int,
) -> dict[str, float]:
model.eval()
loss_sum = 0.0
target_count = 0
for begin in range(0, window_count, eval_batch_size):
end = min(begin + eval_batch_size, window_count)
inputs, targets = corpus.fixed_batch(starts, begin, end)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
logits, _ = model(inputs)
loss = F.cross_entropy(
logits.float().reshape(-1, VOCABULARY),
targets.reshape(-1),
reduction="sum",
)
loss_sum += loss.detach().cpu().item()
target_count += targets.numel()
nats = loss_sum / target_count
return {"cross_entropy_nats": nats, "bits_per_byte": nats / math.log(2)}
def percentile(values: list[float], quantile: float) -> float:
return float(np.quantile(np.asarray(values, dtype=np.float64), quantile))
def core_parameter_gradient_rms(model: ReducedLanguageModel) -> list[float]:
values = []
for block in model.blocks:
sum_square = 0.0
count = 0
for parameter in block.parameters():
if parameter.grad is None:
continue
gradient = parameter.grad.detach().float()
sum_square += gradient.square().sum().detach().cpu().item()
count += gradient.numel()
values.append(math.sqrt(sum_square / count))
return values
def diagnostic(
model: ReducedLanguageModel,
corpus: ByteCorpus,
window_count: int,
) -> dict[str, Any]:
model.eval()
model.zero_grad(set_to_none=True)
inputs, targets = corpus.fixed_batch(
corpus.diagnostic_starts, 0, window_count
)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
logits, trace = model(inputs, capture=True)
loss = cross_entropy(logits, targets)
loss.backward()
gradients = core_parameter_gradient_rms(model)
assert trace is not None
return {
"loss_nats": loss.detach().cpu().item(),
"bits_per_byte": loss.detach().cpu().item() / math.log(2),
"layer_input_rms": trace.layer_input_rms,
"branch_output_rms": trace.branch_output_rms,
"stream_state_rms": trace.stream_state_rms,
"core_parameter_grad_rms_by_block": gradients,
"depth_weights": trace.depth_weights,
"output_weights": trace.output_weights,
}
def parameter_inventory(model: ReducedLanguageModel) -> dict[str, int]:
total = sum(parameter.numel() for parameter in model.parameters())
mixer = sum(
parameter.numel()
for name, parameter in model.named_parameters()
if name.startswith("mixers.") or name.startswith("output_mixer.")
)
embedding = model.token_embedding.weight.numel() + model.position_embedding.weight.numel()
return {
"total": total,
"core": total - mixer,
"mixer": mixer,
"embedding": embedding,
}
def main() -> None:
args = parse_args()
if not torch.cuda.is_available():
raise RuntimeError("CUDA is required by the frozen protocol")
if args.run_kind != "smoke" and args.seed not in EXPECTED_SEEDS:
raise ValueError(f"formal/replay seed is not preregistered: {args.seed}")
configure_determinism(args.seed)
device = torch.device("cuda")
manifest = json.loads(args.manifest.read_text())
if manifest["protocol_id"] != PROTOCOL_ID:
raise ValueError("manifest protocol mismatch")
if manifest["dataset"]["revision"] != (
"b08601e04326c79dfdd32d625aee71d232d685c3"
):
raise ValueError("dataset revision mismatch")
corpus = ByteCorpus(args.cache_dir, manifest, device)
model = ReducedLanguageModel(args.architecture).to(device)
initial_common_hash = state_hash(model, include_mixers=False)
initial_mixer_hash = (
state_hash(model, include_mixers=True)
if args.architecture != "baseline"
else None
)
inventory = parameter_inventory(model)
decay_parameters: list[nn.Parameter] = []
no_decay_parameters: list[nn.Parameter] = []
for parameter in model.parameters():
if parameter.ndim >= 2:
decay_parameters.append(parameter)
else:
no_decay_parameters.append(parameter)
optimizer = torch.optim.AdamW(
[
{"params": decay_parameters, "weight_decay": WEIGHT_DECAY},
{"params": no_decay_parameters, "weight_decay": 0.0},
],
lr=PEAK_LR,
betas=BETAS,
eps=ADAM_EPS,
)
evaluation_steps = sorted(
set(step for step in EVAL_STEPS if step <= args.steps) | {0, args.steps}
)
evaluations = [
{
"step": 0,
**evaluate(
model,
corpus,
corpus.validation_starts,
args.validation_windows,
args.eval_batch_size,
),
}
]
training_history: list[dict[str, float | int]] = []
step_times: list[float] = []
model.train()
for step in range(1, args.steps + 1):
lr = learning_rate(step, args.steps)
for group in optimizer.param_groups:
group["lr"] = lr
inputs, targets = corpus.training_batch(args.seed, step, args.batch_size)
optimizer.zero_grad(set_to_none=True)
torch.cuda.synchronize()
started = time.perf_counter()
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
logits, _ = model(inputs)
loss = cross_entropy(logits, targets)
if not torch.isfinite(loss):
raise RuntimeError(f"non-finite loss at step {step}: {loss}")
loss.backward()
unclipped_norm = torch.nn.utils.clip_grad_norm_(
model.parameters(), GRAD_CLIP
)
optimizer.step()
torch.cuda.synchronize()
elapsed_ms = (time.perf_counter() - started) * 1000
if step == args.timing_warmup:
torch.cuda.reset_peak_memory_stats()
elif step > args.timing_warmup:
step_times.append(elapsed_ms)
if step == 1 or step % 10 == 0 or step == args.steps:
training_history.append(
{
"step": step,
"loss_nats": loss.detach().cpu().item(),
"bits_per_byte": loss.detach().cpu().item() / math.log(2),
"learning_rate": lr,
"unclipped_grad_norm": float(unclipped_norm.detach().cpu()),
}
)
if step in evaluation_steps and step != 0:
evaluations.append(
{
"step": step,
**evaluate(
model,
corpus,
corpus.validation_starts,
args.validation_windows,
args.eval_batch_size,
),
}
)
model.train()
training_peak_allocated = torch.cuda.max_memory_allocated()
training_peak_reserved = torch.cuda.max_memory_reserved()
diagnostic_result = diagnostic(model, corpus, args.diagnostic_windows)
final_common_hash = state_hash(model, include_mixers=False)
final_mixer_hash = (
state_hash(model, include_mixers=True)
if args.architecture != "baseline"
else None
)
timing = {
"warmup_steps_excluded": args.timing_warmup,
"measured_steps": len(step_times),
"mean_ms": statistics.fmean(step_times) if step_times else None,
"median_ms": statistics.median(step_times) if step_times else None,
"p95_ms": percentile(step_times, 0.95) if step_times else None,
"peak_allocated_bytes": training_peak_allocated,
"peak_reserved_bytes": training_peak_reserved,
}
result = {
"schema_version": 1,
"protocol_id": PROTOCOL_ID,
"run_kind": args.run_kind,
"architecture": args.architecture,
"seed": args.seed,
"steps": args.steps,
"batch_size": args.batch_size,
"target_bytes_seen": args.steps * args.batch_size * CONTEXT,
"manifest": {
"path": str(args.manifest),
"file_sha256": file_sha256(args.manifest),
"formal_schedule_sha256": manifest["windows"][
"formal_schedule_sha256"
],
"validation_tensor_sha256": manifest["windows"][
"validation_tensor_sha256"
],
"diagnostic_tensor_sha256": manifest["windows"][
"diagnostic_tensor_sha256"
],
},
"model": {
"layers": LAYERS,
"sublayers": SUBLAYERS,
"blocks_for_block_attnres": BLOCKS,
"sublayers_per_attnres_block": SUBLAYERS_PER_BLOCK,
"d_model": D_MODEL,
"heads": HEADS,
"d_head": D_HEAD,
"d_ff": D_FF,
"context": CONTEXT,
"vocabulary": VOCABULARY,
"parameters": inventory,
},
"optimizer": {
"name": "AdamW",
"betas": list(BETAS),
"epsilon": ADAM_EPS,
"weight_decay_ndim_ge_2": WEIGHT_DECAY,
"peak_lr": PEAK_LR,
"min_lr": MIN_LR,
"warmup_steps": WARMUP_STEPS,
"grad_clip": GRAD_CLIP,
},
"hashes": {
"initial_common_parameters": initial_common_hash,
"initial_mixer_parameters": initial_mixer_hash,
"final_common_parameters": final_common_hash,
"final_mixer_parameters": final_mixer_hash,
},
"evaluations": evaluations,
"training_history": training_history,
"diagnostic": diagnostic_result,
"timing": timing,
"environment": {
"python": platform.python_version(),
"torch": torch.__version__,
"cuda": torch.version.cuda,
"gpu": torch.cuda.get_device_name(0),
"compute_capability": list(torch.cuda.get_device_capability(0)),
"cublas_workspace_config": os.environ["CUBLAS_WORKSPACE_CONFIG"],
"deterministic_algorithms": torch.are_deterministic_algorithms_enabled(),
"autocast": "cuda-bfloat16",
"compile": False,
},
}
result["canonical_sha256_without_self"] = canonical_json_sha256(result)
args.output.parent.mkdir(parents=True, exist_ok=True)
temporary = args.output.with_suffix(args.output.suffix + ".tmp")
temporary.write_text(
json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
)
os.replace(temporary, args.output)
print(
json.dumps(
{
"output": str(args.output),
"architecture": args.architecture,
"seed": args.seed,
"steps": args.steps,
"final_bpc": evaluations[-1]["bits_per_byte"],
"initial_common_hash": initial_common_hash,
"final_common_hash": final_common_hash,
"canonical_sha256": result["canonical_sha256_without_self"],
"timing": timing,
},
ensure_ascii=False,
indent=2,
)
)
if __name__ == "__main__":
main()