fix: enable 4-bit quantized models on single 16GB GPUs

Four bugs prevented bitsandbytes 4-bit quantized models from completing
ablation studies on GPUs with 16GB VRAM:

1. runner.py: quantization parameter was never passed from StudyConfig
   to load_model(), so the loader had no idea quantization was enabled.

2. loader.py (max_memory): GPU memory budget was calculated against the
   unquantized model size, causing accelerate to offload layers to meta
   device even though the quantized model fits comfortably.
   Now divides estimate by 4 (4-bit) or 2 (8-bit) before deciding.

3. evaluator.py: empty strings in wikitext dataset caused zero-length
   tensors that crashed the forward pass with a reshape error.
   Now filters empty/whitespace-only texts and skips empty batches.

4. loader.py (snapshot/restore): snapshot skip decision used unquantized
   size estimate, and restore used strict=True which rejects bitsandbytes
   metadata keys (.absmax, .quant_map, .quant_state). Now uses quantized
   estimate and strict=False.

Tested on RTX 5060 Ti (16GB) with Qwen2.5-Coder-7B-Instruct in 4-bit.
Quick Scan (layer_removal + ffn_ablation) completes all 56 specs.
This commit is contained in:
Jpatching
2026-08-14 20:31:50 -04:00
committed by Joseph Magly
parent 18c910b529
commit e922126405
3 changed files with 56 additions and 28 deletions
-1
View File
@@ -87,7 +87,6 @@ class Evaluator:
desc="Evaluating PPL",
):
batch_texts = ds[i : i + self.batch_size][self.text_column]
# Defensive filtering in case a custom dataset returns unexpected
# values after selection or transformation.
batch_texts = [
+55 -27
View File
@@ -366,7 +366,7 @@ class ModelHandle:
for k, v in self._original_state.items():
target = current_state[k].device if k in current_state else None
restored[k] = v.to(target) if target is not None else v
self.model.load_state_dict(restored)
self.model.load_state_dict(restored, strict=False)
def cleanup(self):
"""Remove temporary offload directory if one was auto-created."""
@@ -622,26 +622,46 @@ def load_model(
load_kwargs["offload_folder"] = _offload_dir
logger.info(f"Auto-created offload folder: {_offload_dir}")
# Reserve GPU headroom for inference (KV cache, activations, generate()).
# Without this, device_map="auto" packs 100% of layers onto GPU, leaving
# no room for forward passes or generation on tight-memory setups.
if dev.is_cuda():
max_memory = {}
for i in range(dev.device_count()):
total = torch.cuda.get_device_properties(i).total_memory
# Reserve 15% or 2 GiB (whichever is larger) for inference headroom
reserve = max(int(total * 0.15), 2 * 1024 ** 3)
usable = total - reserve
max_memory[i] = f"{usable // (1024 ** 2)}MiB"
# Allow overflow to CPU RAM, capped at 85% of physical memory
# to leave room for the OS, Python runtime, and serialization buffers.
total_ram, _ = dev._system_memory_gb()
cpu_budget_gb = int(total_ram * 0.85)
max_memory["cpu"] = f"{max(cpu_budget_gb, 4)}GiB"
load_kwargs["max_memory"] = max_memory
logger.info(
f"GPU memory budget: {', '.join(f'GPU{k}={v}' for k, v in max_memory.items() if k != 'cpu')}"
)
# Skip max_memory when quantization shrinks the model enough to fit
if quantization in ("4bit", "8bit") and est_gb > 0 and gpu_gb > 0:
quant_factor = 4 if quantization == "4bit" else 2
quant_est_gb = est_gb / quant_factor
if quant_est_gb < gpu_gb * 0.7:
logger.info(
f"Quantized estimate ({quant_est_gb:.1f} GB) fits GPU "
f"({gpu_gb:.0f} GB) — skipping max_memory constraint"
)
else:
if dev.is_cuda():
max_memory = {}
for i in range(dev.device_count()):
total = torch.cuda.get_device_properties(i).total_memory
reserve = max(int(total * 0.15), 2 * 1024 ** 3)
usable = total - reserve
max_memory[i] = f"{usable // (1024 ** 2)}MiB"
total_ram, _ = dev._system_memory_gb()
cpu_budget_gb = int(total_ram * 0.85)
max_memory["cpu"] = f"{max(cpu_budget_gb, 4)}GiB"
load_kwargs["max_memory"] = max_memory
logger.info(
f"GPU memory budget: {', '.join(f'GPU{k}={v}' for k, v in max_memory.items() if k != 'cpu')}"
)
else:
# No quantization — use original max_memory logic
if dev.is_cuda():
max_memory = {}
for i in range(dev.device_count()):
total = torch.cuda.get_device_properties(i).total_memory
reserve = max(int(total * 0.15), 2 * 1024 ** 3)
usable = total - reserve
max_memory[i] = f"{usable // (1024 ** 2)}MiB"
total_ram, _ = dev._system_memory_gb()
cpu_budget_gb = int(total_ram * 0.85)
max_memory["cpu"] = f"{max(cpu_budget_gb, 4)}GiB"
load_kwargs["max_memory"] = max_memory
logger.info(
f"GPU memory budget: {', '.join(f'GPU{k}={v}' for k, v in max_memory.items() if k != 'cpu')}"
)
try:
model = model_cls.from_pretrained(**load_kwargs)
@@ -731,12 +751,20 @@ def load_model(
)
else:
handle.snapshot()
elif gpu_gb > 0 and est_gb > 0 and est_gb > gpu_gb * 0.5:
logger.warning(
f"Auto-skipping state dict snapshot to save memory "
f"(model ~{est_gb:.0f} GB vs GPU {gpu_gb:.0f} GB). "
f"Use skip_snapshot=False to force."
)
elif gpu_gb > 0 and est_gb > 0:
effective_gb = est_gb
if quantization == "4bit":
effective_gb = est_gb / 4
elif quantization == "8bit":
effective_gb = est_gb / 2
if effective_gb > gpu_gb * 0.5:
logger.warning(
f"Auto-skipping state dict snapshot to save memory "
f"(model ~{effective_gb:.0f} GB vs GPU {gpu_gb:.0f} GB). "
f"Use skip_snapshot=False to force."
)
else:
handle.snapshot()
else:
handle.snapshot()
+1
View File
@@ -38,6 +38,7 @@ def run_study(config: StudyConfig) -> AblationReport:
dtype=config.model.dtype,
trust_remote_code=config.model.trust_remote_code,
num_labels=config.model.num_labels,
quantization=getattr(config.model, "quantization", None),
)
console.print(f" Architecture: {handle.architecture}")
console.print(f" Layers: {handle.num_layers} Heads: {handle.num_heads}")