From a15e9505d64ff4dbffae16eee4fa16a37e0f6b21 Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:58:25 -0700 Subject: [PATCH 1/6] Add reproducible OPD reference workload driver --- scripts/run_verl_opd_reference_workload.py | 483 ++++++++++++++++++ src/miniverl/cli.py | 5 +- src/miniverl/training/trainer.py | 7 + tests/cli/test_cli.py | 12 + tests/integration/test_prompt_opd_pipeline.py | 5 + 5 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 scripts/run_verl_opd_reference_workload.py diff --git a/scripts/run_verl_opd_reference_workload.py b/scripts/run_verl_opd_reference_workload.py new file mode 100644 index 0000000..a32c943 --- /dev/null +++ b/scripts/run_verl_opd_reference_workload.py @@ -0,0 +1,483 @@ +"""Run the bounded RTX 4080 verl-style OPD developer workload. + +This is systems evidence, not an alignment or task-quality benchmark. It +executes the exact Qwen3 profile twice: one uninterrupted eight-update run and +one four-update interruption followed by checkpoint resume. The published +record contains only portable identities and aggregate runtime measurements. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import statistics +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from miniverl import __version__ +from miniverl.bridge.opd_plan import build_immutable_opd_plan, write_immutable_opd_plan +from miniverl.bridge.opd_runtime import build_system_plan +from miniverl.bridge.opd_v08 import load_verl_opd_v08_source +from miniverl.models.adapter_io import export_adapter +from miniverl.trainer import OPDTrainer +from miniverl.utils.runs import read_jsonl, write_json_atomic + +PROFILE = "verl-opd-v0.8-single-gpu-v1" +BUILTIN = "builtin:qwen3-0.6b-1.7b-opd" +PROMPT_ROWS = 64 +UPDATES = 8 +INTERRUPT_AFTER = 4 +LOGICAL_BATCH = 4 +PROMPT_LIMIT = 128 +RESPONSE_LIMIT = 64 +TOP_K = 32 + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _tree_bytes(path: Path) -> int: + return sum(item.stat().st_size for item in path.rglob("*") if item.is_file()) + + +def _git_head() -> str: + return subprocess.run( + ["git", "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _driver_sha256() -> str: + return _sha256(Path(__file__).resolve()) + + +def _package_version(name: str) -> str | None: + try: + return importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + return None + + +def _driver_version() -> str: + try: + return ( + subprocess.run( + ["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + .stdout.splitlines()[0] + .strip() + ) + except (FileNotFoundError, IndexError, OSError, subprocess.SubprocessError): + return "unavailable" + + +def _sample_records() -> list[dict[str, Any]]: + prompts = [ + "Explain why immutable revisions help reproduce a model run.", + "State one reason to inspect a compatibility report before execution.", + "Describe one benefit of a checksummed training artifact.", + "Name one safe response to a CUDA out-of-memory error.", + "Explain token-mean loss aggregation in one sentence.", + "State one limitation of a single-GPU runtime.", + "Explain why a teacher target cache needs policy-version provenance.", + "Describe the difference between a logical and physical batch.", + ] + rows: list[dict[str, Any]] = [] + for index in range(PROMPT_ROWS): + family = prompts[index % len(prompts)] + rows.append( + { + "prompt": [ + {"role": "system", "content": "Answer clearly and briefly."}, + {"role": "user", "content": f"Workload item {index + 1:02d}: {family}"}, + ], + "data_source": "miniverl_systems_workload", + "ability": "short_answer", + "extra_info": {"workload_item": index + 1}, + } + ) + return rows + + +def write_dataset(path: Path) -> dict[str, Any]: + """Write the deterministic, reward-free Parquet workload.""" + import pyarrow as pa + import pyarrow.parquet as pq + + records = _sample_records() + prompts = {json.dumps(row["prompt"], sort_keys=True) for row in records} + if len(prompts) != PROMPT_ROWS: # pragma: no cover - constant-data guard + raise RuntimeError("reference workload prompts are not distinct") + path.parent.mkdir(parents=True, exist_ok=True) + pq.write_table(pa.Table.from_pylist(records), path) + return {"rows": len(records), "distinct_prompts": len(prompts), "sha256": _sha256(path)} + + +def _overrides(dataset: Path) -> list[str]: + data_path = dataset.resolve().as_posix() + return [ + f'data.train_files=["{data_path}"]', + f"data.train_batch_size={LOGICAL_BATCH}", + f"data.max_prompt_length={PROMPT_LIMIT}", + f"data.max_response_length={RESPONSE_LIMIT}", + f"actor_rollout_ref.actor.ppo_mini_batch_size={LOGICAL_BATCH}", + "actor_rollout_ref.actor.ppo_max_token_len_per_gpu=768", + "actor_rollout_ref.rollout.max_model_len=192", + "actor_rollout_ref.rollout.max_num_batched_tokens=768", + "actor_rollout_ref.rollout.max_num_seqs=4", + f"distillation.distillation_loss.topk={TOP_K}", + "trainer.experiment_name=qwen3-opd-developer-workload", + "trainer.save_freq=4", + f"trainer.total_training_steps={UPDATES}", + "miniverl.batching.rollout_batch_size=4", + "miniverl.batching.teacher_score_batch_size=4", + "miniverl.batching.update_trajectory_batch_size=1", + ] + + +def build_plan(dataset: Path, path: Path) -> tuple[Any, Any]: + compiled = load_verl_opd_v08_source( + BUILTIN, + overrides=_overrides(dataset), + accept_local_reinterpretations=True, + ) + system = build_system_plan(compiled) + plan = build_immutable_opd_plan(compiled, source=BUILTIN, system_plan=system) + write_immutable_opd_plan(path, plan) + from miniverl.config import RunConfig + + native = RunConfig.model_validate(plan.resolved_native_config) + return plan, native + + +def _write_plan_artifacts(trainer: OPDTrainer, plan: Any) -> None: + write_json_atomic( + trainer.paths.root / "local-execution-plan.json", plan.model_dump(mode="json") + ) + write_json_atomic( + trainer.paths.root / "verl-source-config.json", + plan.compiled_plan["source"], + ) + write_json_atomic( + trainer.paths.root / "verl-compatibility-report.json", + plan.compiled_plan, + ) + + +def _train_uninterrupted(native: Any, plan: Any, runs: Path, *, offline: bool) -> dict[str, Any]: + constructed = time.perf_counter() + trainer = OPDTrainer.from_config( + native, + output_dir=runs, + run_id="uninterrupted", + local_files_only=offline, + ) + construction_seconds = time.perf_counter() - constructed + with trainer: + _write_plan_artifacts(trainer, plan) + result = trainer.train() + adapter_manifest, _ = export_adapter( + result.run_dir, + result.run_dir / "checkpoints" / "final", + result.run_dir / "final-peft-adapter", + local_files_only=offline, + ) + return { + "run": result.run_dir, + "construction_seconds": construction_seconds, + "result": result, + "adapter_manifest": adapter_manifest, + } + + +def _train_resumed(native: Any, plan: Any, runs: Path, *, offline: bool) -> dict[str, Any]: + first = OPDTrainer.from_config( + native, + output_dir=runs, + run_id="resumed", + local_files_only=offline, + ) + with first: + _write_plan_artifacts(first, plan) + for cycle in range(INTERRUPT_AFTER): + first.cycle = cycle + first._run_cycle() + interrupt_checkpoint = first.save_checkpoint(name="interrupt") + resume_started = time.perf_counter() + second = OPDTrainer.from_config(native, resume=runs / "resumed", local_files_only=offline) + resume_load_seconds = time.perf_counter() - resume_started + with second: + result = second.train() + adapter_manifest, _ = export_adapter( + result.run_dir, + result.run_dir / "checkpoints" / "final", + result.run_dir / "final-peft-adapter", + local_files_only=offline, + ) + return { + "run": result.run_dir, + "result": result, + "resume_load_seconds": resume_load_seconds, + "interrupt_checkpoint_bytes": _tree_bytes(interrupt_checkpoint), + "adapter_manifest": adapter_manifest, + } + + +def _median(values: list[float]) -> float: + return round(float(statistics.median(values)), 4) + + +def summarize_run(run: Path, *, construction_seconds: float) -> dict[str, Any]: + """Aggregate phase measurements without interpreting task quality.""" + rows = read_jsonl(run / "metrics.jsonl") + cycles = [row for row in rows if row.get("phase") == "opd_cycle"] + updates = [row for row in rows if row.get("phase") == "opd"] + if len(cycles) != UPDATES or len(updates) != UPDATES: + raise RuntimeError( + f"expected {UPDATES} cycle/update rows, got {len(cycles)}/{len(updates)}" + ) + steady_cycles = cycles[1:] + steady_updates = updates[1:] + first = cycles[0] + generated = [float(row["rollouts"]["generated_tokens"]) for row in steady_cycles] + selected = [float(row["selected_positions"]) for row in steady_updates] + all_memory = [row.get("memory") or {} for row in rows] + rollout_execution = [row.get("rollout_execution") or {} for row in cycles] + trajectories = read_jsonl(run / "trajectories.jsonl") + distinct = {str((row.get("metadata") or {}).get("row_digest")) for row in trajectories} + checkpoint = run / "checkpoints" / "final" + adapter = run / "final-peft-adapter" + cache = run / "teacher-cache" + return { + "cold_startup_seconds": round(construction_seconds, 4), + "time_to_first_rollout_seconds": round( + construction_seconds + float(first["rollout_seconds"]), 4 + ), + "time_to_first_teacher_targets_seconds": round( + construction_seconds + + float(first["rollout_seconds"]) + + float(first["teacher_scoring_seconds"]), + 4, + ), + "time_to_first_update_seconds": round(construction_seconds + float(first["seconds"]), 4), + "steady_state_median_seconds": { + "rollout": _median([float(row["rollout_seconds"]) for row in steady_cycles]), + "teacher_scoring": _median( + [float(row["teacher_scoring_seconds"]) for row in steady_cycles] + ), + "actor_update": _median([float(row["seconds"]) for row in steady_updates]), + }, + "steady_state_median_throughput": { + "rollout_tokens_per_second": _median( + [ + tokens / float(row["rollout_seconds"]) + for tokens, row in zip(generated, steady_cycles, strict=True) + ] + ), + "teacher_scored_positions_per_second": _median( + [float(row["teacher_scored_positions_per_second"]) for row in steady_cycles] + ), + "update_positions_per_second": _median( + [ + positions / float(row["seconds"]) + for positions, row in zip(selected, steady_updates, strict=True) + ] + ), + }, + "peak_allocated_gib": round( + max(float(item.get("peak_allocated_gib") or 0.0) for item in all_memory), 4 + ), + "peak_reserved_gib": round( + max(float(item.get("peak_reserved_gib") or 0.0) for item in all_memory), 4 + ), + "batch_downshifts": { + "rollout_oom": sum(int(item.get("oom_downshifts") or 0) for item in rollout_execution), + "update_chunk_oom": sum( + 1 + for row in read_jsonl(run / "events.jsonl") + if row.get("event") == "oom_chunk_retry" + ), + }, + "observed_rollout_physical_batch_sizes": sorted( + { + int(size) + for item in rollout_execution + for size in item.get("physical_batch_sizes", []) + } + ), + "prompts_consumed": len(trajectories), + "distinct_prompts_consumed": len(distinct), + "cache_bytes": _tree_bytes(cache), + "checkpoint_bytes": _tree_bytes(checkpoint), + "adapter_bytes": _tree_bytes(adapter), + "total_run_bytes": _tree_bytes(run), + "checkpoint_hashes": { + name: _sha256(checkpoint / name) + for name in ("adapter.safetensors", "optimizer.safetensors", "state.json") + }, + "trajectory_sha256": _sha256(run / "trajectories.jsonl"), + } + + +def _equivalence(reference: dict[str, Any], resumed: dict[str, Any]) -> dict[str, Any]: + reference_hashes = reference["checkpoint_hashes"] + resumed_hashes = resumed["checkpoint_hashes"] + checkpoint_match = reference_hashes == resumed_hashes + trajectory_match = reference["trajectory_sha256"] == resumed["trajectory_sha256"] + if not checkpoint_match or not trajectory_match: + raise RuntimeError("uninterrupted and resumed executions did not match exactly") + return { + "status": "exact_match", + "checkpoint_files_byte_identical": checkpoint_match, + "trajectories_byte_identical": trajectory_match, + "global_optimizer_steps": UPDATES, + "task_cursor": UPDATES * LOGICAL_BATCH, + } + + +def run_workload(out: Path, result_path: Path, *, offline: bool) -> dict[str, Any]: + if out.exists(): + raise FileExistsError(f"output directory already exists: {out}") + out.mkdir(parents=True) + started = time.perf_counter() + dataset = out / "data" / "reference-workload.parquet" + dataset_identity = write_dataset(dataset) + plan_path = out / "plan.json" + plan, native = build_plan(dataset, plan_path) + runs = out / "runs" + uninterrupted = _train_uninterrupted(native, plan, runs, offline=offline) + reference = summarize_run( + uninterrupted["run"], construction_seconds=uninterrupted["construction_seconds"] + ) + resumed_run = _train_resumed(native, plan, runs, offline=offline) + resumed = summarize_run(resumed_run["run"], construction_seconds=0.0) + equivalence = _equivalence(reference, resumed) + + import torch + + properties = torch.cuda.get_device_properties(torch.cuda.current_device()) + artifact = { + "schema_version": 1, + "kind": "single_gpu_opd_developer_workload", + "status": "measured", + "measured_at": ( + datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + ), + "miniverl_version": __version__, + "source_commit": _git_head(), + "workload_driver_sha256": _driver_sha256(), + "profile": PROFILE, + "verl": { + "tag": plan.pinned_verl["tag"], + "commit": plan.pinned_verl["commit"], + "distributed_execution_tested": False, + }, + "hardware": { + "gpu": properties.name, + "gpu_count": 1, + "vram_gib": round(int(properties.total_memory) / (1024**3), 3), + "driver": _driver_version(), + "torch": torch.__version__, + "cuda_runtime": str(torch.version.cuda), + "transformers": _package_version("transformers"), + "peft": _package_version("peft"), + "bitsandbytes": _package_version("bitsandbytes"), + }, + "models": { + "student": { + "id": native.models.student.model_id, + "revision": native.models.student.revision, + "quantization": native.models.student.quantization.value, + "adapter": "lora-r8-alpha16", + }, + "teacher": { + "id": native.models.teacher.model_id, + "revision": native.models.teacher.revision, + "quantization": native.models.teacher.quantization.value, + }, + }, + "recipe": { + "runtime_strategy": "dual_model_resident", + "dataset_rows": dataset_identity["rows"], + "distinct_dataset_prompts": dataset_identity["distinct_prompts"], + "prompts_consumed": reference["prompts_consumed"], + "distinct_prompts_consumed": reference["distinct_prompts_consumed"], + "prompt_limit": PROMPT_LIMIT, + "response_limit": RESPONSE_LIMIT, + "logical_batch": LOGICAL_BATCH, + "rollout_physical_batch": 4, + "teacher_score_batch": 4, + "update_physical_batch": 1, + "top_k": TOP_K, + "optimizer_updates": UPDATES, + "compiled_plan_sha256": plan.plan_digest, + "input_parquet_sha256": dataset_identity["sha256"], + }, + "measurements": { + key: value + for key, value in reference.items() + if key not in {"checkpoint_hashes", "trajectory_sha256"} + }, + "resume": { + "interrupt_after_optimizer_updates": INTERRUPT_AFTER, + "resume_load_seconds": round(resumed_run["resume_load_seconds"], 4), + "interrupt_checkpoint_bytes": resumed_run["interrupt_checkpoint_bytes"], + **equivalence, + }, + "artifacts": { + "checkpoint_hashes": reference["checkpoint_hashes"], + "trajectory_sha256": reference["trajectory_sha256"], + "standard_peft_adapter_sha256": uninterrupted["adapter_manifest"]["checksums"][ + "adapter_model.safetensors" + ], + "standard_peft_load_verified": True, + "workload_output_bytes": _tree_bytes(out), + }, + "resource_contract": { + "peak_reserved_limit_gib": 14.5, + "peak_reserved_within_limit": reference["peak_reserved_gib"] <= 14.5, + "total_gpu_workload_seconds": round(time.perf_counter() - started, 2), + "gpu_hour_limit": 4.0, + }, + "scientific_scope": { + "runtime_correctness_only": True, + "alignment_quality_evaluated": False, + "task_quality_evaluated": False, + "opd_beats_sft_dpo_or_kd_claimed": False, + }, + } + if not artifact["resource_contract"]["peak_reserved_within_limit"]: + raise RuntimeError("reference workload exceeded the 14.5 GiB reserved-memory limit") + result_path.parent.mkdir(parents=True, exist_ok=True) + write_json_atomic(result_path, artifact) + return artifact + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--result", type=Path, required=True) + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + payload = run_workload(args.out.resolve(), args.result.resolve(), offline=args.offline) + print(json.dumps(payload, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/miniverl/cli.py b/src/miniverl/cli.py index 5cf0862..f91d799 100644 --- a/src/miniverl/cli.py +++ b/src/miniverl/cli.py @@ -173,11 +173,14 @@ def data_sample_command( ] records = [] for index in range(rows): + prompt = prompts[index % len(prompts)] + if index >= len(prompts): + prompt = f"Sample {index + 1}: {prompt}" records.append( { "prompt": [ {"role": "system", "content": "Answer clearly and briefly."}, - {"role": "user", "content": prompts[index % len(prompts)]}, + {"role": "user", "content": prompt}, ], "data_source": "miniverl_quickstart", "ability": "short_answer", diff --git a/src/miniverl/training/trainer.py b/src/miniverl/training/trainer.py index 53d78f3..8dfdaa5 100644 --- a/src/miniverl/training/trainer.py +++ b/src/miniverl/training/trainer.py @@ -299,6 +299,7 @@ def __init__( self._cycles_completed = 0 self._last_cycle_metrics: dict[str, Any] = {} self._last_selection_stats: list[SelectionStats] = [] + self._last_rollout_execution: dict[str, Any] | None = None # -- construction -------------------------------------------------------- @@ -1232,6 +1233,10 @@ def _collect( policy_version=self.policy_version, seed=seed, ) + self._last_rollout_execution = { + "physical_batch_sizes": list(generated.physical_batch_sizes), + "oom_downshifts": generated.oom_downshifts, + } trajectories = self.rollout_runtime.to_trajectories( prepared, generated, @@ -2317,6 +2322,7 @@ def _run_cycle(self) -> list[dict[str, Any]]: cycle_started = time.perf_counter() rollout_policy_version = self.parameter_version self._last_selection_stats = [] + self._last_rollout_execution = None rollout_seconds = 0.0 teacher_scoring_seconds = 0.0 @@ -2470,6 +2476,7 @@ def _run_cycle(self) -> list[dict[str, Any]]: if teacher_scoring_seconds > 0 else None ), + "rollout_execution": self._last_rollout_execution, } ) if self._cache is not None: diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 87d6a1b..f3435d0 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -95,6 +95,18 @@ def _invoke(*args: str) -> Result: return CliRunner().invoke(app, list(args)) +def test_sample_parquet_rows_are_distinct_when_requested(tmp_path: Path) -> None: + pyarrow = pytest.importorskip("pyarrow.parquet") + target = tmp_path / "sample.parquet" + + result = _invoke("data", "sample", "--out", str(target), "--rows", "64") + + assert result.exit_code == 0, result.output + prompts = [str(row["prompt"]) for row in pyarrow.read_table(target).to_pylist()] + assert len(prompts) == 64 + assert len(set(prompts)) == 64 + + def test_bridge_compile_opd_is_an_offline_deterministic_smoke(tmp_path: Path) -> None: source = REPO_ROOT / "examples" / "verl-opd-v0.8-single-gpu.yaml" output = tmp_path / "compiled-plan.json" diff --git a/tests/integration/test_prompt_opd_pipeline.py b/tests/integration/test_prompt_opd_pipeline.py index 75473ea..399bfec 100644 --- a/tests/integration/test_prompt_opd_pipeline.py +++ b/tests/integration/test_prompt_opd_pipeline.py @@ -147,6 +147,11 @@ def test_prompt_opd_trains_without_an_environment_or_reward(tmp_path) -> None: for line in (result.run_dir / "metrics.jsonl").read_text(encoding="utf-8").splitlines() ] update = next(row for row in metrics if row.get("phase") == "opd") + cycle = next(row for row in metrics if row.get("phase") == "opd_cycle") + assert cycle["rollout_execution"] == { + "physical_batch_sizes": [2], + "oom_downshifts": 0, + } assert update["loss_aggregation"] == "token-mean" assert set(update["verl_forward_kl_topk"]) == { "student_mass_mean", From 92422d1dd375593e0785c6ba31033c284b8148fb Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:05:52 -0700 Subject: [PATCH 2/6] Resume Parquet prompts at the saved cursor --- src/miniverl/training/trainer.py | 6 +++- tests/integration/test_prompt_opd_pipeline.py | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/miniverl/training/trainer.py b/src/miniverl/training/trainer.py index 8dfdaa5..cccdd52 100644 --- a/src/miniverl/training/trainer.py +++ b/src/miniverl/training/trainer.py @@ -1133,13 +1133,17 @@ def _next_tasks(self, count: int) -> list[Any]: output: list[Any] = [] while len(output) < count: if self._prompt_train_iterator is None: + rows_per_epoch = int(self.prompt_dataset_manifest.rows["train"]) + self._prompt_train_epoch = self.task_cursor // rows_per_epoch + row_offset = self.task_cursor % rows_per_epoch self._prompt_train_iterator = iter( self.prompt_dataset.iter_split("train", epoch=self._prompt_train_epoch) ) + for _ in range(row_offset): + next(self._prompt_train_iterator) try: record = next(self._prompt_train_iterator) except StopIteration: - self._prompt_train_epoch += 1 self._prompt_train_iterator = None continue output.append(render_prompt(record, self.tokenizer, self.config.source)) diff --git a/tests/integration/test_prompt_opd_pipeline.py b/tests/integration/test_prompt_opd_pipeline.py index 399bfec..4841cc5 100644 --- a/tests/integration/test_prompt_opd_pipeline.py +++ b/tests/integration/test_prompt_opd_pipeline.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +from types import SimpleNamespace +from typing import Any import pyarrow as pa import pyarrow.parquet as pq @@ -13,6 +15,32 @@ pytestmark = [requires_torch, pytest.mark.torch] +def test_prompt_resume_reconstructs_the_dataset_cursor(monkeypatch) -> None: + from miniverl.config.models import VerlParquetSourceConfig + from miniverl.trainer import OPDTrainer + + class Dataset: + def iter_split(self, split: str, *, epoch: int) -> Any: + assert split == "train" + return iter([f"e{epoch}-r{index}" for index in range(4)]) + + monkeypatch.setattr( + "miniverl.data.verl_parquet.render_prompt", + lambda record, tokenizer, source: record, + ) + trainer = object.__new__(OPDTrainer) + trainer.prompt_dataset = Dataset() + trainer.prompt_dataset_manifest = SimpleNamespace(rows={"train": 4}) + trainer.config = SimpleNamespace(source=VerlParquetSourceConfig(train_files=["unused"])) + trainer.tokenizer = object() + trainer._prompt_train_iterator = None + trainer._prompt_train_epoch = 0 + trainer.task_cursor = 6 + + assert trainer._next_tasks(4) == ["e1-r2", "e1-r3", "e2-r0", "e2-r1"] + assert trainer.task_cursor == 10 + + def test_prompt_opd_trains_without_an_environment_or_reward(tmp_path) -> None: from miniverl.config import RunConfig from miniverl.trainer import OPDTrainer From f61f760af2dd3d88aea52b27176e7cca23ffef2e Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:11:32 -0700 Subject: [PATCH 3/6] Define exact real-model resume equivalence --- scripts/run_verl_opd_reference_workload.py | 24 ++++++++++-- tests/unit/test_opd_reference_workload.py | 45 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_opd_reference_workload.py diff --git a/scripts/run_verl_opd_reference_workload.py b/scripts/run_verl_opd_reference_workload.py index a32c943..8bc75ac 100644 --- a/scripts/run_verl_opd_reference_workload.py +++ b/scripts/run_verl_opd_reference_workload.py @@ -331,6 +331,7 @@ def summarize_run(run: Path, *, construction_seconds: float) -> dict[str, Any]: name: _sha256(checkpoint / name) for name in ("adapter.safetensors", "optimizer.safetensors", "state.json") }, + "checkpoint_state": json.loads((checkpoint / "state.json").read_text(encoding="utf-8")), "trajectory_sha256": _sha256(run / "trajectories.jsonl"), } @@ -338,13 +339,28 @@ def summarize_run(run: Path, *, construction_seconds: float) -> dict[str, Any]: def _equivalence(reference: dict[str, Any], resumed: dict[str, Any]) -> dict[str, Any]: reference_hashes = reference["checkpoint_hashes"] resumed_hashes = resumed["checkpoint_hashes"] - checkpoint_match = reference_hashes == resumed_hashes + tensor_match = all( + reference_hashes[name] == resumed_hashes[name] + for name in ("adapter.safetensors", "optimizer.safetensors") + ) + reference_state = reference["checkpoint_state"] + resumed_state = resumed["checkpoint_state"] + # The resolved config includes the run id, so that identity digest must + # differ between separately named reference and resumed runs. Every actual + # training-state field still has to match exactly. + state_fields_match = all( + reference_state[key] == resumed_state[key] + for key in reference_state + if key != "resolved_config_digest" + ) trajectory_match = reference["trajectory_sha256"] == resumed["trajectory_sha256"] - if not checkpoint_match or not trajectory_match: + if not tensor_match or not state_fields_match or not trajectory_match: raise RuntimeError("uninterrupted and resumed executions did not match exactly") return { "status": "exact_match", - "checkpoint_files_byte_identical": checkpoint_match, + "adapter_and_optimizer_byte_identical": tensor_match, + "training_state_fields_identical": state_fields_match, + "excluded_run_identity_field": "resolved_config_digest", "trajectories_byte_identical": trajectory_match, "global_optimizer_steps": UPDATES, "task_cursor": UPDATES * LOGICAL_BATCH, @@ -432,7 +448,7 @@ def run_workload(out: Path, result_path: Path, *, offline: bool) -> dict[str, An "measurements": { key: value for key, value in reference.items() - if key not in {"checkpoint_hashes", "trajectory_sha256"} + if key not in {"checkpoint_hashes", "checkpoint_state", "trajectory_sha256"} }, "resume": { "interrupt_after_optimizer_updates": INTERRUPT_AFTER, diff --git a/tests/unit/test_opd_reference_workload.py b/tests/unit/test_opd_reference_workload.py new file mode 100644 index 0000000..87b8241 --- /dev/null +++ b/tests/unit/test_opd_reference_workload.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any + + +def _script() -> Any: + path = Path("scripts/run_verl_opd_reference_workload.py") + spec = importlib.util.spec_from_file_location("run_verl_opd_reference_workload", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_resume_equivalence_excludes_only_the_run_specific_resolved_digest() -> None: + script = _script() + common = { + "checkpoint_hashes": { + "adapter.safetensors": "a", + "optimizer.safetensors": "b", + "state.json": "different-by-design", + }, + "checkpoint_state": { + "global_step": 8, + "task_cursor": 32, + "resolved_config_digest": "run-specific-a", + }, + "trajectory_sha256": "c", + } + resumed = { + **common, + "checkpoint_state": { + **common["checkpoint_state"], + "resolved_config_digest": "run-specific-b", + }, + } + + report = script._equivalence(common, resumed) + + assert report["status"] == "exact_match" + assert report["adapter_and_optimizer_byte_identical"] is True + assert report["training_state_fields_identical"] is True + assert report["excluded_run_identity_field"] == "resolved_config_digest" From 3b30250408086d25fe3d7abba023ff927bfe354e Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:23:31 -0700 Subject: [PATCH 4/6] Publish the RTX 4080 OPD developer workload --- CHANGELOG.md | 12 + PYPI.md | 15 +- README.md | 15 +- README.zh-CN.md | 12 +- .../rtx4080-verl-opd-developer-v1.json | 122 +++++++++ docs/opd-quickstart.md | 4 + docs/verl-opd-reference-workload-mobile.svg | 58 ++++ docs/verl-opd-reference-workload.md | 60 +++++ docs/verl-opd-reference-workload.svg | 65 +++++ mkdocs.yml | 1 + scripts/check_docs_visual.py | 1 + .../publish_verl_opd_reference_artifacts.py | 250 ++++++++++++++++++ tests/unit/test_opd_reference_workload.py | 49 ++++ tests/unit/test_packaging.py | 1 + 14 files changed, 646 insertions(+), 19 deletions(-) create mode 100644 benchmarks/results/rtx4080-verl-opd-developer-v1.json create mode 100644 docs/verl-opd-reference-workload-mobile.svg create mode 100644 docs/verl-opd-reference-workload.md create mode 100644 docs/verl-opd-reference-workload.svg create mode 100644 scripts/publish_verl_opd_reference_artifacts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7ba0b..36205b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to miniVERL are recorded here. The format follows ## [Unreleased] +### Measured developer workload + +- Published a checksummed RTX 4080 systems workload over 32 distinct consumed + prompts, 64-token responses and eight QLoRA updates. The data-bound figure + reports steady-state phase time, labelled throughput and 3.1914 GiB peak + reserved VRAM without a task-quality or method-comparison claim. +- Fixed Parquet resume to reconstruct the saved row/epoch cursor. A real + Qwen3 interruption after update four now reproduces uninterrupted + trajectories, adapter and optimizer tensors byte for byte; all training + state fields match apart from the intentionally run-specific resolved-config + digest. + ### Immutable plan/run workflow - Added deterministic `plan --out plan.json` artifacts that bind the source diff --git a/PYPI.md b/PYPI.md index 03dba2f..937328f 100644 --- a/PYPI.md +++ b/PYPI.md @@ -144,17 +144,18 @@ arbitrary launch script. ## Measured RTX 4080 path -The packaged Qwen3-0.6B/1.7B smoke completed two 16-token rollouts and one OPD -update on one RTX 4080 with **3.1758 GiB peak reserved VRAM**. The first update -completed in **12.0224 seconds**, the final adapter was exported as standard -PEFT/safetensors, and a clean reload passed. The recipe records immutable model -revisions, phase timings, cache identity, checkpoint bytes and adapter hashes. +The Qwen3-0.6B/1.7B developer workload consumed **32 distinct prompts**, each +with a 64-token response bound, and completed **8 current-policy updates** at +**3.1914 GiB peak reserved VRAM**. Median steady-state rollout, teacher-scoring +and update times were 9.8682, 0.4864 and 2.3391 seconds. A matched 4-update +interruption resumed to the same byte-identical trajectories, adapter and +optimizer tensors. See the [data-bound figure and full record](https://github.com/DaoyuanLi2816/mini-verl/blob/main/docs/verl-opd-reference-workload.md); +the original one-update [pip smoke](https://github.com/DaoyuanLi2816/mini-verl/blob/main/docs/opd-quickstart.md) remains preserved. This is deliberately a runtime and artifact proof. It is not a throughput benchmark, an alignment-quality endpoint, or evidence that OPD beats SFT, DPO or KD. Other NVIDIA GPUs use the same device-name-agnostic CUDA path, but model -fit depends on VRAM, context length, quantization and installed kernels. Read -the [exact smoke record and limitations](https://github.com/DaoyuanLi2816/mini-verl/blob/main/docs/opd-quickstart.md). +fit depends on VRAM, context length, quantization and installed kernels. ### Choose a path by hardware, not GPU branding diff --git a/README.md b/README.md index 12bff25..48d63f2 100644 --- a/README.md +++ b/README.md @@ -144,17 +144,18 @@ arbitrary launch script. ## Measured RTX 4080 path -The packaged Qwen3-0.6B/1.7B smoke completed two 16-token rollouts and one OPD -update on one RTX 4080 with **3.1758 GiB peak reserved VRAM**. The first update -completed in **12.0224 seconds**, the final adapter was exported as standard -PEFT/safetensors, and a clean reload passed. The recipe records immutable model -revisions, phase timings, cache identity, checkpoint bytes and adapter hashes. +The Qwen3-0.6B/1.7B developer workload consumed **32 distinct prompts**, each +with a 64-token response bound, and completed **8 current-policy updates** at +**3.1914 GiB peak reserved VRAM**. Median steady-state rollout, teacher-scoring +and update times were 9.8682, 0.4864 and 2.3391 seconds. A matched 4-update +interruption resumed to the same byte-identical trajectories, adapter and +optimizer tensors. See the [data-bound figure and full record](docs/verl-opd-reference-workload.md); +the original one-update [pip smoke](docs/opd-quickstart.md) remains preserved. This is deliberately a runtime and artifact proof. It is not a throughput benchmark, an alignment-quality endpoint, or evidence that OPD beats SFT, DPO or KD. Other NVIDIA GPUs use the same device-name-agnostic CUDA path, but model -fit depends on VRAM, context length, quantization and installed kernels. Read -the [exact smoke record and limitations](docs/opd-quickstart.md). +fit depends on VRAM, context length, quantization and installed kernels. ### Choose a path by hardware, not GPU branding diff --git a/README.zh-CN.md b/README.zh-CN.md index 0c8710b..8506716 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -120,14 +120,16 @@ Megatron、多 GPU 与多节点均不支持。已知不支持值会得到机器 ## RTX 4080 实测路径 -打包的 Qwen3-0.6B/1.7B smoke 在一张 RTX 4080 上完成两条 16-token rollout 与一次 -OPD update,**peak reserved VRAM 为 3.1758 GiB**,首次 update 在 **12.0224 秒**完成; -标准 PEFT/safetensors adapter 导出与干净重载均通过。recipe 同时记录不可变模型 revision、 -阶段计时、cache 身份、checkpoint 字节数与 adapter 哈希。 +Qwen3-0.6B/1.7B developer workload 实际消费 **32 个不同 prompt**,response 上限为 +64 token,完成 **8 次 current-policy update**,**peak reserved VRAM 为 3.1914 GiB**。 +稳态 rollout、teacher scoring 与 update 的中位耗时分别为 9.8682、0.4864 与 2.3391 秒。 +匹配的运行在第 4 次 update 后中断并恢复;最终 trajectory、adapter 与 optimizer tensor +均字节一致。详见[数据绑定图与完整记录](docs/verl-opd-reference-workload.md);原始一次更新的 +[pip smoke](docs/opd-quickstart.md)仍完整保留。 这只证明一个运行时与产物路径,不是吞吐 benchmark、对齐质量 endpoint,也不证明 OPD 优于 SFT、DPO 或 KD。其他 NVIDIA GPU 使用相同的 device-name-agnostic CUDA 路径,但 -能否装下仍取决于显存、上下文、量化与 kernel。见[精确实测记录](docs/opd-quickstart.md)。 +能否装下仍取决于显存、上下文、量化与 kernel。 ### 按硬件条件选择路径,而不是按显卡名称 diff --git a/benchmarks/results/rtx4080-verl-opd-developer-v1.json b/benchmarks/results/rtx4080-verl-opd-developer-v1.json new file mode 100644 index 0000000..6f6c34e --- /dev/null +++ b/benchmarks/results/rtx4080-verl-opd-developer-v1.json @@ -0,0 +1,122 @@ +{ + "artifacts": { + "checkpoint_hashes": { + "adapter.safetensors": "60e2eced54a3a13d5d66c765b1a9f45d26b2f16bf982fa0011f9316b0cc0433f", + "optimizer.safetensors": "0bb7dde497204151b952d1e5866059822334f71150bfe4a5ce46e253cc84f494", + "state.json": "180fbfe86f8a76c1bb672a1ccd8d7363089101ee9f3d6576cfc7a09e8c2db49b" + }, + "standard_peft_adapter_sha256": "085b9c1241acaf0a2ab2c6c9081484b77eafbd329d92c7a6181ddfa3709bfe0c", + "standard_peft_load_verified": true, + "trajectory_sha256": "56f20a5c0addcbd3e9b76a4165d885a86193a84fb43b887199babe2d0008e230", + "workload_output_bytes": 130233544 + }, + "hardware": { + "bitsandbytes": "0.50.0", + "cuda_runtime": "13.0", + "driver": "596.49", + "gpu": "NVIDIA GeForce RTX 4080", + "gpu_count": 1, + "peft": "0.18.0", + "torch": "2.13.0+cu130", + "transformers": "5.14.1", + "vram_gib": 15.992 + }, + "kind": "single_gpu_opd_developer_workload", + "measured_at": "2026-08-13T07:15:45Z", + "measurements": { + "adapter_bytes": 9213148, + "batch_downshifts": { + "rollout_oom": 0, + "update_chunk_oom": 0 + }, + "cache_bytes": 90727, + "checkpoint_bytes": 27649609, + "cold_startup_seconds": 9.3293, + "distinct_prompts_consumed": 32, + "observed_rollout_physical_batch_sizes": [ + 4 + ], + "peak_allocated_gib": 2.3033, + "peak_reserved_gib": 3.1914, + "prompts_consumed": 32, + "steady_state_median_seconds": { + "actor_update": 2.3391, + "rollout": 9.8682, + "teacher_scoring": 0.4864 + }, + "steady_state_median_throughput": { + "rollout_tokens_per_second": 25.9419, + "teacher_scored_positions_per_second": 526.31, + "update_positions_per_second": 109.4438 + }, + "time_to_first_rollout_seconds": 19.1611, + "time_to_first_teacher_targets_seconds": 19.7555, + "time_to_first_update_seconds": 22.2473, + "total_run_bytes": 65065202 + }, + "miniverl_version": "0.9.0.dev0", + "models": { + "student": { + "adapter": "lora-r8-alpha16", + "id": "Qwen/Qwen3-0.6B", + "quantization": "nf4", + "revision": "c1899de289a04d12100db370d81485cdf75e47ca" + }, + "teacher": { + "id": "Qwen/Qwen3-1.7B", + "quantization": "nf4", + "revision": "70d244cc86ccca08cf5af4e1e306ecf908b1ad5e" + } + }, + "profile": "verl-opd-v0.8-single-gpu-v1", + "recipe": { + "compiled_plan_sha256": "03b956be9ea9ebde5116ce64bc4ba9dc54e9f55f61a84372fa2ae24abd257d34", + "dataset_rows": 64, + "distinct_dataset_prompts": 64, + "distinct_prompts_consumed": 32, + "input_parquet_sha256": "baa99ba8c59d4c30b2e3081346860e7e8b2665dd70fdbcd7952935545a0cd770", + "logical_batch": 4, + "optimizer_updates": 8, + "prompt_limit": 128, + "prompts_consumed": 32, + "response_limit": 64, + "rollout_physical_batch": 4, + "runtime_strategy": "dual_model_resident", + "teacher_score_batch": 4, + "top_k": 32, + "update_physical_batch": 1 + }, + "resource_contract": { + "gpu_hour_limit": 4.0, + "peak_reserved_limit_gib": 14.5, + "peak_reserved_within_limit": true, + "total_gpu_workload_seconds": 243.85 + }, + "resume": { + "adapter_and_optimizer_byte_identical": true, + "excluded_run_identity_field": "resolved_config_digest", + "global_optimizer_steps": 8, + "interrupt_after_optimizer_updates": 4, + "interrupt_checkpoint_bytes": 27649609, + "resume_load_seconds": 8.347, + "status": "exact_match", + "task_cursor": 32, + "training_state_fields_identical": true, + "trajectories_byte_identical": true + }, + "schema_version": 1, + "scientific_scope": { + "alignment_quality_evaluated": false, + "opd_beats_sft_dpo_or_kd_claimed": false, + "runtime_correctness_only": true, + "task_quality_evaluated": false + }, + "source_commit": "f61f760af2dd3d88aea52b27176e7cca23ffef2e", + "status": "measured", + "verl": { + "commit": "7aed6b230776f963fa09509c10d9c3a767d1102c", + "distributed_execution_tested": false, + "tag": "v0.8.0" + }, + "workload_driver_sha256": "47bdc6e5c08e2791d734909d040488080cdac56efb69f2b0d9b91fd21d47f7e1" +} diff --git a/docs/opd-quickstart.md b/docs/opd-quickstart.md index 1c29ed5..183d7bd 100644 --- a/docs/opd-quickstart.md +++ b/docs/opd-quickstart.md @@ -23,6 +23,10 @@ for run artifacts. The plan command itself is CPU-only and weight-free; use ## Measured reference +The v0.9 developer workload extends this install smoke to 32 distinct consumed +prompts, 64 response tokens and eight optimizer updates. See its +[phase-time, throughput, VRAM and resume evidence](verl-opd-reference-workload.md). + | GPU | student / teacher | strategy | limits / top-k | peak reserved | first update | status | | --- | --- | --- | --- | ---: | ---: | --- | | RTX 4080 16 GiB | Qwen3-0.6B / Qwen3-1.7B, both NF4 | dual resident | 128 + 16 tokens / 32 | 3.176 GiB | 12.02 s | measured | diff --git a/docs/verl-opd-reference-workload-mobile.svg b/docs/verl-opd-reference-workload-mobile.svg new file mode 100644 index 0000000..196ca29 --- /dev/null +++ b/docs/verl-opd-reference-workload-mobile.svg @@ -0,0 +1,58 @@ + +RTX 4080 verl-style OPD developer workload +Mobile layout of measured median phase time and throughput plus peak reserved VRAM for eight QLoRA updates over 32 distinct prompts. Resume matched uninterrupted training artifacts. No task quality was evaluated. + + + +ONE RTX 4080 · QWEN3 0.6B → 1.7B +Single-GPU OPD workload +32 prompts · 64 response tokens · 8 updates + +Median phase time +seconds +Actor rollout + + +9.8682 +Teacher scoring + + +0.4864 +Actor update + + +2.3391 +Shared zero baseline + +Median throughput +items / second +Rollout tokens/s + + +25.94 +Teacher positions/s + + +526.31 +Update positions/s + + +109.44 +Tokens and positions labelled separately + +Peak reserved VRAM +3.1914 / 14.5 GiB gate + + +0 OOM downshifts · exact resume +Systems evidence only · no quality endpoint + diff --git a/docs/verl-opd-reference-workload.md b/docs/verl-opd-reference-workload.md new file mode 100644 index 0000000..827616a --- /dev/null +++ b/docs/verl-opd-reference-workload.md @@ -0,0 +1,60 @@ +# RTX 4080 OPD developer workload + +This measured workload asks a systems question: can the documented +`verl-opd-v0.8-single-gpu-v1` path sustain several useful-size rollout, +teacher-scoring and update cycles, survive an interruption, and stay well +inside a 16 GiB consumer-GPU envelope? It is **not** a task-quality or alignment +benchmark. + + + + Three aligned panels show median phase time, labelled throughput and peak reserved VRAM for the RTX 4080 developer workload. The run consumed 32 distinct prompts, completed eight updates at 3.1914 GiB peak reserved VRAM, and had no OOM downshifts. + + +## Measured recipe + +| Field | Exact value | +| --- | --- | +| GPU | 1× NVIDIA GeForce RTX 4080, 15.992 GiB | +| Student | Qwen3-0.6B, pinned commit, NF4 + LoRA r8/alpha16 | +| Teacher | Qwen3-1.7B, pinned commit, NF4 | +| Data | 64 distinct structured prompts; first 32 consumed | +| Bounds | 128 prompt tokens, 64 response tokens | +| Logical / rollout / update batch | 4 / 4 / 1 | +| Objective | reward-free `forward_kl_topk`, top-k 32, token mean | +| Schedule | 8 current-policy rollout/scoring/update cycles | + +The uninterrupted run reached its first rollout in 19.1611 seconds and its +first update in 22.2473 seconds, including 9.3293 seconds of cold construction. +Across cycles 2–8, median phase times were 9.8682 seconds for rollout, 0.4864 +seconds for teacher scoring and 2.3391 seconds for the actor update. Median +rates were 25.94 rollout tokens/s, 526.31 teacher-scored positions/s and 109.44 +update positions/s. + +Peak allocated/reserved VRAM was 2.3033/3.1914 GiB. The runtime retained +physical rollout batch 4 throughout, with zero generation downshifts and zero +projection-chunk OOM retries. The final teacher cache was 90,727 bytes, the +checkpoint 27,649,609 bytes, the exported PEFT adapter 9,213,148 bytes and the +complete uninterrupted run 65,065,202 bytes. + +## Interruption and resume + +A matched execution stopped after update 4, wrote a transactional checkpoint, +constructed a fresh trainer, loaded in 8.3470 seconds and completed updates +5–8. The resumed run consumed the same ordered 32 prompts. Its trajectories, +adapter tensors and optimizer tensors were byte-identical to the uninterrupted +run, and every training-state field matched. Only `resolved_config_digest` +differs by design because it binds the distinct run id. + +The complete paired workload took 243.85 seconds—0.068 GPU hours—and stayed +within the preregistered 14.5 GiB and 4 GPU-hour limits. The machine-readable +record is [`rtx4080-verl-opd-developer-v1.json`](https://github.com/DaoyuanLi2816/mini-verl/blob/main/benchmarks/results/rtx4080-verl-opd-developer-v1.json), +and [`run_verl_opd_reference_workload.py`](https://github.com/DaoyuanLi2816/mini-verl/blob/main/scripts/run_verl_opd_reference_workload.py) +reconstructs the dataset, plan, uninterrupted run and resumed run. + +## Scope + +No reward, task correctness, alignment, preference, safety or method-comparison +endpoint was evaluated. This record does not show that OPD beats SFT, DPO or +KD, and it does not show that a distributed verl job ran. It validates one +Qwen3, one-GPU, forward-top-k compatibility profile. diff --git a/docs/verl-opd-reference-workload.svg b/docs/verl-opd-reference-workload.svg new file mode 100644 index 0000000..e62e071 --- /dev/null +++ b/docs/verl-opd-reference-workload.svg @@ -0,0 +1,65 @@ + +RTX 4080 verl-style OPD developer workload +Measured median phase time and throughput plus peak reserved VRAM for eight QLoRA optimizer updates over 32 distinct prompts. Resume matched uninterrupted adapter, optimizer, trajectories and training state. No task quality was evaluated. + + + +ONE RTX 4080 · QWEN3 0.6B → 1.7B · FORWARD-KL TOP-K 32 +A practical single-GPU OPD workload, measured end to end +32 distinct prompts consumed · 64 response tokens · logical batch 4 · 8 optimizer updates + +Median steady-state phase time +seconds +Actor rollout + + + +9.8682 +Teacher scoring + + + +0.4864 +Actor update + + + +2.3391 +Direct labels; bars share a zero baseline. + +Median steady-state throughput +items / second +Rollout tokens/s + + + +25.94 +Teacher positions/s + + + +526.31 +Update positions/s + + + +109.44 +Token and selected-position rates are labelled separately. + +Peak reserved VRAM +3.1914 GiB / 14.5 GiB release gate + + + +0 OOM downshifts +Resume: adapter + optimizer + trajectories + training state matched exactly · systems evidence only · no quality endpoint + diff --git a/mkdocs.yml b/mkdocs.yml index 0eb690f..d09c3a1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,7 @@ nav: - Immutable plans: immutable-plans.md - Hardware planning: hardware-planning.md - Run verl-style OPD: opd-quickstart.md + - RTX 4080 developer workload: verl-opd-reference-workload.md - Start on one GPU: single-gpu-guide.md - Align: - "External Alignment Gate v1: early stop": alignment-external/alignment-external-v1.md diff --git a/scripts/check_docs_visual.py b/scripts/check_docs_visual.py index 043b80e..002ef62 100644 --- a/scripts/check_docs_visual.py +++ b/scripts/check_docs_visual.py @@ -35,6 +35,7 @@ "/config-overrides/", "/immutable-plans/", "/hardware-planning/", + "/verl-opd-reference-workload/", "/scaleout-materialization/", "/alignment-lab/alignment-lab-v1/", "/alignment-external/alignment-external-v1/", diff --git a/scripts/publish_verl_opd_reference_artifacts.py b/scripts/publish_verl_opd_reference_artifacts.py new file mode 100644 index 0000000..3e4f958 --- /dev/null +++ b/scripts/publish_verl_opd_reference_artifacts.py @@ -0,0 +1,250 @@ +"""Render the measured single-GPU OPD developer workload systems figure.""" + +from __future__ import annotations + +import argparse +import html +import json +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +RESULT = ROOT / "benchmarks/results/rtx4080-verl-opd-developer-v1.json" +FIGURE = ROOT / "docs/verl-opd-reference-workload.svg" +MOBILE_FIGURE = ROOT / "docs/verl-opd-reference-workload-mobile.svg" + + +def _load(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("kind") != "single_gpu_opd_developer_workload": + raise ValueError("not a single-GPU OPD developer workload record") + if payload.get("status") != "measured": + raise ValueError("developer workload is not measured") + if payload["scientific_scope"] != { + "runtime_correctness_only": True, + "alignment_quality_evaluated": False, + "task_quality_evaluated": False, + "opd_beats_sft_dpo_or_kd_claimed": False, + }: + raise ValueError("developer workload scientific scope drifted") + return payload + + +def _text(x: int, y: int, value: str, class_name: str = "label", anchor: str = "start") -> str: + return ( + f'' + f"{html.escape(value)}" + ) + + +def render(payload: dict[str, Any]) -> str: + measurements = payload["measurements"] + phases = measurements["steady_state_median_seconds"] + rates = measurements["steady_state_median_throughput"] + peak = float(measurements["peak_reserved_gib"]) + limit = float(payload["resource_contract"]["peak_reserved_limit_gib"]) + phase_rows = [ + ("Actor rollout", float(phases["rollout"]), "#56B4E9"), + ("Teacher scoring", float(phases["teacher_scoring"]), "#E69F00"), + ("Actor update", float(phases["actor_update"]), "#009E73"), + ] + rate_rows = [ + ("Rollout tokens/s", float(rates["rollout_tokens_per_second"]), "#56B4E9"), + ("Teacher positions/s", float(rates["teacher_scored_positions_per_second"]), "#E69F00"), + ("Update positions/s", float(rates["update_positions_per_second"]), "#009E73"), + ] + parts = [ + '', + 'RTX 4080 verl-style OPD developer workload', + 'Measured median phase time and throughput plus peak reserved VRAM for eight QLoRA optimizer updates over 32 distinct prompts. Resume matched uninterrupted adapter, optimizer, trajectories and training state. No task quality was evaluated.', + """""", + '', + '', + _text(58, 76, "ONE RTX 4080 · QWEN3 0.6B → 1.7B · FORWARD-KL TOP-K 32", "small"), + _text(58, 116, "A practical single-GPU OPD workload, measured end to end", "title"), + _text( + 58, + 148, + "32 distinct prompts consumed · 64 response tokens · logical batch 4 · 8 optimizer updates", + "subtitle", + ), + '', + _text(74, 216, "Median steady-state phase time", "panel-title"), + _text(526, 216, "seconds", "small", "end"), + ] + max_phase = max(value for _, value, _ in phase_rows) + for index, (label, value, color) in enumerate(phase_rows): + y = 260 + index * 76 + width = 300 * value / max_phase + parts.extend( + [ + _text(74, y, label, "label"), + f'', + f'', + f'', + _text(526, y, f"{value:.4f}", "value", "end"), + ] + ) + parts.extend( + [ + _text(74, 472, "Direct labels; bars share a zero baseline.", "note"), + '', + _text(594, 216, "Median steady-state throughput", "panel-title"), + _text(1046, 216, "items / second", "small", "end"), + ] + ) + max_rate = max(value for _, value, _ in rate_rows) + for index, (label, value, color) in enumerate(rate_rows): + y = 260 + index * 76 + width = 278 * value / max_rate + parts.extend( + [ + _text(594, y, label, "label"), + f'', + f'', + f'', + _text(1046, y, f"{value:.2f}", "value", "end"), + ] + ) + gauge_width = 760 * peak / limit + parts.extend( + [ + _text(594, 472, "Token and selected-position rates are labelled separately.", "note"), + '', + _text(74, 550, "Peak reserved VRAM", "panel-title"), + _text(1046, 550, f"{peak:.4f} GiB / {limit:.1f} GiB release gate", "value", "end"), + '', + f'', + f'', + _text(852, 590, "0 OOM downshifts", "value"), + _text( + 74, + 620, + "Resume: adapter + optimizer + trajectories + training state matched exactly · systems evidence only · no quality endpoint", + "note", + ), + "", + ] + ) + return "\n".join(parts) + "\n" + + +def render_mobile(payload: dict[str, Any]) -> str: + measurements = payload["measurements"] + phases = measurements["steady_state_median_seconds"] + rates = measurements["steady_state_median_throughput"] + peak = float(measurements["peak_reserved_gib"]) + limit = float(payload["resource_contract"]["peak_reserved_limit_gib"]) + phase_rows = [ + ("Actor rollout", float(phases["rollout"]), "#56B4E9"), + ("Teacher scoring", float(phases["teacher_scoring"]), "#E69F00"), + ("Actor update", float(phases["actor_update"]), "#009E73"), + ] + rate_rows = [ + ("Rollout tokens/s", float(rates["rollout_tokens_per_second"]), "#56B4E9"), + ("Teacher positions/s", float(rates["teacher_scored_positions_per_second"]), "#E69F00"), + ("Update positions/s", float(rates["update_positions_per_second"]), "#009E73"), + ] + parts = [ + '', + 'RTX 4080 verl-style OPD developer workload', + 'Mobile layout of measured median phase time and throughput plus peak reserved VRAM for eight QLoRA updates over 32 distinct prompts. Resume matched uninterrupted training artifacts. No task quality was evaluated.', + """""", + '', + '', + _text(30, 48, "ONE RTX 4080 · QWEN3 0.6B → 1.7B", "small"), + _text(30, 78, "Single-GPU OPD workload", "title"), + _text(30, 102, "32 prompts · 64 response tokens · 8 updates", "subtitle"), + '', + _text(42, 158, "Median phase time", "panel-title"), + _text(348, 158, "seconds", "small", "end"), + ] + max_phase = max(value for _, value, _ in phase_rows) + for index, (label, value, color) in enumerate(phase_rows): + y = 204 + index * 58 + width = 174 * value / max_phase + parts.extend( + [ + _text(42, y, label, "label"), + f'', + f'', + _text(348, y, f"{value:.4f}", "value", "end"), + ] + ) + parts.extend( + [ + _text(42, 364, "Shared zero baseline", "note"), + '', + _text(42, 436, "Median throughput", "panel-title"), + _text(348, 436, "items / second", "small", "end"), + ] + ) + max_rate = max(value for _, value, _ in rate_rows) + for index, (label, value, color) in enumerate(rate_rows): + y = 482 + index * 58 + width = 174 * value / max_rate + parts.extend( + [ + _text(42, y, label, "label"), + f'', + f'', + _text(348, y, f"{value:.2f}", "value", "end"), + ] + ) + parts.extend( + [ + _text(42, 642, "Tokens and positions labelled separately", "note"), + '', + _text(42, 716, "Peak reserved VRAM", "panel-title"), + _text(348, 744, f"{peak:.4f} / {limit:.1f} GiB gate", "value", "end"), + '', + f'', + _text(42, 818, "0 OOM downshifts · exact resume", "value"), + _text(42, 848, "Systems evidence only · no quality endpoint", "note"), + "", + ] + ) + return "\n".join(parts) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--result", type=Path, default=RESULT) + parser.add_argument("--out", type=Path, default=FIGURE) + parser.add_argument("--mobile-out", type=Path, default=MOBILE_FIGURE) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + payload = _load(args.result) + rendered = render(payload) + mobile = render_mobile(payload) + if args.check: + if not args.out.is_file() or args.out.read_text(encoding="utf-8") != rendered: + raise SystemExit(f"generated artifact is stale: {args.out}") + if not args.mobile_out.is_file() or args.mobile_out.read_text(encoding="utf-8") != mobile: + raise SystemExit(f"generated artifact is stale: {args.mobile_out}") + return + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(rendered, encoding="utf-8", newline="\n") + args.mobile_out.write_text(mobile, encoding="utf-8", newline="\n") + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_opd_reference_workload.py b/tests/unit/test_opd_reference_workload.py index 87b8241..4fe91af 100644 --- a/tests/unit/test_opd_reference_workload.py +++ b/tests/unit/test_opd_reference_workload.py @@ -1,6 +1,8 @@ from __future__ import annotations +import hashlib import importlib.util +import json from pathlib import Path from typing import Any @@ -43,3 +45,50 @@ def test_resume_equivalence_excludes_only_the_run_specific_resolved_digest() -> assert report["adapter_and_optimizer_byte_identical"] is True assert report["training_state_fields_identical"] is True assert report["excluded_run_identity_field"] == "resolved_config_digest" + + +def test_measured_reference_workload_is_scoped_complete_and_frozen() -> None: + path = Path("benchmarks/results/rtx4080-verl-opd-developer-v1.json") + assert hashlib.sha256(path.read_bytes()).hexdigest() == ( + "0684485de9050214029fa978d5cd6fbb1b15659803f1964648dd8ebc3dbbeb3c" + ) + payload = json.loads(path.read_text(encoding="utf-8")) + + assert payload["recipe"]["dataset_rows"] == 64 + assert payload["recipe"]["distinct_dataset_prompts"] == 64 + assert payload["recipe"]["distinct_prompts_consumed"] == 32 + assert payload["recipe"]["response_limit"] == 64 + assert payload["recipe"]["logical_batch"] == 4 + assert payload["recipe"]["optimizer_updates"] == 8 + assert payload["measurements"]["peak_reserved_gib"] <= 14.5 + assert payload["measurements"]["batch_downshifts"] == { + "rollout_oom": 0, + "update_chunk_oom": 0, + } + assert payload["resume"]["status"] == "exact_match" + assert payload["verl"]["distributed_execution_tested"] is False + assert payload["scientific_scope"]["alignment_quality_evaluated"] is False + assert payload["scientific_scope"]["task_quality_evaluated"] is False + + +def test_reference_workload_figure_is_exactly_generated() -> None: + publisher_path = Path("scripts/publish_verl_opd_reference_artifacts.py") + spec = importlib.util.spec_from_file_location( + "publish_verl_opd_reference_artifacts", publisher_path + ) + assert spec is not None and spec.loader is not None + publisher = importlib.util.module_from_spec(spec) + spec.loader.exec_module(publisher) + payload = publisher._load(Path("benchmarks/results/rtx4080-verl-opd-developer-v1.json")) + + expected = publisher.render(payload) + expected_mobile = publisher.render_mobile(payload) + actual = Path("docs/verl-opd-reference-workload.svg").read_text(encoding="utf-8") + actual_mobile = Path("docs/verl-opd-reference-workload-mobile.svg").read_text(encoding="utf-8") + + assert actual == expected + assert actual_mobile == expected_mobile + assert "No task quality was evaluated" in actual + assert "No task quality was evaluated" in actual_mobile + assert "0 OOM downshifts" in actual + assert " Date: Thu, 13 Aug 2026 00:26:52 -0700 Subject: [PATCH 5/6] Type the workload and resume cursor contracts --- scripts/run_verl_opd_reference_workload.py | 2 +- src/miniverl/training/trainer.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/run_verl_opd_reference_workload.py b/scripts/run_verl_opd_reference_workload.py index 8bc75ac..e252111 100644 --- a/scripts/run_verl_opd_reference_workload.py +++ b/scripts/run_verl_opd_reference_workload.py @@ -388,7 +388,7 @@ def run_workload(out: Path, result_path: Path, *, offline: bool) -> dict[str, An import torch properties = torch.cuda.get_device_properties(torch.cuda.current_device()) - artifact = { + artifact: dict[str, Any] = { "schema_version": 1, "kind": "single_gpu_opd_developer_workload", "status": "measured", diff --git a/src/miniverl/training/trainer.py b/src/miniverl/training/trainer.py index cccdd52..34ebaa7 100644 --- a/src/miniverl/training/trainer.py +++ b/src/miniverl/training/trainer.py @@ -1130,6 +1130,7 @@ def _next_tasks(self, count: int) -> list[Any]: from miniverl.data.verl_parquet import render_prompt assert isinstance(self.config.source, VerlParquetSourceConfig) + assert self.prompt_dataset_manifest is not None output: list[Any] = [] while len(output) < count: if self._prompt_train_iterator is None: From 7a36f59d822d9d1393882c19ad4ef56b7364d43e Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:51:50 -0700 Subject: [PATCH 6/6] Bind the final OPD workload evidence --- CHANGELOG.md | 4 + PROJECT_STATE.md | 6 ++ PYPI.md | 4 +- README.md | 4 +- README.zh-CN.md | 4 +- .../rtx4080-smollm2-opd-family-smoke-v1.json | 83 +++++++++++++++++++ .../rtx4080-verl-opd-developer-v1.json | 38 ++++----- docs/verl-opd-reference-workload-mobile.svg | 18 ++-- docs/verl-opd-reference-workload.md | 23 +++-- docs/verl-opd-reference-workload.svg | 26 +++--- tests/unit/test_opd_reference_workload.py | 21 ++++- tests/unit/test_packaging.py | 1 + 12 files changed, 179 insertions(+), 53 deletions(-) create mode 100644 benchmarks/results/rtx4080-smollm2-opd-family-smoke-v1.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 36205b0..d9f961e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ All notable changes to miniVERL are recorded here. The format follows trajectories, adapter and optimizer tensors byte for byte; all training state fields match apart from the intentionally run-specific resolved-config digest. +- Qualified the Apache-2.0 SmolLM2-360M/1.7B pair with a pinned one-update + compatibility smoke covering tokenizer identity, rollout, teacher scoring, + actor update and PEFT reload. This is not a second full recipe or quality + benchmark. ### Immutable plan/run workflow diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 428878d..63348c1 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -54,6 +54,12 @@ adapter, software and output provenance. `launchable: true` means only that the pinned upstream entry point has complete local inputs; distributed execution remains `false` and algorithm parity remains unclaimed. +A second-family compatibility smoke used pinned Apache-2.0 SmolLM2-360M and +SmolLM2-1.7B snapshots. Structural tokenizer identity matched; one strict +forward-top-k rollout, teacher-score, update and PEFT reload completed on the +RTX 4080 at 1.416 GiB peak reserved VRAM. This is a tiny compatibility check, +not a second supported recipe or task-quality result. + ## v0.8.1 product surface The landing pages now lead with the documented one-GPU verl-style OPD journey, diff --git a/PYPI.md b/PYPI.md index 937328f..eae8f96 100644 --- a/PYPI.md +++ b/PYPI.md @@ -147,10 +147,12 @@ arbitrary launch script. The Qwen3-0.6B/1.7B developer workload consumed **32 distinct prompts**, each with a 64-token response bound, and completed **8 current-policy updates** at **3.1914 GiB peak reserved VRAM**. Median steady-state rollout, teacher-scoring -and update times were 9.8682, 0.4864 and 2.3391 seconds. A matched 4-update +and update times were 9.7200, 0.4864 and 2.3260 seconds. A matched 4-update interruption resumed to the same byte-identical trajectories, adapter and optimizer tensors. See the [data-bound figure and full record](https://github.com/DaoyuanLi2816/mini-verl/blob/main/docs/verl-opd-reference-workload.md); the original one-update [pip smoke](https://github.com/DaoyuanLi2816/mini-verl/blob/main/docs/opd-quickstart.md) remains preserved. +A separate pinned SmolLM2-360M/1.7B compatibility smoke completed one full +rollout/scoring/update cycle; it is not a second measured recipe. This is deliberately a runtime and artifact proof. It is not a throughput benchmark, an alignment-quality endpoint, or evidence that OPD beats SFT, DPO diff --git a/README.md b/README.md index 48d63f2..2ce02f1 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,12 @@ arbitrary launch script. The Qwen3-0.6B/1.7B developer workload consumed **32 distinct prompts**, each with a 64-token response bound, and completed **8 current-policy updates** at **3.1914 GiB peak reserved VRAM**. Median steady-state rollout, teacher-scoring -and update times were 9.8682, 0.4864 and 2.3391 seconds. A matched 4-update +and update times were 9.7200, 0.4864 and 2.3260 seconds. A matched 4-update interruption resumed to the same byte-identical trajectories, adapter and optimizer tensors. See the [data-bound figure and full record](docs/verl-opd-reference-workload.md); the original one-update [pip smoke](docs/opd-quickstart.md) remains preserved. +A separate pinned SmolLM2-360M/1.7B compatibility smoke completed one full +rollout/scoring/update cycle; it is not a second measured recipe. This is deliberately a runtime and artifact proof. It is not a throughput benchmark, an alignment-quality endpoint, or evidence that OPD beats SFT, DPO diff --git a/README.zh-CN.md b/README.zh-CN.md index 8506716..307b6b6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -122,10 +122,12 @@ Megatron、多 GPU 与多节点均不支持。已知不支持值会得到机器 Qwen3-0.6B/1.7B developer workload 实际消费 **32 个不同 prompt**,response 上限为 64 token,完成 **8 次 current-policy update**,**peak reserved VRAM 为 3.1914 GiB**。 -稳态 rollout、teacher scoring 与 update 的中位耗时分别为 9.8682、0.4864 与 2.3391 秒。 +稳态 rollout、teacher scoring 与 update 的中位耗时分别为 9.7200、0.4864 与 2.3260 秒。 匹配的运行在第 4 次 update 后中断并恢复;最终 trajectory、adapter 与 optimizer tensor 均字节一致。详见[数据绑定图与完整记录](docs/verl-opd-reference-workload.md);原始一次更新的 [pip smoke](docs/opd-quickstart.md)仍完整保留。 +另一个固定版本的 SmolLM2-360M/1.7B 兼容性 smoke 完成了一次完整的 +rollout/scoring/update 循环;它不是第二套完整实测 recipe。 这只证明一个运行时与产物路径,不是吞吐 benchmark、对齐质量 endpoint,也不证明 OPD 优于 SFT、DPO 或 KD。其他 NVIDIA GPU 使用相同的 device-name-agnostic CUDA 路径,但 diff --git a/benchmarks/results/rtx4080-smollm2-opd-family-smoke-v1.json b/benchmarks/results/rtx4080-smollm2-opd-family-smoke-v1.json new file mode 100644 index 0000000..cd42e81 --- /dev/null +++ b/benchmarks/results/rtx4080-smollm2-opd-family-smoke-v1.json @@ -0,0 +1,83 @@ +{ + "schema_version": 1, + "kind": "single_gpu_opd_model_family_compatibility_smoke", + "status": "measured", + "measured_at": "2026-08-13T07:48:14Z", + "source_commit": "2819f7290ffb0c14a0ff5d13509a0fec29c8100d", + "miniverl_version": "0.9.0.dev0", + "profile": "verl-opd-v0.8-single-gpu-v1", + "models": { + "student": { + "id": "HuggingFaceTB/SmolLM2-360M-Instruct", + "revision": "a10cc1512eabd3dde888204e902eca88bddb4951", + "license": "apache-2.0", + "quantization": "nf4", + "adapter": "lora-r8-alpha16" + }, + "teacher": { + "id": "HuggingFaceTB/SmolLM2-1.7B-Instruct", + "revision": "31b70e2e869a7173562077fd711b654946d38674", + "license": "apache-2.0", + "quantization": "nf4" + } + }, + "tokenizer_identity": { + "tokenizer_class": "GPT2Tokenizer", + "length": 49152, + "structural_digest_v2": "4bf67fa8fdbf333c2f79c00bdc43909ed2ffb4bd7ed1f44a11d65f171ea9d1c5", + "behavioral_fingerprint_v1": "5772ce854a388a7613d1284a34fa0360f9c603256796cb1f72e25713ab82f7b5", + "student_teacher_match": true + }, + "workload": { + "distinct_prompts": 1, + "response_limit": 16, + "logical_batch": 1, + "optimizer_updates": 1, + "top_k": 32, + "loss": "forward_kl_topk", + "runtime_strategy": "dual_model_resident" + }, + "measurements": { + "construction_seconds": 8.5539, + "time_to_first_rollout_seconds": 10.084, + "time_to_first_teacher_targets_seconds": 10.1989, + "time_to_first_update_seconds": 10.7449, + "rollout_tokens_per_second": 10.46, + "teacher_scored_positions_per_second": 139.24, + "update_positions_per_second": 35.5365, + "selected_positions": 16, + "peak_allocated_gib": 1.384, + "peak_reserved_gib": 1.416, + "oom_retries": 0, + "checkpoint_bytes": 19798324, + "run_disk_bytes": 26507663 + }, + "artifacts": { + "adapter_sha256": "1eda290c7e19b55d9d497ec014711fa451c67595525ad5aa304167fd1be189a6", + "peft_adapter_model_sha256": "90bf81a33c6c23acb87c93a1df53caeb5e9b91aa72e5678edfe9832b60716aec", + "peft_load_verified": true + }, + "runtime": { + "gpu": "NVIDIA GeForce RTX 4080", + "gpu_count": 1, + "vram_gib": 15.992, + "torch": "2.13.0+cu130", + "cuda_runtime": "13.0", + "transformers": "5.14.1", + "peft": "0.18.0", + "bitsandbytes": "0.50.0", + "distributed_execution_tested": false + }, + "scope": { + "config_compiled": true, + "tokenizer_identity_verified": true, + "student_loaded": true, + "teacher_loaded": true, + "rollout_completed": true, + "teacher_scoring_completed": true, + "optimizer_update_completed": true, + "task_quality_evaluated": false, + "alignment_quality_evaluated": false, + "full_recipe_supported": false + } +} diff --git a/benchmarks/results/rtx4080-verl-opd-developer-v1.json b/benchmarks/results/rtx4080-verl-opd-developer-v1.json index 6f6c34e..c834f27 100644 --- a/benchmarks/results/rtx4080-verl-opd-developer-v1.json +++ b/benchmarks/results/rtx4080-verl-opd-developer-v1.json @@ -3,12 +3,12 @@ "checkpoint_hashes": { "adapter.safetensors": "60e2eced54a3a13d5d66c765b1a9f45d26b2f16bf982fa0011f9316b0cc0433f", "optimizer.safetensors": "0bb7dde497204151b952d1e5866059822334f71150bfe4a5ce46e253cc84f494", - "state.json": "180fbfe86f8a76c1bb672a1ccd8d7363089101ee9f3d6576cfc7a09e8c2db49b" + "state.json": "e92abdf28f18f07c2ebb7c49a712b806c9462ad422bfeb99fb4cce037a248962" }, "standard_peft_adapter_sha256": "085b9c1241acaf0a2ab2c6c9081484b77eafbd329d92c7a6181ddfa3709bfe0c", "standard_peft_load_verified": true, - "trajectory_sha256": "56f20a5c0addcbd3e9b76a4165d885a86193a84fb43b887199babe2d0008e230", - "workload_output_bytes": 130233544 + "trajectory_sha256": "f0d7e256e977508d698c4db506f1c37f42188b91b27c372bce1dad8bcf6d54d0", + "workload_output_bytes": 130233537 }, "hardware": { "bitsandbytes": "0.50.0", @@ -22,7 +22,7 @@ "vram_gib": 15.992 }, "kind": "single_gpu_opd_developer_workload", - "measured_at": "2026-08-13T07:15:45Z", + "measured_at": "2026-08-13T07:31:03Z", "measurements": { "adapter_bytes": 9213148, "batch_downshifts": { @@ -31,7 +31,7 @@ }, "cache_bytes": 90727, "checkpoint_bytes": 27649609, - "cold_startup_seconds": 9.3293, + "cold_startup_seconds": 9.0802, "distinct_prompts_consumed": 32, "observed_rollout_physical_batch_sizes": [ 4 @@ -40,19 +40,19 @@ "peak_reserved_gib": 3.1914, "prompts_consumed": 32, "steady_state_median_seconds": { - "actor_update": 2.3391, - "rollout": 9.8682, + "actor_update": 2.326, + "rollout": 9.72, "teacher_scoring": 0.4864 }, "steady_state_median_throughput": { - "rollout_tokens_per_second": 25.9419, - "teacher_scored_positions_per_second": 526.31, - "update_positions_per_second": 109.4438 + "rollout_tokens_per_second": 26.3374, + "teacher_scored_positions_per_second": 526.32, + "update_positions_per_second": 110.0602 }, - "time_to_first_rollout_seconds": 19.1611, - "time_to_first_teacher_targets_seconds": 19.7555, - "time_to_first_update_seconds": 22.2473, - "total_run_bytes": 65065202 + "time_to_first_rollout_seconds": 19.0015, + "time_to_first_teacher_targets_seconds": 19.5584, + "time_to_first_update_seconds": 22.0412, + "total_run_bytes": 65065194 }, "miniverl_version": "0.9.0.dev0", "models": { @@ -70,7 +70,7 @@ }, "profile": "verl-opd-v0.8-single-gpu-v1", "recipe": { - "compiled_plan_sha256": "03b956be9ea9ebde5116ce64bc4ba9dc54e9f55f61a84372fa2ae24abd257d34", + "compiled_plan_sha256": "9b2b0b522585e5a88603f83f3ce714080a1560d92940d9d5dd661fe586dc1a5b", "dataset_rows": 64, "distinct_dataset_prompts": 64, "distinct_prompts_consumed": 32, @@ -90,7 +90,7 @@ "gpu_hour_limit": 4.0, "peak_reserved_limit_gib": 14.5, "peak_reserved_within_limit": true, - "total_gpu_workload_seconds": 243.85 + "total_gpu_workload_seconds": 235.49 }, "resume": { "adapter_and_optimizer_byte_identical": true, @@ -98,7 +98,7 @@ "global_optimizer_steps": 8, "interrupt_after_optimizer_updates": 4, "interrupt_checkpoint_bytes": 27649609, - "resume_load_seconds": 8.347, + "resume_load_seconds": 8.2339, "status": "exact_match", "task_cursor": 32, "training_state_fields_identical": true, @@ -111,12 +111,12 @@ "runtime_correctness_only": true, "task_quality_evaluated": false }, - "source_commit": "f61f760af2dd3d88aea52b27176e7cca23ffef2e", + "source_commit": "2819f7290ffb0c14a0ff5d13509a0fec29c8100d", "status": "measured", "verl": { "commit": "7aed6b230776f963fa09509c10d9c3a767d1102c", "distributed_execution_tested": false, "tag": "v0.8.0" }, - "workload_driver_sha256": "47bdc6e5c08e2791d734909d040488080cdac56efb69f2b0d9b91fd21d47f7e1" + "workload_driver_sha256": "00356532e8d1ea6a488340bffc42acf59300e9c130d483866645233e5cc823af" } diff --git a/docs/verl-opd-reference-workload-mobile.svg b/docs/verl-opd-reference-workload-mobile.svg index 196ca29..4a85de9 100644 --- a/docs/verl-opd-reference-workload-mobile.svg +++ b/docs/verl-opd-reference-workload-mobile.svg @@ -22,31 +22,31 @@ Actor rollout -9.8682 +9.7200 Teacher scoring - + 0.4864 Actor update - -2.3391 + +2.3260 Shared zero baseline Median throughput items / second Rollout tokens/s - -25.94 + +26.34 Teacher positions/s -526.31 +526.32 Update positions/s - -109.44 + +110.06 Tokens and positions labelled separately Peak reserved VRAM diff --git a/docs/verl-opd-reference-workload.md b/docs/verl-opd-reference-workload.md index 827616a..a618571 100644 --- a/docs/verl-opd-reference-workload.md +++ b/docs/verl-opd-reference-workload.md @@ -24,29 +24,29 @@ benchmark. | Objective | reward-free `forward_kl_topk`, top-k 32, token mean | | Schedule | 8 current-policy rollout/scoring/update cycles | -The uninterrupted run reached its first rollout in 19.1611 seconds and its -first update in 22.2473 seconds, including 9.3293 seconds of cold construction. -Across cycles 2–8, median phase times were 9.8682 seconds for rollout, 0.4864 -seconds for teacher scoring and 2.3391 seconds for the actor update. Median -rates were 25.94 rollout tokens/s, 526.31 teacher-scored positions/s and 109.44 +The uninterrupted run reached its first rollout in 19.0015 seconds and its +first update in 22.0412 seconds, including 9.0802 seconds of cold construction. +Across cycles 2–8, median phase times were 9.7200 seconds for rollout, 0.4864 +seconds for teacher scoring and 2.3260 seconds for the actor update. Median +rates were 26.34 rollout tokens/s, 526.32 teacher-scored positions/s and 110.06 update positions/s. Peak allocated/reserved VRAM was 2.3033/3.1914 GiB. The runtime retained physical rollout batch 4 throughout, with zero generation downshifts and zero projection-chunk OOM retries. The final teacher cache was 90,727 bytes, the checkpoint 27,649,609 bytes, the exported PEFT adapter 9,213,148 bytes and the -complete uninterrupted run 65,065,202 bytes. +complete uninterrupted run 65,065,194 bytes. ## Interruption and resume A matched execution stopped after update 4, wrote a transactional checkpoint, -constructed a fresh trainer, loaded in 8.3470 seconds and completed updates +constructed a fresh trainer, loaded in 8.2339 seconds and completed updates 5–8. The resumed run consumed the same ordered 32 prompts. Its trajectories, adapter tensors and optimizer tensors were byte-identical to the uninterrupted run, and every training-state field matched. Only `resolved_config_digest` differs by design because it binds the distinct run id. -The complete paired workload took 243.85 seconds—0.068 GPU hours—and stayed +The complete paired workload took 235.49 seconds—0.065 GPU hours—and stayed within the preregistered 14.5 GiB and 4 GPU-hour limits. The machine-readable record is [`rtx4080-verl-opd-developer-v1.json`](https://github.com/DaoyuanLi2816/mini-verl/blob/main/benchmarks/results/rtx4080-verl-opd-developer-v1.json), and [`run_verl_opd_reference_workload.py`](https://github.com/DaoyuanLi2816/mini-verl/blob/main/scripts/run_verl_opd_reference_workload.py) @@ -54,6 +54,13 @@ reconstructs the dataset, plan, uninterrupted run and resumed run. ## Scope +A separate model-family compatibility smoke used pinned Apache-2.0 +SmolLM2-360M/1.7B snapshots. Their structural tokenizer identity matched, and +one 16-token rollout, teacher-score phase, optimizer update and standard PEFT +reload completed at 1.416 GiB peak reserved VRAM. The checksummed +[`rtx4080-smollm2-opd-family-smoke-v1.json`](https://github.com/DaoyuanLi2816/mini-verl/blob/main/benchmarks/results/rtx4080-smollm2-opd-family-smoke-v1.json) +is compatibility evidence only—not a second supported recipe or quality result. + No reward, task correctness, alignment, preference, safety or method-comparison endpoint was evaluated. This record does not show that OPD beats SFT, DPO or KD, and it does not show that a distributed verl job ran. It validates one diff --git a/docs/verl-opd-reference-workload.svg b/docs/verl-opd-reference-workload.svg index e62e071..95e3e14 100644 --- a/docs/verl-opd-reference-workload.svg +++ b/docs/verl-opd-reference-workload.svg @@ -23,36 +23,36 @@ -9.8682 +9.7200 Teacher scoring - - + + 0.4864 Actor update - - -2.3391 + + +2.3260 Direct labels; bars share a zero baseline. Median steady-state throughput items / second Rollout tokens/s - - -25.94 + + +26.34 Teacher positions/s -526.31 +526.32 Update positions/s - - -109.44 + + +110.06 Token and selected-position rates are labelled separately. Peak reserved VRAM diff --git a/tests/unit/test_opd_reference_workload.py b/tests/unit/test_opd_reference_workload.py index 4fe91af..7c1d0b8 100644 --- a/tests/unit/test_opd_reference_workload.py +++ b/tests/unit/test_opd_reference_workload.py @@ -50,7 +50,7 @@ def test_resume_equivalence_excludes_only_the_run_specific_resolved_digest() -> def test_measured_reference_workload_is_scoped_complete_and_frozen() -> None: path = Path("benchmarks/results/rtx4080-verl-opd-developer-v1.json") assert hashlib.sha256(path.read_bytes()).hexdigest() == ( - "0684485de9050214029fa978d5cd6fbb1b15659803f1964648dd8ebc3dbbeb3c" + "d13153734c20a084171763820a961a2c08511ded99854a28f1f5f169a843acf2" ) payload = json.loads(path.read_text(encoding="utf-8")) @@ -92,3 +92,22 @@ def test_reference_workload_figure_is_exactly_generated() -> None: assert "No task quality was evaluated" in actual_mobile assert "0 OOM downshifts" in actual assert " None: + path = Path("benchmarks/results/rtx4080-smollm2-opd-family-smoke-v1.json") + assert hashlib.sha256(path.read_bytes()).hexdigest() == ( + "a6b7421de81af1afa0dd2a8350a0a66e649358cfc6c19da5a0993e625280685e" + ) + payload = json.loads(path.read_text(encoding="utf-8")) + + assert payload["tokenizer_identity"]["student_teacher_match"] is True + assert payload["workload"]["optimizer_updates"] == 1 + assert payload["artifacts"]["peft_load_verified"] is True + assert payload["scope"]["rollout_completed"] is True + assert payload["scope"]["teacher_scoring_completed"] is True + assert payload["scope"]["optimizer_update_completed"] is True + assert payload["scope"]["task_quality_evaluated"] is False + assert payload["scope"]["alignment_quality_evaluated"] is False + assert payload["scope"]["full_recipe_supported"] is False + assert payload["runtime"]["distributed_execution_tested"] is False diff --git a/tests/unit/test_packaging.py b/tests/unit/test_packaging.py index 0ca718c..0ec9b26 100644 --- a/tests/unit/test_packaging.py +++ b/tests/unit/test_packaging.py @@ -294,6 +294,7 @@ def test_every_published_benchmark_result_validates_against_the_schema(): "alignment-external-v1.json", "rtx4080-verl-opd-runtime-v1.json", "rtx4080-verl-opd-developer-v1.json", + "rtx4080-smollm2-opd-family-smoke-v1.json", } ) assert results, "benchmarks/results/ has no published result to validate"