Download code/docs/ARCHITECTURE.md from viai957/align: direct link, hf CLI and curl.
- Browser
- Download file 35.7 kB
-
https://huggingface.co/viai957/align/resolve/main/code/docs/ARCHITECTURE.md
- Command line
-
hf download hf://viai957/align/code/docs/ARCHITECTURE.md
-
curl -L -o ARCHITECTURE.md https://huggingface.co/viai957/align/resolve/main/code/docs/ARCHITECTURE.md
Architecture
Package map (one line per module, ownership)
Config & core
align.config: TOML config parser, struct-mode validation, CLI overrides. Owns config schema, training hyperparameters, stage-specific options.align.registry: Named object factories (schedulers, optimizers, batch sources). No dependencies beyond stdlib + torch.align.exceptions: Shared error types (InvalidCheckpoint, HorizonReached, etc.).align.exit_codes: Stdlib-only trainer <-> supervisor contract. Exit codes 80 (tripwire HALT), 81 (non-finite / skip-cap HALT), 82 (wall budget spent), 83 (preflight refused),ExitCodeError/PreflightRefused, the<run_dir>/HALT.jsonmarker, andrun_with_exit_codes(main)used by every stage script.
Tokenizer
align.tokenizer.core: Pure-Python BPE (Mistral-7B-v0.3 compatible). Loads tokenizer.json, implements encode/decode, handles special tokens and byte fallback.align.tokenizer.__init__: ExportsTokenizerclass. Falls back to vendored copy (assets/tokenizer/mistral-7b-v0.3/) when the HF snapshot is absent.
Model
align.model.hf_config: Reads and writesconfig.json(model architecture, vocab size, rope theta). Typed via dataclass.align.model.mistral:MistralForCausalLM(full model),MistralForValueHead(critic),MistralForProcessReward(per-token scores). FSDP2-compatible modules.align.model.attention: Flash attention v2 wrapper (sdpa, eager fallback). Handles determinism and backward passes.align.model.gates: Token-gating layers (used in some loss functions).align.model.moe: MoE core (no aux loss added; statistics returned). fp32Router(softmax over all experts, then top-k), stable-argsort dropless dispatch with a loop and agrouped_mmbackend, stacked 3-D expert parameters,RouterStats(sufficient statistics of every aux loss),load_balancing_loss(Switch/OLMoE and HF conventions),router_z_loss,all_reduce_totals,expert_load_metrics(MaxVio, utilization, dead experts, entropy).align.model.olmoe: OLMoE architecture (OlmoeConfig,OlmoeForCausalLM): QK-norm over the full projection, 64-expert/top-8 sparse blocks,forward(..., return_router_stats=True)returns(out, [RouterStats]). Parity with transformers 5.15 to ~1e-6 (oracle tier).align.model.olmoe_checkpoint: HF config reader/writer (refuses scaled rope, bias, tied embeddings) and the HF <-> align key mapping (per-expert hub keys or fused, stackedgate_up_proj [E,2I,d]/down_proj [E,d,I]).align.model.checkpoint: Loads/saves checkpoints (safetensors, sharded for FSDP2). Handles tokenizer fallback, atomic writes, state_dict exports.
Training infrastructure
align.train.trainer:Trainerclass (main loop). Owns batch iteration, checkpoint save/resume, logging, tripwire gates. Does NOT own the loss computation; delegates toTrainModule.align.train.train_module:TrainModuleabstract class. Subclasses implementforward()(loss),backward(),post_backward_hook(). Owns gradient clipping, LR scheduling application, optimizer step.align.train.moe_module:MoETrainModule, the MoE-aware TrainModule. Wraps the model so the loss adapter is unchanged, collectsRouterStats, accumulates the Demons Alg. 1 window buffer, adds load-balance + z-loss, logsmoe/*telemetry.MoEAuxConfiglives inalign.config([moe_aux]).align.train.ckpt_format:WeightFormatseam (DenseFormat,OlmoeFormat): which converter writes/reads the weight file and which tokenizer, if any, rides along. The Trainer's checkpoint orchestration is family-independent.align.train.sft_loss: SFT (cross-entropy). Owns label construction and vocabulary.align.train.dpo_loss: DPO (dpop + NLL). Owns preference pair handling, reference logprob attachment, loss symmetry.align.train.dpo_stage.py:DpoStageConfig, stage-specific gates and resumption rules.align.train.offload_adamw: AdamW with CPU offload of the optimizer state (fp32 master + moments on host, ~12 B/param). Handles FSDP2 DTensors, distributed grad norm, staging caps. Also hostsmake_optimizer, the single place a config'soptimizer.namebecomes an optimizer (and the banner string).align.train.adamw8bit/align.train.quant8: torch-only block-wise 8-bit AdamW (optimizer.name = "adamw8bit", what every 7.25B full-FT config names). uint8 dynamic-codebook moments + fp32 absmax per 2048-block, state on the device (2.004 B/param, zero host RSS), stochastic-rounding write-back into bf16 params, local-shard stepping under FSDP2, global pre-clip grad norm, bit-exactstate_dictresume (refuses a different world size).align.train.fsdp: FSDP2 setup (fully sharded data parallel). Owns mixed precision policy (bfloat16 compute, float32 reduce), parameter sharding, process groups.align.train.probes: Telemetry (weight movement, token counts, entropy, skip metrics).TrunkMovementProbetracks onlyrequires_gradparameters.align.train.stage_gates: Data readiness gates (shard counts, manifest, coordinate reference caches).align.train.stage_smoke: Smoke-test harness (small model, synthetic data, mini runs).align.train.ref_cache: Reference policy logprob cache (DPO). Owns hash functions, atomic writes, cache key validation.
Reinforcement Learning (GRPO & rewards)
align.rl.grpo_module:GrpoTrainModule(subclass ofTrainModule). Owns GRPO loss, advantage centering, batch scaling, clip range, entropy baseline.align.rl.grpo_loss: GRPO loss function (grouped relative policy optimization). Owns batch weighting, reward integration, KL penalty.align.rl.advantages:compute_generalized_advantages(GAE). Shared with PPO.align.rl.batch_source:GrpoBatchSource(batch iterator, handles sampling seed, curriculum). Owns generation queuing and batch shuffling.align.rl.rewards:CompositeReward(outcome + KL + length penalties). Separate from loss to allow reward reweighting.align.rl.tripwire: Health monitors (CollapseCollapseReward,EntropyTripwire,RatioTripwire). Armed with thresholds, fire on deviation, halt the run.align.rl.canary: Early-stopping logic (threshold on reward, loss divergence).align.rl.stage_config:RolloutShape,GrpoStageConfig. Owns rollout backend selection, world-size validation.align.rl.format_instrument: Format string templates for logging and telemetry.align.rl.ops: Distributed reduce operations (all-reduce, broadcast).align.rl.types: Shared dataclasses (RolloutBatch,AdvantageData,PolicyLogprobs).
PPO (Process reward models)
align.ppo.trainer:PpoTrainModule(subclass ofTrainModule). Owns PPO loss (clipped surrogate + entropy), GAE, value head training.align.ppo.loss: PPO loss computation. Owns ratio clipping, advantage centering.align.ppo.gae: Generalized advantage estimation (shared with GRPO viaalign.rl.advantages).align.ppo.value_head: Value head architecture options (linear, MLP, per-token or outcome).align.ppo.lora: LoRA adapters for the PPO value head. Own implementation onnn.Linear(no peft). Handles FSDP2-sharded weight distribution.align.ppo.rewards: Process reward module (per-token scores). Integrates with the loss.align.ppo.health: PPO-specific health monitors (RatioTripwirewith skip tracking).align.ppo.rollout: PPO rollout generator (samples from the policy, collects trajectories).align.ppo.stage_config:PPOStageConfig. Owns reward model selection, KL settings, value head options.
Inference
align.inference.engine:Engineclass. KV-cache sampler, greedy/top-k decoding, batch bucketing. Deterministic, no randomness.align.inference.bucketed: Batching logic for variable-length sequences (padding, masking).align.inference.receipts: Checkpoint identity (content hash, manifest hash). Used for golden regression and cache validation.
Rollout (policy sampling)
align.rollout.protocol: Shared dataclasses (RolloutRequest,RolloutResult,RolloutMetrics). Interface between trainer and sampler.align.rollout.client:make_rollout_client()factory. Returns a sampler based onconfig.rollout.backend.align.rollout.native: Native spool sampler. Pure torch KV-cache, generation loop, reward scoring. Runs in-process or on a remote device (via DTensor send/recv).
Data pipeline
align.data.hub: HF hub streaming (JSONL, parquet row groups). HTTP-based, nodatasetsdependency.align.data.schema: Shared dataclasses for prompts, pairs, outcomes (Prompt,PreferencePair,OutcomeExample).align.data.encoding: Text-to-token encoding. Owns tokenization, batching, padding.align.data.build: Dataset builders (SFT, DPO, GRPO, PPO). Owns corpus merging, filtering, curation.align.data.sft_corpus: SFT data sources (math reasoning, instruction following).align.data.prm800k_pairs: PRM800K preference pairs (step-level annotations).align.data.argilla: Argilla task data (preference annotations).align.data.stageb_sources: Stage B outcome data (MATH-500, PRM800K).align.data.pretrain_corpus: Pretraining data (raw text).align.data.store: Shard storage and manifest (local file-based data storage).align.data.loader: Batch iterator (handles streaming, sharding, resumption). Densescripts/sft.pyandscripts/pretrain.pybuild their sampler withbuild_training_sampler, which shards by rank viaDistributedBatchSampler(fixed in 654c20c). Before the fix every rank received the same batch stream, so a 4-rank run averaged four identical gradients (tests/test_training_sampler_sharding.py).align.data.outcome_agreement: Outcome agreement metric (verifier consensus).align.data.receiptss: Data fingerprinting (used for cache validation).align.data.evalsets: Named evaluation datasets (golden regression, benchmarks).align.data.ingest_parquet: One-time parquet-to-shard converter (whitelisted forpyarrow).
Evaluation
align.eval.golden: Golden regression checkpoints and their expected decode outputs. Owns anchor values (0.09 for SFT, 0.258 for DPO).align.eval.verifier: Integration wrapper aroundalign.verify(math answer checking). Metric for outcome agreement.align.eval.harness: Evaluation main loop (golden regression, benchmark runs).align.eval.metrics: Utility functions (win rate, accuracy, kl divergence).align.eval.roundtrip: Checkpoint round-trip testing (save -> load -> forward pass).
Answer verification
align.verify.extract: Boxed-answer extraction (finds\boxed{...}in LaTeX). RaisesNotImplementedErrorif parsing fails.align.verify.normalize: Answer normalization (convert to rational/decimal/interval/set).align.verify.equiv: Equivalence checking (exact rational equality, interval overlap, set equality).align.verify.expr: Small-scale expression evaluator (rationalize\frac{1}{2}etc.). Intentionally limited scope.align.verify.__init__: Exportsis_readable()(can this answer be checked?),are_equivalent()(do two answers match?).align.verify.lean(package): strict Lean 4 proof verifier, the formal-proof reward (ROADMAP P4-1).strict.py(statement and proof assembly, lexical and semantic checks),repl_pool.py(REPL worker pool with timeouts, memory watchdog, process-group kill),reward.py(reward hook, reward 0 for every failure class),toolchain.py(pinned Lean v4.9.0-rc1, Mathlib d1d1e4b7, child environment). Design and measurements:docs/LEAN_VERIFIER.md. Not an OS sandbox: the REPL runs as the same user (see that doc, "No OS sandbox").
Utils
align.utils.dist: Process group / rank utilities. Owns rank 0 sync, all-reduce, gloo group (control plane for rollout).align.utils.guard: Resource guards (heavy-job lock, preflight checks). Enforces the host rules; every refusal is aPreflightRefused(exit 83).align.utils.supervisor+scripts/supervise.py: Bounded-retry launch supervisor (stdlib only). See "Control plane" below.align.utils.tracking: Metrics logging (JSONL history, optional stdout). No wandb dependency.
Trainer / TrainModule split (state management)
Trainer (align/train/trainer.py): The main loop.
- Owns: batch iteration, checkpoint save/resume, terminal conditions (data exhausted, horizon, tripwire), logging.
- Does NOT own: loss computation, backward pass, optimizer step, LR scheduling application.
- Interface to TrainModule: calls
module.forward(batch) -> loss,module.backward(),module.post_backward_hook(...),module.optim_step() -> applied_bool. - State persisted in checkpoint.pt:
stream(batch resumption),scheduler,optimizer_per_rank,micro_step,optimizer_step,seed.
TrainModule (align/train/train_module.py): Computation, not state.
- Owns: forward pass (model, loss function), backward pass (pytorch autograd), gradient clipping, LR schedule application, optimizer step.
- Does NOT own: batch iteration, checkpoint save/resume.
- Subclasses:
SftTrainModule,DpoTrainModule,GrpoTrainModule,PpoTrainModule. - Resumption: built fresh from config + model checkpoint at every launch.
Trainer.resume_from()restores the scheduler and optimizer state into the new module.
Resume flow:
- Load model weights through
Trainer.weight_format(align.train.ckpt_format:DenseFormatby default,OlmoeFormatfor an MoE module; the family is injected, never sniffed from key names). - Build TrainModule from config (initializes optimizer, scheduler).
- Call
Trainer.resume_from(checkpoint_dir)— restoresscheduler.state_dict(),optimizer_state_dict(),micro_step,seed. - Stream resumes from the saved
offsetandconsumed_total. TrainModule.load_extra_state(resume.pt["extra_state"])restores module-private state (GRPO: the collapse tripwire's baseline window and latch). The baseTrainModule.extra_state()returns{}; subclasses override the pair. Only tensors/dicts/lists/primitives (the file is read withweights_only=True).- The resumed checkpoint is registered in
Trainer.checkpoints_written(a per-process list), which launch scripts read for the rollout engine's weights and keep-N pruning reads for deletion. - Optimizer state is either one gathered blob in
resume.pt(default) or oneoptimizer_rank{r}.ptper rank (train.per_rank_optimizer_state = true); the checkpoint's own manifest decides how a resume reads it, and per-rank files refuse a different rank count.
This design avoids serializing the entire TrainModule (which includes the model, the optimizer, and all their state), and instead captures only the restorable pieces.
Control plane (G0): decisions that every rank must take together
One rule drives the design. A rank-local fact (a signal, a wall clock, a tripwire verdict, a non-finite loss) becomes an action only after a MAX all-reduce at a program point every rank reaches. The failure being prevented is always the same: one rank acts alone while its peers sit in a collective, so nothing crashes and the run hangs until the NCCL watchdog fires (the 5-hour stall of 2026-08-12).
| Mechanism | Where | Decision point | Failure it prevents |
|---|---|---|---|
Coordinated stop (SIGTERM/SIGINT, train.max_wall_seconds) |
Trainer._boundary_control, _StopSignals |
Optimizer-step boundary, one MAX reduce of [signalled, wall, nonfinite, skipped] |
Python's default SIGTERM killing a rank mid-save; a stop mid-accumulation (a checkpoint holds no gradients). A stop always writes a checkpoint and wins over max_micro_steps. |
| HALT tripwires and arm-time self-test | Trainer._agree_or_raise |
Reduced verdict, then the same HaltError on every rank (rank-ordered gather of messages) |
A lone raiser unwinding while peers wait in the next micro-step's all-gather. |
| Skip cap | Trainer._record_step_health |
Computed from reduced flags: consecutive bad steps > max_consecutive_skipped_steps (3) or bad fraction > max_skipped_fraction (5%, evaluated after 100 boundaries) |
A run that skips forever (schedule stalls, loss "healthy") training nothing. A bad step is one the grad gate skipped, or any micro-batch with a non-finite loss; a step that returns False by design (PPO critic warmup) does not count. |
| Checkpoint durability | fsync_tree, _publish |
Files, then directories, then the rename, then the parent directory | A rename that reaches the journal before the data (zero-length files that pass isdir). |
| Checkpoint completeness | checkpoint_problem, latest_checkpoint |
Weights header extent, resume.pt loop state, every per-rank optimizer file |
Resuming (or the supervisor counting progress) from a truncated newest directory. |
RNG, pins, skip stats in resume.pt |
capture_rng_state, _resume_payload |
Per-rank gather at save; restored when the world size matches | A resume that repeats or forgets dropout/sampling streams, forgives a bad run, or loses the first-alarm pin. |
| Process-group timeout + flight recorder | utils.dist.init_distributed(run_dir=...) |
ALIGN_PG_TIMEOUT_S (default 1800); NCCL trace dump under <run_dir>/nccl_trace |
A hang leaving no evidence; a dump landing in /tmp. |
Exit codes and the halt marker (align.exit_codes). HALT conditions are verdicts about the run, not the host, and must not be relaunched: 80 tripwire, 81 skip cap / non-finite, 82 wall budget spent (after the checkpoint), 83 preflight refused. HaltError (RuntimeError subclass, exit_code attribute) carries the code; every stage script runs main through run_with_exit_codes, and returns Trainer.stop_exit_code after a coordinated stop instead of falling into the post-train eval (82 for wall clock, 128+signum for a signal). Because torchrun collapses every worker exit code to 1 (measured on torch 2.11), the Trainer also writes <run_dir>/HALT.json (rank 0, tmp + fsync + rename) before raising; the supervisor treats a marker written after the attempt started as the verdict whatever the launcher exit code said.
Launch supervisor (scripts/supervise.py, align.utils.supervisor). Relaunch iff: exit != 0, not a HALT code, no fresh HALT marker, retries < 2, progress made during the attempt (history.jsonl mtime advanced AND the newest complete checkpoints/micro_N increased, validated with checkpoint_problem), and the host preflight passes after the backoff. The child runs in its own process group, which is killed and confirmed empty before any relaunch. The preflight on the first launch belongs to the job itself.
Not covered by the control plane: the PPO critic-only warmup deliberately returns an unapplied step (not a skip); offload_adamw does not clip (P0-3), so the default max_grad_norm = 1.0 is a no-op there; the GPU-idle/temperature check of P0-11 is not implemented; NCCL status: on 2 A10G ranks, a SIGTERM to one rank stops both at the same boundary and resumes bit-exactly, and a NaN on one rank halts both without a hang (MEASUREMENTS (a), (b)). On 4 A10G ranks (OLMoE-1B-7B, FSDP2), a SIGTERM to one rank with supervisor relaunch resumes with loss within the run-to-run floor (MEASUREMENTS (g)). Still open: kill -9 during a save, and NCCL behaviour under a real hang.
Mixture of experts (Track M)
Layering. model/moe.py and model/olmoe.py own the architecture and return statistics; they add no loss. train/moe_module.py owns the objective. train/ckpt_format.py owns the file format. Nothing in the Trainer knows what a router is.
Routing. fp32 router, softmax over all experts, then top-k (not top-k then softmax), no renormalisation for OLMoE-1B-7B. Dispatch is dropless: a stable argsort of the flat expert assignment gives contiguous per-expert segments; a Python loop or F.grouped_mm computes them. Experts are stacked 3-D parameters (gate_up_proj [E,2I,d], down_proj [E,d,I]), sharded on dim 0 by FSDP2, so E must divide the world size. The combine is fixed-order (unsort, then sum in a fixed order, custom gather backward). An earlier bf16 index_add_ combine was nondeterministic: 5 of 5 runs on identical inputs gave different top-8 expert sets. Fixed in align/model/moe.py (MEASUREMENTS, Track M (f)).
Aux losses on the seam (MoETrainModule).
- The loss adapter is unchanged. The module hands it a proxy model that adds
return_router_stats=True(only when gradients are enabled, so ano_gradreference forward is not counted), unwraps(out, stats), and keeps the stats. - Window buffer (Demons Alg. 1). Per micro-batch one packed float64 all-reduce of detached
[counts, tokens, prob_sum, z_sum, entropy_sum]; summed into a buffer that lives for one accumulation window and is reset atoptim_step()/zero_grads(). - Global-scope load balance.
f_icomes from the buffer;P_istays each rank's differentiableprob_sum. Micro-batchmis scaled byworld * m / n_buf, which is exactlyworld * accum / N_windowoncem = accum, so after the Trainer's/grad_accumand the gradient reduction's mean the window's gradient is the gradient of one loss over the global batch. The approximation is confined tof_iof micro-batches 1..accum-1; at the last micro-batchf_iis exact.lb_scope = "micro"keeps the per-rank formulation for ablation. - Router z-loss uses the same scale, with the global token count as denominator.
- Defaults.
[moe_aux]: load balance 0.01 and z-loss 0.001 when the stage ispretrainorproxy; both 0 for every post-training stage (a pretrained router already balances the pretraining distribution, and narrow post-training data legitimately skews expert use).Nonemeans "stage default"; any number overrides. - Telemetry (always on unless disabled):
moe/lb_loss,moe/z_loss(exact global-window values),moe/max_vio,moe/max_vio_worst,moe/utilization,moe/utilization_worst,moe/frac_dead_worst,moe/load_entropy,moe/router_entropy, plus per-layer MaxVio/utilization withper_layer_metrics. Values are those of the last completed window (NaN before the first), so every history row has the same columns. - The returned
LossOutput.lossis the CE alone (comparable with dense runs); a non-finite aux term turns it into NaN so the Trainer's non-finite accounting still sees it.
Limits. A decoupled-backward adapter (DPO) cannot carry an aux loss (raises; telemetry still works). GRPO/PPO modules override train_batch and are not MoE-aware. Measured on 4x A10G with FSDP2 and real OLMoE-1B-7B weights (MEASUREMENTS, Track M (g)): SFT with the MoE telemetry on. Not measured: grouped_mm end to end under FSDP, expert parallelism (only its all-to-all volume was timed, (h)), and MoE under DPO, GRPO or PPO. Router fp32 optimizer state (P1-8), the collapse tripwire (P1-9) and ModelConfig.moe (P1-1) are not done.
Checkpoints. OlmoeFormat writes HF-loadable weights + config.json (bf16 weights, fp32 optimizer master is authoritative, as for dense). It copies a tokenizer only from an explicit tokenizer_dir: the dense path's pinned Mistral files would be wrong for a 50304-token model. A checkpoint written by align loads in transformers with equal logits (oracle tier).
Reward composition vs. loss (the seam)
Three separate computations, three separate containers:
DPO path (align.train.dpo_loss.py)
- Loss:
dpo_loss(policy_logps, reference_logps, pairs) -> scalar loss.- Formula:
-log(sigmoid(beta * (log(pi/pi_ref) - log(pi_c/pi_ref_c))))(dpop) + NLL on the chosen arm. - Owns preference pair handling, reference logprob integration, label masking.
- No "reward" object; the beta scaling is the reward weighting.
- Formula:
- Testing:
tests/test_dpo_loss.py,tests/test_dpo_stage.py.
GRPO path (align.rl.grpo_module.py + align.rl.rewards.py)
- Rewards:
CompositeReward(outcome, kl_penalty, overlong_penalty) -> tensor of per-sample scores.- Owns reward composition, penalty scaling, validation.
- Does NOT apply loss weighting; that is the module's job.
- Loss:
grpo_loss(policy_logps, reference_logps, advantages, ...) -> scalar loss.- Formula:
-(rho * advantages).mean() - beta * kl_penalty. - Clips advantages to a range, centers around zero, scales by world size (distributed gradient averaging correction).
- Owns advantage centering, clipping, batch scaling.
- Formula:
- Module:
GrpoTrainModule.forward(batch)calls both, combines them. - Testing:
tests/test_grpo_module.py,tests/test_grpo_loss.py,tests/test_rewards.py.
PPO path (align.ppo.trainer.py + align.ppo.loss.py)
- Rewards: Per-token value targets (via GAE on per-token scores) or outcome rewards. The value head predicts these.
- Loss: PPO surrogate loss (clipped ratio * advantage) + value head MSE + entropy bonus.
- Owns ratio clipping (default 0.2), advantage centering.
- Module:
PpoTrainModule.forward(batch)computes advantages via GAE, then the loss. - Testing:
tests/test_ppo.py,tests/test_ppo_loss.py.
The seam: rewards are computed outside the loss, so they can be reweighted (e.g., curriculum) without recomputing logprobs. Inside the loss, rewards are summarized (summed, averaged, gated) to a single scalar or per-token target.
Three-distributions rule for PPO
PPO requires three policy distributions to be in sync:
- Reference policy: frozen weights (SFT checkpoint or a prior PPO checkpoint).
- Training policy: weights being updated (the main model).
- Rollout policy: the one generating data (should match training policy).
Invariant: rollout_policy == training_policy. If they diverge, the KL penalty is computed against stale data.
Implementation:
- The rollout samples from the latest
training_policyweights. - The reference policy is loaded once and frozen.
- If the rollout is on a remote device (native backend), weights are synced after each checkpoint via
align.rollout.checkpoint_weight_loader.
PPO loss math depends on this: the ratio pi / pi_ref uses the current step's logprobs; a stale reference introduces a train-infer mismatch.
Rho-correction contract for GRPO
In GRPO, the advantage is computed as A = r - beta * log(pi / pi_ref). But pi and pi_ref can be stale or inexact due to:
- Batch async: the rollout samples from step N, training happens on step N+1.
- Precision loss: logits are bf16 under FSDP2 mixed precision; log_softmax is inexact.
- Implementation divergence: the rollout uses a custom sampler; training uses the model.
The rho correction factor measures this: rho = exp(log_pi_train - log_pi_rollout). If they match, rho = 1. If the training policy is slightly better, rho > 1 (advantage is inflated, we correct down). If training is worse, rho < 1.
Contract: The rollout reports logprobs as log p(action | state) (temperature-scaled if T > 1). Training recomputes them the same way. If logprobs match, advantages are exact; if they diverge, rho absorbs the error.
Known divergences that rho cannot fix:
- Determinism: if the sampler uses torch.multinomial and training uses log_softmax, bit-level differences are expected.
- Sequence-level effects: if the sampler reorders prompts, state dependencies shift.
- Model architecture: if the sampler runs an old checkpoint, rho correction works only if the two policies are close in KL.
Testing: tests/test_grpo_module.py::test_rho_correction_recovers_exact_advantage_when_logprobs_match.
What moved to extras/ and why
extras/vllm_rollout/: vLLM worker for high-throughput sampling (100x faster than native torch sampler).- Why it is optional: introduces ~20 heavy dependencies (vllm, ray, pydantic, etc.); most runs don't need this throughput.
- How it works: separate venv (
.venv-rollout), separate process, talks to trainer viaalign.rollout.protocol.py(shared dataclasses only). - When to use: multi-node rollout, >1000 tokens/s target.
align/data/ingest_parquet.py: one-time converter from parquet (GSM8K, MATH, argilla) to the repo's shard format.- Why it is optional:
pyarrowis heavy (C++ build, wheels may fail). Most development uses pre-ingested shards. - How it works: whitelisted in firewall (
path, not import), lazy import (importorskip), does not affect runtime. - When to use: when onboarding a new dataset source.
- Why it is optional:
Parity oracle design
Testing against the legacy stack (nano-mistral-o1 with transformers 5.15, math_verify) requires:
Two venvs: legacy (with transformers) in
/apps/development/rlhf/nano-mistral-o1/.venv, and align (torch-only) in/apps/development/rlhf/align/.venv.Oracle tests: marked with
@pytest.mark.oraclein the test file.Running oracle tests:
cd /apps/development/rlhf/nano-mistral-o1 PYTHONPATH=/apps/development/rlhf/align .venv/bin/python -m pytest /apps/development/rlhf/align/tests/ -m oracleThis runs the align test file in the legacy venv context, so transformers and math_verify are available.
Test structure: oracle tests have two branches:
- Legacy code path:
oracle = transformers.AutoTokenizer.from_pretrained(...). - Align code path:
align_result = align.tokenizer.Tokenizer.from_snapshot(...). - Assert:
oracle == align_resultororacle.logits ≈ align_result.logits.
- Legacy code path:
Coverage: Parity is checked on:
- Tokenizer: 365 texts x2 directions (encode, decode) + 176 id lists x2 + vocab maps. Oracle: 100% identical.
- Verifier: 400 constructed pairs (gold standard answers vs. predicted). Oracle: 99.75% agreement (1 disagreement where align is correct and legacy is wrong).
- Model forward: Tiny models, sdpa vs. eager attention, bfloat16. Oracle: identical logits.
- Checkpoint load: Transformers-written checkpoint loads via align. Oracle: weights match exactly.
Why oracle is separate: transformers introduces 100+ dependencies (datasets, huggingface_hub, tokenizers, tqdm, ...). Align must not import any of them at runtime. Oracle is run on-demand, isolated, and never shipped.
Summary
The architecture cleanly separates concerns:
- Config → Model → Trainer → TrainModule (computation) + Batch source (data).
- Loss is pluggable (SFT, DPO, GRPO, PPO all have different formulas).
- Reward is separate from loss (enables curriculum, reward analysis).
- Checkpoint is atomic (temp file + os.replace, never partial).
- Resume is lossless (every micro-step is recoverable; no data is dropped mid-accumulation).
- Parity is tested via oracle (legacy venv, no import overhead on align).
This design makes it possible to:
- Swap loss functions without touching batch sources.
- Rerun old checkpoints and get the same output (deterministic inference engine).
- Add new reward signals without recomputing logprobs.
- Keep the dependency footprint minimal (torch + triton + numpy + safetensors).
Expert parallelism
align/model/moe_ep.py (opt-in; default MoE path is bitwise unchanged, hooks in moe.py are ep=None).
Why. Measured on this box: FSDP over SHM NCCL (~4 GB/s, no P2P) spends 13.55 s of a 14.8 s OLMoE micro-step in collectives, because FSDP all-gathers all 64 experts of a layer (805 MB bf16, forward and again in backward) to use the ~1/64 each token hits. EP keeps the weights where they are and moves the tokens.
Pipeline per layer. Local routing (router replicated, so RouterStats and the load-balancing loss are identical to
the non-EP model; MoEAux all-reduces over the data-parallel group as before) -> local stable sort by global expert id ->
equal-split all-to-all of the [ep, E/ep] count matrix (one host sync) -> row all-to-all (dropless, no capacity or
padding) -> fixed regroup to expert-major, source-rank-ascending order -> local experts through the same
run_experts_backend -> inverse permutation -> reverse all-to-all -> the unchanged weight-scale and fixed-order slot sum.
No collective accumulates anything, so no reduction order is inherited from NCCL. The receive order equals the order a
single process sees when it stable-sorts the rank-ordered concatenation of all tokens, which is why equality is bitwise
in fp64. Backward is the reverse all-to-all with swapped splits (_AllToAllRows, exact: a row move has no arithmetic).
Layout. World W = R x ep. EP groups are ep adjacent ranks; the R replicas of one expert shard form a replicate
group. Non-expert parameters (attention, norms, embeddings, lm_head, router) are reduced or FSDP-sharded over all W
ranks exactly as today. Expert shards stay OUT of that FSDP unit. On the 4-GPU box W = ep = 4, R = 1: experts are never
gathered. Order of operations: build model -> apply_expert_parallel -> optimizer -> (FSDP the rest).
Gradient scale (the silent one). Replicated parameters get (1/W) * sum over ranks. An expert shard's gradient
arrives already SUMMED over its EP group (the dispatch backward delivers every contribution). finalize_expert_grads
sums over the replicate group (when R > 1) and multiplies by 1/W; skipping it trains the experts at ep times the
learning rate with no error. The FSDP-of-experts alternative (fully_shard(block.mlp.experts, mesh=replicate_mesh))
must set set_gradient_divide_factor(W) to land on the same scale; it is specified, not implemented, here.
MoETrainModule(ep_groups=...) calls it in optim_step.
Clipping. Grad-norm clipping must see all expert shards: global_grad_norm all-reduces the shard squares over the EP
group. An optimizer that clips by its own norm is refused (max_grad_norm must be None; pass ep_max_grad_norm),
otherwise each rank clips by a different coefficient and replicated parameters drift apart.
Deadlocks closed. A rank that receives zero rows must still issue every forward and backward all-to-all
(_no_rows ties the empty output to the local weights, which also yields exact-zero weight grads instead of None);
combine_experts and _DispatchGather.backward take the width explicitly so zero-row tensors pass. A desynchronised
collective stream is reported by a split-size sanity check rather than an allocator error.
Checkpoints. Expert tensors are sharded on dim 0. MoETrainModule.full_model_state_dict gathers them (collective over
the EP group; like the base it returns the state on rank 0), load_model_state_dict slices, so the file is the one a
non-EP run writes.
Bytes per micro-step per rank, OLMoE-1B-7B (d=2048, I=1024, E=64, k=8, 16 layers, bf16, ep=W=4), analytic:
| tokens/micro-step/rank | EP all-to-all (4 passes) | FSDP expert all-gather + reduce-scatter (3 collectives) | ratio |
|---|---|---|---|
| 1024 | 1.50 GiB | 27.0 GiB | 18.0x |
| 4096 | 6.00 GiB | 27.0 GiB | 4.5x |
EP traffic scales with tokens, FSDP traffic with parameters; the crossover is ~18.4k tokens per rank per micro-step.
Counted-vs-analytic is checked in tests/test_moe_ep.py (exact on balanced routing; within 10% on a real OLMoE-shaped layer
with a random router). The number to trust for wall time is the on-GPU timing in the GPU validation plan.
Limits. Validated with gloo on CPU only; NCCL all-to-all, bf16 wire dtype and DTensor norm paths are specified, not run.
E must divide by ep. Load imbalance (one rank receiving most rows) is dropless-correct but makes the slowest rank set the
step time; EP does not fix routing skew.