unsloth/studio/backend/utils/hardware/vram_estimation.py
Datta Nimmaturi 09505fcc6e
Update VRAM estimator to cater to broader model configs (#5175)
* Update VRAM estimator to cater to broader model configs

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix attn backend check, better support for MoE etc

* Studio: tighten VRAM estimator structured-shape and attention paths

- Conservative attention fallback: when resolve_attention_implementation
  fails, charge the quadratic non-flash activation path instead of
  silently keeping the optimistic flash_attention_2 default.
- Resolve attention on a shallow config copy so _set_attn_impl does not
  mutate the cached config returned by _load_config_for_gpu_estimate.
- Use getattr for AutoModelForCausalLM._model_mapping to avoid raising
  on private-attribute renames in transformers.
- Treat sdpa as O(n) linear attention; PyTorch SDPA dispatches to flash
  or memory-efficient backends, only eager needs the quadratic term.
- Per-layer activation accounting: structured archs (head_dim,
  layer_types, attention_k_eq_v, num_kv_shared_layers, double-wide MLP)
  now flow into compute_activation_bytes via _text_linear_dims, instead
  of using the legacy hidden_size//num_attention_heads KV/MLP shape.
- Exclude MLA configs (q_lora_rank set) from the structured-shape path
  so q_lora low-rank projection formulas keep applying when head_dim is
  also present.
- _build_text_module_elements emits a single MLA self_attn aggregate
  using _compute_attn_elements when q_lora_rank is set, avoiding the
  ~10% overcount that fed into _compute_skipped_quantizable_elements.
- Restrict _module_path_matches to known text-tower prefixes so VLM
  skip names like vision_tower.model.layers.<i>.self_attn.q_proj no
  longer falsely shadow the text alias model.layers.<i>.self_attn.q_proj.
- Pick up enable_moe_block from the config and add the per-layer dense
  MLP alongside the MoE experts in compute_total_params and
  compute_lora_params (Gemma4-style parallel dense + MoE block).
- Single-pass structured layer accounting in _compute_layer_elements,
  removing the duplicate _text_linear_dims walks.
- Drop the now-zero (activations - activations_computed) shard term in
  VramBreakdown.min_gpu_vram and the stale comment that referred to it.
- attention_implementation typed as Optional[str] to match call sites
  that pass None.
- Inline rationale comments on DOUBLE_QUANT_4BIT_FACTOR and
  NON_FLASH_ATTENTION_FACTOR pointing at VRAM_ESTIMATION.md.

* Studio: extend parallel-MoE accounting + non-prefix dense layer support

- Apply enable_moe_block / moe_has_dense_mlp symmetrically: activation
  per-layer MLP size in _layer_qkv_mlp_sizes now adds the parallel dense
  MLP for MoE layers, matching the weight and LoRA accounting added in
  the prior commit. Skip-quantizable mapping in _build_text_module_elements
  now registers both mlp.experts and per-projection mlp.{name} entries
  for MoE layers when the parallel dense block is present, so an
  llm_int8_skip_modules entry like "model.layers.N.mlp" covers both.
- Track dense layer indices as a tuple (dense_layer_indices) extracted
  from first_k_dense_replace or decoder_sparse_step + mlp_only_layers,
  and dispatch dense-vs-MoE accounting through _is_dense_mlp_layer. The
  prior count-based path silently mis-bucketed layers when mlp_only_layers
  was non-prefix (e.g. [3, 5] on an 8-layer model). num_dense_layers is
  derived from len(dense_layer_indices) for backward compatibility.
- Drop the redundant ">0" check in _is_kv_shared_layer so configs with
  num_kv_shared_layers == num_hidden_layers (every layer shared) are
  correctly recognized as shared.
- Refresh VRAM_ESTIMATION.md section 5 to note that sdpa joins
  flash_attention_2 in the linear activation path; refresh the
  VramBreakdown.activations_computed comment now that the activation
  floor is gone.

* Studio: Gemma4 PLE accounting, flex_attention, KV-share guard restore

- Add flex_attention to LINEAR_ATTENTION_IMPLS. Unsloth's
  resolve_attention_implementation returns "flex_attention" when
  HAS_FLASH_ATTENTION is False and the model class supports flex; PyTorch
  FlexAttention is a memory-efficient kernel, not a quadratic eager
  attention path. Without this, activation estimates over-charge ~36x.
- Restore the `> 0` guard in _is_kv_shared_layer. Transformers Gemma4
  (modeling_gemma4.py:1031, modular_gemma4.py:863, :926) uses
  `layer_idx >= first_kv_shared_layer_idx > 0`, so configs that mark
  every layer as KV-shared raise on construction. Reverting the
  unconditional acceptance avoids producing a detailed estimate for a
  shape the actual model code rejects.
- Extend the parallel dense MLP path (`enable_moe_block`) in
  _build_text_module_elements: when the arch is non-structured, use
  arch.intermediate_size for the dense gate/up/down dims instead of
  _text_linear_dims (which returns moe_intermediate_size via
  _get_mlp_size). Prior code under-counted skipped quantizable elements
  for the parallel dense block by up to 8x on GLM-style configs.
- Add Gemma4 per-layer-input (PLE) module accounting:
  per_layer_model_projection (one global Linear) plus per-layer
  per_layer_input_gate and per_layer_projection are added to the
  quantizable text-linear total in _compute_layer_elements;
  post_per_layer_input_norm and per_layer_projection_norm flow into
  the non-quantizable bucket. compute_lora_params adds the same three
  Linear modules to the all-linear total. References:
  transformers_versions/5.7.0/.../gemma4/modular_gemma4.py:1077-1083,
  :1247-1253.
- VRAM_ESTIMATION.md section 5 now lists flex_attention alongside sdpa
  and flash_attention_2 as linear-memory backends.

* Studio: shared-expert variants, mlp_layer_types dispatch, PLE skip, all-linear str, deepcopy resolver

Five targeted estimator corrections:

- _compute_dense_layer_indices now reads `mlp_layer_types` ahead of
  `first_k_dense_replace` / `decoder_sparse_step`. Transformers Exaone-MoE,
  Laguna, Hy_v3, GLM-MoE-DSA, GLM4-MoE-Lite, Ernie4_5_VL_MoE etc. ship the
  per-position list and may omit the prefix-style fields entirely.
- _build_text_module_elements registers per_layer_input_gate /
  per_layer_projection (per layer) and per_layer_model_projection (global)
  in the canonical element map and alias map. The PLE element count was
  added to total_quantizable in a prior commit but skip-module matching
  against names like model.layers.0.per_layer_input_gate produced 0-byte
  delta. Layer aggregate text.layers.<i> now sums all layer modules so
  prefix skip names cover the PLE pieces too.
- _targets_all_linear coerces a bare string `"all-linear"` to `["all-linear"]`
  before set comparison; the previous set comprehension iterated chars.
  PEFT LoraConfig.target_modules accepts the bare-string convention.
- ModelArchConfig gains `shared_expert_intermediate_size`. extract_arch_config
  reads `n_shared_experts` / `num_shared_experts` aliases and infers
  `n_shared_experts=1` when only `shared_expert_intermediate_size` is set.
  _compute_moe_mlp_elements and the structured + non-structured LoRA paths
  size the shared expert with its own intermediate (Qwen3.5-MoE: 512 vs
  routed moe_intermediate_size).
- _determine_attention_impl_for_gpu_estimate uses copy.deepcopy so the
  resolver does not mutate nested text_config on the cached source.
  PreTrainedConfig._attn_implementation setter walks `sub_configs` and the
  prior shallow copy still touched the inner objects.

* Studio: extend MoE/PLE/KV-share accounting to activation and skip-alias paths

Five activation-path corrections plus two LoRA / skip-alias corrections so
that shared-expert, per-layer-input, and KV-shared-layer support is symmetric
across weights, LoRA, skip-quantizable, and activation paths.

- _layer_qkv_mlp_sizes: include shared-expert FFN in mlp_size (live shared
  expert per token alongside routed experts) and keep K/V activation memory
  for KV-shared layers; only the WEIGHT path uses has_k/has_v from
  _layer_attention_dims.
- _per_layer_activation_bytes / compute_activation_bytes: account for
  per_layer_input_gate (hd-sized) and per_layer_projection (pli-sized) per
  layer plus the global per_layer_model_projection [B,S,L,PLI] tensor when
  hidden_size_per_layer_input is set.
- _build_text_module_elements: split mlp.experts into routed and
  mlp.shared_expert canonical entries; register layers.<i>.experts alias for
  Gemma4 enable_moe_block layouts and mlp.shared_experts (plural) alias for
  Exaone-MoE / Laguna / GLM4-MoE-Lite shared-expert variants.
- _compute_moe_mlp_elements: split into _compute_routed_moe_elements and
  _compute_shared_moe_elements; only count shared_expert_gate (hd->1 Linear
  per shared expert) when shared_expert_intermediate_size is set, which is
  the Qwen2-MoE / Qwen3.5-MoE discriminator. Other shared-expert families
  (Exaone-MoE, HY-V3, GLM4-MoE-Lite, Laguna) lack the gate.
- compute_lora_params: when target_modules='all-linear' bare keyword, drop
  routed and shared MoE expert LoRA contributions. PEFT's all-linear targets
  nn.Linear only; Unsloth's get_moe_target_parameters expands MoE expert
  nn.Parameter LoRA only when target_modules contains explicit
  gate_proj/up_proj/down_proj/gate_up_proj names.
- _per_layer_input_lora_params: thread target_modules through and add the
  per-PLE-module contribution when the corresponding name appears, not only
  under all-linear.

* Studio: top-k MoE activations, ERNIE list configs, suffix skips, multimodal full bytes

Six estimator corrections aligning the detailed accounting paths with real
training behavior:

- _layer_qkv_mlp_sizes scales the MoE-layer mlp_size by num_experts_per_tok
  so the active routed-expert intermediate tensors are charged for activations.
  Adds num_experts_per_tok to ModelArchConfig and extracts it from
  num_experts_per_tok / top_k_experts (Gemma4 alias) in extract_arch_config.
- compute_lora_params splits routed and shared MoE LoRA contributions so that
  bare target_modules='all-linear' zeroes routed (nn.Parameter expert tensors,
  which Unsloth's get_moe_target_parameters does NOT enable for the bare
  keyword) but keeps shared-expert LoRA (regular nn.Linear MLPs that
  Unsloth's get_peft_regex DOES match).
- extract_arch_config gains a _first_scalar helper for ERNIE-style
  moe_intermediate_size = [routed, shared] lists, plus moe_num_experts and
  moe_num_shared_experts attribute aliases. When moe_intermediate_size is a
  pair and shared_expert_intermediate_size is unset, the second element is
  treated as the shared-expert intermediate.
- estimate_required_model_memory_gb's detailed branch retains
  max(0, model_size_bytes - compute_total_params(arch) * 2) on top of the
  arch-derived breakdown.model_weights so multimodal models (vision/audio
  towers) and partially-modeled families (Gemma3n AltUp/Laurel etc.) do not
  silently drop bytes that the safetensors total includes.
- _module_path_matches accepts a tail-only match when the skip entry is
  shorter than the alias path. Transformers' BNB quantizer suffix-matches
  short skip entries like ['q_proj'] / ['lm_head'] against full module
  paths; the previous len(skip) < len(alias) early-return missed those.
- _per_layer_input_lora_params drops the all_linear branch and only counts
  PLE LoRA when the user explicitly names per_layer_input_gate /
  per_layer_projection / per_layer_model_projection. Unsloth's
  get_peft_regex requires module names to contain a component tag
  (mlp/attn/...); PLE module names lack any tag, so all-linear training
  does not attach LoRA to them.

* Studio: full-FT extra optimizer/gradient inflation, MoE top-k aliases, ERNIE position dispatch, sibling experts aggregate

When the safetensors total exceeds the text-arch fp16 estimate (multimodal
vision/audio towers, partially-modeled families), only inflate the model
weights line for adapter methods but extend optimizer + gradient bytes
under full fine-tuning, where the extra params are trainable.

DBRX exposes top-k routing as moe_top_k and Hunyuan-V1-MoE as moe_topk;
neither is aliased to num_experts_per_tok via attribute_map, so probe both
when extracting arch config.

ERNIE 4.5 MoE / VL MoE configs declare MoE layers via
moe_layer_start_index / moe_layer_end_index / moe_layer_interval (with -1
meaning the last layer); add the position-style dispatch alongside the
existing mlp_layer_types / first_k_dense_replace / decoder_sparse_step
paths.

When moe_has_dense_mlp is set (Gemma4 enable_moe_block) the routed experts
live as a sibling of self.mlp at layers.<i>.experts in the actual model
layout; keep the layer mlp aggregate to the dense path and add a separate
experts aggregate so a skip module model.layers.<i>.mlp does not collapse
the routed experts as well.

* Studio: extend MoE family extraction (Llama4 / DBRX / Hunyuan / ERNIE) and align dense vs routed MLP widths

- Llama4: pick up `config.moe_layers` (auto-populated from
  interleave_moe_layer_step) so dense layer indices reflect the actual
  is_moe_layer dispatch.
- Llama4: add a separate `dense_intermediate_size` derived from
  `intermediate_size_mlp` (used for the dense feed_forward path) and keep
  `intermediate_size` for the routed/shared expert width. Auto-attach one
  shared expert per MoE layer when the dense-vs-MoE width split is present.
- DBRX: walk the `ffn_config` sub-config when extracting MoE attrs
  (moe_num_experts / moe_top_k / ffn_hidden_size). Without this DBRX is
  misclassified as a dense arch.
- Hunyuan: normalize layer-wise `moe_topk` (and the canonical
  `num_experts_per_tok` lookup it shadows via attribute_map) through a
  worst-case scalar so the int(...) cast cannot crash on list values.
- ERNIE 4.5 MoE: switch the start/end/interval dispatch to the model's
  `(layer_idx + 1) % interval == 0` modulo gate so MoE layers match the
  decoder when interval > 1.
- ERNIE 4.5 VL MoE: drop the heuristic that read
  `moe_intermediate_size[1]` as the shared expert width; in VL configs [1]
  is the vision-routed width and shared experts are sized from [0].
- estimate_fp16_model_size_bytes: prefer the larger of config-derived and
  local-weight bytes so the multimodal extra_bytes correction can fire
  for local VLM directories.

* Add tests for VRAM estimator extensions

* Studio: trim verbose comments in VRAM estimator

Collapse multi-paragraph rationale blocks to 1-3 lines stating the single
load-bearing fact. Fix one inverted "fall through ... last" comment whose
claim disagreed with the surrounding code.

* Consolidate added tests into existing test_vram_estimation.py and test_gpu_selection.py

Move Llama4 / DBRX / ERNIE arch-extraction tests into test_vram_estimation.py
as TestLlama4ArchExtraction / TestDbrxFfnConfigExtraction /
TestErniePhaseModuloDispatch / TestErnieVlSharedExpertWidth classes. Move
estimate_fp16_model_size_bytes prefer-larger-of-config-or-local tests into
test_gpu_selection.py as TestEstimateFp16ModelSizeBytesPrefersLocalWeights.
Drop one redundant Llama4 num_dense_layers assertion already covered by the
moe_layers dispatch test.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-05-05 04:12:36 -07:00

1308 lines
48 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 base layers skip activation storage, but you always need
# at least ~1 layer in flight during backprop recomputation.
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`; retained for backward compatibility with
# consumers that read this field.
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:
"""Minimum VRAM a single GPU needs: its shard + non-shardable costs.
Weights/LoRA/optimizer/gradients shard across GPUs.
Activations do NOT shard (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):
# why: ERNIE MoE configs ship 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):
# why: Hunyuan-V1-MoE moe_topk can be a per-layer list; activation
# accounting uses the 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."""
# why: transformers Exaone-MoE / Laguna / Hy_v3 / GLM-MoE-DSA / GLM4-MoE-Lite /
# Ernie4_5_VL_MoE prefer per-position `mlp_layer_types` over the prefix-style
# `first_k_dense_replace` and may omit `decoder_sparse_step` entirely.
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"
)
# why: 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)
# why: transformers ERNIE 4.5 MoE / ERNIE 4.5 VL MoE declare MoE layers
# via moe_layer_start_index / moe_layer_end_index / moe_layer_interval;
# the model's per-layer guard is `(layer_idx + 1) % interval == 0` with
# start <= layer_idx <= 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)
# why: DBRX places its MoE attrs on the DbrxFFNConfig sub-config; probe
# ffn_config as a secondary source so DBRX is not 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)
# why: Exaone-MoE / ERNIE families 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
# why: DBRX exposes moe_top_k, Hunyuan-V1-MoE exposes moe_topk (which can
# be a per-layer list); _max_scalar normalizes list values to the worst
# case so int(...) below cannot crash on the canonical attribute_map path.
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)
# why: Llama4 dense layers use intermediate_size_mlp; routed and shared
# experts use intermediate_size. Llama4TextMoe builds 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:
# why: 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
# why: transformers Gemma4 (modeling_gemma4.py:1031, modular_gemma4.py:863)
# uses the same `> 0` guard so a fully-shared config raises during model
# construction; matching upstream avoids producing a detailed estimate
# for a shape the actual 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:
# why: Gemma4 PLE block adds per_layer_model_projection (single Linear),
# per_layer_input_gate (per layer), and per_layer_projection (per layer);
# see transformers 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:
# why: Unsloth's get_peft_regex (unsloth_zoo/peft_utils.py) requires module
# names to contain a component tag (mlp/attn/...); PLE module names lack
# any tag, so all-linear training does NOT attach LoRA to them. Only count
# PLE LoRA when the user explicitly names PLE modules.
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):
# why: transformers BNB quantizer suffix-matches short skip entries
# like ["q_proj"] / ["lm_head"] against full module paths, 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
# why: bound the prefix to known text-tower roots so VLM skip names like
# vision_tower.model.layers.<i>.self_attn.q_proj do not shadow the text
# alias model.layers.<i>.self_attn.q_proj.
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:
# why: _text_linear_dims uses (hd, hd) for q/o; MLA actually splits
# into q_a/q_b/kv_a/kv_b, so 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:
# why: Qwen3.5-MoE exposes shared expert as
# mlp.shared_expert; Exaone-MoE/Laguna/GLM-style configs use
# mlp.shared_experts. Register both names so child-path
# llm_int8_skip_modules entries match the right shared block.
layer_modules["mlp.shared_expert"] = shared_moe
if arch.moe_has_dense_mlp:
# why: enable_moe_block runs the dense MLP and the MoE
# experts in parallel; register both for skip matching.
# Non-structured _text_linear_dims returns mlp_size from
# _get_mlp_size which prefers moe_intermediate_size, so
# rebuild dense dims from arch.intermediate_size 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:
# why: 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.")
)
# why: gemma4 enable_moe_block puts routed experts at the sibling
# layers.<i>.experts attribute, not under self.mlp; the layer's "mlp"
# aggregate must reflect only the dense MLP path so a skip module
# `model.layers.0.mlp` does not over-skip into the experts block.
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:
# why: gemma4 enable_moe_block exposes routed experts at
# layers.<i>.experts (sibling of self.mlp), not under mlp.
_add_module_aliases(aliases, canonical, f"layers.{layer_idx}.experts")
elif name == "mlp.shared_expert":
# why: Exaone-MoE / Laguna / GLM-style configs use the plural
# `shared_experts` attribute name; register both spellings.
_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:
# why: 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:
# why: 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
# why: only Qwen2-MoE / Qwen3.5-MoE define a shared_expert_gate Linear
# (hidden_size→1); other families (Exaone-MoE, HY-V3, GLM4-MoE-Lite, Laguna)
# have shared_experts without a gate. 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:
# why: 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
# why: 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 and
# are 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:
# why: 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
# why: 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:
# why: 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)
# why: KV-shared layers (Gemma4/Gemma3n) drop k_proj/v_proj WEIGHTS but
# the donor layer's K/V tensors stay alive across the shared range, so
# activation memory still pays for 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:
# why: each token routes through `num_experts_per_tok` experts; their
# gate/up/down intermediates are all 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)
# why: per_layer_input_gate (hd-sized) and per_layer_projection (pli-sized)
# outputs materialize once per decoder layer when hidden_size_per_layer_input
# is set; see gemma4/modular_gemma4.py: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)
# why: 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,
)