Files
unslothai--unsloth/studio/backend/utils/hardware/vram_estimation.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

1198 lines
46 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""
Training VRAM estimation.
Total VRAM = weights + LoRA adapters + optimizer states + gradients
+ activations + CUDA overhead.
Activation formula from unsloth_zoo/vllm_utils.py.
All constants empirically calibrated against Llama-3.2-1B on B200.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Optional
QUANT_4BIT_FACTOR = 16 / 5
DOUBLE_QUANT_4BIT_FACTOR = 3.6 # bnb_4bit_use_double_quant; see VRAM_ESTIMATION.md section 1
CUDA_OVERHEAD_BYTES = int(1.4 * 1024**3) # calibrated on RTX 5070 Ti
NON_FLASH_ATTENTION_FACTOR = (
12.0 # eager attention score+workspace overhead; see VRAM_ESTIMATION.md section 5
)
LINEAR_ATTENTION_IMPLS = frozenset({"flash_attention_2", "sdpa", "flex_attention"})
_SKIP_MODULE_TEXT_PREFIXES = frozenset(
{
"model",
"model.model",
"language_model",
"language_model.model",
"model.language_model",
"model.language_model.model",
}
)
DEFAULT_TARGET_MODULES = [
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
]
ATTENTION_TARGET_MODULES = {"q_proj", "k_proj", "v_proj", "o_proj"}
MLP_TARGET_MODULES = {"gate_proj", "up_proj", "down_proj"}
# Empirically calibrated bytes/param — see VRAM_ESTIMATION.md for rationale.
OPTIMIZER_BYTES_PER_PARAM: Dict[str, int] = {
"adamw_8bit": 4, # BNB upcasts to fp32 during step
"paged_adamw_8bit": 4,
"adamw_bnb_8bit": 4,
"paged_adamw_32bit": 8,
"adamw_torch": 6, # fused, no master copy
"adamw_torch_fused": 6,
"sgd": 4,
}
# (full_ft_multiplier, lora_multiplier) — fraction of num_layers.
# LoRA: frozen layers skip activation storage, but ~1 is in flight during backprop.
GC_LAYER_MULTIPLIERS = {
"none": (None, None),
"true": (2.0, 1.0),
"unsloth": (1.5, 1.0),
}
@dataclass
class ModelArchConfig:
hidden_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
intermediate_size: int
vocab_size: int
tie_word_embeddings: bool = True
num_experts: Optional[int] = None
moe_intermediate_size: Optional[int] = None
n_shared_experts: int = 0
shared_expert_intermediate_size: Optional[int] = None
num_experts_per_tok: int = 1
num_dense_layers: int = 0
q_lora_rank: Optional[int] = None
kv_lora_rank: Optional[int] = None
qk_nope_head_dim: Optional[int] = None
qk_rope_head_dim: Optional[int] = None
v_head_dim: Optional[int] = None
head_dim: Optional[int] = None
global_head_dim: Optional[int] = None
num_global_key_value_heads: Optional[int] = None
attention_k_eq_v: bool = False
layer_types: Optional[list] = None
num_kv_shared_layers: int = 0
use_double_wide_mlp: bool = False
vocab_size_per_layer_input: int = 0
hidden_size_per_layer_input: int = 0
quantization_skip_modules: list = field(default_factory = list)
quant_4bit_factor: float = QUANT_4BIT_FACTOR
moe_has_dense_mlp: bool = False
dense_layer_indices: tuple = ()
dense_intermediate_size: Optional[int] = None
@dataclass
class TrainingVramConfig:
training_method: str = "qlora"
batch_size: int = 4
max_seq_length: int = 2048
lora_rank: int = 16
target_modules: list = field(default_factory = lambda: list(DEFAULT_TARGET_MODULES))
gradient_checkpointing: str = "unsloth"
optimizer: str = "adamw_8bit"
load_in_4bit: bool = True
attention_implementation: str = "flash_attention_2"
@dataclass
class VramBreakdown:
model_weights: int
lora_adapters: int
optimizer_states: int
gradients: int
activations: int
cuda_overhead: int
# Equals `activations`; kept for backward compat with field consumers.
activations_computed: int = 0
@property
def total(self) -> int:
return (
self.model_weights
+ self.lora_adapters
+ self.optimizer_states
+ self.gradients
+ self.activations
+ self.cuda_overhead
)
def min_gpu_vram(self, n_gpus: int) -> int:
"""Min VRAM one GPU needs: its shard + non-shardable costs.
Weights/LoRA/optimizer/gradients shard across GPUs; activations do
NOT (the GPU running a layer holds them).
"""
shardable = self.model_weights + self.lora_adapters + self.optimizer_states + self.gradients
per_gpu_fixed = self.activations + self.cuda_overhead
return shardable // max(n_gpus, 1) + per_gpu_fixed
def to_gb_dict(self) -> Dict[str, float]:
return {
"model_weights_gb": round(self.model_weights / (1024**3), 3),
"lora_adapters_gb": round(self.lora_adapters / (1024**3), 3),
"optimizer_states_gb": round(self.optimizer_states / (1024**3), 3),
"gradients_gb": round(self.gradients / (1024**3), 3),
"activations_gb": round(self.activations / (1024**3), 3),
"cuda_overhead_gb": round(self.cuda_overhead / (1024**3), 3),
"total_gb": round(self.total / (1024**3), 3),
}
def _first_scalar(value):
# ERNIE MoE ships moe_intermediate_size / moe_num_experts as
# [routed, shared] lists; downstream arithmetic needs the routed scalar.
if isinstance(value, (list, tuple)):
return value[0] if value else None
return value
def _max_scalar(value):
# Hunyuan-V1-MoE moe_topk can be a per-layer list; activation accounting
# uses max top-k as a conservative upper bound.
if isinstance(value, (list, tuple)):
items = [v for v in value if v is not None]
return max(items) if items else None
return value
def _compute_dense_layer_indices(text_config, total_layers: int) -> tuple:
"""Layer indices that use dense MLP instead of MoE. Position matters."""
# Exaone-MoE / Laguna / Hy_v3 / GLM-MoE-DSA / GLM4-MoE-Lite / Ernie4_5_VL_MoE
# prefer per-position `mlp_layer_types` over prefix `first_k_dense_replace`.
layer_types = getattr(text_config, "mlp_layer_types", None)
if layer_types:
return tuple(
i for i, t in enumerate(layer_types[:total_layers]) if str(t).lower() == "dense"
)
# Llama4TextConfig.__init__ auto-populates self.moe_layers from
# interleave_moe_layer_step; Llama4TextDecoderLayer dispatches via
# `layer_idx in config.moe_layers` (modeling_llama4.py).
llama4_moe_layers = getattr(text_config, "moe_layers", None)
if llama4_moe_layers is not None:
moe_indices = {int(i) for i in llama4_moe_layers}
return tuple(i for i in range(total_layers) if i not in moe_indices)
# ERNIE 4.5 (VL) MoE: layers via moe_layer_start/end_index + interval;
# per-layer guard `(layer_idx+1) % interval == 0` within [start, end]
# (modeling_ernie4_5_moe.py).
moe_start = getattr(text_config, "moe_layer_start_index", None)
moe_interval = getattr(text_config, "moe_layer_interval", None)
if moe_start is not None and moe_interval is not None and int(moe_interval) > 0:
moe_end_raw = getattr(text_config, "moe_layer_end_index", None)
end = (
total_layers
if moe_end_raw is None or int(moe_end_raw) == -1
else min(int(moe_end_raw) + 1, total_layers)
)
start = max(0, int(moe_start))
interval = int(moe_interval)
moe_indices = {i for i in range(start, end) if (i + 1) % interval == 0}
return tuple(i for i in range(total_layers) if i not in moe_indices)
first_k = getattr(text_config, "first_k_dense_replace", None)
if first_k is not None:
return tuple(range(min(int(first_k), total_layers)))
sparse_step = getattr(text_config, "decoder_sparse_step", None)
mlp_only = getattr(text_config, "mlp_only_layers", None) or []
if sparse_step is not None and sparse_step > 0:
mlp_only_set = {int(i) for i in mlp_only}
return tuple(
i for i in range(total_layers) if i in mlp_only_set or (i + 1) % sparse_step != 0
)
return ()
def extract_arch_config(hf_config) -> Optional[ModelArchConfig]:
text_config = getattr(hf_config, "text_config", None) or hf_config
quantization_config = getattr(hf_config, "quantization_config", None) or {}
if not isinstance(quantization_config, dict):
quantization_config = getattr(quantization_config, "to_dict", lambda: {})()
quant_4bit_factor = (
DOUBLE_QUANT_4BIT_FACTOR
if quantization_config.get("bnb_4bit_use_double_quant", False)
else QUANT_4BIT_FACTOR
)
hidden_size = getattr(text_config, "hidden_size", None)
num_layers = getattr(text_config, "num_hidden_layers", None)
num_heads = getattr(text_config, "num_attention_heads", None)
intermediate_size = getattr(text_config, "intermediate_size", None)
vocab_size = getattr(text_config, "vocab_size", None)
if isinstance(intermediate_size, (list, tuple)):
intermediate_size = intermediate_size[0] if intermediate_size else None
if intermediate_size is None and hidden_size is not None:
intermediate_size = hidden_size * 4
if not all(
v is not None for v in (hidden_size, num_layers, num_heads, intermediate_size, vocab_size)
):
return None
if num_heads <= 0:
return None
num_kv_heads = getattr(text_config, "num_key_value_heads", num_heads)
# DBRX places its MoE attrs on the DbrxFFNConfig sub-config; probe
# ffn_config as a secondary source so DBRX isn't misclassified as dense.
ffn_config = getattr(text_config, "ffn_config", None)
def _moe_attr(name):
value = getattr(text_config, name, None)
if value is None and ffn_config is not None:
value = getattr(ffn_config, name, None)
return value
num_experts = None
for attr in (
"num_local_experts",
"num_experts",
"n_routed_experts",
"moe_num_experts",
):
num_experts = _first_scalar(_moe_attr(attr))
if num_experts is not None:
break
moe_intermediate_raw = _moe_attr("moe_intermediate_size")
if moe_intermediate_raw is None:
moe_intermediate_raw = _moe_attr("ffn_hidden_size")
moe_intermediate = _first_scalar(moe_intermediate_raw)
# Exaone-MoE / ERNIE alias num_shared_experts / moe_num_shared_experts
# to the canonical n_shared_experts.
n_shared_experts = (
_first_scalar(_moe_attr("n_shared_experts"))
or _first_scalar(_moe_attr("num_shared_experts"))
or _first_scalar(_moe_attr("moe_num_shared_experts"))
or 0
)
shared_expert_intermediate_size = _moe_attr("shared_expert_intermediate_size")
if shared_expert_intermediate_size and n_shared_experts == 0:
n_shared_experts = 1
# DBRX moe_top_k; Hunyuan-V1-MoE moe_topk (may be a per-layer list).
# _max_scalar normalizes lists to the worst case so int(...) can't crash.
num_experts_per_tok = (
_max_scalar(_moe_attr("num_experts_per_tok"))
or _max_scalar(_moe_attr("top_k_experts"))
or _max_scalar(_moe_attr("moe_top_k"))
or _max_scalar(_moe_attr("moe_topk"))
or 1
)
dense_layer_indices: tuple = ()
if num_experts is not None and num_experts > 1:
dense_layer_indices = _compute_dense_layer_indices(text_config, num_layers)
num_dense_layers = len(dense_layer_indices)
# Llama4 dense layers use intermediate_size_mlp; experts use
# intermediate_size. One shared_expert per MoE layer (modeling_llama4.py).
intermediate_size_mlp_raw = _first_scalar(_moe_attr("intermediate_size_mlp"))
dense_intermediate_size = (
int(intermediate_size_mlp_raw) if intermediate_size_mlp_raw is not None else None
)
if (
intermediate_size_mlp_raw is not None
and num_experts is not None
and num_experts > 1
and shared_expert_intermediate_size is None
and n_shared_experts == 0
):
n_shared_experts = 1
q_lora_rank = getattr(text_config, "q_lora_rank", None)
kv_lora_rank = getattr(text_config, "kv_lora_rank", None)
qk_nope_head_dim = getattr(text_config, "qk_nope_head_dim", None)
qk_rope_head_dim = getattr(text_config, "qk_rope_head_dim", None)
v_head_dim = getattr(text_config, "v_head_dim", None)
return ModelArchConfig(
hidden_size = hidden_size,
num_hidden_layers = num_layers,
num_attention_heads = num_heads,
num_key_value_heads = num_kv_heads,
intermediate_size = intermediate_size,
vocab_size = vocab_size,
tie_word_embeddings = getattr(text_config, "tie_word_embeddings", True),
num_experts = num_experts,
moe_intermediate_size = moe_intermediate,
n_shared_experts = n_shared_experts,
shared_expert_intermediate_size = shared_expert_intermediate_size,
num_experts_per_tok = int(num_experts_per_tok),
num_dense_layers = num_dense_layers,
q_lora_rank = q_lora_rank,
kv_lora_rank = kv_lora_rank,
qk_nope_head_dim = qk_nope_head_dim,
qk_rope_head_dim = qk_rope_head_dim,
v_head_dim = v_head_dim,
head_dim = getattr(text_config, "head_dim", None),
global_head_dim = getattr(text_config, "global_head_dim", None),
num_global_key_value_heads = getattr(
text_config,
"num_global_key_value_heads",
None,
),
attention_k_eq_v = bool(getattr(text_config, "attention_k_eq_v", False)),
layer_types = getattr(text_config, "layer_types", None),
num_kv_shared_layers = getattr(text_config, "num_kv_shared_layers", None) or 0,
use_double_wide_mlp = bool(getattr(text_config, "use_double_wide_mlp", False)),
vocab_size_per_layer_input = getattr(
text_config,
"vocab_size_per_layer_input",
None,
)
or 0,
hidden_size_per_layer_input = getattr(
text_config,
"hidden_size_per_layer_input",
None,
)
or 0,
quantization_skip_modules = list(quantization_config.get("llm_int8_skip_modules", []) or []),
quant_4bit_factor = quant_4bit_factor,
moe_has_dense_mlp = bool(getattr(text_config, "enable_moe_block", False)),
dense_layer_indices = dense_layer_indices,
dense_intermediate_size = dense_intermediate_size,
)
def _targets_all_linear(target_modules) -> bool:
# peft LoraConfig accepts target_modules="all-linear" as a bare string;
# iterating a string yields chars and never matches the set.
if isinstance(target_modules, str):
target_modules = [target_modules]
normalized = {str(module).lower().replace("_", "-") for module in target_modules}
return normalized == {"all-linear"}
def _head_dim(arch: ModelArchConfig) -> int:
return arch.head_dim or arch.hidden_size // arch.num_attention_heads
def _layer_types(arch: ModelArchConfig) -> list:
if arch.layer_types and len(arch.layer_types) == arch.num_hidden_layers:
return arch.layer_types
return ["full_attention"] * arch.num_hidden_layers
def _uses_structured_layer_shapes(arch: ModelArchConfig) -> bool:
# MLA configs have their own q/kv low-rank projection shape formulas in
# _compute_attn_elements / _lora_attn_elements; do not let head_dim or
# other structured fields override that path.
if arch.q_lora_rank is not None:
return False
return bool(
arch.layer_types
or arch.head_dim is not None
or arch.global_head_dim is not None
or arch.num_global_key_value_heads is not None
or arch.attention_k_eq_v
or arch.num_kv_shared_layers > 0
or arch.use_double_wide_mlp
)
def _is_kv_shared_layer(arch: ModelArchConfig, layer_idx: int) -> bool:
if arch.num_kv_shared_layers <= 0:
return False
first_shared = arch.num_hidden_layers - arch.num_kv_shared_layers
# Gemma4 (modeling_gemma4.py:1031, modular_gemma4.py:863) uses the same
# `> 0` guard so a fully-shared config raises at model construction;
# matching upstream avoids estimating a shape the model code rejects.
return layer_idx >= first_shared > 0
def _is_dense_mlp_layer(arch: ModelArchConfig, layer_idx: int) -> bool:
if arch.dense_layer_indices:
return layer_idx in arch.dense_layer_indices
return layer_idx < arch.num_dense_layers
def _per_layer_input_quantizable(arch: ModelArchConfig) -> int:
# Gemma4 PLE block adds per_layer_model_projection (single Linear),
# per_layer_input_gate (per layer), and per_layer_projection (per layer);
# see gemma4/modular_gemma4.py:1077-1083 and :1247-1253.
pli = arch.hidden_size_per_layer_input
if pli <= 0:
return 0
n_layers = arch.num_hidden_layers
hd = arch.hidden_size
return hd * (n_layers * pli) + (hd * pli) * n_layers + (pli * hd) * n_layers
def _per_layer_input_norm_elements(arch: ModelArchConfig) -> int:
pli = arch.hidden_size_per_layer_input
if pli <= 0:
return 0
n_layers = arch.num_hidden_layers
hd = arch.hidden_size
return hd * n_layers + pli
def _per_layer_input_lora_params(arch: ModelArchConfig, r: int, target_modules) -> int:
# get_peft_regex requires a component tag (mlp/attn/...); PLE names lack
# one, so all-linear skips them. Count PLE LoRA only when named explicitly.
pli = arch.hidden_size_per_layer_input
if pli <= 0:
return 0
targets = {target_modules} if isinstance(target_modules, str) else set(target_modules or [])
n_layers = arch.num_hidden_layers
hd = arch.hidden_size
total = 0
if "per_layer_model_projection" in targets:
total += hd * r + r * (n_layers * pli)
if "per_layer_input_gate" in targets:
total += (hd * r + r * pli) * n_layers
if "per_layer_projection" in targets:
total += (pli * r + r * hd) * n_layers
return total
def _layer_attention_dims(arch: ModelArchConfig, layer_idx: int) -> tuple:
layer_types = _layer_types(arch)
layer_type = layer_types[layer_idx]
is_sliding = layer_type == "sliding_attention"
head_dim = arch.global_head_dim if not is_sliding and arch.global_head_dim else _head_dim(arch)
use_alt_attention = arch.attention_k_eq_v and not is_sliding
num_kv_heads = (
arch.num_global_key_value_heads
if use_alt_attention and arch.num_global_key_value_heads
else arch.num_key_value_heads
)
q_size = arch.num_attention_heads * head_dim
kv_size = num_kv_heads * head_dim
has_k = not _is_kv_shared_layer(arch, layer_idx)
has_v = has_k and not use_alt_attention
return q_size, kv_size, has_k, has_v
def _layer_mlp_size(arch: ModelArchConfig, layer_idx: int) -> int:
if arch.use_double_wide_mlp and _is_kv_shared_layer(arch, layer_idx):
return _dense_mlp_size(arch) * 2
return _dense_mlp_size(arch)
def _text_linear_dims(arch: ModelArchConfig, layer_idx: int) -> Dict[str, tuple[int, int]]:
hd = arch.hidden_size
if _uses_structured_layer_shapes(arch):
q_size, kv_size, has_k, has_v = _layer_attention_dims(arch, layer_idx)
mlp_size = _layer_mlp_size(arch, layer_idx)
else:
q_size = hd
kv_size = _get_kv_size(arch)
has_k = True
has_v = True
mlp_size = _get_mlp_size(arch)
dims = {
"q_proj": (hd, q_size),
"o_proj": (q_size, hd),
}
if has_k:
dims["k_proj"] = (hd, kv_size)
if has_v:
dims["v_proj"] = (hd, kv_size)
dims.update(
{
"gate_proj": (hd, mlp_size),
"up_proj": (hd, mlp_size),
"down_proj": (mlp_size, hd),
}
)
return dims
def _module_path_matches(skip_module: str, alias: str) -> bool:
skip_parts = [part for part in skip_module.split(".") if part]
alias_parts = [part for part in alias.split(".") if part]
if not skip_parts or not alias_parts:
return False
if alias_parts[0] == "layers":
return skip_parts == alias_parts
if len(skip_parts) <= len(alias_parts):
# BNB suffix-matches short skip entries (["q_proj"], ["lm_head"]) so a
# skip shorter than the alias is a tail match.
return alias_parts[-len(skip_parts) :] == skip_parts
if skip_parts[-len(alias_parts) :] != alias_parts:
return False
prefix_parts = skip_parts[: len(skip_parts) - len(alias_parts)]
if not prefix_parts:
return True
# Bound the prefix to text-tower roots so VLM skips like
# vision_tower.model.layers... don't shadow the text alias.
return ".".join(prefix_parts) in _SKIP_MODULE_TEXT_PREFIXES
def _add_module_aliases(aliases: Dict[str, str], canonical: str, suffix: str) -> None:
for prefix in (
"",
"model",
"model.model",
"language_model",
"language_model.model",
"model.language_model",
"model.language_model.model",
):
alias = f"{prefix}.{suffix}" if prefix else suffix
aliases[alias] = canonical
def _build_text_module_elements(arch: ModelArchConfig) -> tuple[Dict[str, int], Dict[str, str]]:
elements: Dict[str, int] = {}
aliases: Dict[str, str] = {}
is_mla = arch.q_lora_rank is not None and not _uses_structured_layer_shapes(arch)
pli = arch.hidden_size_per_layer_input
hd_global = arch.hidden_size
for layer_idx in range(arch.num_hidden_layers):
layer_modules: Dict[str, int] = {}
dims = _text_linear_dims(arch, layer_idx)
attn_dims = {name: dim for name, dim in dims.items() if name in ATTENTION_TARGET_MODULES}
mlp_dims = {name: dim for name, dim in dims.items() if name in MLP_TARGET_MODULES}
if is_mla:
# MLA splits q/o into q_a/q_b/kv_a/kv_b; emit a single self_attn
# aggregate at the authoritative MLA per-layer total.
layer_modules["self_attn"] = _compute_attn_elements(arch)
else:
for name, (in_dim, out_dim) in attn_dims.items():
layer_modules[f"self_attn.{name}"] = in_dim * out_dim
if arch.num_experts and arch.num_experts > 1:
if _is_dense_mlp_layer(arch, layer_idx):
layer_modules.update(
{
f"mlp.{name}": in_dim * out_dim
for name, (in_dim, out_dim) in mlp_dims.items()
}
)
else:
layer_modules["mlp.experts"] = _compute_routed_moe_elements(arch)
shared_moe = _compute_shared_moe_elements(arch)
if shared_moe:
# Qwen3.5-MoE: mlp.shared_expert; Exaone-MoE/Laguna/GLM:
# mlp.shared_experts. Register both so skip_modules match.
layer_modules["mlp.shared_expert"] = shared_moe
if arch.moe_has_dense_mlp:
# enable_moe_block runs dense MLP and experts in parallel;
# register both. Non-structured _get_mlp_size prefers
# moe_intermediate_size, so rebuild dense dims directly.
if _uses_structured_layer_shapes(arch):
dense_dims = mlp_dims
else:
hd = arch.hidden_size
inter = arch.intermediate_size
dense_dims = {
"gate_proj": (hd, inter),
"up_proj": (hd, inter),
"down_proj": (inter, hd),
}
layer_modules.update(
{
f"mlp.{name}": in_dim * out_dim
for name, (in_dim, out_dim) in dense_dims.items()
}
)
else:
layer_modules.update(
{f"mlp.{name}": in_dim * out_dim for name, (in_dim, out_dim) in mlp_dims.items()}
)
if pli > 0:
# Register PLE per-layer linears so llm_int8_skip_modules entries
# like model.layers.0.per_layer_input_gate match.
layer_modules["per_layer_input_gate"] = hd_global * pli
layer_modules["per_layer_projection"] = pli * hd_global
attn_total = sum(
value
for name, value in layer_modules.items()
if name == "self_attn" or name.startswith("self_attn.")
)
# gemma4 enable_moe_block puts routed experts at sibling
# layers.<i>.experts, not under self.mlp; keep the "mlp" aggregate to
# the dense path so a `model.layers.0.mlp` skip doesn't over-skip.
is_sibling_experts = bool(arch.moe_has_dense_mlp)
mlp_total = sum(
value
for name, value in layer_modules.items()
if (
name == "mlp"
or (name.startswith("mlp.") and not (is_sibling_experts and name == "mlp.experts"))
)
)
experts_total = layer_modules.get("mlp.experts", 0) if is_sibling_experts else 0
layer_total = sum(layer_modules.values())
aggregate_modules = {
f"text.layers.{layer_idx}": layer_total,
f"text.layers.{layer_idx}.self_attn": attn_total,
f"text.layers.{layer_idx}.mlp": mlp_total,
}
if experts_total:
aggregate_modules[f"text.layers.{layer_idx}.experts"] = experts_total
elements.update(aggregate_modules)
for canonical in aggregate_modules:
suffix = canonical.removeprefix("text.")
_add_module_aliases(aliases, canonical, suffix)
for name, value in layer_modules.items():
canonical = f"text.layers.{layer_idx}.{name}"
elements[canonical] = value
_add_module_aliases(aliases, canonical, canonical.removeprefix("text."))
if name == "mlp.experts" and arch.moe_has_dense_mlp:
# gemma4: routed experts at sibling layers.<i>.experts, not mlp.
_add_module_aliases(aliases, canonical, f"layers.{layer_idx}.experts")
elif name == "mlp.shared_expert":
# Exaone-MoE/Laguna/GLM use plural `shared_experts`; add both.
_add_module_aliases(
aliases,
canonical,
f"layers.{layer_idx}.mlp.shared_experts",
)
if pli > 0:
canonical = "text.per_layer_model_projection"
elements[canonical] = hd_global * (arch.num_hidden_layers * pli)
_add_module_aliases(aliases, canonical, canonical.removeprefix("text."))
return elements, aliases
def _compute_skipped_quantizable_elements(arch: ModelArchConfig) -> int:
if not arch.quantization_skip_modules:
return 0
module_elements, aliases = _build_text_module_elements(arch)
matched = set()
for skip_module in arch.quantization_skip_modules:
for alias, canonical in aliases.items():
if _module_path_matches(skip_module, alias):
matched.add(canonical)
pruned = {
canonical
for canonical in matched
if not any(canonical != parent and canonical.startswith(f"{parent}.") for parent in matched)
}
return sum(module_elements[canonical] for canonical in pruned)
def _get_kv_size(arch: ModelArchConfig) -> int:
return (arch.hidden_size // arch.num_attention_heads) * arch.num_key_value_heads
def _get_mlp_size(arch: ModelArchConfig) -> int:
if arch.moe_intermediate_size is not None:
return arch.moe_intermediate_size
return arch.intermediate_size
def _dense_mlp_size(arch: ModelArchConfig) -> int:
# Llama4 dense layers use intermediate_size_mlp; routed/shared experts use
# intermediate_size. Other configs leave the field None.
return arch.dense_intermediate_size or arch.intermediate_size
def _get_num_experts(arch: ModelArchConfig) -> int:
return arch.num_experts if arch.num_experts and arch.num_experts > 1 else 1
def _compute_attn_elements(arch: ModelArchConfig) -> int:
"""Attention weight elements per layer."""
hd = arch.hidden_size
if arch.q_lora_rank is not None:
nh = arch.num_attention_heads
qk_head = arch.qk_nope_head_dim + arch.qk_rope_head_dim
q_a = hd * arch.q_lora_rank
q_b = arch.q_lora_rank * (nh * qk_head)
kv_a = hd * (arch.kv_lora_rank + arch.qk_rope_head_dim)
kv_b = arch.kv_lora_rank * (nh * (arch.qk_nope_head_dim + arch.v_head_dim))
o = (nh * arch.v_head_dim) * hd
norms = arch.q_lora_rank + arch.kv_lora_rank
return q_a + q_b + kv_a + kv_b + o + norms
kv_size = _get_kv_size(arch)
return (hd + kv_size + kv_size + hd) * hd
def _compute_dense_mlp_elements(arch: ModelArchConfig) -> int:
return arch.hidden_size * _dense_mlp_size(arch) * 3
def _shared_expert_size(arch: ModelArchConfig) -> int:
# Qwen3.5-MoE shared expert has its own intermediate_size (default 512)
# distinct from moe_intermediate_size; fall back to routed mlp_size for
# families that share it (deepseek-style configs).
return arch.shared_expert_intermediate_size or _get_mlp_size(arch)
def _compute_routed_moe_elements(arch: ModelArchConfig) -> int:
hd = arch.hidden_size
n_experts = _get_num_experts(arch)
return hd * _get_mlp_size(arch) * 3 * n_experts + n_experts * hd
def _compute_shared_moe_elements(arch: ModelArchConfig) -> int:
if not arch.n_shared_experts:
return 0
hd = arch.hidden_size
shared_size = _shared_expert_size(arch)
total = hd * shared_size * 3 * arch.n_shared_experts
# Only Qwen2/Qwen3.5-MoE add a shared_expert_gate Linear (hidden_size->1);
# shared_expert_intermediate_size is the Qwen-style discriminator.
if arch.shared_expert_intermediate_size:
total += arch.n_shared_experts * hd
return total
def _compute_moe_mlp_elements(arch: ModelArchConfig) -> int:
return _compute_routed_moe_elements(arch) + _compute_shared_moe_elements(arch)
def _compute_layer_elements(arch: ModelArchConfig):
"""Return (total_quantizable, layernorms_per_layer, embed, lm_head) element counts.
total_quantizable is summed across ALL layers (not per-layer).
"""
hd = arch.hidden_size
n_layers = arch.num_hidden_layers
n_experts = _get_num_experts(arch)
if _uses_structured_layer_shapes(arch):
attn_total = 0
per_layer_dense_mlp = []
for layer_idx in range(n_layers):
layer_dense_mlp = 0
for name, (in_dim, out_dim) in _text_linear_dims(
arch,
layer_idx,
).items():
elements = in_dim * out_dim
if name in ATTENTION_TARGET_MODULES:
attn_total += elements
elif name in MLP_TARGET_MODULES:
layer_dense_mlp += elements
per_layer_dense_mlp.append(layer_dense_mlp)
if n_experts > 1:
n_dense = arch.num_dense_layers
n_moe = n_layers - n_dense
moe_mlp_total = _compute_moe_mlp_elements(arch) * n_moe
if arch.moe_has_dense_mlp:
# enable_moe_block runs dense MLP and MoE experts in parallel;
# count dense for every layer alongside MoE.
mlp_total = sum(per_layer_dense_mlp) + moe_mlp_total
else:
dense_only_total = sum(
value
for i, value in enumerate(per_layer_dense_mlp)
if _is_dense_mlp_layer(arch, i)
)
mlp_total = moe_mlp_total + dense_only_total
else:
mlp_total = sum(per_layer_dense_mlp)
elif n_experts > 1:
attn_total = _compute_attn_elements(arch) * n_layers
n_dense = arch.num_dense_layers
n_moe = n_layers - n_dense
moe_mlp_total = _compute_moe_mlp_elements(arch) * n_moe
if arch.moe_has_dense_mlp:
mlp_total = _compute_dense_mlp_elements(arch) * n_layers + moe_mlp_total
else:
mlp_total = moe_mlp_total + _compute_dense_mlp_elements(arch) * n_dense
else:
attn_total = _compute_attn_elements(arch) * n_layers
mlp_total = _compute_dense_mlp_elements(arch) * n_layers
layernorms = 2 * hd
per_layer_embed = arch.vocab_size_per_layer_input * arch.hidden_size_per_layer_input * n_layers
ple_text_linear = _per_layer_input_quantizable(arch)
ple_norms = _per_layer_input_norm_elements(arch)
embed_tokens = arch.vocab_size * hd + per_layer_embed + ple_norms
lm_head = 0 if arch.tie_word_embeddings else arch.vocab_size * hd
return attn_total + mlp_total + ple_text_linear, layernorms, embed_tokens, lm_head
def compute_model_weights_bytes(
arch: ModelArchConfig, training_method: str, load_in_4bit: bool
) -> int:
total_quantizable, layernorms, embed_tokens, lm_head = _compute_layer_elements(arch)
n_layers = arch.num_hidden_layers
non_quantizable = layernorms * n_layers + embed_tokens + lm_head
if training_method == "qlora" and load_in_4bit:
skipped_quantizable = min(
_compute_skipped_quantizable_elements(arch),
total_quantizable,
)
quantized = total_quantizable - skipped_quantizable
return int(
quantized * 2 / arch.quant_4bit_factor + skipped_quantizable * 2 + non_quantizable * 2
)
return int((total_quantizable + non_quantizable) * 2)
def compute_total_params(arch: ModelArchConfig) -> int:
total_quantizable, layernorms, embed_tokens, lm_head = _compute_layer_elements(arch)
n_layers = arch.num_hidden_layers
return total_quantizable + layernorms * n_layers + embed_tokens + lm_head
def _lora_attn_elements(arch: ModelArchConfig, r: int, target_modules: list) -> int:
hd = arch.hidden_size
if arch.q_lora_rank is not None:
# MLA: q_proj->q_b, k_proj->kv_a, v_proj->kv_b, o_proj->o
nh = arch.num_attention_heads
qk_head = arch.qk_nope_head_dim + arch.qk_rope_head_dim
kv_out = nh * (arch.qk_nope_head_dim + arch.v_head_dim)
o_in = nh * arch.v_head_dim
dims = {
"q_proj": (arch.q_lora_rank, nh * qk_head),
"k_proj": (hd, arch.kv_lora_rank + arch.qk_rope_head_dim),
"v_proj": (arch.kv_lora_rank, kv_out),
"o_proj": (o_in, hd),
}
else:
kv_size = _get_kv_size(arch)
dims = {
"q_proj": (hd, hd),
"k_proj": (hd, kv_size),
"v_proj": (hd, kv_size),
"o_proj": (hd, hd),
}
total = 0
for name, (in_dim, out_dim) in dims.items():
if name in target_modules:
total += in_dim * r + r * out_dim
return total
def _lora_mlp_elements(
hd: int, mlp_size: int, r: int, target_modules: list, expert_mult: int
) -> int:
module_ab = {
"gate_proj": (hd * r, r * mlp_size),
"up_proj": (hd * r, r * mlp_size),
"down_proj": (mlp_size * r, r * hd),
}
total = 0
for name, (a, b) in module_ab.items():
if name in target_modules:
total += (a + b) * expert_mult
return total
def compute_lora_params(arch: ModelArchConfig, lora_rank: int, target_modules: list) -> int:
all_linear = _targets_all_linear(target_modules)
selected_modules = list(DEFAULT_TARGET_MODULES) if all_linear else target_modules
hd = arch.hidden_size
r = lora_rank
n_layers = arch.num_hidden_layers
n_experts = _get_num_experts(arch)
use_structured_shapes = _uses_structured_layer_shapes(arch)
if use_structured_shapes:
attn_total = 0
structured_dense_mlp = 0
per_layer_dense_mlp = []
for layer_idx in range(n_layers):
layer_dense = 0
for name, (in_dim, out_dim) in _text_linear_dims(
arch,
layer_idx,
).items():
if name not in selected_modules:
continue
if name in ATTENTION_TARGET_MODULES:
attn_total += in_dim * r + r * out_dim
elif name in MLP_TARGET_MODULES:
layer_dense += in_dim * r + r * out_dim
per_layer_dense_mlp.append(layer_dense)
structured_dense_mlp += layer_dense
if n_experts > 1:
n_dense = arch.num_dense_layers
n_moe = n_layers - n_dense
# peft "all-linear" attaches LoRA to nn.Linear only; routed experts
# are nn.Parameter and need explicit gate_proj/up_proj/down_proj
# naming via Unsloth's get_moe_target_parameters. Shared experts are
# nn.Linear, picked up by get_peft_regex.
routed_moe = (
0
if all_linear
else _lora_mlp_elements(
hd,
_get_mlp_size(arch),
r,
selected_modules,
n_experts,
)
)
shared_moe = _lora_mlp_elements(
hd,
_shared_expert_size(arch),
r,
selected_modules,
arch.n_shared_experts,
)
moe_mlp = routed_moe + shared_moe
if arch.moe_has_dense_mlp:
# Parallel dense MLP coexists with MoE on every layer.
mlp_total = structured_dense_mlp + moe_mlp * n_moe
else:
dense_only = sum(
value
for i, value in enumerate(per_layer_dense_mlp)
if _is_dense_mlp_layer(arch, i)
)
mlp_total = moe_mlp * n_moe + dense_only
else:
mlp_total = structured_dense_mlp
return attn_total + mlp_total + _per_layer_input_lora_params(arch, r, target_modules)
elif n_experts > 1:
attn_total = _lora_attn_elements(arch, r, selected_modules) * n_layers
n_dense = arch.num_dense_layers
n_moe = n_layers - n_dense
# Routed and shared experts may use different intermediate sizes
# (Qwen3.5-MoE: routed mlp_size != shared_expert_intermediate_size).
# See structured branch for the all-linear exclusion rationale; only
# routed (nn.Parameter) experts are excluded under all-linear.
routed_moe = (
0
if all_linear
else _lora_mlp_elements(
hd,
_get_mlp_size(arch),
r,
selected_modules,
n_experts,
)
)
shared_moe = _lora_mlp_elements(
hd,
_shared_expert_size(arch),
r,
selected_modules,
arch.n_shared_experts,
)
moe_mlp = routed_moe + shared_moe
dense_mlp = _lora_mlp_elements(
hd,
_dense_mlp_size(arch),
r,
selected_modules,
1,
)
if arch.moe_has_dense_mlp:
mlp_total = moe_mlp * n_moe + dense_mlp * n_layers
else:
mlp_total = moe_mlp * n_moe + dense_mlp * n_dense
else:
attn_total = _lora_attn_elements(arch, r, selected_modules) * n_layers
mlp_total = (
_lora_mlp_elements(
hd,
_dense_mlp_size(arch),
r,
selected_modules,
1,
)
* n_layers
)
return attn_total + mlp_total + _per_layer_input_lora_params(arch, r, target_modules)
def compute_lora_adapter_bytes(lora_params: int) -> int:
return lora_params * 2
def compute_optimizer_bytes(trainable_params: int, optimizer: str) -> int:
optimizer_key = optimizer.lower().replace("-", "_")
bytes_per_param = OPTIMIZER_BYTES_PER_PARAM.get(optimizer_key, 4)
return trainable_params * bytes_per_param
def compute_gradient_bytes(trainable_params: int) -> int:
return trainable_params * 2
def _is_linear_attention(attention_implementation: Optional[str]) -> bool:
# PyTorch SDPA dispatches to flash/memory-efficient O(n) backends; only
# eager (and other non-flash impls) need the quadratic correction.
return attention_implementation in LINEAR_ATTENTION_IMPLS
def _compute_non_flash_attention_bytes(
arch: ModelArchConfig, batch_size: int, seq_len: int, effective_layers: float
) -> int:
score_elements = batch_size * arch.num_attention_heads * seq_len * seq_len
return int(score_elements * 2 * NON_FLASH_ATTENTION_FACTOR * effective_layers)
def _layer_qkv_mlp_sizes(arch: ModelArchConfig, layer_idx: int) -> tuple:
n_experts = _get_num_experts(arch)
is_moe_layer = n_experts > 1 and not _is_dense_mlp_layer(arch, layer_idx)
if _uses_structured_layer_shapes(arch):
q_size, kv_size, _has_k, _has_v = _layer_attention_dims(arch, layer_idx)
# KV-shared layers (Gemma4/Gemma3n) drop k/v WEIGHTS but the donor's
# K/V tensors stay alive, so activations still pay kv_size; only the
# weight path uses has_k/has_v.
layer_type = _layer_types(arch)[layer_idx]
use_alt_attention = arch.attention_k_eq_v and layer_type != "sliding_attention"
kv_count = 1 if use_alt_attention else 2
qkv_size = q_size + kv_size * kv_count
if is_moe_layer:
# Each token routes through num_experts_per_tok experts; all their
# gate/up/down intermediates are live during MLP forward.
mlp_size = _get_mlp_size(arch) * arch.num_experts_per_tok
if arch.n_shared_experts:
mlp_size += _shared_expert_size(arch) * arch.n_shared_experts
if arch.moe_has_dense_mlp:
mlp_size += _layer_mlp_size(arch, layer_idx)
else:
mlp_size = _layer_mlp_size(arch, layer_idx)
return qkv_size, mlp_size
kv_size = _get_kv_size(arch)
if is_moe_layer:
mlp_size = _get_mlp_size(arch) * arch.num_experts_per_tok
if arch.n_shared_experts:
mlp_size += _shared_expert_size(arch) * arch.n_shared_experts
if arch.moe_has_dense_mlp:
mlp_size += arch.intermediate_size
else:
mlp_size = _get_mlp_size(arch)
return arch.hidden_size + kv_size + kv_size, mlp_size
def _per_layer_activation_bytes(
arch: ModelArchConfig, layer_idx: int, batch_size: int, seq_len: int
) -> int:
qkv_size, mlp_size = _layer_qkv_mlp_sizes(arch, layer_idx)
activation_qkv = seq_len * batch_size * qkv_size
residual_memory = (seq_len * batch_size) * 2
activation_mlp = seq_len * batch_size * (mlp_size + mlp_size)
# PLE gate (hd) + projection (pli) outputs materialize once per decoder
# layer when hidden_size_per_layer_input is set (gemma4 modular:1141-1145).
pli = arch.hidden_size_per_layer_input
activation_ple = seq_len * batch_size * (arch.hidden_size + pli) if pli > 0 else 0
return int((activation_qkv + residual_memory + activation_mlp + activation_ple) * 2 * 1.25)
def compute_activation_bytes(
arch: ModelArchConfig,
batch_size: int,
seq_len: int,
gradient_checkpointing: str,
is_lora: bool = False,
attention_implementation: Optional[str] = "flash_attention_2",
) -> int:
n_layers = arch.num_hidden_layers
gc_key = gradient_checkpointing.lower()
gc_entry = GC_LAYER_MULTIPLIERS.get(gc_key, (None, None))
full_ft_mult, lora_mult = gc_entry
gc_multiplier = lora_mult if is_lora else full_ft_mult
if gc_multiplier is None:
effective_layers = n_layers
linear_bytes = sum(
_per_layer_activation_bytes(arch, i, batch_size, seq_len) for i in range(n_layers)
)
else:
effective_layers = gc_multiplier
max_layer_bytes = max(
_per_layer_activation_bytes(arch, i, batch_size, seq_len) for i in range(n_layers)
)
linear_bytes = int(max_layer_bytes * effective_layers)
# gemma4 per_layer_model_projection runs once outside the per-decoder loop
# and materializes a [B, S, L, PLI] tensor; see modular_gemma4.py:1247.
pli = arch.hidden_size_per_layer_input
if pli > 0:
linear_bytes += int(seq_len * batch_size * n_layers * pli * 2 * 1.25)
if _is_linear_attention(attention_implementation):
return linear_bytes
return max(
linear_bytes,
_compute_non_flash_attention_bytes(
arch,
batch_size,
seq_len,
effective_layers,
),
)
def estimate_training_vram(arch: ModelArchConfig, config: TrainingVramConfig) -> VramBreakdown:
method = config.training_method.lower()
is_lora = method in ("qlora", "lora")
load_in_4bit = config.load_in_4bit or method == "qlora"
model_weights = compute_model_weights_bytes(arch, method, load_in_4bit)
lora_params = 0
lora_adapter_bytes = 0
if is_lora:
lora_params = compute_lora_params(
arch,
config.lora_rank,
config.target_modules,
)
lora_adapter_bytes = compute_lora_adapter_bytes(lora_params)
trainable_params = lora_params if is_lora else compute_total_params(arch)
optimizer_bytes = compute_optimizer_bytes(trainable_params, config.optimizer)
activations_computed = compute_activation_bytes(
arch,
config.batch_size,
config.max_seq_length,
config.gradient_checkpointing,
is_lora = is_lora,
attention_implementation = config.attention_implementation,
)
raw_gradient_bytes = compute_gradient_bytes(trainable_params)
gradient_floor = int(model_weights * 0.15)
if is_lora:
gradient_floor = min(
gradient_floor,
max(activations_computed, optimizer_bytes),
)
gradient_bytes = max(raw_gradient_bytes, gradient_floor)
activation_bytes = activations_computed
return VramBreakdown(
model_weights = model_weights,
lora_adapters = lora_adapter_bytes,
optimizer_states = optimizer_bytes,
gradients = gradient_bytes,
activations = activation_bytes,
cuda_overhead = CUDA_OVERHEAD_BYTES,
activations_computed = activations_computed,
)