Skip to content

Commit 1eab710

Browse files
sotanengelclaude
andcommitted
feat: add --check-env preflight flag for OOM risk detection
Adds a --check-env CLI flag that collects physical hardware characteristics (GPU VRAM, CPU RAM, disk space) and model memory estimates before quantization starts, then classifies OOM risk as safe/warning/danger. Exits with code 1 on danger; otherwise prints a report and proceeds with quantization. - onecomp/utils/vram_estimator.py: new EnvironmentSnapshot, ModelMemoryProfile, EnvCheckResult dataclasses; check_environment() and print_env_report() functions reusing existing weight_memory_gb() and estimate_target_bitwidth() - onecomp/utils/__init__.py: export 5 new public symbols - onecomp/cli.py: --check-env argparse flag with preflight invocation - onecomp/runner.py: check_env=False kwarg in auto_run() for library API use - pyproject.toml: optional extras [check-env] = ["psutil>=5.9"] Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6caa479 commit 1eab710

5 files changed

Lines changed: 325 additions & 0 deletions

File tree

onecomp/cli.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ def main():
6363
default="auto",
6464
help='save directory (default: auto-generated, "none" to skip)',
6565
)
66+
parser.add_argument(
67+
"--check-env",
68+
action="store_true",
69+
help=(
70+
"Print an environment and memory report before quantization. "
71+
"Exits with code 1 if OOM risk is 'danger'."
72+
),
73+
)
6674
parser.add_argument(
6775
"--version",
6876
action="version",
@@ -76,6 +84,23 @@ def main():
7684
# Lazy import to keep --help fast
7785
from .runner import Runner # pylint: disable=import-outside-toplevel
7886

87+
if args.check_env:
88+
import sys # pylint: disable=import-outside-toplevel
89+
from .utils.vram_estimator import ( # pylint: disable=import-outside-toplevel
90+
check_environment,
91+
print_env_report,
92+
)
93+
94+
env_result = check_environment(
95+
args.model_id,
96+
total_vram_gb=args.total_vram_gb,
97+
group_size=args.groupsize,
98+
save_dir=save_dir if isinstance(save_dir, str) and save_dir != "auto" else None,
99+
)
100+
print_env_report(env_result, total_vram_gb_override=args.total_vram_gb)
101+
if env_result.risk == "danger":
102+
sys.exit(1)
103+
79104
Runner.auto_run(
80105
model_id=args.model_id,
81106
wbits=args.wbits,

onecomp/runner.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ def auto_run(
417417
evaluate: bool = True,
418418
eval_original_model: bool = False,
419419
save_dir: str = "auto",
420+
check_env: bool = False,
420421
**kwargs,
421422
):
422423
"""One-liner quantization with sensible defaults.
@@ -487,6 +488,24 @@ def auto_run(
487488
setup_logger()
488489
logger = getLogger(__name__)
489490

491+
if check_env:
492+
from .utils.vram_estimator import ( # pylint: disable=import-outside-toplevel
493+
check_environment,
494+
print_env_report,
495+
)
496+
497+
env_result = check_environment(
498+
model_id,
499+
total_vram_gb=total_vram_gb,
500+
group_size=groupsize,
501+
save_dir=save_dir if isinstance(save_dir, str) and save_dir != "auto" else None,
502+
)
503+
print_env_report(env_result, total_vram_gb_override=total_vram_gb)
504+
if env_result.risk == "danger":
505+
raise RuntimeError(
506+
f"Environment check failed (OOM risk=danger): {env_result.risk_detail}"
507+
)
508+
490509
candidate_bits = (2, 3, 4, 8)
491510

492511
if wbits is None:

onecomp/utils/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
effective_bits_for_quantizer,
2626
weight_memory_gb,
2727
VRAMBitwidthEstimation,
28+
EnvironmentSnapshot,
29+
ModelMemoryProfile,
30+
EnvCheckResult,
31+
check_environment,
32+
print_env_report,
2833
)
2934

3035
from .model_inputs import add_model_specific_inputs

onecomp/utils/vram_estimator.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,42 @@ class VRAMBitwidthEstimation:
153153
meta_bits_per_param: float
154154

155155

156+
@dataclass
157+
class EnvironmentSnapshot:
158+
"""Physical hardware readings at check-env time."""
159+
160+
gpu_count: int
161+
gpu_name: str | None
162+
gpu_total_vram_gb: float | None
163+
gpu_free_vram_gb: float | None
164+
ram_total_gb: float | None
165+
ram_available_gb: float | None
166+
disk_available_gb: float | None
167+
disk_path: str
168+
169+
170+
@dataclass
171+
class ModelMemoryProfile:
172+
"""Derived memory footprint for the target model."""
173+
174+
total_params: int
175+
fp16_gb: float
176+
quantized_gb: dict
177+
calibration_overhead_gb: float
178+
179+
180+
@dataclass
181+
class EnvCheckResult:
182+
"""Composite result returned by check_environment()."""
183+
184+
model_id: str
185+
env: EnvironmentSnapshot
186+
model: ModelMemoryProfile
187+
estimation: VRAMBitwidthEstimation | None
188+
risk: str
189+
risk_detail: str
190+
191+
156192
def estimate_target_bitwidth(
157193
model: torch.nn.Module,
158194
vram_ratio: float = 0.70,
@@ -322,3 +358,241 @@ def estimate_wbits_from_vram(
322358
wbits=wbits,
323359
logger=logger,
324360
)
361+
362+
363+
def check_environment(
364+
model_id: str,
365+
*,
366+
total_vram_gb: float | None = None,
367+
group_size: int = 128,
368+
save_dir: str | None = None,
369+
vram_ratio: float = 0.80,
370+
calibration_overhead_ratio: float = 0.15,
371+
) -> EnvCheckResult:
372+
"""Collect hardware info and estimate OOM risk before quantization.
373+
374+
Loads the model architecture on a ``meta`` device (no GPU/CPU memory)
375+
to count parameters, then compares available VRAM against estimated
376+
memory requirements at 2/4/8-bit quantization.
377+
378+
Args:
379+
model_id: Hugging Face model ID or local path.
380+
total_vram_gb: Override GPU VRAM in GB for estimation math only.
381+
Physical GPU readings are always from the real device.
382+
group_size: GPTQ group size for metadata calculation.
383+
save_dir: Path used for disk-space check. Defaults to cwd.
384+
vram_ratio: Fraction of VRAM allocated for the estimation budget.
385+
calibration_overhead_ratio: Calibration activation buffer as a
386+
fraction of the FP16 model footprint (default 15 %).
387+
388+
Returns:
389+
:class:`EnvCheckResult` with hardware snapshot, memory profile,
390+
VRAM estimation, and risk level (``"safe"``, ``"warning"``,
391+
``"danger"``, or ``"unknown"``).
392+
"""
393+
import os
394+
import pathlib
395+
import shutil
396+
397+
from transformers import AutoConfig, AutoModelForCausalLM
398+
399+
# --- GPU snapshot --------------------------------------------------------
400+
gpu_count = torch.cuda.device_count()
401+
if gpu_count > 0:
402+
dev = torch.cuda.current_device()
403+
props = torch.cuda.get_device_properties(dev)
404+
gpu_name = props.name
405+
gpu_total_vram_gb = props.total_memory / _BYTES_PER_GB
406+
try:
407+
free_bytes, _ = torch.cuda.mem_get_info(dev)
408+
gpu_free_vram_gb = free_bytes / _BYTES_PER_GB
409+
except Exception:
410+
gpu_free_vram_gb = None
411+
else:
412+
gpu_name = None
413+
gpu_total_vram_gb = None
414+
gpu_free_vram_gb = None
415+
416+
# --- CPU RAM (psutil optional) -------------------------------------------
417+
try:
418+
import psutil
419+
420+
vm = psutil.virtual_memory()
421+
ram_total_gb = vm.total / _BYTES_PER_GB
422+
ram_available_gb = vm.available / _BYTES_PER_GB
423+
except ImportError:
424+
ram_total_gb = None
425+
ram_available_gb = None
426+
427+
# --- Disk space (stdlib) -------------------------------------------------
428+
check_path = save_dir if save_dir else os.getcwd()
429+
p = pathlib.Path(check_path)
430+
while not p.exists():
431+
p = p.parent
432+
disk_available_gb = shutil.disk_usage(p).free / _BYTES_PER_GB
433+
434+
# --- Model memory profile ------------------------------------------------
435+
config = AutoConfig.from_pretrained(model_id)
436+
with torch.device("meta"):
437+
model = AutoModelForCausalLM.from_config(config, torch_dtype=torch.float16)
438+
439+
total_params = sum(p.numel() for p in model.parameters())
440+
fp16_gb = (total_params * 2) / _BYTES_PER_GB
441+
quantized_gb = {b: weight_memory_gb(total_params, b, group_size) for b in (2, 4, 8)}
442+
calibration_overhead_gb = fp16_gb * calibration_overhead_ratio
443+
444+
# --- VRAM bitwidth estimation (reuse existing) ---------------------------
445+
try:
446+
estimation = estimate_target_bitwidth(
447+
model,
448+
vram_ratio=vram_ratio,
449+
total_vram_gb=total_vram_gb,
450+
group_size=group_size,
451+
)
452+
except (RuntimeError, ValueError):
453+
estimation = None
454+
455+
# --- OOM risk assessment -------------------------------------------------
456+
# Use free VRAM (runtime reality) when available; fall back to override.
457+
effective_vram = gpu_free_vram_gb if gpu_free_vram_gb is not None else total_vram_gb
458+
459+
if effective_vram is None:
460+
risk = "unknown"
461+
risk_detail = "No GPU detected and no --total-vram-gb provided."
462+
else:
463+
need_4bit = quantized_gb[4] + calibration_overhead_gb
464+
if effective_vram >= fp16_gb * 1.2:
465+
risk = "safe"
466+
risk_detail = (
467+
f"Free VRAM ({effective_vram:.1f} GB) comfortably fits "
468+
f"even FP16 weights ({fp16_gb:.1f} GB × 1.2)."
469+
)
470+
elif effective_vram >= need_4bit:
471+
risk = "warning"
472+
risk_detail = (
473+
f"Free VRAM ({effective_vram:.1f} GB) fits 4-bit quantized "
474+
f"weights but is tight (calibration overhead included)."
475+
)
476+
else:
477+
risk = "danger"
478+
risk_detail = (
479+
f"Free VRAM ({effective_vram:.1f} GB) is insufficient for "
480+
f"4-bit + calibration ({need_4bit:.1f} GB needed)."
481+
)
482+
483+
return EnvCheckResult(
484+
model_id=model_id,
485+
env=EnvironmentSnapshot(
486+
gpu_count=gpu_count,
487+
gpu_name=gpu_name,
488+
gpu_total_vram_gb=gpu_total_vram_gb,
489+
gpu_free_vram_gb=gpu_free_vram_gb,
490+
ram_total_gb=ram_total_gb,
491+
ram_available_gb=ram_available_gb,
492+
disk_available_gb=disk_available_gb,
493+
disk_path=str(p),
494+
),
495+
model=ModelMemoryProfile(
496+
total_params=total_params,
497+
fp16_gb=fp16_gb,
498+
quantized_gb=quantized_gb,
499+
calibration_overhead_gb=calibration_overhead_gb,
500+
),
501+
estimation=estimation,
502+
risk=risk,
503+
risk_detail=risk_detail,
504+
)
505+
506+
507+
def print_env_report(result: EnvCheckResult, *, total_vram_gb_override: float | None = None) -> None:
508+
"""Print a human-readable environment and OOM risk report to stdout.
509+
510+
Args:
511+
result: The :class:`EnvCheckResult` from :func:`check_environment`.
512+
total_vram_gb_override: When not ``None``, annotates the VRAM budget
513+
line with ``[--total-vram-gb override]``.
514+
"""
515+
_W = 60
516+
_SEP = "=" * _W
517+
_COL = 22
518+
519+
def _row(label: str, value: str) -> str:
520+
return f" {label:<{_COL}}: {value}"
521+
522+
risk_labels = {
523+
"safe": "SAFE",
524+
"warning": "WARNING",
525+
"danger": "DANGER !!",
526+
"unknown": "UNKNOWN",
527+
}
528+
risk_label = risk_labels.get(result.risk, result.risk.upper())
529+
530+
e = result.env
531+
m = result.model
532+
533+
print(_SEP)
534+
print(" OneComp Environment Check")
535+
print(_SEP)
536+
print()
537+
538+
# Hardware
539+
print("Hardware")
540+
print(_row("GPU count", str(e.gpu_count)))
541+
if e.gpu_name is not None:
542+
print(_row("GPU name", e.gpu_name))
543+
if e.gpu_total_vram_gb is not None:
544+
label = "GPU VRAM (total)"
545+
value = f"{e.gpu_total_vram_gb:.1f} GB"
546+
if total_vram_gb_override is not None:
547+
value += " [physical]"
548+
print(_row(label, value))
549+
if total_vram_gb_override is not None:
550+
print(_row("VRAM budget used", f"{total_vram_gb_override:.1f} GB [--total-vram-gb override]"))
551+
if e.gpu_free_vram_gb is not None:
552+
print(_row("GPU VRAM (free)", f"{e.gpu_free_vram_gb:.1f} GB"))
553+
if e.ram_total_gb is not None:
554+
print(_row("CPU RAM (total)", f"{e.ram_total_gb:.1f} GB"))
555+
print(_row("CPU RAM (avail)", f"{e.ram_available_gb:.1f} GB"))
556+
else:
557+
print(_row("CPU RAM", "n/a (install psutil for RAM info)"))
558+
print(_row("Disk (avail)", f"{e.disk_available_gb:.1f} GB [{e.disk_path}]"))
559+
print()
560+
561+
# Model
562+
print(f"Model: {result.model_id}")
563+
print(_row("Parameters", f"{m.total_params:,}"))
564+
print(_row("FP16 footprint", f"{m.fp16_gb:.2f} GB"))
565+
print()
566+
567+
# Memory estimates
568+
gs = "(group_size varies)"
569+
print(f"Memory Estimates")
570+
for bits in (2, 4, 8):
571+
print(_row(f"{bits}-bit quantized", f"{m.quantized_gb[bits]:.2f} GB"))
572+
print(_row("Calib. overhead", f"{m.calibration_overhead_gb:.2f} GB (15% of FP16)"))
573+
print(_row("4-bit + overhead", f"{m.quantized_gb[4] + m.calibration_overhead_gb:.2f} GB"))
574+
print()
575+
576+
# OOM risk
577+
print("OOM Risk Assessment")
578+
print(_row("Risk level", risk_label))
579+
detail_words = result.risk_detail.split()
580+
detail_line = ""
581+
detail_lines = []
582+
for word in detail_words:
583+
if len(detail_line) + len(word) + 1 > 34:
584+
detail_lines.append(detail_line)
585+
detail_line = word
586+
else:
587+
detail_line = (detail_line + " " + word).lstrip()
588+
if detail_line:
589+
detail_lines.append(detail_line)
590+
for i, dl in enumerate(detail_lines):
591+
if i == 0:
592+
print(_row("Detail", dl))
593+
else:
594+
print(f" {'':<{_COL}} {dl}")
595+
print()
596+
if result.estimation is not None:
597+
print(_row("Recommended wbits", f"{result.estimation.target_bitwidth:.2f} (VRAM-estimated)"))
598+
print(_SEP)

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ cu124 = ["torch", "torchvision"]
5353
cu126 = ["torch", "torchvision"]
5454
cu128 = ["torch", "torchvision"]
5555
cu130 = ["torch", "torchvision"]
56+
check-env = ["psutil>=5.9"]
5657
dev = [
5758
"black",
5859
"hydra-core",
5960
"pylint",
61+
"psutil>=5.9",
6062
"pytest",
6163
]
6264
visualize = [

0 commit comments

Comments
 (0)