align / code /scripts /qwen3_export_check.py
viai957's picture
Add files using upload-large-folder tool
bcc8d35 verified
Raw History Blame Contribute Delete
25.4 kB
#!/usr/bin/env python
"""Merge a LoRA SFT checkpoint into the base, export HF Qwen3, and prove vLLM reads what align wrote.
CUDA_VISIBLE_DEVICES=0,1 .venv/bin/python scripts/qwen3_export_check.py run \
--ckpt RUN/checkpoints/micro_N --out-dir .tmp/p2/p2a/export [--keep]
The claim under test: *the file align writes is the model align trained.* Five independent checks,
each of which fails if the export is wrong (the process exit status gates ALL of them, `export_ok`):
1. MERGE PATH vs TRAINER FILE. align merges the adapter (fp32 B@A * alpha/r, one bf16 cast) into
the pristine snapshot weights from the adapter stored in the checkpoint's optimizer payload,
and writes it with `save_hf_qwen3`. The trainer's own checkpoint weight file was built by a
different route (FSDP gather -> `export_merged_state_dict`). The two must be bit-identical.
2. vLLM LOADS IT. A separate venv (`.venv-rollout`, vLLM 0.26) loads the exported directory
unmodified -- sharded safetensors, config.json (rope_theta under both spellings), tokenizer --
and greedy-generates 64 tokens on 2 prompts, returning the top-20 logprobs at every position.
3. FIRST-TOKEN LOGPROBS. align's own forward on the merged weights (bf16, GPU 1) scores the same
prompt; the first-token top-1 id must agree and the logprob difference is reported.
4. TEACHER-FORCED AGREEMENT over all 64 generated tokens: align scores prompt+generation in one
pass; per-position argmax agreement with what vLLM sampled, and max |dlogprob| of the sampled
token. bf16 TOLERANCE (documented, not fitted): two correct bf16 engines with different
attention/GEMM kernels and accumulation order differ by a few ulps of a logit of magnitude
10-30 (ulp 0.0625-0.125) amplified through 36 layers; the gate is |dlogprob| <= 0.25 on the
first token and argmax agreement >= 95% over the 64 positions (a near-tie legitimately flips
argmax; a wrong merge, a missing QK-norm or a wrong rope_theta disagrees on most positions and
by whole nats). The thresholds are constants below, asserted by `evaluate`, and exercised on
injected mutants in tests/test_qwen3_scripts.py.
5. ADAPTER vs MERGED. The single bf16 cast of W+delta rounds away any delta below half an ulp of
W (a dead zone, not noise), so at small LoRA movement the merged file can be a different
policy from base+adapter -- and checks 1-4 all score the MERGED file against itself. Here the
SAME teacher-forced tokens are scored by align with the adapter applied live (base bf16 +
fp32 delta) and by the merged file; the gates are the first-token / argmax-agreement
constants of check 4 (reused, not refitted). The retained-delta cosine (merged-W vs the
intended delta) is recorded alongside. Also gated: the real snapshot's hf<->align mapping
round trip, and that the adapter MOVED every adapted matrix (a zero adapter exports the base).
The export is DELETED afterwards unless --keep (16.4 GB on a 94%-full disk).
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO)
ROLLOUT_PY = os.path.join(REPO, ".venv-rollout", "bin", "python")
SNAPSHOT = os.environ.get("P2_EXPORT_BASE_SNAPSHOT") or ("/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/"
"dfd02e6271a58375dfbf3ece0175277cf6b6a89a")
WORK = os.path.join(REPO, ".tmp", "p2", "p2a", "export_check")
N_NEW = 64
TOPK = 20
FIRST_TOKEN_MAX_ABS_DLOGPROB = 0.25
MIN_ARGMAX_AGREEMENT = 0.95
PROMPTS = (
"<|im_start|>user\nComplete the following Lean 4 code:\n\n```lean4\nimport Mathlib\nimport Aesop\n\n"
"set_option maxHeartbeats 400000\n\nopen BigOperators Real Nat Topology Rat\n\n"
"theorem mathd_algebra_10 : abs ((120 : ℝ) / 100 * 30 - 130 / 100 * 20) = 10 := by\n sorry\n```\n"
"<|im_end|>\n<|im_start|>assistant\n",
"<|im_start|>user\nWhat is 17 * 23? Answer briefly.<|im_end|>\n<|im_start|>assistant\n",
)
# ---------------------------------------------------------------------------
# merge (pure tensor math; unit-tested)
# ---------------------------------------------------------------------------
def merge_lora_state(base: dict, lora: dict, scaling: float) -> dict:
"""`base` (align-keyed, `<path>.weight`) with `scaling * B @ A` folded in for every adapter path
in `lora` (`<path>.A` / `<path>.B`, as `align.ppo.lora.lora_state_dict` writes them).
The delta is accumulated in fp32 and cast ONCE to the weight's dtype (the same arithmetic as
`export_merged_state_dict`, so the two routes can be compared bit for bit). Every adapter path
must resolve to a base weight and must have both halves: a half-present adapter or a path that
matches nothing would otherwise "export" an unmodified base.
"""
import torch
a_keys = {k[:-2] for k in lora if k.endswith(".A")}
b_keys = {k[:-2] for k in lora if k.endswith(".B")}
stray = sorted(k for k in lora if not (k.endswith(".A") or k.endswith(".B")))
if stray:
raise KeyError(f"adapter state has keys that are neither .A nor .B: {stray[:4]}")
if a_keys != b_keys:
raise KeyError(f"adapter halves do not pair: A-only={sorted(a_keys - b_keys)[:3]} B-only={sorted(b_keys - a_keys)[:3]}")
if not a_keys:
raise KeyError("adapter state is empty; refusing to export an unmodified base as a merge")
out = dict(base)
for p in sorted(a_keys):
wk = f"{p}.weight"
if wk not in base:
raise KeyError(f"adapter path {p!r} has no base weight {wk!r}")
w, A, B = base[wk], lora[f"{p}.A"], lora[f"{p}.B"]
if A.shape[0] != B.shape[1] or (B.shape[0], A.shape[1]) != tuple(w.shape):
raise ValueError(f"{p}: A{tuple(A.shape)} B{tuple(B.shape)} do not compose to {tuple(w.shape)}")
out[wk] = (w.float() + (B.float() @ A.float()) * scaling).to(w.dtype)
return out
# ---------------------------------------------------------------------------
# verdict (pure; unit-tested)
# ---------------------------------------------------------------------------
def compare_generation(vllm: dict, align: dict) -> dict:
"""Per-prompt metrics between vLLM's generation record and align's teacher-forced scoring.
`vllm[i]`: {"token_ids": [64 ints], "chosen_logprobs": [64 floats], "first_top": {id: logprob}}.
`align[i]`: {"argmax": [64 ints], "chosen_logprobs": [64 floats], "first_logprobs": {id: logprob}}
(align's log-softmax at the same 64 positions, and at position 0 for vLLM's top-20 ids).
"""
per = []
for v, a in zip(vllm, align):
n = len(v["token_ids"])
if not (len(a["argmax"]) == len(a["chosen_logprobs"]) == n):
raise ValueError("generation records have different lengths")
agree = sum(int(x == y) for x, y in zip(v["token_ids"], a["argmax"]))
dchosen = [abs(x - y) for x, y in zip(v["chosen_logprobs"], a["chosen_logprobs"])]
v_top1 = max(v["first_top"], key=v["first_top"].get)
a_first = a["first_logprobs"]
per.append({
"tokens": n,
"argmax_agreement": agree / n,
"max_abs_dlogprob_chosen": max(dchosen),
"mean_abs_dlogprob_chosen": sum(dchosen) / n,
"first_token_top1_same": int(v_top1) == int(max(a_first, key=a_first.get)),
"first_token_abs_dlogprob": abs(v["first_top"][v_top1] - a_first[v_top1]),
"first_token_max_abs_dlogprob_top20": max(abs(v["first_top"][k] - a_first[k]) for k in v["first_top"]),
})
return {"per_prompt": per}
def evaluate(metrics: dict) -> dict:
checks = {}
for i, p in enumerate(metrics["per_prompt"]):
checks[f"prompt{i}.first_token_top1_same"] = bool(p["first_token_top1_same"])
checks[f"prompt{i}.first_token_dlogprob"] = p["first_token_abs_dlogprob"] <= FIRST_TOKEN_MAX_ABS_DLOGPROB
checks[f"prompt{i}.argmax_agreement"] = p["argmax_agreement"] >= MIN_ARGMAX_AGREEMENT
return {"checks": checks, "passed": bool(checks) and all(checks.values()),
"thresholds": {"first_token_max_abs_dlogprob": FIRST_TOKEN_MAX_ABS_DLOGPROB,
"min_argmax_agreement": MIN_ARGMAX_AGREEMENT}}
def merge_fidelity_stats(base: dict, merged: dict, lora: dict, scaling: float) -> dict:
"""How much of the intended delta `scaling * B @ A` survived the cast: aggregate cosine and
relative error of (merged - base) against the delta, and the fraction of nonzero delta
elements that rounded to exactly zero (the bf16 dead zone). Same arithmetic as
`align.ppo.lora.export_merged_state_dict`'s report."""
import math
import torch
dot = ne = nd = 0.0
nz_total = dropped = layers = 0
for p in sorted({k[:-2] for k in lora if k.endswith(".A")}):
wk = f"{p}.weight"
d = ((lora[f"{p}.B"].float() @ lora[f"{p}.A"].float()) * scaling).flatten()
e = (merged[wk].float() - base[wk].float()).flatten()
dot, ne, nd = dot + float(torch.dot(e, d)), ne + float(torch.dot(e, e)), nd + float(torch.dot(d, d))
nz = d != 0
nz_total += int(nz.sum())
dropped += int((nz & (e == 0)).sum())
layers += 1
if nd == 0.0:
return {"layers": layers, "cosine": 1.0, "rel_error": 0.0, "dropped_fraction": 0.0, "delta_norm": 0.0}
return {"layers": layers, "cosine": dot / math.sqrt(max(ne * nd, 1e-300)),
"rel_error": math.sqrt(max(ne - 2.0 * dot + nd, 0.0) / nd),
"dropped_fraction": dropped / nz_total if nz_total else 0.0, "delta_norm": math.sqrt(nd)}
def compare_adapter_vs_merged(adapter_lps: list, merged_lps: list) -> dict:
"""Per-prompt agreement between align's log-softmax at the generated positions with the adapter
applied live (`adapter_lps[i]`, [T, V]) and on the merged bf16 file (`merged_lps[i]`, [T, V])."""
import torch
per = []
for a, m in zip(adapter_lps, merged_lps):
if a.shape != m.shape or a.dim() != 2:
raise ValueError(f"log-prob records differ in shape: {tuple(a.shape)} vs {tuple(m.shape)}")
a, m = a.float(), m.float()
a_top, m_top = a.argmax(-1), m.argmax(-1)
t0 = int(a_top[0])
per.append({
"tokens": a.shape[0],
"argmax_agreement": float((a_top == m_top).float().mean()),
"first_token_abs_dlogprob": abs(float(a[0, t0] - m[0, t0])),
"max_abs_dlogprob_top1": float((a.gather(1, a_top[:, None]) - m.gather(1, a_top[:, None])).abs().max()),
"kl_adapter_to_merged_mean": float((a.exp() * (a - m)).sum(-1).mean()),
})
return {"per_prompt": per}
def evaluate_adapter_vs_merged(metrics: dict) -> dict:
checks = {}
for i, p in enumerate(metrics["per_prompt"]):
checks[f"prompt{i}.first_token_dlogprob"] = p["first_token_abs_dlogprob"] <= FIRST_TOKEN_MAX_ABS_DLOGPROB
checks[f"prompt{i}.argmax_agreement"] = p["argmax_agreement"] >= MIN_ARGMAX_AGREEMENT
return {"checks": checks, "passed": bool(checks) and all(checks.values()),
"thresholds": {"first_token_max_abs_dlogprob": FIRST_TOKEN_MAX_ABS_DLOGPROB,
"min_argmax_agreement": MIN_ARGMAX_AGREEMENT}}
def export_ok(report: dict) -> bool:
"""The process verdict. EVERY advertised check must have run and passed: a check that did not
run (key absent) is a failure, not a pass -- a zero adapter, a lossy hf<->align mapping or a
merge that dropped the delta must not exit 0."""
rel_min = (report.get("merge_relative_weight_change") or {}).get("min")
return bool(
(report.get("verdict") or {}).get("passed") is True
and (report.get("merge_vs_trainer_file") or {}).get("bitwise_mismatches") == 0
and (report.get("snapshot_mapping_roundtrip") or {}).get("bitwise_equal") is True
and (report.get("merge_changed_tensors_vs_snapshot") or 0) > 0
and isinstance(rel_min, (int, float)) and rel_min > 0
and ((report.get("adapter_vs_merged") or {}).get("verdict") or {}).get("passed") is True
)
# ---------------------------------------------------------------------------
# vLLM side: runs in .venv-rollout (source string: not an import of this file)
# ---------------------------------------------------------------------------
_VLLM_SCRIPT = r'''
import json, os, sys, time
model_dir, prompts_json, out_path, n_new, topk = sys.argv[1:6]
n_new, topk = int(n_new), int(topk)
os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
from transformers import AutoTokenizer
from vllm import LLM, SamplingParams
tok = AutoTokenizer.from_pretrained(model_dir)
prompts = json.loads(prompts_json)
ids = [tok(p, add_special_tokens=False)["input_ids"] for p in prompts]
t0 = time.time()
llm = LLM(model=model_dir, tokenizer=model_dir, dtype="bfloat16", max_model_len=2048,
gpu_memory_utilization=0.85, enforce_eager=True, seed=0)
t_load = time.time() - t0
sp = SamplingParams(temperature=0.0, max_tokens=n_new, logprobs=topk, ignore_eos=True)
outs = llm.generate([{"prompt_token_ids": x} for x in ids], sp)
res = []
for x, o in zip(ids, outs):
c = o.outputs[0]
lps = c.logprobs
res.append({
"prompt_ids": x,
"token_ids": list(c.token_ids),
"text": c.text,
"chosen_logprobs": [float(lps[t][c.token_ids[t]].logprob) for t in range(len(c.token_ids))],
"first_top": {str(k): float(v.logprob) for k, v in lps[0].items()},
})
json.dump({"results": res, "load_s": t_load, "model": model_dir}, open(out_path, "w"))
print(json.dumps({"ok": True, "n": len(res), "load_s": round(t_load, 1)}), flush=True)
# vLLM 0.26's EngineCore subprocess can hang at interpreter shutdown (measured: the result file was
# written at 00:44 and the process was still alive 20 minutes later holding 19 GiB of GPU 0). The
# result is on disk and flushed; leave without running the atexit/engine teardown.
sys.stdout.flush(); sys.stderr.flush()
os._exit(0)
'''
def _first_visible_gpu(env=None) -> str:
"""The child's CUDA_VISIBLE_DEVICES is relative to PHYSICAL indices, so a hard-coded "0" put
vLLM (0.85 of the card) on physical GPU 0 even when the operator pinned this check to 2,3.
Use the parent's FIRST visible entry ("0" when the parent is unpinned)."""
v = (os.environ if env is None else env).get("CUDA_VISIBLE_DEVICES", "")
first = v.split(",")[0].strip()
return first or "0"
def _rollout_env(gpu: str) -> dict:
scrub = ("RANK", "LOCAL_RANK", "WORLD_SIZE", "LOCAL_WORLD_SIZE", "MASTER_ADDR", "MASTER_PORT", "TORCHELASTIC")
env = {k: v for k, v in os.environ.items() if not k.startswith(scrub)}
env.update({
"CUDA_VISIBLE_DEVICES": gpu, "VLLM_USE_FLASHINFER_SAMPLER": "0",
"TMPDIR": os.path.join(REPO, ".tmp", "p2", "p2a"), "XDG_CACHE_HOME": os.path.join(REPO, ".cache"),
"HF_HOME": "/apps/development/rlhf/hf_cache", "HF_HUB_CACHE": "/apps/development/rlhf/hf_cache/hub",
"HF_HUB_DISABLE_XET": "1", "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1",
"TRITON_CACHE_DIR": os.path.join(REPO, ".cache", "triton"),
"TORCHINDUCTOR_CACHE_DIR": os.path.join(REPO, ".cache", "torchinductor"),
"PYTHONUNBUFFERED": "1", "TOKENIZERS_PARALLELISM": "false",
})
return env
# ---------------------------------------------------------------------------
# the run
# ---------------------------------------------------------------------------
def run_vllm_child(cmd, result_path, *, env, cwd, log_path, grace_s: float = 30.0, timeout_s: float = 3000.0):
"""Run the vLLM child WITHOUT inheriting pipes, and stop its process group once it has written
its result.
DEFECT THIS PREVENTS (measured in the P2 smoke loop): vLLM 0.26's EngineCore can outlive its
parent at interpreter exit (re-parented to init, holding the parent's stdout/stderr pipes). With
`subprocess.run(capture_output=True)` the parent then blocks in communicate() until the ORPHAN
closes the pipe -- 12+ minutes, the result file already written, GPU 0 held at 19 GB, the heavy
lock held. Here the child logs to a file (nothing for an orphan to hold open), runs in its own
session so the whole group can be signalled by its exact pgid, and is terminated `grace_s` after
the result file appears. Returns (returncode or None if it had to be terminated, log tail).
"""
import signal
with open(log_path, "w") as log:
proc = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT, env=env, cwd=cwd, start_new_session=True)
t0 = time.time()
done_at = None
rc = None
while True:
rc = proc.poll()
if rc is not None:
break
if os.path.isfile(result_path) and done_at is None:
done_at = time.time()
if (done_at is not None and time.time() - done_at > grace_s) or time.time() - t0 > timeout_s:
break
time.sleep(1.0)
# signal the group even when the leader exited: orphaned workers keep the pgid
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(proc.pid, sig)
except ProcessLookupError:
break
time.sleep(5.0)
if rc is None:
try:
rc = proc.wait(timeout=10)
except subprocess.TimeoutExpired:
rc = None
with open(log_path) as f:
return rc, f.read()[-3000:]
def _lora_scaling(ckpt: str, r: int | None, alpha: float | None) -> float:
if r is None or alpha is None:
raise SystemExit("--r and --alpha are required: the checkpoint stores A/B but not alpha/r, and guessing the scaling would export a wrong merge silently")
return alpha / r
def run(a) -> dict:
from align.utils.guard import apply_resource_guards
apply_resource_guards(cpu_threads=8, heavy=True, label="p2a-qwen3-export-check")
import torch
from align.model import qwen3 as q3
from align.model.checkpoint import load_hf_safetensors
from align.model.qwen3_checkpoint import align_to_hf, hf_to_align, read_qwen3_config, save_hf_qwen3
work = os.path.abspath(a.work)
out_dir = os.path.abspath(a.out_dir)
os.makedirs(work, exist_ok=True)
report: dict = {"ckpt": a.ckpt, "out_dir": out_dir}
qcfg = read_qwen3_config(SNAPSHOT)
scaling = _lora_scaling(a.ckpt, a.r, a.alpha)
# -- 1. merge path vs the trainer's file ---------------------------------------------------
t0 = time.time()
raw = load_hf_safetensors(SNAPSHOT, dtype=torch.bfloat16)
base = hf_to_align(raw, qcfg)
# real-checkpoint mapping round trip: hf -> align -> hf must return every tensor bit for bit
# (any tensor dropped, renamed or transposed by the table fails here, on the real shapes).
back = align_to_hf(base, qcfg)
report["snapshot_mapping_roundtrip"] = {"tensors": len(raw), "bitwise_equal": all(torch.equal(back[k], raw[k]) for k in raw)
and set(back) == set(raw)}
del raw, back
payload = torch.load(os.path.join(a.ckpt, "optimizer_rank0.pt"), weights_only=True, map_location="cpu")
merged = merge_lora_state(base, payload["lora"], scaling)
report["merge_delta_retention"] = merge_fidelity_stats(base, merged, payload["lora"], scaling)
del base
save_hf_qwen3(merged, out_dir, cfg=qcfg, dtype=torch.bfloat16, tokenizer_dir=SNAPSHOT)
report["merge_s"] = round(time.time() - t0, 1)
ckpt_state = hf_to_align(load_hf_safetensors(a.ckpt, dtype=None), qcfg)
mism = [k for k, v in merged.items() if not torch.equal(v, ckpt_state[k])]
n_changed = sum(1 for p in {k[:-2] for k in payload["lora"] if k.endswith(".A")})
report["merge_vs_trainer_file"] = {"tensors": len(merged), "bitwise_mismatches": len(mism), "first_mismatches": mism[:3],
"adapted_layers": n_changed}
del ckpt_state
# the adapter must have MOVED the weights: a zero adapter exports the base and "passes" check 1
base2 = hf_to_align(load_hf_safetensors(SNAPSHOT, dtype=torch.bfloat16), qcfg)
moved = sum(1 for k, v in merged.items() if not torch.equal(v, base2[k]))
report["merge_changed_tensors_vs_snapshot"] = moved
# How far the adapter moved the weights: ||W' - W||_F / ||W||_F per adapted matrix (the number
# an LR choice is judged by; bf16 rounding of W' contributes ~2^-9 ~ 0.2% and is included).
# Over the ADAPTED matrices only (every one must have moved: `export_ok` gates the minimum).
adapted = {f"{k[:-2]}.weight" for k in payload["lora"] if k.endswith(".A")}
rel = [((merged[k].float() - base2[k].float()).norm() / base2[k].float().norm()).item() for k in sorted(adapted)]
report["merge_relative_weight_change"] = {"n": len(rel), "mean": sum(rel) / len(rel), "max": max(rel), "min": min(rel)}
del base2, merged
# -- 2. vLLM generates from the export -----------------------------------------------------
vout = os.path.join(work, "vllm_out.json")
env = _rollout_env(_first_visible_gpu())
if os.path.isfile(vout):
os.remove(vout) # a stale result from an earlier run must never be read as this run's
rc, tail = run_vllm_child([ROLLOUT_PY, "-c", _VLLM_SCRIPT, out_dir, json.dumps(list(PROMPTS)), vout, str(N_NEW), str(TOPK)],
vout, env=env, cwd=work, log_path=os.path.join(work, "vllm.log"))
report["vllm_returncode"] = rc
if not os.path.isfile(vout):
report["vllm_stderr_tail"] = tail
return report
vres = json.load(open(vout))
report["vllm_load_s"] = vres["load_s"]
report["vllm_text"] = [r["text"][:200] for r in vres["results"]]
# -- 3/4. align scores the same tokens on the merged weights (GPU 1) ------------------------
dev = torch.device("cuda", 1 if torch.cuda.device_count() > 1 else 0)
cfg2, sd = (qcfg, hf_to_align(load_hf_safetensors(out_dir, dtype=torch.bfloat16), qcfg))
model = q3.Qwen3ForCausalLM.from_state_dict(cfg2, sd, attn_implementation="sdpa", device="cuda").to(dev).eval()
del sd
arec, mlps = [], []
with torch.no_grad():
for r in vres["results"]:
ids = r["prompt_ids"] + r["token_ids"]
logits = model(input_ids=torch.tensor([ids], device=dev))[0].float()
lp = torch.log_softmax(logits, dim=-1)
n0 = len(r["prompt_ids"])
pos = lp[n0 - 1: n0 - 1 + len(r["token_ids"])] # position t predicts generated token t
chosen = pos.gather(1, torch.tensor(r["token_ids"], device=dev)[:, None])[:, 0]
first = {k: float(pos[0, int(k)]) for k in r["first_top"]}
arec.append({"argmax": pos.argmax(-1).tolist(), "chosen_logprobs": chosen.tolist(), "first_logprobs": first})
mlps.append(pos.cpu())
metrics = compare_generation(vres["results"], arec)
report["metrics"] = metrics
report["verdict"] = evaluate(metrics)
# -- 5. the same tokens through base + live adapter (fp32 delta) vs the merged file ---------
from align.ppo.lora import LoRAConfig, inject_lora, load_lora_state_dict
del model
torch.cuda.empty_cache()
base_sd = hf_to_align(load_hf_safetensors(SNAPSHOT, dtype=torch.bfloat16), qcfg)
amodel = q3.Qwen3ForCausalLM.from_state_dict(qcfg, base_sd, attn_implementation="sdpa", device="cuda").to(dev).eval()
del base_sd
inject_lora(amodel, LoRAConfig(r=a.r, alpha=a.alpha), adapter="sft", generator=torch.Generator().manual_seed(0))
load_lora_state_dict(amodel, "sft", payload["lora"], strict=True)
alps = []
with torch.no_grad():
for r in vres["results"]:
ids = r["prompt_ids"] + r["token_ids"]
lp = torch.log_softmax(amodel(input_ids=torch.tensor([ids], device=dev))[0].float(), dim=-1)
n0 = len(r["prompt_ids"])
alps.append(lp[n0 - 1: n0 - 1 + len(r["token_ids"])].cpu())
m5 = compare_adapter_vs_merged(alps, mlps)
report["adapter_vs_merged"] = {"metrics": m5, "verdict": evaluate_adapter_vs_merged(m5)}
return report
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
sub = ap.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("run")
r.add_argument("--ckpt", required=True, help="a trainer checkpoint dir (micro_N) with optimizer_rank0.pt")
r.add_argument("--out-dir", default=os.path.join(WORK, "export"))
r.add_argument("--work", default=WORK)
r.add_argument("--r", type=int, default=None)
r.add_argument("--alpha", type=float, default=None)
r.add_argument("--keep", action="store_true")
a = ap.parse_args(argv)
rep = {}
try:
rep = run(a)
finally:
if not a.keep and os.path.isdir(a.out_dir):
shutil.rmtree(a.out_dir, ignore_errors=True)
rep["export_deleted"] = True
os.makedirs(a.work, exist_ok=True)
with open(os.path.join(a.work, "report.json"), "w") as f:
json.dump(rep, f, indent=2, default=str)
print(json.dumps(rep, indent=2, default=str))
return 0 if export_ok(rep) else 1
if __name__ == "__main__":
raise SystemExit(main())