unsloth/tests/flex_sleep_mode_smoke.py
2026-04-21 14:58:27 +00:00

297 lines
11 KiB
Python

# SPDX-License-Identifier: GNU Affero General Public License v3.0
# Copyright 2023-present the Unsloth team. All rights reserved.
"""Smoke-test :meth:`FlexEngine.sleep` and :meth:`FlexEngine.wake_up`.
Invoked as:
CUDA_VISIBLE_DEVICES=3 \
UNSLOTH_FAST_INFERENCE=1 UNSLOTH_VLLM_STANDBY=1 \
python tests/flex_sleep_mode_smoke.py --model unsloth/Qwen3-4B-Base
What it checks:
1. ``model.vllm_engine._sleep_mode_enabled`` is True when vLLM is
importable.
2. Captured CUDA graphs survive a sleep/wake round-trip: a second
``fast_generate`` call after ``sleep`` -> ``wake_up`` does not
re-capture and still produces the same token ids.
3. ``torch.cuda.memory_allocated()`` drops on sleep and returns close
to the pre-sleep value on wake. With ``--no-standby``, memory
should be identical across the three probes (no-op path).
4. Hardens the regression guard by running the same workflow with
``UNSLOTH_VLLM_STANDBY`` unset (``--no-standby``); ``sleep`` /
``wake_up`` must be exact no-ops.
This is a smoke test, not the full verification matrix from the plan
(that lives under ``scripts/benchmarks``).
"""
from __future__ import annotations
import argparse
import gc
import os
import sys
import time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def _gb(n: int) -> float:
return round(n / 1e9, 3)
def _probe(label: str) -> dict:
import torch
gc.collect()
torch.cuda.synchronize()
# ``memory_allocated`` / ``memory_reserved`` only track torch's
# caching allocator and ignore cuMem-backed pools, so they do NOT
# drop on sleep even though cuMem has unmapped the pages.
# ``mem_get_info()`` asks the CUDA runtime directly, so it sees
# cuMem unmaps and is the right probe for sleep / wake verification.
free_bytes, total_bytes = torch.cuda.mem_get_info()
return {
"label": label,
"allocated_gb": _gb(torch.cuda.memory_allocated()),
"reserved_gb": _gb(torch.cuda.memory_reserved()),
"cuda_free_gb": _gb(free_bytes),
"cuda_used_gb": _gb(total_bytes - free_bytes),
}
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default = "unsloth/Qwen3-4B-Base")
p.add_argument("--dtype", choices = ["bf16", "fp16"], default = "bf16")
p.add_argument("--load_in_4bit", action = "store_true")
p.add_argument("--max_new_tokens", type = int, default = 32)
p.add_argument("--max_seq_length", type = int, default = 1024)
p.add_argument("--prompt", default = "The quick brown fox jumps over")
p.add_argument(
"--no-standby",
action = "store_true",
help = "Force UNSLOTH_VLLM_STANDBY=0 to validate the no-op regression path.",
)
p.add_argument(
"--cycles",
type = int,
default = 1,
help = "Number of sleep / wake / generate cycles after warmup. "
">1 exercises the repeated-cycle regression (run #6).",
)
args = p.parse_args()
os.environ.setdefault("UNSLOTH_FAST_INFERENCE", "1")
if args.no_standby:
os.environ["UNSLOTH_VLLM_STANDBY"] = "0"
else:
os.environ.setdefault("UNSLOTH_VLLM_STANDBY", "1")
standby = os.environ.get("UNSLOTH_VLLM_STANDBY", "0") == "1"
print(
f"[sleep-smoke] UNSLOTH_FAST_INFERENCE="
f"{os.environ.get('UNSLOTH_FAST_INFERENCE')} "
f"UNSLOTH_VLLM_STANDBY={os.environ.get('UNSLOTH_VLLM_STANDBY')}"
)
import torch
import unsloth
from unsloth import FastLanguageModel
print(f"[sleep-smoke] unsloth={unsloth.__file__}")
dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16
t0 = time.perf_counter()
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = args.model,
max_seq_length = args.max_seq_length,
dtype = dtype,
load_in_4bit = args.load_in_4bit,
fast_inference = True,
)
print(
f"[sleep-smoke] loaded {args.model} in "
f"{time.perf_counter() - t0:.1f}s; dtype={model.dtype}"
)
engine = model.vllm_engine
print(f"[sleep-smoke] engine type: {type(engine).__name__}")
sleep_enabled = getattr(engine, "_sleep_mode_enabled", None)
print(f"[sleep-smoke] engine._sleep_mode_enabled: {sleep_enabled}")
if standby:
if sleep_enabled is not True:
# Most likely vLLM is not importable in this environment.
print(
"[sleep-smoke] WARNING: UNSLOTH_VLLM_STANDBY=1 was set "
"but engine._sleep_mode_enabled is False (vLLM missing?)"
)
else:
assert sleep_enabled is False, (
f"Expected sleep mode to be disabled with UNSLOTH_VLLM_STANDBY=0, "
f"got {sleep_enabled}"
)
ll_cfg = engine.llm_engine.vllm_config.model_config
print(
f"[sleep-smoke] llm_engine.vllm_config.model_config.enable_sleep_mode: "
f"{getattr(ll_cfg, 'enable_sleep_mode', None)}"
)
assert getattr(ll_cfg, "enable_sleep_mode", None) == bool(sleep_enabled), (
"_LLMEngineStub.model_config.enable_sleep_mode must mirror "
"engine._sleep_mode_enabled"
)
from unsloth.inference.vllm_shim import LoRARequest # noqa: F401
prompts = [args.prompt]
# ----- warmup -----
t0 = time.perf_counter()
out1 = engine.generate(
prompts,
sampling_params = type(
"SP",
(),
{"max_tokens": args.max_new_tokens, "temperature": 0.0},
)(),
)
print(
f"[sleep-smoke] warmup generate: {time.perf_counter() - t0:.2f}s; "
f"tok_ids[:10]={out1[0].outputs[0].token_ids[:10]}"
)
pre_tokens = list(out1[0].outputs[0].token_ids)
probe_pre = _probe("pre-sleep")
print(f"[sleep-smoke] {probe_pre}")
# Diagnostic: checksum the inference-model weights so we can detect
# if cuMem's sleep/wake round-trip corrupts any parameter.
def _checksum_params(mod, limit = 16):
import torch as _t
out = []
for i, (name, p) in enumerate(mod.named_parameters()):
if i >= limit:
break
t = p.detach()
out.append((name, list(t.shape), float(t.float().abs().sum().item())))
return out
pre_sums = _checksum_params(engine._inference_model)
print("[sleep-smoke] pre-sleep first-16 param |sum|:")
for n, s, v in pre_sums:
print(f" {n} {s} {v:.4f}")
# Repeated sleep / wake / generate cycle test (plan matrix run #6).
# Each cycle validates that the engine does not drift: tokens remain
# bitwise identical, memory returns to baseline, weights round-trip
# cleanly. A bug that only surfaces on the second or third cycle
# (stale Python state, double-wake, leaked handles) fails here.
for cycle in range(args.cycles):
if args.cycles > 1:
print(f"[sleep-smoke] --- cycle {cycle + 1}/{args.cycles} ---")
# ----- sleep -----
t0 = time.perf_counter()
engine.sleep(level = 1)
t_sleep = time.perf_counter() - t0
probe_post = _probe(f"post-sleep[{cycle + 1}]")
print(f"[sleep-smoke] sleep(level=1) took {t_sleep:.3f}s; {probe_post}")
if sleep_enabled:
drop = probe_pre["cuda_used_gb"] - probe_post["cuda_used_gb"]
print(
f"[sleep-smoke] process-level VRAM drop on sleep: "
f"{drop:+.3f} GB (cuMem-managed; not visible in "
f"torch.memory_allocated)"
)
if not getattr(engine, "_single_copy_mode", False):
# 16-bit path: both weights + kv_cache pools are dropped.
assert drop >= 1.0, (
f"Expected multi-GB drop in process-level VRAM on "
f"sleep(level=1); got {drop:+.3f} GB"
)
else:
# 4-bit single-copy: only KV cache drops; weights stay.
assert drop > 0.0, (
f"Expected KV-cache drop in process-level VRAM on "
f"sleep(level=1); got {drop:+.3f} GB"
)
else:
# With sleep mode off, the sleep() call must not free VRAM.
# Process-level jitter is allowed (shared GPU); torch-owned
# allocations must be untouched.
assert probe_post["allocated_gb"] == probe_pre["allocated_gb"], (
"With sleep mode disabled, torch.memory_allocated must "
"be unchanged by sleep()"
)
# ----- wake -----
t0 = time.perf_counter()
engine.wake_up()
t_wake = time.perf_counter() - t0
probe_wake = _probe(f"post-wake[{cycle + 1}]")
print(f"[sleep-smoke] wake_up() took {t_wake:.3f}s; {probe_wake}")
post_sums = _checksum_params(engine._inference_model)
diffs = []
for (n1, s1, v1), (n2, s2, v2) in zip(pre_sums, post_sums):
delta = abs(v1 - v2)
if delta > 0.0:
diffs.append((n1, v1, v2, delta))
print(
f"[sleep-smoke] post-wake weight diff: "
f"{len(diffs)}/{len(pre_sums)} params changed "
f"(bitwise-exact restore expected)"
)
for n, v1, v2, d in diffs[:8]:
print(f" diff {n}: pre={v1:.4f} post={v2:.4f} delta={d:.4f}")
assert len(diffs) == 0, (
f"Weight corruption on sleep / wake (cycle {cycle + 1}): "
f"{len(diffs)}/{len(pre_sums)} first-layer params changed"
)
# ----- verify we can still generate -----
t0 = time.perf_counter()
out2 = engine.generate(
prompts,
sampling_params = type(
"SP",
(),
{"max_tokens": args.max_new_tokens, "temperature": 0.0},
)(),
)
t_regen = time.perf_counter() - t0
post_tokens = list(out2[0].outputs[0].token_ids)
match = pre_tokens == post_tokens
print(
f"[sleep-smoke] post-wake generate: {t_regen:.2f}s; "
f"tok_ids[:10]={post_tokens[:10]}; matches_pre={match}"
)
assert match, (
f"Pre-sleep / post-wake token ids must match exactly "
f"(cycle {cycle + 1}).\n"
f"Pre: {pre_tokens}\nPost: {post_tokens}"
)
if sleep_enabled:
delta = probe_wake["cuda_used_gb"] - probe_pre["cuda_used_gb"]
print(
f"[sleep-smoke] post-wake vs pre-sleep process-level "
f"VRAM delta: {delta:+.3f} GB (tolerance: +/- 1.5 GB)"
)
assert abs(delta) < 1.5, (
f"Post-wake VRAM diverged from pre-sleep "
f"(cycle {cycle + 1}): delta={delta:+.3f} GB"
)
print(f"[sleep-smoke] PASS ({args.cycles} cycle(s))")
if __name__ == "__main__":
main()