Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Export is automatic: after training, rank 0 exports every `checkpoint-<step>` to
| Task | Log evidence | Artifact |
| --- | --- | --- |
| Hidden-state dump (offline) | vLLM extraction completes over the input data | Dump directory populated |
| Training | `Training time: N seconds`, then `=== Exporting: <ckpt> → <export_dir> ===` and `=== Regression Check (...) ===` | `<output_dir>/checkpoint-*/trainer_state.json`, `<output_dir>/exported-checkpoint-*` |
| Training | `Training time: N seconds`, then `=== Exporting: <ckpt> → <export_dir> ===` and `=== Regression Check (...) ===` | `<output_dir>/checkpoint-*/trainer_state.json`, `<output_dir>/exported-checkpoint-*`, `<output_dir>/regression_check.json` |
| Smoke test | `Auto-detected draft model: ...`, `Server ready after Ns` | Smoke-test log with responses |
| AR eval | Per-category MT-Bench AR output | AR results |
| Benchmark | `Average_AL` in the saved results | JSON under `--save_dir` |
Expand All @@ -137,9 +137,12 @@ convergence baseline (8×B200, bs=1, seq_len=4096, 5-layer draft, block_size=16,
samples, 1 epoch ≈ 12,500 steps) is in the YAML header — compare against it when
judging whether a run under-trained.

Note: `check_regression.py` is invoked with `|| true`, and it only warns when no
`trainer_state.json` exists. A green Slurm exit is not proof the gate ran — confirm
the `=== Regression Check ===` block is present in the log.
Judge the run by `<output_dir>/regression_check.json`: it passed only if `pass` is true
and `thresholds` is non-empty; no file means the check never ran (training failed).
`failure_class` is `TRAIN_REGRESSION` (a metric missed its threshold or is NaN/inf) or
`METRICS_MISSING` (no readable metrics, or an invalid threshold). Only
`REGRESSION_STRICT=1` (DFlash2 online) fails the task on either; elsewhere a green Slurm
exit proves nothing. Any value but `0`/`1` fails before training.

**2. Smoke test** — `MIN_ACCEPTANCE_LENGTH` env var (Qwen3-8B online uses 1.4) with
`NUM_SPEC_TOKENS` speculative tokens.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,11 @@ task_2 (`vllm_smoke_test.sh`, gate `MIN_ACCEPTANCE_LENGTH=1.2`); the online exam
ships no such step, so benchmark its export separately on the serving stack
(vLLM PR #52816) or via the offline acceptance-length harness.

The training-regression gate from `dflash.md` (`MAX_FINAL_LOSS`, `MIN_FINAL_ACC` via
`check_regression.py`) applies; the online example sets `MAX_FINAL_LOSS=5.0` and
`MIN_FINAL_ACC=0.15` for its 2000-step convergence check.
The training-regression gate from `dflash.md` applies. The online example sets
`MAX_FINAL_LOSS=7.0` (DFlash2's loss runs higher than DFlash's, so 5.0 fails healthy
runs) and `MIN_FINAL_ACC=0.15` for its 2000-step convergence check, and runs it strict
(`REGRESSION_STRICT=1`): a missed threshold, or no metrics to check, fails the
training task.

## Known failures

Expand Down
159 changes: 121 additions & 38 deletions tools/launcher/common/check_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,51 @@
passing unevaluated.
MAX_FINAL_PERPLEXITY: Final perplexity must be below this value

An empty variable counts as unset, and one that isn't a finite number fails the check.
Once any threshold is set, a missing or unreadable trainer_state.json, or one without
training logs, also fails the check, and so does a NaN or infinite value for a metric
that has a threshold.

Usage:
python check_regression.py /path/to/output_dir
python check_regression.py /path/to/output_dir [--json verdict.json]

Or from a shell script:
python common/check_regression.py ${OUTPUT_DIR}

--json also writes the verdict as {"pass", "failure_class", "trainer_state",
"metrics", "thresholds", "messages"}, so a pipeline gate can read it without
parsing logs. failure_class is null, "TRAIN_REGRESSION" or "METRICS_MISSING". It is
strict JSON: a non-finite metric is written as null.

Exit codes:
0 — all checks pass (or no thresholds set)
1 — regression detected
1 — regression detected, or thresholds set but no metrics to check
"""

import argparse
import json
import math
import os
import sys
from glob import glob
from typing import Any

THRESHOLD_VARS = ("MAX_FINAL_LOSS", "MIN_FINAL_ACC", "MAX_FINAL_PERPLEXITY")


def _checkpoint_step(trainer_state_path):
"""Step number of <output_dir>/checkpoint-<step>/trainer_state.json, or -1."""
name = os.path.basename(os.path.dirname(trainer_state_path))
step = name.removeprefix("checkpoint-")
return int(step) if step.isdigit() else -1


def find_trainer_state(output_dir):
"""Find the latest trainer_state.json in the output directory."""
# Check checkpoint subdirs first (sorted by step number)
checkpoint_states = sorted(glob(os.path.join(output_dir, "checkpoint-*", "trainer_state.json")))
checkpoint_states = sorted(
glob(os.path.join(output_dir, "checkpoint-*", "trainer_state.json")),
key=_checkpoint_step,
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check checkpoint subdirs first, by numeric step: a lexicographic sort would rank checkpoint-8000 after checkpoint-12000.

if checkpoint_states:
return checkpoint_states[-1]
Comment on lines +70 to 75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The step-number sort fixes ordering among checkpoints, but the final state in output_dir is still only a fallback — so "latest" can still be wrong.

Any numeric checkpoint-*/trainer_state.json wins over <output_dir>/trainer_state.json, even when the latter is the newer, end-of-training state. That happens whenever max_steps is not a multiple of save_steps: HF Trainer's final save_state() lands in output_dir while the last periodic checkpoint is some steps behind, and the gate then grades the older one. Note hf_online_dflash2.yaml has save_steps=5000 with max_steps=2000, so that example relies entirely on the fallback path today — the preference only bites once save_steps < max_steps (the 92K-step recipe this PR targets).

Since the point of the change is "grade the newest state", consider making the choice uniform by comparing global_step from each candidate file (the HF trainer_state.json records it), or at minimum preferring <output_dir>/trainer_state.json when its global_step is higher than the best checkpoint's. A test with checkpoint-1000/ plus a higher-step output_dir/trainer_state.json would pin the behaviour either way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HF Trainer saves a checkpoint at the final step whatever save_steps is, so the newest numbered checkpoint never lags a completed run's final state. I have tested this example (save_steps=5000, max_steps=2000). It produced checkpoint-2000, whose trainer_state.json has the same global_step (2000) as <output_dir>/trainer_state.json, and the gate graded checkpoint-2000. If training stops early, save_state() never runs, so the output_dir copy isn't newer in that case either.

# Fall back to output_dir itself
Expand Down Expand Up @@ -81,8 +105,8 @@ def get_final_metrics(trainer_state_path):
return metrics


def check_regression(metrics):
"""Check metrics against environment variable thresholds. Returns (passed, messages)."""
def check_regression(metrics, thresholds):
"""Check metrics against {env_var: threshold}. Returns (passed, messages)."""
checks = [
(
"MAX_FINAL_LOSS",
Expand All @@ -108,10 +132,9 @@ def check_regression(metrics):
messages = []

for env_var, metric_key, check_fn, fail_msg in checks:
thresh_str = os.environ.get(env_var)
if thresh_str is None:
if env_var not in thresholds:
continue
thresh = float(thresh_str)
thresh = thresholds[env_var]
val = metrics.get(metric_key)
if val is None:
# Setting a threshold asserts the run must be checked against it. If the
Expand All @@ -123,6 +146,10 @@ def check_regression(metrics):
)
passed = False
continue
if not math.isfinite(val):
messages.append(f"REGRESSION: {metric_key} is {val}, not a finite number")
passed = False
continue
if check_fn(val, thresh):
messages.append(f"PASS: {metric_key}={val:.3f} (threshold: {env_var}={thresh})")
else:
Expand All @@ -132,40 +159,96 @@ def check_regression(metrics):
return passed, messages


def main():
"""Entry point for regression check CLI."""
if len(sys.argv) < 2:
print("Usage: python check_regression.py <output_dir>")
sys.exit(0)

output_dir = sys.argv[1]

# Skip if no thresholds set
if not any(
os.environ.get(v) for v in ["MAX_FINAL_LOSS", "MIN_FINAL_ACC", "MAX_FINAL_PERPLEXITY"]
):
return
def parse_thresholds(environ):
"""Returns ({env_var: value}, [error]) for the set threshold variables."""
thresholds, errors = {}, []
for v in THRESHOLD_VARS:
raw = environ.get(v)
if not raw:
continue
try:
value = float(raw)
except ValueError:
value = math.nan
if math.isfinite(value):
thresholds[v] = value
else:
errors.append(f"REGRESSION: {v}={raw!r} is not a finite number")
return thresholds, errors


def evaluate(output_dir):
"""Run the check on output_dir. Returns the verdict that --json writes."""
thresholds, errors = parse_thresholds(os.environ)
verdict: dict[str, Any] = {
"pass": True,
"failure_class": None,
"trainer_state": None,
"metrics": {},
"thresholds": thresholds,
"messages": [],
}
if errors:
# The recipe's thresholds are unusable, so the run cannot be checked against them.
verdict.update({"pass": False, "failure_class": "METRICS_MISSING", "messages": errors})
return verdict
if not thresholds:
return verdict

trainer_state = find_trainer_state(output_dir)
if not trainer_state:
print(f"WARNING: No trainer_state.json found in {output_dir}, skipping regression check")
return

print(f"=== Regression Check ({trainer_state}) ===")
metrics = get_final_metrics(trainer_state)
verdict["trainer_state"] = trainer_state
where = trainer_state or f"{output_dir} (no trainer_state.json)"
try:
metrics = get_final_metrics(trainer_state) if trainer_state else {}
missing = f"no training logs to check in {where}"
# TypeError/AttributeError: valid JSON of the wrong shape, e.g. "loss": null or a list.
except (OSError, ValueError, TypeError, AttributeError) as e:
metrics, missing = {}, f"cannot read {where}: {e}"
if not metrics:
Comment on lines +199 to 207

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] Two unexpected-exception paths produce no verdict file, which the non-strict branch then swallows.

  1. get_final_metrics() can raise outside (OSError, ValueError). state.get("log_history", ...) raises AttributeError if the JSON parses to a non-object ([], null), and float(last["loss"]) raises TypeError on "loss": null. These are less likely than the truncation case you test, but they land in the same bucket as "a job killed mid-save".
  2. thresholds = {v: float(os.environ[v]) ...} on line 156 raises ValueError before the verdict dict exists, so a typo'd MAX_FINAL_LOSS: "7,0" in a recipe is a bare traceback.

In both cases the process exits non-zero with no regression_check.json written, and in the default (non-strict) branch || true discards the traceback's significance — so a downstream gate cannot tell "the checker was misconfigured / crashed" from "the training script never got far enough to run it". Given that distinction is the whole point of --json, consider widening to except Exception for the metrics read, and validating/reporting a malformed threshold as its own failure_class (e.g. THRESHOLDS_INVALID) that still writes the file.

@Zhehao-Hu Zhehao-Hu Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the metrics read in 9a5904c. TypeError/AttributeError from a well-formed but wrong-shaped trainer_state.json now give METRICS_MISSING, with tests for [] and "loss": null.

print("No training logs found in trainer_state.json")
return
# Thresholds were set, so an unreadable run must not pass as unchecked.
verdict.update(
{
"pass": False,
"failure_class": "METRICS_MISSING",
"messages": [f"REGRESSION: {missing}"],
}
)
return verdict

passed, messages = check_regression(metrics, thresholds)
verdict.update({"pass": passed, "metrics": metrics, "messages": messages})
if not passed:
verdict["failure_class"] = "TRAIN_REGRESSION"
return verdict

print(f"Final metrics: {metrics}")
passed, messages = check_regression(metrics)
for msg in messages:
print(f" {msg}")

if not passed:
sys.exit(1)
print("Regression check PASSED")
def main(argv=None):
"""Entry point for regression check CLI."""
parser = argparse.ArgumentParser(description="Regression check for training jobs.")
parser.add_argument("output_dir", nargs="?", help="Training output directory")
parser.add_argument("--json", dest="json_path", help="Create a JSON file storing the verdict")
args = parser.parse_args(argv)
if args.output_dir is None:
parser.print_usage()
return 0

verdict = evaluate(args.output_dir)
if verdict["thresholds"] or verdict["messages"]:
print(f"=== Regression Check ({verdict['trainer_state'] or args.output_dir}) ===")
if verdict["metrics"]:
print(f"Final metrics: {verdict['metrics']}")
Comment on lines +238 to +239

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The human-readable log now prints the JSON-sanitized metrics, so a diverged run emits two contradictory lines:

=== Regression Check (/scratchspace/dflash2_bs16/checkpoint-2000/trainer_state.json) ===
Final metrics: {'loss': None}
  REGRESSION: loss is nan, not a finite number
Regression check FAILED

{'loss': None} reads as "the trainer never logged a loss" — the METRICS_MISSING story — when the actual fact is that it logged nan. That is the single most diagnostic value in the log for a divergence, and the None substitution is an artifact of a constraint (strict JSON) that only applies to the file.

The sanitization is only needed for json.dump, so keep the raw dict for the log and sanitize at write time:

# in evaluate(): keep the raw values
verdict.update({"pass": passed, "metrics": metrics, "messages": messages})

# in main(), at the write:
if args.json_path:
    # Strict JSON: NaN/Infinity would break non-Python readers of the verdict.
    serializable = dict(verdict)
    serializable["metrics"] = {
        k: v if math.isfinite(v) else None for k, v in verdict["metrics"].items()
    }
    with open(args.json_path, "w") as f:
        json.dump(serializable, f, indent=2, allow_nan=False)

This also keeps Final metrics: byte-identical to main on every path, matching the PR's "existing log lines are unchanged" claim. The existing CLI test still passes, since it asserts on failure_class/pass/thresholds rather than on metrics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 4101423. evaluate() keeps the raw metrics, so the log prints {'loss': nan}, and main() writes non-finite values as null only in the file.

for msg in verdict["messages"]:
print(f" {msg}")
print(f"Regression check {'PASSED' if verdict['pass'] else 'FAILED'}")

if args.json_path:
# Strict JSON, serialized before the file is opened so a failure cannot truncate it.
metrics = {k: v if math.isfinite(v) else None for k, v in verdict["metrics"].items()}
text = json.dumps({**verdict, "metrics": metrics}, indent=2, allow_nan=False)
with open(args.json_path, "w") as f:
f.write(text)
return 0 if verdict["pass"] else 1


if __name__ == "__main__":
main()
sys.exit(main())
53 changes: 41 additions & 12 deletions tools/launcher/common/specdec/dflash_online_training.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
source ${SCRIPT_DIR}/../service_utils.sh

OUTPUT_DIR=$(python3 -c "
import sys
for arg in sys.argv[1:]:
if arg.startswith('training.output_dir='):
print(arg.split('=', 1)[1])
break
" "$@")
# A verdict left in a reused output_dir by an earlier run must never be read as this run's,
# so remove it before anything below can stop the script.
if [ "${SLURM_PROCID:-0}" = "0" ] && [ -n "$OUTPUT_DIR" ]; then
rm -f "${OUTPUT_DIR}/regression_check.json"
fi

# Checked before any work: a typo such as "true" must not cost a full training run.
case "${REGRESSION_STRICT:-0}" in
0|1) ;;
*) echo "ERROR: REGRESSION_STRICT must be 0 or 1, got '${REGRESSION_STRICT}'" >&2; exit 1 ;;
esac

pip install -r modules/Model-Optimizer/examples/speculative_decoding/requirements.txt
pip install huggingface-hub>=1.2.1
export PATH=$PATH:/workspace/.local/bin
Expand Down Expand Up @@ -117,20 +136,23 @@ fi

set -x
start_time=$(date +%s)
accelerate launch $ACCEL_CONFIG_ARGS --mixed_precision "${MIXED_PRECISION:-bf16}" $MULTI_NODE_ARGS $MAIN_PY "$@"
# The ERR trap only records a failure and lets the script go on, so test the status here:
# a crashed run must not export or write a regression verdict from whatever checkpoints
# and trainer_state.json it, or an earlier run, left behind.
if accelerate launch $ACCEL_CONFIG_ARGS --mixed_precision "${MIXED_PRECISION:-bf16}" $MULTI_NODE_ARGS $MAIN_PY "$@"; then
TRAIN_STATUS=0
else
# The if suppresses the ERR trap, so report as it would; $? is still the launch status.
error_handler "$0" "$LINENO"
TRAIN_STATUS=1
fi
echo "Training time: $(( $(date +%s) - start_time )) seconds"
set +x

if [ "$TRAIN_STATUS" != 0 ]; then
echo "ERROR: training failed; skipping export and regression check." >&2
# Export last checkpoint to deployment format (rank 0 only, single GPU)
if [ "${SLURM_PROCID:-0}" = "0" ]; then
OUTPUT_DIR=$(python3 -c "
import sys
for arg in sys.argv[1:]:
if arg.startswith('training.output_dir='):
print(arg.split('=', 1)[1])
break
" "$@")

elif [ "${SLURM_PROCID:-0}" = "0" ]; then
if [ -n "$OUTPUT_DIR" ]; then
# Export all checkpoints that don't already have an exported version
EXPORTED=0
Expand Down Expand Up @@ -162,8 +184,15 @@ for arg in sys.argv[1:]:
fi
fi

# Regression check (uses env vars MAX_FINAL_LOSS, MIN_FINAL_ACC, etc.)
# Regression check (uses env vars MAX_FINAL_LOSS, MIN_FINAL_ACC, etc.). The verdict
# lands next to the exports for downstream gates. A regression only warns unless
# REGRESSION_STRICT=1, which fails the task (via the ERR trap) so Slurm reports it.
if [ -n "$OUTPUT_DIR" ]; then
python3 common/check_regression.py "${OUTPUT_DIR}" || true
REGRESSION_ARGS=("${OUTPUT_DIR}" --json "${OUTPUT_DIR}/regression_check.json")
if [ "${REGRESSION_STRICT:-0}" = 1 ]; then
python3 common/check_regression.py "${REGRESSION_ARGS[@]}"
else
python3 common/check_regression.py "${REGRESSION_ARGS[@]}" || true
fi
fi
fi
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ pipeline:
# Sliding-window draft attention, matching the published DFlash2 drafters.
- dflash.dflash_swa_window_size=2048
environment:
- MAX_FINAL_LOSS: "5.0"
# DFlash2's loss runs higher than DFlash's; 5.0 fails healthy runs.
- MAX_FINAL_LOSS: "7.0"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calibrated on this config (global batch 8): a GB300 run finished at step 2000 with loss 6.51 and acc 0.181. The 5.0 inherited from the DFlash example is unreachable here: DFlash2 adds a selector loss term and stops at 2000 steps.

- MIN_FINAL_ACC: "0.15"
# Fail the task on a regression instead of only logging it.
- REGRESSION_STRICT: "1"
slurm_config:
_factory_: "slurm_factory"
nodes: 1
Expand Down
Loading
Loading