safffrron's picture
Upload folder using huggingface_hub
f157cf0 verified
Raw
History Blame Contribute Delete
16.8 kB
"""Loading, inspecting and slimming the Qwen3.5-4B checkpoint.
Qwen3.5-4B is a hybrid multimodal model — 24 Gated DeltaNet (linear attention)
layers interleaved with 8 full-attention layers, plus a 24-block vision tower
and a multi-token-prediction head. For a text-only math benchmark the vision
tower and MTP head are never executed, so they are ~455M parameters (9.7% of the
checkpoint) of pure dead weight.
Exact class names and module paths for this architecture vary across
transformers releases, so everything here introspects the loaded module tree
rather than hardcoding paths. ``describe_model`` exists to dump ground truth
before we commit to any of it.
"""
from __future__ import annotations
import os
import re
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import torch
DEFAULT_MODEL = "Qwen/Qwen3.5-4B"
# First matching pattern wins, so order matters: `visual`/`mtp` must precede the
# generic `mlp`/`norm` patterns since those submodules contain MLPs too.
BUDGET_GROUPS: tuple[tuple[str, str], ...] = (
("vision", r"visual|vision"),
("mtp", r"(^|\.)mtp\."),
("embed", r"embed_tokens|lm_head"),
("linear_attn", r"linear_attn"),
("full_attn", r"self_attn"),
("mlp", r"\.mlp\."),
("norm", r"norm"),
)
# Tiny but precision-critical: the SSM recurrence dynamics. Keeping every one of
# these in fp32 costs ~3 MB, and quantizing them is what makes low-bit SSM
# quantization collapse.
SSM_SENSITIVE = r"A_log|dt_bias|conv1d|in_proj_a|in_proj_b"
@dataclass
class GroupStats:
name: str
num_tensors: int = 0
num_params: int = 0 # unique storage only
num_bytes: int = 0 # unique storage only
raw_params: int = 0 # counting tied aliases twice
num_aliases: int = 0
examples: list[str] = field(default_factory=list)
def add(self, name: str, tensor: torch.Tensor, *, counted: bool) -> None:
self.num_tensors += 1
self.raw_params += tensor.numel()
if counted:
self.num_params += tensor.numel()
self.num_bytes += tensor.numel() * tensor.element_size()
else:
self.num_aliases += 1
if len(self.examples) < 3:
self.examples.append(name + (" (tied alias)" if not counted else ""))
def _group_for(name: str) -> str:
for group, pattern in BUDGET_GROUPS:
if re.search(pattern, name):
return group
return "other"
def parameter_budget(state_dict: dict[str, torch.Tensor]) -> dict[str, Any]:
"""Group every tensor by component and report params / bytes per group.
Tied weights (Qwen3.5 ties ``lm_head`` to ``embed_tokens``) appear twice in a
``state_dict`` while sharing one allocation. Counting both would overstate
the checkpoint by 636M params / 1.27 GB here — and checkpoint bytes is the
metric we are graded on — so aliases are detected by storage pointer and
counted once.
"""
groups: dict[str, GroupStats] = {}
seen_storage: dict[int, str] = {}
aliases: list[dict[str, str]] = []
for name, tensor in state_dict.items():
if not isinstance(tensor, torch.Tensor):
continue
pointer = tensor.data_ptr()
is_alias = pointer != 0 and pointer in seen_storage
if is_alias:
aliases.append({"name": name, "aliases": seen_storage[pointer]})
else:
seen_storage[pointer] = name
groups.setdefault(_group_for(name), GroupStats(_group_for(name))).add(
name, tensor, counted=not is_alias
)
total_params = sum(g.num_params for g in groups.values())
total_bytes = sum(g.num_bytes for g in groups.values())
return {
"total_params": total_params,
"total_bytes": total_bytes,
"total_gb": total_bytes / 1e9,
"raw_params_with_aliases": sum(g.raw_params for g in groups.values()),
"tied_aliases": aliases,
"groups": {
name: {
"num_tensors": g.num_tensors,
"num_params": g.num_params,
"num_bytes": g.num_bytes,
"gb": g.num_bytes / 1e9,
"num_aliases": g.num_aliases,
"pct_params": 100 * g.num_params / total_params if total_params else 0.0,
"examples": g.examples,
}
for name, g in sorted(groups.items(), key=lambda kv: -kv[1].num_params)
},
}
def checkpoint_size_bytes(state_dict: dict[str, torch.Tensor]) -> int:
"""Unique bytes in a state_dict — the number the leaderboard scores."""
return parameter_budget(state_dict)["total_bytes"]
def format_budget(budget: dict[str, Any]) -> str:
lines = [
f"{'component':<16}{'params':>16}{'% params':>11}{'GB':>9}{'tensors':>9}",
"-" * 61,
]
for name, stats in budget["groups"].items():
suffix = f" ({stats['num_aliases']} tied)" if stats["num_aliases"] else ""
lines.append(
f"{name:<16}{stats['num_params']:>16,}{stats['pct_params']:>10.1f}%"
f"{stats['gb']:>9.3f}{stats['num_tensors']:>9}{suffix}"
)
lines.append("-" * 61)
lines.append(
f"{'TOTAL':<16}{budget['total_params']:>16,}{100.0:>10.1f}%"
f"{budget['total_gb']:>9.3f}"
)
if budget.get("tied_aliases"):
excess = budget["raw_params_with_aliases"] - budget["total_params"]
lines.append(
f" ({len(budget['tied_aliases'])} tied alias tensor(s) counted once; "
f"naive state_dict sum would overstate by {excess:,} params)"
)
return "\n".join(lines)
def load_model(
model_id: str = DEFAULT_MODEL,
dtype: str = "bfloat16",
device_map: str | None = "auto",
cache_dir: str | None = None,
device: str | None = None,
multimodal: bool = False,
):
"""Load the model, trying each plausible auto-class for this architecture.
On transformers >= 5, ``AutoModelForCausalLM`` resolves Qwen3.5 to
``Qwen3_5ForCausalLM`` and materializes only the language model — the vision
tower and MTP head are never allocated, so the 455M-param text-only saving
happens for free at load time.
Pass ``device`` to pin the whole model to one GPU. At 8.4 GB it fits on any
of ours, and ``device_map="auto"`` across several GPUs would pipeline-shard
it instead, adding cross-device hops on every layer for no benefit.
"""
import transformers
torch_dtype = {
"bfloat16": torch.bfloat16,
"float16": torch.float16,
"float32": torch.float32,
}[dtype]
kwargs: dict[str, Any] = {
"dtype": torch_dtype,
"trust_remote_code": True,
}
if device is not None:
kwargs["device_map"] = {"": device}
elif device_map is not None:
kwargs["device_map"] = device_map
if cache_dir:
kwargs["cache_dir"] = cache_dir
candidates = [
"AutoModelForCausalLM",
"AutoModelForImageTextToText",
"AutoModelForVision2Seq",
"AutoModel",
]
if multimodal:
# vLLM only registers Qwen3_5ForConditionalGeneration and rejects a
# text-only Qwen3_5TextConfig (vllm#39231). Any checkpoint we intend to
# evaluate with vLLM must therefore keep the multimodal wrapper, even
# though the vision tower is never executed for a math prompt.
candidates = [
"Qwen3_5ForConditionalGeneration",
"AutoModelForImageTextToText",
"AutoModelForVision2Seq",
"AutoModelForCausalLM",
]
errors: list[str] = []
for class_name in candidates:
auto_class = getattr(transformers, class_name, None)
if auto_class is None:
continue
try:
model = auto_class.from_pretrained(model_id, **kwargs)
model.eval()
print(f"[model] loaded {model_id} via {class_name}")
return model
except Exception as exc: # noqa: BLE001 - we want the full error list
errors.append(f" {class_name}: {type(exc).__name__}: {exc}")
raise RuntimeError(
f"Could not load {model_id} with any auto-class.\n" + "\n".join(errors)
)
def load_tokenizer(model_id: str = DEFAULT_MODEL, cache_dir: str | None = None):
from transformers import AutoTokenizer
kwargs: dict[str, Any] = {"trust_remote_code": True}
if cache_dir:
kwargs["cache_dir"] = cache_dir
tokenizer = AutoTokenizer.from_pretrained(model_id, **kwargs)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "left" # required for correct batched generation
return tokenizer
def estimate_memory(
model: torch.nn.Module, batch_size: int, seq_len: int
) -> dict[str, float]:
"""Predict peak VRAM for a generation run, so OOM is caught before it costs an hour.
Only the 8 full-attention layers hold a growing KV cache; the 24 Gated
DeltaNet layers keep a fixed-size recurrent state regardless of length,
which is why this model's cache is far cheaper than a dense transformer's.
"""
config = getattr(model, "config", None)
config = getattr(config, "text_config", config)
layer_types = getattr(config, "layer_types", None)
num_layers = getattr(config, "num_hidden_layers", 32)
if layer_types:
num_full = sum(1 for t in layer_types if "full" in str(t))
else:
interval = getattr(config, "full_attention_interval", 4)
num_full = num_layers // interval
kv_heads = getattr(config, "num_key_value_heads", 4)
head_dim = getattr(config, "head_dim", 256)
dtype_size = 2
kv_bytes_per_token = num_full * kv_heads * head_dim * 2 * dtype_size
kv_bytes = kv_bytes_per_token * seq_len * batch_size
# DynamicCache grows by torch.cat on every decode step: allocate (n+1),
# copy, free n. Peak is therefore ~2x the steady-state cache, which is
# exactly the "ladder" you see climbing in nvtop until it OOMs.
# expandable_segments lets the allocator grow a segment in place, cutting
# the transient sharply -- but we still budget for it.
growth_factor = 1.4 if "expandable_segments" in os.environ.get(
"PYTORCH_CUDA_ALLOC_CONF", ""
) else 2.0
kv_peak_bytes = kv_bytes * growth_factor
# Gated DeltaNet recurrent state: [v_heads, k_dim, v_dim] per layer, length-independent.
v_heads = getattr(config, "linear_num_value_heads", 32)
k_dim = getattr(config, "linear_key_head_dim", 128)
v_dim = getattr(config, "linear_value_head_dim", 128)
state_bytes = (num_layers - num_full) * v_heads * k_dim * v_dim * 4 * batch_size
weight_bytes = sum(p.numel() * p.element_size() for p in model.parameters())
return {
"weights_gb": weight_bytes / 1e9,
"kv_cache_gb": kv_bytes / 1e9,
"kv_peak_gb": kv_peak_bytes / 1e9,
"ssm_state_gb": state_bytes / 1e9,
"steady_gb": (weight_bytes + kv_bytes + state_bytes) / 1e9,
# What the preflight must check against: peak, not steady state.
"total_gb": (weight_bytes + kv_peak_bytes + state_bytes) / 1e9,
"kv_bytes_per_token": kv_bytes_per_token,
"growth_factor": growth_factor,
}
def free_vram_gb(device: str | torch.device) -> tuple[float, float]:
"""(free, total) GB on ``device`` — reflects other users' jobs on a shared box."""
free, total = torch.cuda.mem_get_info(torch.device(device))
return free / 1e9, total / 1e9
def describe_model(model: torch.nn.Module, max_depth: int = 3) -> dict[str, Any]:
"""Summarize the module tree — the ground truth we build stripping on."""
tree: list[dict[str, Any]] = []
for name, module in model.named_modules():
depth = name.count(".")
if name and depth <= max_depth:
n_params = sum(p.numel() for p in module.parameters(recurse=True))
tree.append(
{
"path": name,
"class": type(module).__name__,
"depth": depth,
"num_params": n_params,
}
)
state_dict = model.state_dict()
sensitive = [n for n in state_dict if re.search(SSM_SENSITIVE, n)]
return {
"model_class": type(model).__name__,
"num_parameters": sum(p.numel() for p in model.parameters()),
"module_tree": tree,
"budget": parameter_budget(state_dict),
"ssm_sensitive_tensors": {
"count": len(sensitive),
"num_params": sum(state_dict[n].numel() for n in sensitive),
"names": sensitive[:20],
},
"state_dict_prefixes": sorted(
{".".join(n.split(".")[:2]) for n in state_dict}
),
}
def find_submodule(model: torch.nn.Module, pattern: str) -> list[str]:
"""Paths of modules whose name matches ``pattern`` and whose parent does not."""
regex = re.compile(pattern)
hits = [name for name, _ in model.named_modules() if name and regex.search(name)]
# Keep only the shallowest match on each branch.
return [h for h in hits if not any(h.startswith(o + ".") for o in hits)]
def strip_unused_modules(
model: torch.nn.Module,
drop_vision: bool = True,
drop_mtp: bool = True,
) -> dict[str, Any]:
"""Delete text-irrelevant submodules in place; return what was removed.
The vision tower and MTP head together are ~455M params. A text-only math
eval never executes either: the ViT has no image inputs, and the MTP head is
a speculative-decoding draft head that HF ``generate()`` does not call.
"""
removed: list[dict[str, Any]] = []
targets: list[str] = []
if drop_vision:
targets.append(r"(^|\.)(visual|vision_tower)$")
if drop_mtp:
targets.append(r"(^|\.)mtp$")
for pattern in targets:
for path in find_submodule(model, pattern):
parent = model
parts = path.split(".")
for part in parts[:-1]:
parent = getattr(parent, part)
child = getattr(parent, parts[-1], None)
if child is None:
continue
n_params = sum(p.numel() for p in child.parameters(recurse=True))
setattr(parent, parts[-1], None)
removed.append({"path": path, "num_params": n_params})
# transformers >= 5 loads Qwen3.5 through Qwen3_5ForCausalLM, which never
# allocates the vision tower or MTP head. Finding nothing to remove is the
# expected outcome there, not a failure.
already_text_only = not removed and not find_submodule(model, r"visual|vision_tower|mtp")
return {
"removed": removed,
"num_params_removed": sum(r["num_params"] for r in removed),
"bytes_removed_bf16": 2 * sum(r["num_params"] for r in removed),
"already_text_only": already_text_only,
}
# Files the model repo carries that `save_pretrained` does not reproduce.
# vLLM's multimodal path builds an image processor even for a text-only prompt,
# so a checkpoint missing `preprocessor_config.json` fails to load outright.
AUXILIARY_FILES = (
"preprocessor_config.json",
"video_preprocessor_config.json",
"chat_template.jinja",
)
def save_checkpoint(
model: torch.nn.Module,
out_dir: str | Path,
source_model: str = DEFAULT_MODEL,
cache_dir: str | None = None,
) -> list[str]:
"""Write a checkpoint that vLLM and transformers can both load.
``model.save_pretrained`` emits weights + config, and the tokenizer covers
vocab/merges, but the processor and chat-template files come from the source
repo and must be copied across explicitly.
"""
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
model.save_pretrained(out)
load_tokenizer(source_model, cache_dir=cache_dir).save_pretrained(out)
copied: list[str] = []
source_dir = Path(source_model)
for filename in AUXILIARY_FILES:
target = out / filename
if target.exists():
copied.append(filename)
continue
try:
if source_dir.is_dir():
candidate = source_dir / filename
if not candidate.is_file():
continue
shutil.copy(candidate, target)
else:
from huggingface_hub import hf_hub_download
downloaded = hf_hub_download(
source_model, filename, cache_dir=cache_dir
)
shutil.copy(downloaded, target)
copied.append(filename)
except Exception as exc: # noqa: BLE001 - optional files; report and continue
print(f"[save_checkpoint] could not copy {filename}: {exc}")
return copied