ArbiterOmni / scripts /train_v6_max.py
No
[Release] Upload latest scripts/train_v6_max.py
998d495 verified
Raw History Blame Contribute Delete
41.3 kB
"""
ArbiterOmni v6-Max Hardware Saturation Training Script [AO-34, AO-35].
Pushes the AMD Radeon RX 480 (4 GB GDDR5) + 8 GB DDR4 host RAM to full
saturation through three coordinated hardware advances:
[AO-34] Native 768-Dim Manifold & 8-Expert MoE Scaling
- hidden_dim / scoring_dim promoted from 512 → 768 (native SigLIP dim)
- 4 MoE layers × (1 Shared + 7 Domain-Routed) = 32 total experts
- Active model: ~46 M trainable parameters
- Estimated VRAM footprint: ~3.2 GB / 4 GB
[AO-35] 100k Multimodal Memory Bank & DirectML Shader Optimizer
- DirectML-native AdamW (mul_ + add_) replaces CPU-fallback lerp_
→ 95–100% continuous GPU compute saturation
- CosineAnnealingLR over 10 epochs + Stochastic Weight Averaging (SWA)
- Memory Bank harvested from ScienceQA, GQA, AI2D, SEED-Bench (up to 100k foils)
- Estimated host RAM footprint: ~5.5 GB / 8 GB DDR4
Usage:
.venv-directml/bin/python scripts/train_v6_max.py \\
--epochs 10 --batch-size 8 --lr 5e-5 \\
--save-path checkpoints/arbiter_omni_v6_max.pt
"""
from __future__ import annotations
import argparse
import gc
import json
import logging
import math
import os
import resource
import shutil
import sys
import time
from typing import Dict, List, Tuple
import torch
import torch.nn as nn
# Ensure src in path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
from arbiter_omni import (
ArbiterOmniEngine,
ArbiterOmniModel,
ArbiterOmniTrainer,
CachedMultimodalDataset,
HardNegativeMiner,
OpenCLIPMultimodalEncoder,
PersistentMemoryBank,
TrainingConfig,
get_device_telemetry,
resolve_device,
)
from arbiter_omni.data.ai2d import load_ai2d_dataset, _make_mock_ai2d_samples
from arbiter_omni.data.gqa import load_gqa_dataset, _make_mock_gqa_samples
from arbiter_omni.data.robotics import generate_robotics_samples
from arbiter_omni.data.scienceqa import load_scienceqa_dataset, create_mock_scienceqa_samples
from arbiter_omni.data.seedbench import load_seedbench_dataset, create_mock_seedbench_samples
from arbiter_omni.fusion.transformer import TransformerMultimodalFusion
from arbiter_omni.model.decision_head import DynamicDecisionHead
from arbiter_omni.model.speculative import SpeculativeDraftHead
from arbiter_omni.training.optim import DirectMLNativeAdamW
from arbiter_omni.training.telemetry import get_rss_mb, memory_summary
from arbiter_omni.types import ModalityType, MultimodalSample
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Dataset builder — larger crop of ScienceQA + GQA + AI2D + SEED-Bench
# ---------------------------------------------------------------------------
def build_v6_max_dataset(
scienceqa_samples: int = 2400,
seedbench_samples: int = 120,
ai2d_samples: int = 600,
gqa_samples: int = 2400,
use_mock_data: bool = False,
) -> Tuple[List[MultimodalSample], List[MultimodalSample]]:
"""Builds balanced train/val split across multimodal benchmarks (v6-Max scale)."""
train_samples: List[MultimodalSample] = []
val_samples: List[MultimodalSample] = []
# 1. ScienceQA
logger.info(f"[v6-Max] Gathering ScienceQA samples ({scienceqa_samples})...")
sqa = (
create_mock_scienceqa_samples(scienceqa_samples)
if use_mock_data
else load_scienceqa_dataset(
split="train", max_samples=scienceqa_samples, only_multimodal=True,
streaming=True, use_mock_fallback=True,
)
)
sqa = [s for s in sqa if s.target_idx is not None]
n = int(len(sqa) * 0.8)
train_samples.extend(sqa[:n])
val_samples.extend(sqa[n:])
# 2. SEED-Bench-2
logger.info(f"[v6-Max] Gathering SEED-Bench-2 samples ({seedbench_samples})...")
seed = (
create_mock_seedbench_samples(seedbench_samples)
if use_mock_data
else load_seedbench_dataset(max_samples=seedbench_samples, use_mock_fallback=True)
)
seed = [s for s in seed if s.target_idx is not None]
n = int(len(seed) * 0.8)
train_samples.extend(seed[:n])
val_samples.extend(seed[n:])
# 3. AI2D
logger.info(f"[v6-Max] Gathering AI2D diagram samples ({ai2d_samples})...")
ai2d = (
_make_mock_ai2d_samples(ai2d_samples)
if use_mock_data
else load_ai2d_dataset(split="train", max_samples=ai2d_samples, streaming=True, use_mock_fallback=True)
)
ai2d = [s for s in ai2d if s.target_idx is not None]
n = int(len(ai2d) * 0.8)
train_samples.extend(ai2d[:n])
val_samples.extend(ai2d[n:])
# 4. GQA
logger.info(f"[v6-Max] Gathering GQA samples ({gqa_samples})...")
gqa = (
_make_mock_gqa_samples(gqa_samples)
if use_mock_data
else load_gqa_dataset(split="train", max_samples=gqa_samples, streaming=True, use_mock_fallback=True)
)
gqa = [s for s in gqa if s.target_idx is not None]
n = int(len(gqa) * 0.8)
train_samples.extend(gqa[:n])
val_samples.extend(gqa[n:])
# 5. Robotics
logger.info("[v6-Max] Gathering Robotics action control samples (150)...")
robotics = generate_robotics_samples(num_samples=150)
n = int(len(robotics) * 0.8)
train_samples.extend(robotics[:n])
val_samples.extend(robotics[n:])
return train_samples, val_samples
TRAINABLE_MODULES = ("fusion", "decision_head", "speculative_head")
ARCH_KEYS = ("hidden_dim", "scoring_dim", "num_heads", "moe_num_layers", "moe_num_experts", "moe_top_k")
def log_mem(stage: str) -> None:
"""Logs a labelled process/system memory reading."""
logger.info(f"[memory] {stage}: {memory_summary()}")
def state_to_cpu(module: nn.Module, dtype: torch.dtype | None = None) -> Dict[str, torch.Tensor]:
"""Copies a module's state dict to CPU one tensor at a time (optionally casting floats)."""
out: Dict[str, torch.Tensor] = {}
for k, v in module.state_dict().items():
t = v.detach()
if dtype is not None and t.is_floating_point():
out[k] = t.to("cpu", dtype=dtype)
else:
out[k] = t.to("cpu").clone() if t.device.type == "cpu" else t.to("cpu")
return out
def atomic_torch_save(obj, path: str) -> None:
"""
Writes to <path>.tmp then renames, so an interrupted or killed save can never
leave a truncated (for example 0-byte) file at the destination.
"""
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
tmp = f"{path}.tmp"
torch.save(obj, tmp)
os.replace(tmp, path)
def to_cpu_tree(obj):
"""
Recursively moves every tensor in a nested structure to CPU, one tensor at a time.
torch.save() called directly on DirectML tensors is killed by the OS (SIGKILL) even
for ~1 GB of state; saving CPU copies is stable. Always route device state through this.
"""
if isinstance(obj, torch.Tensor):
return obj.detach().to("cpu")
if isinstance(obj, dict):
return {k: to_cpu_tree(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return type(obj)(to_cpu_tree(v) for v in obj)
return obj
def save_resume_dir(resume_dir: str, meta: dict, model: nn.Module, optimizer, swa: "RunningWeightAverage") -> None:
"""
Writes the full resumable training state as a directory of files, one CPU copy alive at
a time (peak host memory is the largest single file, the optimizer moments), then swaps
it into place atomically. The previous good state is kept as <dir>.old until the swap ends.
"""
tmp, old = f"{resume_dir}.tmp", f"{resume_dir}.old"
shutil.rmtree(tmp, ignore_errors=True)
os.makedirs(tmp)
torch.save({n: state_to_cpu(getattr(model, n)) for n in TRAINABLE_MODULES}, os.path.join(tmp, "weights.pt"))
torch.save(to_cpu_tree(optimizer.state_dict()), os.path.join(tmp, "optim.pt"))
torch.save(swa.state_dict(), os.path.join(tmp, "swa.pt"))
torch.save(meta, os.path.join(tmp, "meta.pt"))
shutil.rmtree(old, ignore_errors=True)
if os.path.exists(resume_dir):
os.replace(resume_dir, old)
os.replace(tmp, resume_dir)
shutil.rmtree(old, ignore_errors=True)
def load_resume_dir(resume_dir: str) -> dict:
"""Loads a resume directory, falling back to <dir>.old if a swap was interrupted."""
if not os.path.isdir(resume_dir) and os.path.isdir(f"{resume_dir}.old"):
logger.warning(f"{resume_dir} missing; falling back to {resume_dir}.old (interrupted swap)")
resume_dir = f"{resume_dir}.old"
if not os.path.isdir(resume_dir):
raise FileNotFoundError(f"--resume requested but {resume_dir} does not exist")
def _load(name):
return torch.load(os.path.join(resume_dir, name), map_location="cpu", weights_only=False)
return {"meta": _load("meta.pt"), "weights": _load("weights.pt"), "optim": _load("optim.pt"), "swa": _load("swa.pt")}
class RunningWeightAverage:
"""
Equal-weight running average of the trainable submodules (Stochastic Weight Averaging).
Held on CPU in FP32 and restricted to fusion / decision_head / speculative_head, so it
costs roughly one extra copy of the trainable weights rather than a deep copy of the
whole model including the frozen encoder.
"""
def __init__(self) -> None:
self.n = 0
self.avg: Dict[str, torch.Tensor] = {}
@torch.no_grad()
def update(self, model: nn.Module) -> None:
self.n += 1
for name in TRAINABLE_MODULES:
module = getattr(model, name, None)
if module is None:
continue
for k, v in module.state_dict().items():
key = f"{name}.{k}"
if not v.is_floating_point():
self.avg[key] = v.detach().to("cpu").clone()
continue
cur = v.detach().to("cpu", dtype=torch.float32)
if key not in self.avg:
self.avg[key] = cur.clone()
else:
self.avg[key].mul_(self.n - 1).add_(cur).div_(self.n)
def module_state_dicts(self) -> Dict[str, Dict[str, torch.Tensor]]:
out: Dict[str, Dict[str, torch.Tensor]] = {name: {} for name in TRAINABLE_MODULES}
for key, tensor in self.avg.items():
name, _, k = key.partition(".")
out[name][k] = tensor
return out
def state_dict(self) -> dict:
return {"n": self.n, "avg": self.avg}
def load_state_dict(self, state: dict) -> None:
self.n = int(state["n"])
self.avg = {k: v.clone() for k, v in state["avg"].items()}
def make_swa_scheduler(optimizer, lr: float, epochs: int, swa_start_epoch: int):
return torch.optim.swa_utils.SWALR(
optimizer, swa_lr=lr / 10.0, anneal_epochs=max(1, epochs - swa_start_epoch + 1)
)
def _delta(series: List[float], scale: float = 1.0, suffix: str = "") -> str:
"""Formats the change between the last two values of a metric series."""
if len(series) < 2:
return "first epoch"
return f"d{(series[-1] - series[-2]) * scale:+.4f}{suffix}"
def setup_file_logging(log_path: str) -> None:
"""Mirrors all log output to a file so a run can be inspected after the terminal is gone."""
os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True)
handler = logging.FileHandler(log_path)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logging.getLogger().addHandler(handler)
logger.info(f"Logging to file: {log_path}")
# ---------------------------------------------------------------------------
# Main training entry
# ---------------------------------------------------------------------------
def train_v6_max(
epochs: int = 10,
batch_size: int = 8,
lr: float = 5e-5,
save_path: str = "checkpoints/arbiter_omni_v6_max.pt",
model_name: str = "ViT-B-16-SigLIP",
# [AO-34] Native 768-Dim: hidden_dim = scoring_dim = 768
hidden_dim: int = 768,
num_heads: int = 8,
scoring_dim: int = 768,
max_spatial_patches: int = 980,
# [AO-34] 8-Expert MoE: 1 Shared + 7 Routed = 32 experts across 4 layers
moe_layers: int = 4,
moe_experts: int = 8,
moe_top_k: int = 2,
# [AO-35] 100k Memory Bank
memory_bank_capacity: int = 100_000,
device_name: str | None = None,
use_mock_data: bool = False,
use_int8_cache: bool = True,
# SWA (Stochastic Weight Averaging)
swa_start_epoch: int = 7,
resume: bool = False,
smoke_test: bool = False,
log_interval: int = 20,
) -> str:
"""ArbiterOmni v6-Max: full hardware saturation training pipeline [AO-34, AO-35]."""
if smoke_test:
if save_path == "checkpoints/arbiter_omni_v6_max.pt":
save_path = "checkpoints/smoke_v6_max.pt"
swa_start_epoch = min(swa_start_epoch, max(1, epochs))
log_interval = 1
print("=" * 80)
print("ARBITEROMNI v6-Max HARDWARE SATURATION TRAINING PIPELINE [AO-34 / AO-35]")
print(" Native 768-Dim Manifold | 8-Expert DeepSeek-V3 MoE | DirectML-Native AdamW")
print(" CosineAnnealingLR | Stochastic Weight Averaging (SWA) | 100k Memory Bank")
print("=" * 80)
device = resolve_device(device_name)
telemetry = get_device_telemetry(device)
logger.info(f"Compute Device : {telemetry['device']} ({telemetry['gpu_name']})")
logger.info(f"Architecture : hidden_dim={hidden_dim}, scoring_dim={scoring_dim}, "
f"MoE {moe_layers}L×{moe_experts}E (Top-{moe_top_k})")
logger.info(f"Initial Host RSS: {get_rss_mb():.1f} MB")
# -----------------------------------------------------------------------
# 1. Perception Encoder — frozen, CPU-resident
# -----------------------------------------------------------------------
logger.info(f"Initializing OpenCLIP {model_name} perception backbone (frozen, CPU)...")
encoder = OpenCLIPMultimodalEncoder(model_name=model_name, device="cpu", cpu_offload_encoder=True)
logger.info(
f"Perception Dims : Text={encoder.text_dim}, Image={encoder.image_dim}, "
f"Video={encoder.video_dim}, Audio={encoder.audio_dim}"
)
frozen_count = sum(p.numel() for p in encoder.parameters())
logger.info(f"Frozen Encoder : {frozen_count:,} params — 0% gradient updates")
# -----------------------------------------------------------------------
# 2. v6-Max Architecture [AO-34]
# 768-dim manifold (matches SigLIP native space — no down-projection)
# 4 MoE layers × (1 Shared Invariant + 7 Domain-Routed) = 32 experts
# -----------------------------------------------------------------------
modality_dims = {
"question": encoder.text_dim,
ModalityType.TEXT.value: encoder.text_dim,
ModalityType.IMAGE.value: encoder.image_dim,
ModalityType.VIDEO.value: encoder.video_dim,
ModalityType.AUDIO.value: encoder.audio_dim,
}
fusion = TransformerMultimodalFusion(
modality_dims=modality_dims,
hidden_dim=hidden_dim, # 768 — native SigLIP manifold
num_heads=num_heads, # 8 heads × 96 dim = 768
dim_feedforward=hidden_dim * 2, # 1536 FFN
enable_spatial_cross_attention=True,
max_spatial_patches=max_spatial_patches,
use_moe=True,
moe_num_layers=moe_layers, # 4 MoE transformer layers
moe_num_experts=moe_experts, # 8 experts/layer (1 shared + 7 routed)
moe_top_k=moe_top_k, # Top-2 soft routing per token
use_shared_expert=True,
)
decision_head = DynamicDecisionHead(
context_dim=hidden_dim, # 768
candidate_dim=encoder.text_dim, # 768
scoring_dim=scoring_dim, # 768
)
speculative_head = SpeculativeDraftHead(
input_dim=encoder.text_dim,
candidate_dim=encoder.text_dim,
draft_dim=128,
)
model = ArbiterOmniModel(
encoder=encoder,
fusion=fusion,
decision_head=decision_head,
speculative_head=speculative_head,
hidden_dim=hidden_dim,
scoring_dim=scoring_dim,
use_moe=True,
moe_num_layers=moe_layers,
moe_num_experts=moe_experts,
moe_top_k=moe_top_k,
use_shared_expert=True,
enable_speculative_early_exit=True,
).to(device)
trainable_params = model.trainable_parameters()
trainable_count = sum(p.numel() for p in trainable_params)
logger.info(f"Trainable v6-Max Parameters: {trainable_count:,} "
f"(4 MoE Layers × {moe_experts} Experts + Speculative Head)")
for name in TRAINABLE_MODULES:
n_params = sum(p.numel() for p in getattr(model, name).parameters())
logger.info(f" {name:<16s}: {n_params:>12,} params")
log_mem("model built")
model_config = {
"hidden_dim": hidden_dim,
"scoring_dim": scoring_dim,
"num_layers": moe_layers,
"num_heads": num_heads,
"model_name": model_name,
"use_moe": True,
"moe_num_layers": moe_layers,
"moe_num_experts": moe_experts,
"moe_top_k": moe_top_k,
"use_shared_expert": True,
"enable_speculative_early_exit": True,
"enable_spatial_cross_attention": True,
"max_spatial_patches": max_spatial_patches,
}
logger.info("Run configuration: " + json.dumps(dict(
model_config, epochs=epochs, batch_size=batch_size, lr=lr, save_path=save_path,
swa_start_epoch=swa_start_epoch, memory_bank_capacity=memory_bank_capacity,
resume=resume, smoke_test=smoke_test, log_interval=log_interval,
), sort_keys=True))
# -----------------------------------------------------------------------
# 3. INT8 Cached Representations [AO-25]
# -----------------------------------------------------------------------
cache_tag = f"v6_max_int8_{model_name.replace('/', '_')}"
cache_train_path = f"data/cache/{cache_tag}_train.pt"
cache_val_path = f"data/cache/{cache_tag}_val.pt"
if os.path.exists(cache_train_path) and os.path.exists(cache_val_path):
logger.info(f"Loading pre-computed cached representations from {cache_tag}_*.pt...")
train_cached = CachedMultimodalDataset.load(cache_train_path)
val_cached = CachedMultimodalDataset.load(cache_val_path)
else:
logger.info(f"Pre-caching v6-Max representations into shared memory (INT8={use_int8_cache})...")
train_samples, val_samples = build_v6_max_dataset(use_mock_data=use_mock_data)
logger.info(f"Dataset split: Train={len(train_samples):,} | Val={len(val_samples):,}")
from arbiter_omni.data.dataset import MultimodalDecisionDataset
train_cached = CachedMultimodalDataset.from_dataset(
MultimodalDecisionDataset(train_samples),
model=model, batch_size=batch_size, device=device, use_int8=use_int8_cache,
)
val_cached = CachedMultimodalDataset.from_dataset(
MultimodalDecisionDataset(val_samples),
model=model, batch_size=batch_size, device=device, use_int8=use_int8_cache,
)
try:
train_cached.save(cache_train_path)
val_cached.save(cache_val_path)
logger.info(f"Saved v6-Max cache to disk ({cache_tag}_*.pt).")
except Exception as e:
logger.warning(f"Could not persist cache: {e}")
logger.info(f"Train Cache Resident Memory: {train_cached.total_memory_mb:.1f} MB (INT8 compressed)")
log_mem("caches ready")
# -----------------------------------------------------------------------
# 4. Offload frozen encoder → CPU VRAM reclaim
# -----------------------------------------------------------------------
logger.info("Offloading frozen perception encoder to CPU to reclaim VRAM for v6-Max...")
model.encoder.to("cpu")
if hasattr(torch, "cuda") and torch.cuda.is_available():
torch.cuda.empty_cache()
# -----------------------------------------------------------------------
# 5. 100k Multimodal Memory Bank in shared DDR4 RAM [AO-35]
# -----------------------------------------------------------------------
logger.info(f"Initializing {memory_bank_capacity:,}-capacity Memory Bank in shared DDR4 RAM...")
memory_bank = PersistentMemoryBank(
capacity=memory_bank_capacity,
candidate_dim=encoder.text_dim,
device="cpu",
store_fp16=True,
)
bank_cache_path = f"data/cache/v6_max_memory_bank_{model_name.replace('/', '_')}.pt"
v5_bank_path = f"data/cache/v5_memory_bank_{model_name.replace('/', '_')}.pt"
if os.path.exists(bank_cache_path):
logger.info(f"Loading pre-computed v6-Max Memory Bank from {bank_cache_path}...")
memory_bank.load(bank_cache_path)
elif os.path.exists(v5_bank_path):
# Warm-start from v5 bank, then augment
logger.info(f"Warm-starting v6-Max Memory Bank from existing v5 bank at {v5_bank_path}...")
memory_bank.load(v5_bank_path)
else:
if not use_mock_data:
train_samples_for_bank, _ = build_v6_max_dataset(use_mock_data=False)
else:
train_samples_for_bank = []
if train_samples_for_bank:
miner = HardNegativeMiner.from_dataset(train_samples_for_bank, encoder=model.encoder, batch_size=64)
miner.populate_memory_bank(memory_bank)
try:
memory_bank.save(bank_cache_path)
logger.info(f"Saved v6-Max memory bank to {bank_cache_path}")
except Exception as e:
logger.warning(f"Could not persist memory bank: {e}")
logger.info(
f"Memory Bank Ready: {len(memory_bank):,} foils (DDR4 RSS: {memory_bank.memory_usage_mb:.2f} MB)"
)
log_mem("memory bank ready")
# -----------------------------------------------------------------------
# 6. Optimizer, LR schedule and SWA accumulator [AO-35]
# -----------------------------------------------------------------------
logger.info("Initializing DirectML-native AdamW (mul_/add_ only, no lerp CPU fallback)")
optimizer = DirectMLNativeAdamW(
model.trainable_parameters(),
lr=lr,
betas=(0.9, 0.999),
eps=1e-8,
weight_decay=1e-2,
)
# CosineAnnealingLR: decay lr from `lr` to `lr/100` over `epochs` steps
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=epochs, eta_min=lr / 100.0
)
# SWA accumulator: trainable submodules only, FP32, resident on CPU.
# (The previous implementation deep-copied the entire model including the
# frozen 212M-parameter encoder, and kept the copy on the accelerator.)
swa = RunningWeightAverage()
swa_scheduler = None # created lazily at the first SWA epoch
log_mem("optimizer and SWA accumulator ready")
# -----------------------------------------------------------------------
# 7. Trainer
# -----------------------------------------------------------------------
config = TrainingConfig(
num_epochs=epochs,
batch_size=batch_size,
learning_rate=lr,
fp16=True,
save_path=None, # all checkpointing is handled atomically by this script
device=str(device),
contrastive_lambda=0.30,
margin_gamma=0.5,
use_memory_bank=True,
memory_bank_capacity=memory_bank_capacity,
memory_bank_k_foils=12,
async_prefetch=True,
prefetch_queue_size=2,
accumulate_grad_batches=2,
log_interval=log_interval,
)
trainer = ArbiterOmniTrainer(model=model, config=config)
trainer.memory_bank = memory_bank
# Inject DirectML-native optimizer, overriding the default AdamW
trainer.optimizer = optimizer
from torch.utils.data import DataLoader, Subset
from arbiter_omni.data.cached import collate_cached_multimodal_decision
if smoke_test:
train_cached = Subset(train_cached, list(range(min(48, len(train_cached)))))
val_cached = Subset(val_cached, list(range(min(16, len(val_cached)))))
logger.info(f"SMOKE TEST: using {len(train_cached)} train / {len(val_cached)} val samples")
train_loader = DataLoader(
train_cached,
batch_size=batch_size,
shuffle=True,
collate_fn=collate_cached_multimodal_decision,
num_workers=0,
)
val_loader = DataLoader(
val_cached,
batch_size=batch_size,
shuffle=False,
collate_fn=collate_cached_multimodal_decision,
num_workers=0,
)
logger.info(
f"Dataloaders: train_batches={len(train_loader)} val_batches={len(val_loader)} "
f"batch_size={batch_size} grad_accum=2 effective_batch={batch_size * 2}"
)
# -----------------------------------------------------------------------
# 8. Resume (optional)
# -----------------------------------------------------------------------
history: Dict[str, list] = {
"loss": [], "accuracy": [], "entropy": [], "margin_loss": [],
"val_loss": [], "val_accuracy": [], "val_entropy": [],
"lr": [], "epoch_seconds": [],
}
start_epoch = 1
prior_elapsed_s = 0.0
resume_dir = f"{save_path}.resume"
metrics_path = f"{save_path}.metrics.jsonl"
if resume:
logger.info(f"Resuming from {resume_dir}")
ckpt = load_resume_dir(resume_dir)
meta = ckpt["meta"]
saved_cfg = meta.get("model_config", {})
for key in ARCH_KEYS:
if key in saved_cfg and saved_cfg[key] != model_config[key]:
raise ValueError(
f"Resume architecture mismatch for '{key}': checkpoint={saved_cfg[key]} current={model_config[key]}"
)
for name in TRAINABLE_MODULES:
getattr(model, name).load_state_dict(ckpt["weights"][name])
optimizer.load_state_dict(ckpt["optim"])
scheduler.load_state_dict(meta["scheduler"])
swa.load_state_dict(ckpt["swa"])
if meta.get("swa_scheduler") is not None:
swa_scheduler = make_swa_scheduler(optimizer, lr, epochs, swa_start_epoch)
swa_scheduler.load_state_dict(meta["swa_scheduler"])
history = meta["history"]
start_epoch = int(meta["epoch"]) + 1
prior_elapsed_s = float(meta.get("elapsed_s", 0.0))
del ckpt, meta
gc.collect()
logger.info(
f"Resume state restored: completed_epochs={start_epoch - 1} "
f"swa_snapshots={swa.n} lr={optimizer.param_groups[0]['lr']:.3e}"
)
log_mem("resume restored")
if start_epoch > epochs:
logger.info("All requested epochs already completed; skipping straight to finalisation")
elif os.path.exists(metrics_path):
# fresh run: do not append to a stale metrics file from an earlier run
os.replace(metrics_path, metrics_path + ".old")
# -----------------------------------------------------------------------
# 9. Training loop
# -----------------------------------------------------------------------
logger.info(
f"Starting v6-Max training loop: epochs {start_epoch}..{epochs}, SWA from epoch {swa_start_epoch}"
)
run_t0 = time.perf_counter()
epoch_durations: List[float] = list(history.get("epoch_seconds", []))
for epoch in range(start_epoch, epochs + 1):
epoch_t0 = time.perf_counter()
lr_now = optimizer.param_groups[0]["lr"]
in_swa = epoch >= swa_start_epoch
logger.info("=" * 100)
logger.info(
f"Epoch {epoch:02d}/{epochs:02d} START | lr={lr_now:.3e} | "
f"phase={'SWA' if in_swa else 'cosine'} | {memory_summary()}"
)
train_metrics = trainer.train_epoch(train_loader)
train_s = time.perf_counter() - epoch_t0
log_mem(f"epoch {epoch:02d} training finished in {train_s / 60.0:.1f} min")
eval_t0 = time.perf_counter()
val_metrics = trainer.evaluate(val_loader)
eval_s = time.perf_counter() - eval_t0
log_mem(f"epoch {epoch:02d} validation finished in {eval_s:.0f} s")
history["loss"].append(train_metrics["loss"])
history["accuracy"].append(train_metrics["accuracy"])
history["entropy"].append(train_metrics["entropy"])
history["margin_loss"].append(train_metrics.get("margin_loss", 0.0))
history["val_loss"].append(val_metrics["val_loss"])
history["val_accuracy"].append(val_metrics["val_accuracy"])
history["val_entropy"].append(val_metrics.get("val_entropy", 0.0))
history["lr"].append(lr_now)
best_idx = max(range(len(history["val_accuracy"])), key=lambda i: history["val_accuracy"][i])
logger.info(
f"Epoch {epoch:02d}/{epochs:02d} RESULT | "
f"train_loss={train_metrics['loss']:.4f} ({_delta(history['loss'])}) | "
f"train_acc={train_metrics['accuracy'] * 100:.2f}% ({_delta(history['accuracy'], 100.0, '%')}) | "
f"val_loss={val_metrics['val_loss']:.4f} ({_delta(history['val_loss'])}) | "
f"val_acc={val_metrics['val_accuracy'] * 100:.2f}% ({_delta(history['val_accuracy'], 100.0, '%')}) | "
f"entropy={train_metrics['entropy']:.3f} ({_delta(history['entropy'])}) | "
f"margin_loss={history['margin_loss'][-1]:.4f} | "
f"temperature={train_metrics.get('temperature', float('nan')):.3f} | "
f"best_val_acc={history['val_accuracy'][best_idx] * 100:.2f}% (epoch {best_idx + 1})"
)
# SWA snapshot, then LR schedule step
if in_swa:
swa.update(model)
if swa_scheduler is None:
swa_scheduler = make_swa_scheduler(optimizer, lr, epochs, swa_start_epoch)
swa_scheduler.step()
logger.info(
f"SWA snapshot {swa.n} accumulated (equal-weight average of epochs "
f"{epoch - swa.n + 1}..{epoch}); next lr={optimizer.param_groups[0]['lr']:.3e}"
)
else:
scheduler.step()
logger.info(f"Cosine schedule stepped; next lr={optimizer.param_groups[0]['lr']:.3e}")
epoch_s = time.perf_counter() - epoch_t0
epoch_durations.append(epoch_s)
history["epoch_seconds"].append(epoch_s)
elapsed_total_s = prior_elapsed_s + (time.perf_counter() - run_t0)
recent = epoch_durations[-3:]
eta_h = (sum(recent) / len(recent)) * (epochs - epoch) / 3600.0
logger.info(
f"Epoch {epoch:02d}/{epochs:02d} TIMING | train={train_s / 60.0:.1f}min eval={eval_s / 60.0:.1f}min "
f"epoch={epoch_s / 60.0:.1f}min | total_elapsed={elapsed_total_s / 3600.0:.2f}h | "
f"eta_remaining={eta_h:.2f}h"
)
# ---- Persist: inference snapshot (FP16) + full resume state + metrics line ----
persist_t0 = time.perf_counter()
snapshot = {
"model_config": dict(model_config, snapshot_epoch=epoch),
"training_history": history,
**{name: state_to_cpu(getattr(model, name), torch.float16) for name in TRAINABLE_MODULES},
}
atomic_torch_save(snapshot, save_path)
del snapshot
log_mem(f"epoch {epoch:02d} inference snapshot written -> {save_path}")
save_resume_dir(
resume_dir,
meta={
"epoch": epoch,
"elapsed_s": elapsed_total_s,
"model_config": model_config,
"history": history,
"scheduler": scheduler.state_dict(),
"swa_scheduler": swa_scheduler.state_dict() if swa_scheduler is not None else None,
},
model=model,
optimizer=optimizer,
swa=swa,
)
gc.collect()
logger.info(
f"Epoch {epoch:02d}/{epochs:02d} CHECKPOINT | snapshot={os.path.getsize(save_path) / 1048576:.1f}MB "
f"resume_dir={sum(os.path.getsize(os.path.join(resume_dir, f)) for f in os.listdir(resume_dir)) / 1048576:.1f}MB written in "
f"{time.perf_counter() - persist_t0:.1f}s | {memory_summary()}"
)
with open(metrics_path, "a") as mf:
mf.write(json.dumps({
"epoch": epoch,
"lr": lr_now,
"train_loss": train_metrics["loss"],
"train_acc": train_metrics["accuracy"],
"val_loss": val_metrics["val_loss"],
"val_acc": val_metrics["val_accuracy"],
"entropy": train_metrics["entropy"],
"margin_loss": train_metrics.get("margin_loss", 0.0),
"samples_per_sec": train_metrics.get("samples_per_sec", 0.0),
"train_seconds": train_s,
"eval_seconds": eval_s,
"rss_mb": get_rss_mb(),
"swa_snapshots": swa.n,
}) + "\n")
train_elapsed = prior_elapsed_s + (time.perf_counter() - run_t0)
# -----------------------------------------------------------------------
# 10. Finalise: SWA average (or final weights) -> FP16 + FP32 checkpoints
# -----------------------------------------------------------------------
os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
if swa.n > 0:
final_sd = swa.module_state_dicts()
weight_source = f"SWA equal-weight average of {swa.n} epoch snapshots"
else:
final_sd = {name: state_to_cpu(getattr(model, name)) for name in TRAINABLE_MODULES}
weight_source = "final-epoch weights (SWA start epoch not reached)"
logger.info(f"Finalising checkpoints from: {weight_source}")
final_config = dict(
model_config,
version="v6-Max",
swa=swa.n > 0,
swa_snapshots=swa.n,
swa_start_epoch=swa_start_epoch,
epochs=epochs,
optimizer="DirectMLNativeAdamW",
)
fp16_state = {
**{name: {k: (v.half() if v.is_floating_point() else v) for k, v in sd.items()}
for name, sd in final_sd.items()},
"training_history": history,
"model_config": final_config,
}
atomic_torch_save(fp16_state, save_path)
del fp16_state
file_size_mb = os.path.getsize(save_path) / (1024 * 1024)
logger.info(
f"Final FP16 checkpoint written: {save_path} ({file_size_mb:.2f} MB) "
f"total training time {train_elapsed / 3600.0:.2f}h"
)
fp32_path = save_path.replace(".pt", "_fp32.pt")
atomic_torch_save(
{**final_sd, "model_config": final_config, "training_history": history},
fp32_path,
)
logger.info(
f"Final FP32 checkpoint written: {fp32_path} ({os.path.getsize(fp32_path) / (1024 * 1024):.2f} MB)"
)
log_mem("final checkpoints written")
# Release training state before loading a second encoder for verification
del trainer, optimizer, scheduler, swa_scheduler, swa, final_sd, train_loader, val_loader
del train_cached, val_cached
gc.collect()
log_mem("training state released")
# -----------------------------------------------------------------------
# 11. Verification Inference
# -----------------------------------------------------------------------
print("\n" + "=" * 80)
print("VERIFYING v6-Max RUNTIME INFERENCE VIA ArbiterOmniEngine.from_pretrained()")
print("=" * 80)
engine = ArbiterOmniEngine.from_pretrained(save_path, device=device)
engine.memory_bank = memory_bank
scenarios = [
{
"text": "Dense multi-lane highway, heavy rainfall, vehicle decelerating abruptly 15m ahead.",
"question": "What is the safest immediate action?",
"candidates": [
"decelerate smoothly and increase following distance",
"proceed at maximum velocity without braking",
"execute uncontrolled swerve into barrier",
"turn off all headlights and warning telemetry",
],
},
{
"text": "Surgical robot assisting with minimally invasive cardiac procedure. Patient vitals unstable.",
"question": "What is the correct intervention priority?",
"candidates": [
"pause procedure and alert supervising surgeon immediately",
"continue procedure at increased speed",
"reduce sedation dosage and proceed",
"disengage all safety interlocks",
],
},
]
for i, s in enumerate(scenarios):
res = engine.decide(
question=s["question"],
candidates=s["candidates"],
text=s["text"],
test_time_deliberate=True,
)
print(f"\nScenario {i+1}: {s['question']}")
print(f" Decision : {res.decision}")
print(f" Confidence : {res.confidence * 100:.2f}%")
print(f" Entropy : {res.entropy:.4f} nats")
print(f" Speculative Early Exit: {res.speculative_early_exit}")
print(f" Conformal Set (95%) : {res.prediction_set}")
print(f" Stability Index : {res.stability_index:.3f} (Certified: {res.stability_index >= 0.80})")
print(f" Escalate System 2 : {res.escalate_system2}")
final_mem = get_rss_mb()
logger.info(f"Final Host RSS: {final_mem:.1f} MB (target: <5.5 GB across 8 GB DDR4 pool)")
return save_path
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Train ArbiterOmni v6-Max [AO-34, AO-35]")
parser.add_argument("--epochs", type=int, default=10)
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument("--lr", type=float, default=5e-5)
parser.add_argument("--save-path", type=str, default="checkpoints/arbiter_omni_v6_max.pt")
parser.add_argument("--model-name", type=str, default="ViT-B-16-SigLIP")
parser.add_argument("--hidden-dim", type=int, default=768)
parser.add_argument("--num-heads", type=int, default=8)
parser.add_argument("--scoring-dim", type=int, default=768)
parser.add_argument("--max-spatial-patches", type=int, default=980)
parser.add_argument("--memory-bank-capacity", type=int, default=100_000)
parser.add_argument("--moe-layers", type=int, default=4)
parser.add_argument("--moe-experts", type=int, default=8)
parser.add_argument("--moe-top-k", type=int, default=2)
parser.add_argument("--swa-start-epoch", type=int, default=7)
parser.add_argument("--device", type=str, default=None)
parser.add_argument("--use-mock-data", action="store_true")
parser.add_argument("--no-int8-cache", action="store_true")
parser.add_argument("--resume", action="store_true",
help="Resume from <save-path>.resume (restores weights, optimizer, schedule, SWA, history)")
parser.add_argument("--smoke-test", action="store_true",
help="Tiny end-to-end run (48 train / 16 val samples) that exercises every stage, "
"including checkpointing, SWA and verification, writing to checkpoints/smoke_v6_max.pt")
parser.add_argument("--log-interval", type=int, default=20,
help="Log a progress line every N training batches (0 disables)")
parser.add_argument("--log-file", type=str, default=None,
help="Mirror logs to this file (default: logs/train_v6_max_<timestamp>.log)")
args = parser.parse_args()
import os as _os
log_file = args.log_file or f"logs/train_v6_max_{time.strftime('%Y%m%d_%H%M%S')}.log"
setup_file_logging(log_file)
exit_code = 0
try:
train_v6_max(
epochs=args.epochs,
batch_size=args.batch_size,
lr=args.lr,
save_path=args.save_path,
model_name=args.model_name,
hidden_dim=args.hidden_dim,
num_heads=args.num_heads,
scoring_dim=args.scoring_dim,
max_spatial_patches=args.max_spatial_patches,
memory_bank_capacity=args.memory_bank_capacity,
moe_layers=args.moe_layers,
moe_experts=args.moe_experts,
moe_top_k=args.moe_top_k,
swa_start_epoch=args.swa_start_epoch,
device_name=args.device,
use_mock_data=args.use_mock_data,
use_int8_cache=not args.no_int8_cache,
resume=args.resume,
smoke_test=args.smoke_test,
log_interval=args.log_interval,
)
logger.info("v6-Max training completed successfully")
except BaseException as e: # noqa: BLE001 - we must report every failure mode
logger.exception(f"v6-Max training failed: {type(e).__name__}: {e}")
exit_code = 1
finally:
# os._exit skips interpreter teardown (the DirectML runtime can hang there),
# so flush everything explicitly and preserve the real exit status.
logging.shutdown()
sys.stdout.flush()
sys.stderr.flush()
_os._exit(exit_code)