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 @@
+
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.
+
+
+