"""vLLM generation driver for Lean proving (experiment P2): Goedel prompts in, Generation records out. OPTIONAL EXTRA -- NOT part of the `align` package. Runs under `.venv-rollout` (vllm 0.26.0, python3.12); never the `align` venv. All third-party imports (vllm, transformers) are lazy inside functions, so the pure planning / resume / record logic below is importable -- and tested -- without them (tests/test_lean_gen_contract.py). .venv-rollout/bin/python extras/vllm_rollout/lean_gen.py \\ --problems P.jsonl --out gen_r0.jsonl --n 4 --cap-tokens 16384 --round 0 --tp 4 # correction round k: parents are round k-1 samples + their verification records ... --round 1 --n 1 --corrections-from verif_r0.jsonl gen_r0.jsonl --out gen_r1.jsonl Design decisions, each tied to a defect it prevents: * Honest caps. `cap_tokens` is the nominal cap every reported number is labelled with; `sampling.max_tokens` is what vLLM was actually given, `min(cap, max_model_len - len(prompt))`. They differ only when the context window, not the cap, ends a sample, and then the record says `context_clipped: true`. Without this a "40960 cap" run would silently be a "40610 cap" run (prompt tokens come out of the same 40960 window). * Resumable and kill-safe. Output is append-only JSONL. A sample is keyed (problem_id, sample_idx, round, cap_tokens); keys already on disk are skipped. Records are fsync'd in groups (`--flush-every` / `--flush-secs`); SIGKILL loses the unflushed group plus whatever is in flight (<= `--inflight` samples, never the finished ones). A torn last line (kill mid-write) is truncated back to the last newline on startup rather than parsed or appended after -- appending after a torn line would glue two records. A resume is only into a file this invocation could have written: records already on disk must carry this run's `model_rev`, the seed `derive_seed` gives for their key, and the registered temperature / top_p, and the manifest's earlier sessions must have used this `lean_prompts.py`; otherwise it RAISES (`resume_conflicts`, `prompt_template_conflicts`) instead of skipping the keys and appending a second policy's samples to the first one's file. * No chunk barrier. vLLM `LLM.generate` returns only when its slowest sample finishes, and sample lengths here span ~1k..16k+ tokens, so a batch barrier idles the GPUs on the longest tail. This driver instead drives `llm.llm_engine.add_request/step` with a refill window, so finished samples are written immediately and the batch stays full. * Per-sample seeds. n is expanded client-side (n=1 per request) so every sample has its own seed in its own record. Seeds are derived from (base seed, problem, sample, round) and NOT the cap, so the same sample at cap 16384 and cap 40960 shares its seed. Bitwise replay is not promised (batch composition changes float reduction order); the seed makes the sampling randomness a recorded input, not a reproducibility claim. * No re-tokenisation downstream. `prompt_ids` come from the model's tokenizer on the chat template text; `completion_ids` are vLLM's `token_ids`, which INCLUDE the terminating stop token (<|im_end|>) when `finish_reason == "stop"`; the SFT shard builder trains on exactly these ids. * Class-B trajectory ledger (docs/SOTA_PLAN.md v3 section 1). Every record carries `trajectory`: {root_trajectory_id "|", root_sample_idx, round, kind attempt|repair|resampled_no_feedback, completion_tokens, prior_completion_tokens, cum_completion_tokens (the whole lineage so far), truncated, budget_clipped, parent_key, partial}. `--trajectory-cap-tokens N` (class B: 40000) enforces a cumulative completion-token cap per trajectory: a repair's `max_tokens` is clipped to the remaining budget (`budget_clipped: true`) and one with none left is NOT generated but listed in `.skipped.jsonl` as `skipped_trajectory_cap`. A failed parent with no compiler error to show (truncated at the cap, no `repl_errors`, or an empty list) is never silently dropped: `--no-feedback-policy skip` (default) writes it to `.skipped.jsonl` as `skipped_no_feedback` with its reason; `resample` draws fresh round-0-style attempts for it (`kind: resampled_no_feedback`, same root, tokens still accumulate). Selection rule: Goedel's, the DEFAULT -- only failed samples of problems with NO accepted sample in round k-1 are repaired (`load_parents`); `--repair-solved` also repairs failed samples of already-solved problems (needed to account every trajectory, not to match Goedel). Backward compatible: pre-ledger files have no `trajectory`; lineage is reconstructed from the `parent` chain when the earlier rounds' files are passed in `--corrections-from`, otherwise the entry is `partial` (a lower bound) and enforcing a cap on it refuses. Resume only keys on (problem, sample, round, cap), so old files resume unchanged. * Un-merged LoRA serving. `--lora-adapter DIR` (a PEFT dir: adapter_config.json + adapter_model.safetensors) serves the adapter through vLLM `enable_lora` / `LoRARequest` on the base `--model`, instead of the bf16 merge that drops small delta elements. The adapter's sha256 is in the manifest session, in every record (`adapter`), and in `model_rev` (`+lora-`), so a resume cannot mix it with the base file. GPU smoke command: see extras/vllm_rollout/README.md. * Exit status. 0 only when every planned sample was generated; SIGTERM/SIGINT exit 143 so a supervisor can never mistake a partial sample set for a finished one (U12-2). * Unsupported configuration raises: max_model_len above the model's position limit, tp above the visible GPUs, a round-0 prompt that cannot fit, missing verification records for a parent, and so on. The only skips are correction prompts that cannot fit the context and parents with nothing to correct; both are counted in the manifest and listed in `.skipped.jsonl`. """ from __future__ import annotations import argparse import dataclasses import hashlib import json import os import signal import subprocess import sys import threading import time from typing import Iterator, Optional HERE = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) sys.path.insert(0, HERE) import lean_prompts as LP # noqa: E402 (sibling module, stdlib only) DEFAULT_MODEL = ( "/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/" "dfd02e6271a58375dfbf3ece0175277cf6b6a89a" ) TEMPERATURE = 1.0 # Goedel inference.py --temp default and the dpskcot setting TOP_P = 0.95 # hard-coded in Goedel inference.py Key = tuple # (problem_id, sample_idx, round, cap_tokens) # -------------------------------------------------------------------------- # pure logic: seeds, keys, jsonl, planning # -------------------------------------------------------------------------- def derive_seed(base_seed: int, problem_id: str, sample_idx: int, round_: int) -> int: """Per-sample seed in [0, 2**31). Independent of the cap on purpose (see module doc).""" h = hashlib.sha256(f"{base_seed}|{problem_id}|{sample_idx}|{round_}".encode()).digest() return int.from_bytes(h[:8], "big") % (2**31) def rec_key(rec: dict) -> Key: return (rec["problem_id"], int(rec["sample_idx"]), int(rec["round"]), int(rec["cap_tokens"])) def repair_torn_tail(path: str) -> int: """Truncate a JSONL file back to its last complete line. Returns bytes removed. A kill between `write` and `fsync` can leave a partial last line. Reading it would raise (or worse, parse as a different record); appending after it would fuse two records into one unparseable line and lose the NEW record too. """ if not os.path.exists(path): return 0 size = os.path.getsize(path) if size == 0: return 0 with open(path, "rb+") as f: f.seek(-1, os.SEEK_END) if f.read(1) == b"\n": return 0 # walk back to the previous newline pos = size chunk = 1 << 16 last_nl = -1 while pos > 0 and last_nl < 0: start = max(0, pos - chunk) f.seek(start) buf = f.read(pos - start) i = buf.rfind(b"\n") if i >= 0: last_nl = start + i pos = start keep = last_nl + 1 # 0 if there was no newline at all f.truncate(keep) f.flush() os.fsync(f.fileno()) return size - keep def read_jsonl(path: str) -> Iterator[dict]: with open(path, encoding="utf-8") as f: for ln, line in enumerate(f, 1): if not line.strip(): continue try: yield json.loads(line) except json.JSONDecodeError as e: raise ValueError(f"{path}:{ln}: not valid JSON ({e})") from e def load_done_keys(out_path: str) -> set: if not os.path.exists(out_path): return set() return {rec_key(r) for r in read_jsonl(out_path)} def resume_state(out_path: str, repair: bool = True) -> tuple[set, int]: """(done_keys, torn_bytes_removed) for an output file that a killed run may have left torn. The repair MUST precede the read: found by the real SIGKILL test, where the first version read the file first and died on its own torn last line, i.e. the run could not be resumed from exactly the crash the resume logic exists for. `repair=False` (dry run) must not touch the file, so it ignores an unterminated last line instead. """ if repair: torn = repair_torn_tail(out_path) return load_done_keys(out_path), torn if not os.path.exists(out_path): return set(), 0 with open(out_path, "rb") as f: data = f.read() cut = data.rfind(b"\n") + 1 # drop anything after the last newline keys = {rec_key(json.loads(line)) for line in data[:cut].decode("utf-8").splitlines() if line.strip()} return keys, len(data) - cut def resume_conflicts(out_path: str, model_rev: str, base_seed: int, limit: int = 5, adapter_sha: Optional[str] = None, temperature: float = TEMPERATURE, top_p: float = TOP_P, prompt_format: str = "goedel") -> list[str]: """Records already in `out_path` that THIS invocation could not have produced (empty list = resumable). THE MIXED-OUTPUT DEFECT (R2 review). A sample is keyed (problem, sample, round, cap) and nothing else, so resuming into a file written by a DIFFERENT model (the post-SFT run pointed at the baseline's --out), a different `--model-rev`, or a different `--seed` silently skips every key that is already there and appends the rest: one output file, two policies, each row honestly labelled with its own `model_rev` and nothing complaining. A baseline/candidate comparison built on it compares the model with a blend of itself. Same call, same answer on a clean resume: the check is over the records' own provenance (`model_rev`, `sampling.seed`), which a faithful resume reproduces exactly (`derive_seed` is a pure function of (seed, problem, sample, round)). """ bad: list[str] = [] n_bad = 0 if not os.path.exists(out_path): return bad with open(out_path, "rb") as f: # streamed: the pool file is >1 GB lines = enumerate(f, 1) for ln, raw in lines: if not raw.endswith(b"\n"): break # torn tail of a killed writer (the repair truncates it); not a record if not raw.strip(): continue r = json.loads(raw) want = derive_seed(base_seed, r["problem_id"], int(r["sample_idx"]), int(r["round"])) why = None if r.get("model_rev") != model_rev: why = f"model_rev {r.get('model_rev')!r} != {model_rev!r}" elif r.get("sampling", {}).get("seed") != want: why = f"sampling.seed {r.get('sampling', {}).get('seed')!r} != {want} (--seed {base_seed})" elif (r.get("adapter") or {}).get("sha256") != adapter_sha: why = f"adapter {(r.get('adapter') or {}).get('sha256')!r} != {adapter_sha!r}" elif r.get("sampling", {}).get("temperature") != temperature or r.get("sampling", {}).get("top_p") != top_p: why = f"sampling {r.get('sampling')!r} differs from temperature={temperature}, top_p={top_p}" elif r.get("prompt_format", "goedel") != prompt_format: why = f"prompt_format {r.get('prompt_format', 'goedel')!r} != {prompt_format!r}" if why: n_bad += 1 if len(bad) < limit: bad.append(f"line {ln} {rec_key(r)}: {why}") if n_bad > len(bad): bad.append(f"... and {n_bad - len(bad)} more") return bad # lean_prompts.py module bytes before the prompt-format / temperature work (commits d956862, 901a82c). Goedel # prompts rendered by the current module are pinned byte-identical to that version by a test # (tests/test_gen_temperature_kimina.py), so goedel sessions recorded under it stay resumable. LEGACY_GOEDEL_PROMPTS_SHA256 = frozenset({"700ab6c7fc1f1cb342ed2969bd5ddbcf1ffe0f8e070b9332fb1247fad57677af"}) _PROBE_PROBLEMS = ( {"problem_id": "probe_a", "formal_statement": "theorem probe_a (x : ℕ) (h : x = 2) : x + x = 4 := by sorry", "informal_prefix": "/-- Show that x + x = 4 when x = 2. -/\n"}, {"problem_id": "probe_b", "formal_statement": "theorem probe_b (a b : ℝ) (h₀ : 0 < a) (h₁ : a < b) : a ^ 2 < b ^ 2 := by", "informal_prefix": None}, ) def prompt_render_sha(prompt_format: str, tokenizer) -> str: """sha256 of the prompts this invocation would actually SEND (rendered through the tokenizer's chat template) for fixed probe problems, plus one goedel correction prompt. Unlike the module-bytes hash, an edit to lean_prompts.py that leaves the rendered bytes unchanged does not block a resume, and an edit that changes them does.""" h = hashlib.sha256(prompt_format.encode()) for prob in _PROBE_PROBLEMS: if prompt_format == "goedel_bank": # probe the example block too prob = dict(prob, bank_examples=[{"statement": "theorem probe_ex : 1 = 1 := by", "proof": " rfl"}]) text, _ = LP.initial_prompt_for(prompt_format, prob, tokenizer) h.update(text.encode()) if prompt_format == "goedel": p0 = _PROBE_PROBLEMS[0] _, msgs = LP.initial_prompt_for("goedel", p0, tokenizer) text, _ = LP.correction_prompt(p0["formal_statement"], msgs, "```lean4\nexact rfl\n```", "exact rfl", [{"severity": "error", "pos": {"line": 1, "column": 0}, "endPos": {"line": 1, "column": 9}, "data": "probe error"}], tokenizer, 1) h.update(text.encode()) return h.hexdigest() def prompt_template_conflicts(manifest_path: str, render_sha256: str, out_path: str, prompt_format: str = "goedel") -> list[str]: """Earlier sessions of this output file that sampled under DIFFERENT prompt bytes. The policy is conditioned on the rendered prompt bytes; an edit between two sessions that changes them leaves a file whose halves answer different prompts while every key looks consistent. Sessions record `prompt_render_sha256`. Older sessions recorded only the module bytes (`lean_prompts_sha256`); they are compatible iff that sha is the pinned legacy goedel module and this run is goedel.""" if not os.path.exists(manifest_path) or not os.path.exists(out_path) or os.path.getsize(out_path) == 0: return [] # nothing on disk was sampled under the old module with open(manifest_path, encoding="utf-8") as f: doc = json.load(f) bad = set() for s in doc.get("sessions", []): rs = s.get("prompt_render_sha256") if rs is not None: if rs != render_sha256: bad.add(f"render:{rs}") elif not (prompt_format == "goedel" and s.get("lean_prompts_sha256") in LEGACY_GOEDEL_PROMPTS_SHA256): bad.add(f"module:{s.get('lean_prompts_sha256')}") return sorted(bad) PROBLEM_FIELDS = ("problem_id", "source", "split", "formal_statement", "informal_prefix", "statement_sha") def load_problems(path: str, limit: Optional[int] = None) -> list[dict]: rows = [] seen = set() for r in read_jsonl(path): missing = [k for k in PROBLEM_FIELDS if k not in r] if missing: raise ValueError(f"{path}: problem {r.get('problem_id')!r} lacks fields {missing}") if r["problem_id"] in seen: raise ValueError(f"{path}: duplicate problem_id {r['problem_id']!r}") seen.add(r["problem_id"]) rows.append(r) if limit is not None and len(rows) >= limit: break if not rows: raise ValueError(f"{path}: no problems") return rows @dataclasses.dataclass class Spec: """One sample to generate. `parent_ctx` is set only for correction rounds.""" problem: dict sample_idx: int round: int parent: Optional[dict] = None # {"problem_id","sample_idx","round"} parent_ctx: Optional[dict] = None # messages / completion_text / compile_code / repl_errors resample: bool = False # fresh initial-style prompt at round >= 1 (no-feedback parents) trajectory: Optional[dict] = None # ledger inputs; None = a root attempt (round 0) def plan_initial(problems: list[dict], n: int, round_: int = 0) -> Iterator[Spec]: for p in problems: for i in range(n): yield Spec(p, i, round_) def record_trajectory(g: dict, index: dict) -> tuple[int, int, bool]: """(root_sample_idx, cumulative completion tokens through `g`, partial) for a generation record. Trusts the record's own `trajectory` ledger when present; otherwise rebuilds it from the `parent` chain through `index` ((problem, sample_idx, round) -> record, every round the caller passed). A chain that leaves the provided files is `partial`: the root is the furthest ancestor named and the token count a lower bound -- reported, never guessed.""" t = g.get("trajectory") if t and "cum_completion_tokens" in t: return int(t["root_sample_idx"]), int(t["cum_completion_tokens"]), bool(t.get("partial", False)) ids = g.get("completion_ids") n = len(ids) if ids is not None else 0 miss = ids is None # a record without its token ids cannot be counted: lower bound, flagged if int(g["round"]) == 0: return int(g["sample_idx"]), n, miss par = g.get("parent") if not par: return int(g["sample_idx"]), n, True nxt = index.get((par["problem_id"], int(par["sample_idx"]), int(par["round"]))) if nxt is None: return int(par["sample_idx"]), n, True root, cum, partial = record_trajectory(nxt, index) return root, cum + n, partial or miss def load_parents_ex(pairs: list[tuple[str, str]], round_k: int, repair_solved: bool = False) -> tuple[list[dict], dict, list[dict]]: """Join verification and generation records of round k-1 -> parent contexts. Returns (parents, stats, no_feedback). Mirrors Goedel's `load_data_for_correction` (the DEFAULT): * only samples that FAILED, and only for problems with NO accepted sample in the previous round, are corrected (`repair_solved=True` lifts the second condition); * every failed sample is a parent (not just one per problem). Failed parents with nothing to show (no `repl_errors`/`repl_code` keys, an empty list, or a sample truncated at its cap) are returned in `no_feedback` with a `reason` instead of being dropped: Goedel would send an empty "error message:" block; here the caller decides (`--no-feedback-policy`). Each parent / no-feedback entry carries `traj` = (root_sample_idx, cumulative completion tokens, partial) for the class-B ledger. """ gens: dict = {} vers: dict = {} index: dict = {} for vpath, gpath in pairs: for g in read_jsonl(gpath): index[(g["problem_id"], int(g["sample_idx"]), int(g["round"]))] = g if int(g["round"]) == round_k - 1: gens[rec_key(g)] = g for v in read_jsonl(vpath): if int(v["round"]) == round_k - 1: vers[rec_key(v)] = v if not gens: raise ValueError(f"no generation records with round == {round_k - 1} in {pairs}") unverified = sorted(set(gens) - set(vers)) if unverified: raise ValueError( f"{len(unverified)} round-{round_k - 1} generation records have no verification record " f"(first: {unverified[0]}); verify every parent before correcting" ) solved = {k[0] for k, v in vers.items() if v["accepted"] and k in gens} stats = {"parents_total": len(gens), "problems_solved_prev_round": len(solved), "skipped_solved_problem": 0, "skipped_accepted": 0, "no_repl_errors": 0, "no_error_messages": 0, "no_feedback_truncated": 0} parents, no_feedback = [], [] for k in sorted(gens): g, v = gens[k], vers[k] if v["accepted"]: stats["skipped_accepted"] += 1 continue if k[0] in solved and not repair_solved: stats["skipped_solved_problem"] += 1 continue traj = record_trajectory(g, index) has_keys = isinstance(v.get("repl_errors"), list) and isinstance(v.get("repl_code"), str) if not has_keys or not v["repl_errors"]: stats["no_repl_errors" if not has_keys else "no_error_messages"] += 1 if g.get("finish_reason") == "length": stats["no_feedback_truncated"] += 1 reason = "truncated" else: reason = "no_repl_keys" if not has_keys else "no_error_messages" no_feedback.append({"gen": g, "ver": v, "reason": reason, "traj": traj}) continue parents.append({"gen": g, "ver": v, "traj": traj}) return parents, stats, no_feedback def load_parents(pairs: list[tuple[str, str]], round_k: int) -> tuple[list[dict], dict]: """Back-compatible (parents, stats) view of `load_parents_ex` with Goedel's selection rule.""" parents, stats, _ = load_parents_ex(pairs, round_k) return parents, stats def _parent_ref(g: dict) -> dict: return {"problem_id": g["problem_id"], "sample_idx": int(g["sample_idx"]), "round": int(g["round"])} def _traj_inputs(traj: tuple, g: dict, kind: str) -> dict: root, cum, partial = traj return {"root_sample_idx": root, "prior_completion_tokens": cum, "partial": partial, "kind": kind, "parent_key": [g["problem_id"], int(g["sample_idx"]), int(g["round"])]} def plan_corrections(problems: list[dict], parents: list[dict], n: int, round_k: int) -> Iterator[Spec]: """n correction samples per failed parent; sample_idx = parent.sample_idx * n + i is unique within (problem, round) and stable under resume.""" by_id = {p["problem_id"]: p for p in problems} for par in parents: g, v = par["gen"], par["ver"] prob = by_id.get(g["problem_id"]) if prob is None: raise ValueError(f"parent problem {g['problem_id']!r} is not in --problems") ctx = { "messages": g["messages"], "completion_text": g["completion_text"], "compile_code": v["repl_code"], "repl_errors": v["repl_errors"], } for i in range(n): yield Spec(prob, int(g["sample_idx"]) * n + i, round_k, _parent_ref(g), ctx, trajectory=_traj_inputs(par["traj"], g, "repair")) def plan_resamples(problems: list[dict], no_feedback: list[dict], n: int, round_k: int) -> Iterator[Spec]: """`--no-feedback-policy resample`: n fresh round-0-style attempts for each failed parent that has no compiler error to show. Same root trajectory; the failed parent's tokens stay on the ledger.""" by_id = {p["problem_id"]: p for p in problems} for nf in no_feedback: g = nf["gen"] prob = by_id.get(g["problem_id"]) if prob is None: raise ValueError(f"parent problem {g['problem_id']!r} is not in --problems") for i in range(n): yield Spec(prob, int(g["sample_idx"]) * n + i, round_k, _parent_ref(g), None, resample=True, trajectory=_traj_inputs(nf["traj"], g, "resampled_no_feedback")) def no_feedback_entry(nf: dict, round_k: int, policy: str, cap: Optional[int] = None) -> dict: """The `.skipped.jsonl` line for a failed parent with no compiler error (never a silent drop).""" g = nf["gen"] root, cum, partial = nf["traj"] return {"key": [g["problem_id"], int(g["sample_idx"]), round_k, cap], "outcome": "skipped_no_feedback" if policy == "skip" else "resampled_no_feedback", "reason": nf["reason"], "root_trajectory_id": f"{g['problem_id']}|{root}", "cum_completion_tokens": cum, "partial": partial, "round": round_k} def skip_outcome(why: str) -> str: if why.startswith("skipped_trajectory_cap"): return "skipped_trajectory_cap" if why.startswith("prompt_"): return "skipped_prompt_too_long" return why def append_skipped(path: str, entry: dict) -> bool: """Append `entry` to the skipped ledger unless an identical line exists (a resume replans the same skips).""" line = json.dumps(entry, sort_keys=True) if os.path.exists(path): with open(path, encoding="utf-8") as f: for old in f: try: if json.dumps(json.loads(old), sort_keys=True) == line: return False except json.JSONDecodeError: continue with open(path, "a", encoding="utf-8") as f: f.write(line + "\n") return True @dataclasses.dataclass class Prepared: spec: Spec key: Key prompt_text: str prompt_ids: list messages: list max_tokens: int seed: int context_clipped: bool budget_clipped: bool = False # the trajectory token cap, not the context window or --cap-tokens, set max_tokens def prepare(spec: Spec, tokenizer, cap: int, max_model_len: int, base_seed: int, error_thres: bool = True, traj_cap: Optional[int] = None, prompt_format: str = "goedel"): """Build the prompt and decide the per-request budget. Returns (Prepared, None) or (None, skip_reason). A round-0 prompt that does not fit is a configuration error and raises; a correction prompt that does not fit (parent output + errors can exceed the window) is skipped, with the reason recorded by the caller. """ fs = spec.problem["formal_statement"] remaining = None if traj_cap is not None and spec.trajectory is not None: if spec.trajectory.get("partial"): raise ValueError(f"{spec.problem['problem_id']}: the trajectory lineage is incomplete (pre-ledger files); " "pass every earlier round's generation file in --corrections-from to enforce " "--trajectory-cap-tokens") remaining = traj_cap - int(spec.trajectory["prior_completion_tokens"]) if remaining < 1: return None, (f"skipped_trajectory_cap: {spec.trajectory['prior_completion_tokens']} completion tokens " f"already spent of {traj_cap}") if spec.round == 0 or spec.resample: text, messages = (LP.initial_prompt(fs, tokenizer) if prompt_format == "goedel" else LP.initial_prompt_for(prompt_format, spec.problem, tokenizer)) else: if prompt_format != "goedel": raise ValueError(f"--prompt-format {prompt_format} has no published correction prompt; round >= 1 needs goedel") c = spec.parent_ctx text, messages = LP.correction_prompt( fs, c["messages"], c["completion_text"], c["compile_code"], c["repl_errors"], tokenizer, spec.round, error_thres=error_thres) ids = list(tokenizer.encode(text)) room = max_model_len - len(ids) if len(ids) > max_model_len * 3 // 4 or room < 1: # Goedel inference.py drops prompts longer than 3/4 of max_model_len if spec.round == 0: raise ValueError(f"round-0 prompt of {spec.problem['problem_id']} is {len(ids)} tokens; " f"max_model_len={max_model_len} cannot hold it") return None, f"prompt_{len(ids)}_tokens_over_3/4_of_max_model_len_{max_model_len}" limit = cap if remaining is None else min(cap, remaining) mt = min(limit, room) return Prepared(spec, (spec.problem["problem_id"], spec.sample_idx, spec.round, cap), text, ids, messages, mt, derive_seed(base_seed, spec.problem["problem_id"], spec.sample_idx, spec.round), room < limit, remaining is not None and remaining < min(cap, room)), None def build_record(pr: Prepared, completion_ids: list, completion_text: str, finish_reason: str, cap: int, model_path: str, model_rev: str, extra: Optional[dict] = None, temperature: float = TEMPERATURE, top_p: float = TOP_P, prompt_format: str = "goedel") -> dict: if finish_reason not in ("stop", "length"): raise ValueError(f"unsupported finish_reason {finish_reason!r} (contract: 'stop'|'length')") if finish_reason == "length" and len(completion_ids) != pr.max_tokens: raise ValueError(f"{pr.key}: finish_reason=length with {len(completion_ids)} tokens != max_tokens {pr.max_tokens}") ti = pr.spec.trajectory or {"root_sample_idx": pr.spec.sample_idx, "prior_completion_tokens": 0, "kind": "attempt", "partial": False, "parent_key": None} n_comp = len(completion_ids) traj = { "root_trajectory_id": f"{pr.spec.problem['problem_id']}|{ti['root_sample_idx']}", "problem_id": pr.spec.problem["problem_id"], "root_sample_idx": int(ti["root_sample_idx"]), "round": pr.spec.round, "kind": ti["kind"], "parent_key": ti["parent_key"], "completion_tokens": n_comp, "prior_completion_tokens": int(ti["prior_completion_tokens"]), "cum_completion_tokens": int(ti["prior_completion_tokens"]) + n_comp, "truncated": finish_reason == "length", "budget_clipped": pr.budget_clipped, "partial": bool(ti["partial"]), } code, err = LP.lean_code_for_record(pr.spec.problem["formal_statement"], completion_text, prompt_format) rec = { "problem_id": pr.spec.problem["problem_id"], "sample_idx": pr.spec.sample_idx, "round": pr.spec.round, "cap_tokens": cap, "prompt_ids": pr.prompt_ids, "completion_ids": completion_ids, "prompt_text": pr.prompt_text, "completion_text": completion_text, "finish_reason": finish_reason, "lean_code": code, "lean_code_error": err, "messages": pr.messages, "model_path": model_path, "model_rev": model_rev, "sampling": {"temperature": temperature, "top_p": top_p, "top_k": 0, "n": 1, "max_tokens": pr.max_tokens, "seed": pr.seed}, "context_clipped": pr.context_clipped, "parent": pr.spec.parent, "trajectory": traj, } if prompt_format != "goedel": rec["prompt_format"] = prompt_format # absent for goedel: those records stay byte-identical to before the flag if extra: rec.update(extra) return rec def sampled_logprobs_extra(c) -> dict: """`{"sampling_logprobs": [...]}`: the logprob of each SAMPLED token, from vLLM's `logprobs=0` output (a list of {token_id: Logprob} per position). Raises if any position lacks the sampled token, so a record never carries a misaligned behaviour distribution.""" toks = list(c.token_ids) lps = c.logprobs if lps is None or len(lps) != len(toks): raise RuntimeError(f"engine returned {None if lps is None else len(lps)} logprob positions for {len(toks)} tokens") out = [] for t, d in zip(toks, lps): if t not in d: raise RuntimeError(f"sampled token {t} missing from its logprob entry") out.append(float(d[t].logprob)) return {"sampling_logprobs": out} def model_rev_of(model_path: str, override: Optional[str] = None) -> str: p = os.path.realpath(model_path) if override: return override if os.path.basename(os.path.dirname(p)) == "snapshots": return os.path.basename(p) raise ValueError(f"cannot infer a revision from {model_path!r} (not an HF snapshot dir); pass --model-rev") class AppendWriter: """Append-only JSONL with grouped fsync. `add` buffers; `flush` writes + fsyncs the group.""" def __init__(self, path: str, flush_every: int = 8, flush_secs: float = 5.0): self.torn_bytes_removed = repair_torn_tail(path) self.f = open(path, "ab") self.flush_every, self.flush_secs = flush_every, flush_secs self.buf: list[bytes] = [] self.last = time.monotonic() self.n_written = 0 def add(self, rec: dict) -> None: self.buf.append((json.dumps(rec, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")) if len(self.buf) >= self.flush_every or time.monotonic() - self.last >= self.flush_secs: self.flush() def flush(self) -> None: if self.buf: # one write call per group: a kill leaves either a whole group or a torn tail, which # repair_torn_tail removes; never an interleaving self.f.write(b"".join(self.buf)) self.f.flush() os.fsync(self.f.fileno()) self.n_written += len(self.buf) self.buf = [] self.last = time.monotonic() def close(self) -> None: self.flush() self.f.close() def build_trajectory_ledger(gens: list[dict], vers: list[dict], skipped: list[dict]) -> dict: """Per root trajectory: rounds, cumulative completion tokens, and a final state. Pure. final = accepted_round (earliest accepted attempt on the trajectory) | the skip outcome (`skipped_no_feedback`, `skipped_trajectory_cap`, ...) | `unrepaired` (failed, no later record).""" index = {(g["problem_id"], int(g["sample_idx"]), int(g["round"])): g for g in gens} accepted = {rec_key(v): bool(v["accepted"]) for v in vers} led: dict = {} for g in gens: root, cum, partial = record_trajectory(g, index) tid = f"{g['problem_id']}|{root}" e = led.setdefault(tid, {"rounds_seen": set(), "cum_completion_tokens": 0, "accepted_round": None, "skips": [], "partial": False}) e["rounds_seen"].add(int(g["round"])) e["cum_completion_tokens"] = max(e["cum_completion_tokens"], cum) e["partial"] |= partial if accepted.get(rec_key(g)): r = int(g["round"]) e["accepted_round"] = r if e["accepted_round"] is None else min(e["accepted_round"], r) for sk in skipped: tid = sk.get("root_trajectory_id") if tid in led: led[tid]["skips"].append(sk.get("outcome")) led[tid]["cum_completion_tokens"] = max(led[tid]["cum_completion_tokens"], int(sk.get("cum_completion_tokens", 0))) out = {} for tid, e in led.items(): if e["accepted_round"] is not None: final = f"accepted_round{e['accepted_round']}" elif e["skips"]: final = e["skips"][-1] else: final = "unrepaired" out[tid] = {"final": final, "rounds": len(e["rounds_seen"]), "cum_completion_tokens": e["cum_completion_tokens"], "partial": e["partial"]} return out LORA_RANKS = (1, 8, 16, 32, 64, 128, 256, 320, 512) # vLLM 0.26 MaxLoRARanks def _sha256_file(path: str) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def adapter_provenance(path: str) -> dict: """Validate a PEFT adapter dir and hash it (config + weights): the identity recorded for an un-merged run.""" cfg_p = os.path.join(path, "adapter_config.json") if not os.path.isfile(cfg_p): raise ValueError(f"{path}: no adapter_config.json (not a PEFT adapter directory)") wfile = next((f for f in ("adapter_model.safetensors", "adapter_model.bin") if os.path.isfile(os.path.join(path, f))), None) if wfile is None: raise ValueError(f"{path}: no adapter_model.safetensors / adapter_model.bin") with open(cfg_p, encoding="utf-8") as f: cfg = json.load(f) if "r" not in cfg: raise ValueError(f"{cfg_p}: no LoRA rank `r`") wsha, csha = _sha256_file(os.path.join(path, wfile)), _sha256_file(cfg_p) return {"path": os.path.abspath(path), "weights_file": wfile, "weights_sha256": wsha, "config_sha256": csha, "sha256": hashlib.sha256((csha + wsha).encode()).hexdigest(), "rank": int(cfg["r"]), "lora_alpha": cfg.get("lora_alpha"), "target_modules": cfg.get("target_modules"), "base_model_name_or_path": cfg.get("base_model_name_or_path")} def lora_engine_kwargs(pv: dict) -> dict: """`LLM(...)` kwargs that enable one adapter slot of at least the adapter's rank.""" ranks = [r for r in LORA_RANKS if r >= pv["rank"]] if not ranks: raise ValueError(f"adapter rank {pv['rank']} exceeds vLLM's largest supported max_lora_rank {LORA_RANKS[-1]}") return {"enable_lora": True, "max_loras": 1, "max_lora_rank": ranks[0]} def rev_with_adapter(base_rev: str, pv: Optional[dict], explicit: bool) -> str: """model_rev that cannot collide with the base model's: `+lora-` (unless the user fixed one).""" return base_rev if (pv is None or explicit) else f"{base_rev}+lora-{pv['sha256'][:12]}" def make_lora_request(pv: Optional[dict], name: str, request_cls, int_id: int = 1): """`vllm.lora.request.LoRARequest(name, id, path)`; the class is injected so this stays importable without vllm.""" if pv is None: return None return request_cls(lora_name=name, lora_int_id=int_id, lora_path=pv["path"]) def adapter_record_extra(pv: Optional[dict], name: str) -> Optional[dict]: if pv is None: return None return {"adapter": {"name": name, "sha256": pv["sha256"], "weights_sha256": pv["weights_sha256"], "config_sha256": pv["config_sha256"], "rank": pv["rank"], "path": pv["path"], "serving": "unmerged_vllm_lora"}} def final_status(stop_flag: bool, inflight_left: int, exhausted: bool) -> str: """`interrupted` only if a stop request actually left planned work undone.""" return "interrupted" if stop_flag and (inflight_left > 0 or not exhausted) else "complete" def exit_code_for(status: str) -> int: """0 only for a complete sample set. 143 = 128 + SIGTERM for an interruption (U12-2).""" return {"complete": 0, "interrupted": 143}.get(status, 1) def write_manifest(path: str, session: dict) -> None: """sessions[] list: a resumed run appends a session instead of overwriting history.""" doc = {"sessions": []} if os.path.exists(path): with open(path, encoding="utf-8") as f: doc = json.load(f) sessions = [s for s in doc["sessions"] if s.get("session_id") != session["session_id"]] sessions.append(session) doc["sessions"] = sessions tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(doc, f, indent=1, ensure_ascii=False) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) # -------------------------------------------------------------------------- # engine side (lazy third-party imports) # -------------------------------------------------------------------------- def prepare_env() -> None: """Environment that must be right BEFORE `import vllm` (see rollout_worker.py).""" d = os.environ.setdefault d("HF_HOME", "/apps/development/rlhf/hf_cache") d("HF_HUB_CACHE", "/apps/development/rlhf/hf_cache/hub") d("HF_HUB_DISABLE_XET", "1") d("TMPDIR", os.path.join(REPO_ROOT, ".tmp")) d("XDG_CACHE_HOME", os.path.join(REPO_ROOT, ".cache")) d("TRITON_CACHE_DIR", os.path.join(REPO_ROOT, ".cache", "triton")) d("TORCHINDUCTOR_CACHE_DIR", os.path.join(REPO_ROOT, ".cache", "torchinductor")) d("VLLM_USE_FLASHINFER_SAMPLER", "0") # flashinfer's JIT ninja build fails on this box d("TOKENIZERS_PARALLELISM", "false") d("PYTHONUNBUFFERED", "1") for var in ("TMPDIR", "XDG_CACHE_HOME", "TRITON_CACHE_DIR", "TORCHINDUCTOR_CACHE_DIR", "HF_HOME", "HF_HUB_CACHE"): if not os.path.realpath(os.environ[var]).startswith("/apps/"): raise RuntimeError(f"{var}={os.environ[var]!r} is not under /apps; / has 13 GB and /tmp is noexec tmpfs") os.makedirs(os.environ[var], exist_ok=True) def preflight_and_lock(label: str, wait: bool, inherit_pid: int | None = None) -> dict: """Host preflight (>=45 GB MemAvailable, load < 2x cores, disk queue < 64) + the repo heavy-job flock. `align.utils.guard` is stdlib-only at import (align/__init__ is lazy).""" sys.path.insert(0, REPO_ROOT) from align.utils import guard t0 = time.monotonic() # lock FIRST: with --wait-lock we may sleep for hours behind another job, and a preflight taken # before that sleep describes a host that no longer exists if inherit_pid is not None: # a supervisor holds the lock for the whole job (scripts/p2/launch_baseline.py) guard.inherit_heavy_job_lock(inherit_pid) else: guard.acquire_heavy_job_lock(label, blocking=wait) st = guard.preflight_host_check() return {"host_state": st, "lock_wait_s": round(time.monotonic() - t0, 1)} class GpuMonitor(threading.Thread): """Peak per-GPU memory.used from nvidia-smi (what the driver sees, not torch's view).""" def __init__(self, period: float = 10.0): super().__init__(daemon=True) self.period, self.peak, self.stop_ev = period, {}, threading.Event() def run(self) -> None: while not self.stop_ev.is_set(): try: out = subprocess.run( ["nvidia-smi", "--query-gpu=index,memory.used", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=20).stdout for line in out.strip().splitlines(): i, m = (x.strip() for x in line.split(",")) self.peak[i] = max(self.peak.get(i, 0), int(m)) except Exception: pass self.stop_ev.wait(self.period) def main(argv: Optional[list] = None) -> int: ap = argparse.ArgumentParser(description="vLLM generation driver for Lean proving (P2).") ap.add_argument("--problems", required=True) ap.add_argument("--out", required=True) ap.add_argument("--model", default=DEFAULT_MODEL) ap.add_argument("--model-rev", default=None) ap.add_argument("--n", type=int, required=True, help="samples per problem (round 0) or per failed parent (round>=1)") ap.add_argument("--cap-tokens", type=int, required=True) ap.add_argument("--round", type=int, default=0) ap.add_argument("--corrections-from", nargs=2, action="append", metavar=("VERIF_JSONL", "GEN_JSONL"), default=[]) ap.add_argument("--tp", type=int, default=4) ap.add_argument("--seed", type=int, default=0) ap.add_argument("--max-model-len", type=int, default=40960) ap.add_argument("--gpu-mem-util", type=float, default=0.90) ap.add_argument("--max-num-seqs", type=int, default=128) ap.add_argument("--inflight", type=int, default=None, help="refill window (default max_num_seqs + 32)") ap.add_argument("--flush-every", type=int, default=8) ap.add_argument("--flush-secs", type=float, default=5.0) ap.add_argument("--limit-problems", type=int, default=None) ap.add_argument("--verify-tokenization", type=int, default=4, help="send N prompts to vLLM as TEXT and assert its token ids equal ours") ap.add_argument("--trajectory-cap-tokens", type=int, default=None, metavar="N", help="cumulative completion-token cap per trajectory (class B: 40000); a repair is clipped to the " "remaining budget, one with none left is skipped as skipped_trajectory_cap") ap.add_argument("--no-feedback-policy", choices=("skip", "resample"), default="skip", help="failed parents with no compiler error: record as skipped_no_feedback (default) or draw " "fresh round-0-style attempts on the same trajectory") ap.add_argument("--repair-solved", action="store_true", help="also repair failed samples of problems that already have an accepted sample " "(default = Goedel's rule: repair only unsolved problems)") ap.add_argument("--lora-adapter", default=None, metavar="DIR", help="serve this PEFT adapter UN-MERGED through vLLM enable_lora on top of --model") ap.add_argument("--lora-name", default="adapter") ap.add_argument("--no-error-thres", action="store_true", help="Goedel --error_thres False (all errors, full spans)") ap.add_argument("--wait-lock", action="store_true", help="block on the heavy-job lock instead of refusing") ap.add_argument("--inherit-lock", type=int, default=None, metavar="PARENT_PID", help="the heavy-job lock is already held by this (parent) pid; verify that and do not take it again") ap.add_argument("--dry-run", action="store_true", help="plan + build prompts only; no GPU, no lock") ap.add_argument("--label", default="p2-lean-gen") ap.add_argument("--temperature", type=float, default=TEMPERATURE, help=f"sampling temperature (default {TEMPERATURE}: Goedel protocol; Kimina card recommends 0.6)") ap.add_argument("--top-p", type=float, default=TOP_P, help=f"nucleus top_p (default {TOP_P})") ap.add_argument("--prompt-format", choices=LP.PROMPT_FORMATS, default="goedel", help="goedel (default, byte-identical prompts) or kimina (docs/KIMINA_BASE.md; round 0 / resample only)") ap.add_argument("--record-logprobs", action="store_true", help="RL rollouts (scripts/p2/rl_lean.py): store the sampler's logprob of every sampled token as " "`sampling_logprobs` (the BEHAVIOUR policy). Meaningful only with top_p == 1.0 and top_k " "disabled; the RL loop refuses records that say otherwise.") a = ap.parse_args(argv) if not a.temperature > 0: ap.error("--temperature must be > 0") if not 0 < a.top_p <= 1: ap.error("--top-p must be in (0, 1]") if a.prompt_format != "goedel" and a.round >= 1: ap.error(f"--prompt-format {a.prompt_format} has no correction prompt; use --round 0") if a.round == 0 and a.corrections_from: ap.error("--corrections-from needs --round >= 1") if a.round >= 1 and not a.corrections_from: ap.error("--round >= 1 needs --corrections-from VERIF GEN") if a.n < 1 or a.cap_tokens < 1 or a.tp < 1: ap.error("--n, --cap-tokens, --tp must be positive") if a.trajectory_cap_tokens is not None and a.trajectory_cap_tokens < 1: ap.error("--trajectory-cap-tokens must be positive") if a.round == 0 and (a.repair_solved or a.no_feedback_policy != "skip" or a.trajectory_cap_tokens is not None): ap.error("--repair-solved / --no-feedback-policy / --trajectory-cap-tokens only apply to --round >= 1") adapter = adapter_provenance(a.lora_adapter) if a.lora_adapter else None lora_kw = lora_engine_kwargs(adapter) if adapter else {} rev = rev_with_adapter(model_rev_of(a.model, a.model_rev), adapter, explicit=bool(a.model_rev)) cfg = json.load(open(os.path.join(a.model, "config.json"))) if a.max_model_len > cfg["max_position_embeddings"]: raise ValueError(f"--max-model-len {a.max_model_len} exceeds the model's max_position_embeddings " f"{cfg['max_position_embeddings']} (no rope scaling configured); refusing") inflight = a.inflight or a.max_num_seqs + 32 prepare_env() problems = load_problems(a.problems, a.limit_problems) parent_stats = None no_feedback: list = [] skipped_path = a.out + ".skipped.jsonl" if a.round == 0: specs: Iterator[Spec] = plan_initial(problems, a.n, 0) else: parents, parent_stats, no_feedback = load_parents_ex( [tuple(p) for p in a.corrections_from], a.round, repair_solved=a.repair_solved) specs = plan_corrections(problems, parents, a.n, a.round) if a.no_feedback_policy == "resample": specs = _chain(list(specs), plan_resamples(problems, no_feedback, a.n, a.round)) os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True) done, torn_at_start = resume_state(a.out, repair=not a.dry_run) conflicts = resume_conflicts(a.out, rev, a.seed, adapter_sha=adapter["sha256"] if adapter else None, temperature=a.temperature, top_p=a.top_p, prompt_format=a.prompt_format) if conflicts: raise ValueError( f"--out {a.out} already holds records this invocation (--model-rev {rev}, --seed {a.seed}) did not " "produce; resuming would mix two policies / seed streams in one file. Use a fresh --out. First: " + "; ".join(conflicts)) from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained(a.model) prompts_sha = hashlib.sha256(open(os.path.join(HERE, "lean_prompts.py"), "rb").read()).hexdigest() render_sha = prompt_render_sha(a.prompt_format, tok) stale_prompts = prompt_template_conflicts(a.out + ".manifest.json", render_sha, a.out, a.prompt_format) if stale_prompts: raise ValueError(f"--out {a.out} holds samples written under different prompt bytes " f"({stale_prompts}); the current rendering is {render_sha}. Use a fresh --out.") session = { "session_id": f"{int(time.time())}-{os.getpid()}", "started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "ended_at": None, "status": "running", "argv": sys.argv, "args": vars(a), "model_path": a.model, "model_rev": rev, "lean_prompts_sha256": prompts_sha, "prompt_render_sha256": render_sha, "temperature": a.temperature, "top_p": a.top_p, "prompt_format": a.prompt_format, "prompt_format_sha256": hashlib.sha256(f"{prompts_sha}|{a.prompt_format}".encode()).hexdigest(), "parent_stats": parent_stats, "already_done_at_start": len(done), "adapter": adapter, "no_feedback_parents": len(no_feedback), "no_feedback_policy": a.no_feedback_policy if a.round else None, "trajectory_cap_tokens": a.trajectory_cap_tokens, } manifest_path = a.out + ".manifest.json" if a.round and a.no_feedback_policy == "skip" and not a.dry_run: # explicit outcome, not a silent drop: every failed parent with nothing to show is listed for nf in no_feedback: if append_skipped(skipped_path, no_feedback_entry(nf, a.round, "skip", a.cap_tokens)): session["skipped_no_feedback"] = session.get("skipped_no_feedback", 0) + 1 # materialise lazily: tokenising 10^5 long correction prompts up front would stall the start def todo() -> Iterator[Prepared]: for s in specs: key = (s.problem["problem_id"], s.sample_idx, s.round, a.cap_tokens) if key in done: session["skipped_already_done"] = session.get("skipped_already_done", 0) + 1 continue pr, why = prepare(s, tok, a.cap_tokens, a.max_model_len, a.seed, not a.no_error_thres, traj_cap=a.trajectory_cap_tokens, prompt_format=a.prompt_format) if pr is None: outcome = skip_outcome(why) session[outcome] = session.get(outcome, 0) + 1 if not a.dry_run: ent = {"key": list(key), "reason": why, "outcome": outcome} if s.trajectory: ent["root_trajectory_id"] = f"{s.problem['problem_id']}|{s.trajectory['root_sample_idx']}" ent["cum_completion_tokens"] = s.trajectory["prior_completion_tokens"] append_skipped(skipped_path, ent) continue session["budget_clipped"] = session.get("budget_clipped", 0) + int(pr.budget_clipped) yield pr if a.dry_run: n = clipped = 0 lens = [] for pr in todo(): n += 1 clipped += pr.context_clipped lens.append(len(pr.prompt_ids)) session.update(status="dry-run", planned=n, context_clipped=clipped, prompt_tokens_max=max(lens, default=0), prompt_tokens_mean=sum(lens) / max(1, len(lens))) print(json.dumps(session, ensure_ascii=False, indent=1, default=str)) return 0 session.update(preflight_and_lock(a.label, a.wait_lock, a.inherit_lock)) write_manifest(manifest_path, session) # --- bring the engine up ------------------------------------------------- import vllm from vllm import LLM, SamplingParams from vllm.sampling_params import RequestOutputKind import torch if a.tp > torch.cuda.device_count(): raise RuntimeError(f"--tp {a.tp} but only {torch.cuda.device_count()} CUDA devices visible") session.update(vllm_version=vllm.__version__, torch_version=torch.__version__) mon = GpuMonitor() mon.start() t_load = time.perf_counter() llm = LLM(model=a.model, tokenizer=a.model, dtype="bfloat16", tensor_parallel_size=a.tp, enable_prefix_caching=True, gpu_memory_utilization=a.gpu_mem_util, max_model_len=a.max_model_len, max_num_seqs=a.max_num_seqs, seed=a.seed, disable_log_stats=False, **lora_kw) lora_req = None if adapter: from vllm.lora.request import LoRARequest lora_req = make_lora_request(adapter, a.lora_name, LoRARequest) rec_extra = adapter_record_extra(adapter, a.lora_name) if a.record_logprobs and a.top_p != 1.0: raise ValueError("--record-logprobs needs --top-p 1.0: vLLM returns raw logprobs, which equal the sampling " "(behaviour) distribution only without nucleus truncation") session["engine_load_s"] = round(time.perf_counter() - t_load, 1) eng = llm.llm_engine try: cc = eng.vllm_config.cache_config session["kv_cache"] = {"num_gpu_blocks": cc.num_gpu_blocks, "block_size": cc.block_size, "tokens": (cc.num_gpu_blocks or 0) * cc.block_size, "gpu_memory_utilization": cc.gpu_memory_utilization} except Exception as e: # informational only; vLLM also logs it session["kv_cache"] = {"error": repr(e)} stop = {"flag": False} def _sig(signum, _frm): stop["flag"] = True signal.signal(signal.SIGTERM, _sig) signal.signal(signal.SIGINT, _sig) writer = AppendWriter(a.out, a.flush_every, a.flush_secs) session["torn_bytes_removed"] = torn_at_start + writer.torn_bytes_removed gen_iter = todo() # tokenisation self-check: vLLM's own text path must yield OUR prompt ids if a.verify_tokenization: probe = [] for pr in gen_iter: probe.append(pr) if len(probe) >= a.verify_tokenization: break gen_iter = _chain(probe, gen_iter) ok = _verify_tokenization(eng, probe, SamplingParams) session["tokenization_check"] = {"n": len(probe), "ok": ok} if not ok: raise RuntimeError("vLLM's tokenisation of the prompt TEXT differs from tokenizer.encode(); " "prompt_ids in records would not be what the model saw") inflight_map: dict[str, Prepared] = {} n_done = n_tokens = n_len = 0 t0 = time.perf_counter() last_log = t0 exhausted = False status = "crashed" # overwritten on a clean exit; a raise leaves it try: while True: while not stop["flag"] and not exhausted and len(inflight_map) < inflight: pr = next(gen_iter, None) if pr is None: exhausted = True break sp = SamplingParams(n=1, temperature=a.temperature, top_p=a.top_p, max_tokens=pr.max_tokens, seed=pr.seed, output_kind=RequestOutputKind.FINAL_ONLY, **({"logprobs": 0} if a.record_logprobs else {})) rid = f"{pr.key[0]}|{pr.key[1]}|{pr.key[2]}|{pr.key[3]}" eng.add_request(rid, {"prompt_token_ids": pr.prompt_ids}, sp, lora_request=lora_req) inflight_map[rid] = pr if not inflight_map: break if stop["flag"]: eng.abort_request(list(inflight_map)) break for o in eng.step(): if not o.finished: continue pr = inflight_map.pop(_match_rid(o.request_id, inflight_map)) c = o.outputs[0] if list(o.prompt_token_ids) != pr.prompt_ids: raise RuntimeError(f"{pr.key}: engine prompt ids differ from the recorded prompt ids") writer.add(build_record(pr, list(c.token_ids), c.text, c.finish_reason, a.cap_tokens, a.model, rev, extra=({**(rec_extra or {}), **sampled_logprobs_extra(c)} if a.record_logprobs else rec_extra), temperature=a.temperature, top_p=a.top_p, prompt_format=a.prompt_format)) n_done += 1 n_tokens += len(c.token_ids) n_len += c.finish_reason == "length" now = time.perf_counter() if now - last_log > 30: print(f"[lean_gen] done={n_done} inflight={len(inflight_map)} tokens={n_tokens} " f"avg_tok/s={n_tokens / (now - t0):.0f}", flush=True) last_log = now status = final_status(stop["flag"], len(inflight_map), exhausted) finally: writer.close() mon.stop_ev.set() wall = time.perf_counter() - t0 session.update( ended_at=time.strftime("%Y-%m-%dT%H:%M:%S%z"), status=status, generate_wall_s=round(wall, 1), samples_written=n_done, tokens_generated=n_tokens, finish_length=n_len, tokens_per_s_end_to_end=round(n_tokens / wall, 1) if wall else None, gpu_mem_used_peak_mib=mon.peak, aborted_inflight=len(inflight_map), ) write_manifest(manifest_path, session) print(json.dumps({k: session[k] for k in ("status", "samples_written", "tokens_generated", "generate_wall_s", "tokens_per_s_end_to_end")})) return exit_code_for(status) def _chain(head: list, tail: Iterator): yield from head yield from tail def _match_rid(rid: str, inflight: dict) -> str: """vLLM may decorate request ids; accept an exact match or a unique prefix match.""" if rid in inflight: return rid cand = [k for k in inflight if rid.startswith(k) or k.startswith(rid)] if len(cand) != 1: raise RuntimeError(f"cannot match engine request id {rid!r} to an in-flight request ({len(cand)} candidates)") return cand[0] def _verify_tokenization(eng, probe: list, SamplingParams) -> bool: from vllm.sampling_params import RequestOutputKind want = {} for i, pr in enumerate(probe): rid = f"tokcheck-{i}" eng.add_request(rid, pr.prompt_text, SamplingParams(max_tokens=1, temperature=0.0, output_kind=RequestOutputKind.FINAL_ONLY)) want[rid] = pr.prompt_ids ok = True left = set(want) while left: for o in eng.step(): if o.finished: rid = _match_rid(o.request_id, {k: 0 for k in left}) left.discard(rid) ok &= list(o.prompt_token_ids) == want[rid] return ok if __name__ == "__main__": raise SystemExit(main())