vramcalc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vramcalc/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """vramcalc: predict GPU VRAM usage from config.json."""
2
+
3
+ from .arch import Arch, UnsupportedModelError
4
+ from .bench import BenchResult, bench
5
+ from .estimate import Estimate, EstimateParams, FitResult, Suggestion, estimate
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["Arch", "BenchResult", "Estimate", "EstimateParams", "FitResult", "Suggestion",
9
+ "UnsupportedModelError", "bench", "estimate"]
vramcalc/arch.py ADDED
@@ -0,0 +1,111 @@
1
+ """Normalize a Hugging Face config.json into the fields the memory formulas need."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ SUPPORTED_MODEL_TYPES = ("llama", "mistral", "qwen2", "gemma", "gemma2")
8
+
9
+ _REQUIRED = ("hidden_size", "num_hidden_layers", "num_attention_heads",
10
+ "intermediate_size", "vocab_size")
11
+
12
+
13
+ class UnsupportedModelError(ValueError):
14
+ """Raised when model_type is outside the supported allowlist."""
15
+
16
+
17
+ def _positive_int(cfg: dict, key: str) -> int:
18
+ value = cfg[key]
19
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
20
+ raise ValueError(f"{key} must be a positive integer, got {value!r}")
21
+ return value
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Arch:
26
+ hidden_size: int
27
+ num_layers: int
28
+ num_heads: int
29
+ num_kv_heads: int
30
+ head_dim: int
31
+ intermediate_size: int
32
+ vocab_size: int
33
+ tie_word_embeddings: bool
34
+ attention_bias: bool
35
+ norms_per_layer: int
36
+ name: str = ""
37
+
38
+ @property
39
+ def q(self) -> int:
40
+ """Width of the query projection output."""
41
+ return self.num_heads * self.head_dim
42
+
43
+ @property
44
+ def kv(self) -> int:
45
+ """Width of each of the key and value projection outputs."""
46
+ return self.num_kv_heads * self.head_dim
47
+
48
+ @classmethod
49
+ def from_config(cls, cfg: dict) -> Arch:
50
+ model_type = cfg.get("model_type")
51
+ if model_type not in SUPPORTED_MODEL_TYPES:
52
+ raise UnsupportedModelError(
53
+ f"model_type {model_type!r} is not supported; "
54
+ f"supported: {', '.join(SUPPORTED_MODEL_TYPES)}"
55
+ )
56
+ missing = [k for k in _REQUIRED if k not in cfg]
57
+ if missing:
58
+ raise ValueError(f"config is missing required keys: {', '.join(missing)}")
59
+
60
+ hidden = _positive_int(cfg, "hidden_size")
61
+ layers = _positive_int(cfg, "num_hidden_layers")
62
+ heads = _positive_int(cfg, "num_attention_heads")
63
+ kv_heads = _positive_int(cfg, "num_key_value_heads") if "num_key_value_heads" in cfg else heads
64
+ if heads % kv_heads != 0:
65
+ raise ValueError(
66
+ f"num_key_value_heads={kv_heads} must divide num_attention_heads={heads}"
67
+ )
68
+ if "head_dim" in cfg:
69
+ head_dim = _positive_int(cfg, "head_dim")
70
+ else:
71
+ if hidden % heads != 0:
72
+ raise ValueError(
73
+ f"hidden_size={hidden} is not divisible by num_attention_heads={heads}"
74
+ )
75
+ head_dim = hidden // heads
76
+
77
+ is_gemma = model_type in ("gemma", "gemma2")
78
+ name = str(cfg.get("_name_or_path", "")).rstrip("/").split("/")[-1]
79
+ return cls(
80
+ hidden_size=hidden,
81
+ num_layers=layers,
82
+ num_heads=heads,
83
+ num_kv_heads=kv_heads,
84
+ head_dim=head_dim,
85
+ intermediate_size=_positive_int(cfg, "intermediate_size"),
86
+ vocab_size=_positive_int(cfg, "vocab_size"),
87
+ tie_word_embeddings=bool(cfg.get("tie_word_embeddings", is_gemma)),
88
+ attention_bias=(model_type == "qwen2") or bool(cfg.get("attention_bias", False)),
89
+ norms_per_layer=4 if model_type == "gemma2" else 2,
90
+ name=name,
91
+ )
92
+
93
+ # Parameter-count pieces are exposed so memory.py can reuse them for P_cast.
94
+ def attn_params(self) -> int:
95
+ h = self.hidden_size
96
+ return h * self.q + 2 * h * self.kv + self.q * h
97
+
98
+ def attn_bias_params(self) -> int:
99
+ return self.q + 2 * self.kv if self.attention_bias else 0
100
+
101
+ def mlp_params(self) -> int:
102
+ return 3 * self.hidden_size * self.intermediate_size
103
+
104
+ def embed_params(self) -> int:
105
+ return self.vocab_size * self.hidden_size
106
+
107
+ def param_count(self) -> int:
108
+ per_layer = (self.attn_params() + self.attn_bias_params() + self.mlp_params()
109
+ + self.norms_per_layer * self.hidden_size)
110
+ lm_head = 0 if self.tie_word_embeddings else self.embed_params()
111
+ return self.embed_params() + self.num_layers * per_layer + self.hidden_size + lm_head
vramcalc/bench.py ADDED
@@ -0,0 +1,220 @@
1
+ """Measure real GPU memory with torch and compare against the prediction. Spec section 7."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import dataclasses
7
+ import datetime as dt
8
+ import gc
9
+ import json
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from .estimate import GB, Estimate, EstimateParams, estimate
15
+
16
+ BUCKETS = ("weights", "states", "peak", "total", "overhead")
17
+ LORA_TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj"]
18
+ _ATTN_IMPL = {"eager": "eager", "sdpa": "sdpa", "flash": "flash_attention_2"}
19
+
20
+
21
+ def buckets(est: Estimate) -> dict[str, int]:
22
+ inference = est.params.mode == "inference"
23
+ return {
24
+ "weights": est.weights,
25
+ "states": est.gradients + est.optimizer,
26
+ "peak": est.peak_extra + (est.kv_cache if inference else 0),
27
+ "total": est.total,
28
+ "overhead": est.overhead,
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class BenchResult:
34
+ status: str # "ok" | "oom" | "failed"
35
+ error: str | None
36
+ params: EstimateParams
37
+ predicted: dict[str, int]
38
+ measured: dict[str, int]
39
+ gpu_name: str
40
+ versions: dict[str, str]
41
+ timestamp: str
42
+
43
+ def to_dict(self) -> dict:
44
+ d = dataclasses.asdict(self)
45
+ d["params"]["arch"] = dataclasses.asdict(self.params.arch)
46
+ return d
47
+
48
+ def table(self) -> str:
49
+ lines = [f"{'bucket':<10}{'predicted':>12}{'measured':>12}{'error':>9}"]
50
+ for k in BUCKETS:
51
+ p = self.predicted.get(k, 0)
52
+ m = self.measured.get(k)
53
+ err = "N/A" if not m else f"{(p - m) / m * 100:+.1f}%"
54
+ m_txt = f"{m / GB:.2f}" if m is not None else "-"
55
+ lines.append(f"{k:<10}{p / GB:>12.2f}{m_txt:>12}{err:>9}")
56
+ if self.status != "ok":
57
+ lines.append(f"status: {self.status}: {self.error}")
58
+ return "\n".join(lines)
59
+
60
+
61
+ def write_record(result: BenchResult, path: str | Path) -> None:
62
+ with open(path, "a", encoding="utf-8") as f:
63
+ f.write(json.dumps(result.to_dict(), ensure_ascii=False) + "\n")
64
+
65
+
66
+ _ABS_PATH = re.compile(r"(?<![\w])/[^\s'\"]+")
67
+
68
+
69
+ def _sanitize(message: str, model_path) -> str:
70
+ """Keep local paths out of recorded errors (spec 7.4)."""
71
+ message = message.replace(str(model_path), "<model>")
72
+ return _ABS_PATH.sub("<path>", message)[:500]
73
+
74
+
75
+ def _require_bench_deps():
76
+ try:
77
+ import peft
78
+ import torch
79
+ import transformers
80
+ except ImportError as exc:
81
+ raise ImportError(
82
+ "bench needs torch, transformers and peft; install vramcalc[bench]") from exc
83
+ if not torch.cuda.is_available():
84
+ raise RuntimeError("bench needs a CUDA device")
85
+ return torch, transformers, peft
86
+
87
+
88
+ def bench(model_path, *, mode="inference", dtype="bf16", batch=1, seq_len=2048, lora_rank=None,
89
+ grad_checkpoint=False, optimizer="adamw", precision="amp", attn_impl="sdpa",
90
+ overhead_gb=0.75, steps=3, record=None) -> BenchResult:
91
+ # Pre-validation: everything here raises; nothing after model load does.
92
+ compute_fp32 = dtype == "fp32" and (mode == "inference" or precision == "pure")
93
+ if attn_impl == "sdpa" and compute_fp32:
94
+ # The pinned efficient/flash backends do not serve fp32 GQA; math would store scores.
95
+ raise ValueError("attn_impl='sdpa' cannot be pinned for fp32 compute; use attn_impl='eager'")
96
+ torch, transformers, peft = _require_bench_deps()
97
+ if attn_impl == "flash":
98
+ try:
99
+ import flash_attn # noqa: F401
100
+ except ImportError as exc:
101
+ raise ImportError("attn_impl='flash' needs the flash-attn package") from exc
102
+ if not isinstance(steps, int) or steps <= 0:
103
+ raise ValueError(f"steps must be a positive integer, got {steps!r}")
104
+ est = estimate(model_path, mode=mode, dtype=dtype, batch=batch, seq_len=seq_len,
105
+ lora_rank=lora_rank, grad_checkpoint=grad_checkpoint, optimizer=optimizer,
106
+ precision=precision, attn_impl=attn_impl, overhead_gb=overhead_gb)
107
+ p = est.params
108
+ versions = {"torch": torch.__version__, "transformers": transformers.__version__,
109
+ "peft": peft.__version__}
110
+ gpu_name = torch.cuda.get_device_name(torch.cuda.current_device())
111
+ stamp = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
112
+
113
+ status, error, measured = "ok", None, {}
114
+ try:
115
+ measured = _measure(torch, transformers, peft, model_path, p, steps)
116
+ except torch.cuda.OutOfMemoryError as exc:
117
+ status, error = "oom", _sanitize((str(exc).splitlines() or [""])[0], model_path)
118
+ except Exception as exc: # noqa: BLE001 - runtime failure after model load, spec 7.3
119
+ status, error = "failed", _sanitize(f"{type(exc).__name__}: {exc}", model_path)
120
+ finally:
121
+ gc.collect()
122
+ torch.cuda.empty_cache()
123
+
124
+ result = BenchResult(status, error, p, buckets(est), measured, gpu_name, versions, stamp)
125
+ if record is not None:
126
+ write_record(result, record)
127
+ return result
128
+
129
+
130
+ def _reset(torch):
131
+ torch.cuda.synchronize()
132
+ gc.collect()
133
+ torch.cuda.empty_cache()
134
+ torch.cuda.reset_peak_memory_stats()
135
+
136
+
137
+ def _measure(torch, transformers, peft, model_path, p: EstimateParams, steps: int) -> dict[str, int]:
138
+ torch_dtype = {"fp32": torch.float32, "bf16": torch.bfloat16, "fp16": torch.float16}[p.dtype]
139
+ load_dtype = torch.float32 if (p.mode == "train" and p.precision == "amp") else torch_dtype
140
+ measured: dict[str, int] = {}
141
+
142
+ _reset(torch)
143
+ preexisting = torch.cuda.memory_allocated() # tensors the caller already holds
144
+ model = transformers.AutoModelForCausalLM.from_pretrained(
145
+ model_path, torch_dtype=load_dtype, attn_implementation=_ATTN_IMPL[p.attn_impl],
146
+ ).cuda()
147
+ if p.lora_rank is not None:
148
+ cfg = peft.LoraConfig(r=p.lora_rank, lora_alpha=p.lora_rank, lora_dropout=0.0,
149
+ target_modules=LORA_TARGETS, task_type="CAUSAL_LM")
150
+ model = peft.get_peft_model(model, cfg)
151
+ torch.cuda.synchronize()
152
+ measured["weights"] = torch.cuda.memory_allocated() - preexisting
153
+
154
+ input_ids = torch.randint(0, p.arch.vocab_size, (p.batch, p.seq_len), device="cuda")
155
+
156
+ if p.attn_impl == "sdpa":
157
+ from torch.nn.attention import SDPBackend, sdpa_kernel
158
+
159
+ def attn_ctx():
160
+ return sdpa_kernel([SDPBackend.EFFICIENT_ATTENTION, SDPBackend.FLASH_ATTENTION])
161
+ else:
162
+ attn_ctx = contextlib.nullcontext
163
+
164
+ if p.mode == "inference":
165
+ model.eval()
166
+ _reset(torch)
167
+ with torch.inference_mode(), attn_ctx():
168
+ out = model(input_ids=input_ids, use_cache=True)
169
+ torch.cuda.synchronize()
170
+ measured["peak"] = torch.cuda.max_memory_allocated() - preexisting - measured["weights"]
171
+ measured["states"] = 0
172
+ del out
173
+ else:
174
+ model.train()
175
+ model.config.use_cache = False
176
+ if p.grad_checkpoint:
177
+ model.gradient_checkpointing_enable(
178
+ gradient_checkpointing_kwargs={"use_reentrant": False})
179
+ trainable = [q for q in model.parameters() if q.requires_grad]
180
+ if p.optimizer == "adamw":
181
+ opt = torch.optim.AdamW(trainable, lr=1e-5, foreach=True)
182
+ else:
183
+ opt = torch.optim.SGD(trainable, lr=1e-5, momentum=0.9, foreach=True)
184
+ amp = p.precision == "amp"
185
+ if amp:
186
+ def autocast():
187
+ return torch.autocast("cuda", dtype=torch_dtype)
188
+ else:
189
+ autocast = contextlib.nullcontext
190
+
191
+ def step():
192
+ # attn_ctx stays active through backward so checkpoint recomputation
193
+ # uses the same SDPA backend as the forward pass
194
+ with attn_ctx():
195
+ with autocast():
196
+ out = model(input_ids=input_ids, labels=input_ids, use_cache=False)
197
+ loss = out.loss
198
+ loss.backward()
199
+ del out, loss # release logits before step() so workspace does not overlap (spec 5.6)
200
+ opt.step()
201
+ opt.zero_grad(set_to_none=False)
202
+
203
+ step() # warm-up
204
+ torch.cuda.synchronize()
205
+ baseline = torch.cuda.memory_allocated()
206
+ measured["states"] = baseline - preexisting - measured["weights"]
207
+ peak = 0
208
+ for _ in range(steps):
209
+ _reset(torch)
210
+ step()
211
+ torch.cuda.synchronize()
212
+ peak = max(peak, torch.cuda.max_memory_allocated() - baseline)
213
+ measured["peak"] = peak
214
+ del opt
215
+
216
+ measured["total"] = measured["weights"] + measured["states"] + measured["peak"]
217
+ torch.cuda.synchronize()
218
+ free, total = torch.cuda.mem_get_info()
219
+ measured["overhead"] = (total - free) - torch.cuda.memory_reserved()
220
+ return measured # model and inputs are released when this frame returns
vramcalc/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """Command-line interface: `vramcalc MODEL ...`, `vramcalc bench ...`, `vramcalc gpus`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import gpus
10
+ from .estimate import GB, estimate
11
+
12
+ _SUBCOMMANDS = ("bench", "gpus")
13
+
14
+
15
+ def _add_estimate_args(p: argparse.ArgumentParser) -> None:
16
+ p.add_argument("--train", action="store_true")
17
+ p.add_argument("--lora", type=int, metavar="R")
18
+ p.add_argument("--dtype", default="bf16", choices=("fp32", "bf16", "fp16"))
19
+ p.add_argument("--batch", type=int, default=1)
20
+ p.add_argument("--seq", type=int, default=2048)
21
+ p.add_argument("--grad-checkpoint", action="store_true")
22
+ p.add_argument("--optimizer", default="adamw", choices=("adamw", "sgd"))
23
+ p.add_argument("--precision", default="amp", choices=("amp", "pure"))
24
+ p.add_argument("--attn", default="sdpa", choices=("eager", "sdpa", "flash"))
25
+ p.add_argument("--overhead-gb", type=float, default=0.75)
26
+
27
+
28
+ def _estimate_kwargs(a: argparse.Namespace) -> dict:
29
+ return {
30
+ "mode": "train" if a.train else "inference", "dtype": a.dtype, "batch": a.batch,
31
+ "seq_len": a.seq, "lora_rank": a.lora, "grad_checkpoint": a.grad_checkpoint,
32
+ "optimizer": a.optimizer, "precision": a.precision, "attn_impl": a.attn,
33
+ "overhead_gb": a.overhead_gb,
34
+ }
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ parser = argparse.ArgumentParser(prog="vramcalc", description=__doc__)
39
+ sub = parser.add_subparsers(dest="command")
40
+
41
+ est = sub.add_parser("estimate", help="predict memory (default when MODEL is given)")
42
+ est.add_argument("model")
43
+ _add_estimate_args(est)
44
+ est.add_argument("--gpu", help="GPU name or GB number; exit 1 if the model does not fit")
45
+ est.add_argument("--json", action="store_true")
46
+
47
+ sub.add_parser("gpus", help="list built-in GPU table")
48
+
49
+ bench = sub.add_parser(
50
+ "bench", help="measure on the local GPU and compare (needs vramcalc[bench])")
51
+ bench.add_argument("model_path")
52
+ _add_estimate_args(bench)
53
+ bench.add_argument("--steps", type=int, default=3)
54
+ bench.add_argument("--record", metavar="PATH")
55
+ bench.add_argument("--json", action="store_true")
56
+ return parser
57
+
58
+
59
+ def _parse_gpu(value: str | None):
60
+ """Table names win over numbers so that `--gpu 3090` means the card, not 3090 GB."""
61
+ if value is None:
62
+ return None
63
+ if gpus._key(value) in gpus._INDEX:
64
+ return value
65
+ try:
66
+ return float(value)
67
+ except ValueError:
68
+ return value
69
+
70
+
71
+ def cmd_estimate(a: argparse.Namespace) -> int:
72
+ e = estimate(a.model, **_estimate_kwargs(a))
73
+ fit = e.fits(_parse_gpu(a.gpu)) if a.gpu is not None else None
74
+ if a.json:
75
+ out = {"estimate": e.to_dict()}
76
+ if fit is not None:
77
+ out["fit"] = fit.to_dict()
78
+ print(json.dumps(out, indent=2))
79
+ else:
80
+ print(e.breakdown())
81
+ if fit is not None:
82
+ print()
83
+ print(fit.message)
84
+ for s in fit.suggestions:
85
+ tag = " (changes training objective)" if s.changes_objective else ""
86
+ change = ", ".join(f"{k}={v}" for k, v in s.change.items())
87
+ need = (s.estimate.total + s.estimate.overhead) / GB
88
+ print(f" -> {change}: {need:.1f} GB{tag}")
89
+ return 0 if fit is None or fit.ok else 1
90
+
91
+
92
+ def cmd_gpus(_: argparse.Namespace) -> int:
93
+ for name, size in gpus.GPUS.items():
94
+ print(f"{name:<20} {size / gpus.GIB:>5.0f} GiB")
95
+ return 0
96
+
97
+
98
+ def cmd_bench(a: argparse.Namespace) -> int:
99
+ from .bench import bench
100
+
101
+ kwargs = _estimate_kwargs(a)
102
+ r = bench(a.model_path, steps=a.steps, record=a.record, **kwargs)
103
+ print(json.dumps(r.to_dict(), indent=2) if a.json else r.table())
104
+ return 0 if r.status == "ok" else 1
105
+
106
+
107
+ def main(argv: list[str] | None = None) -> int:
108
+ argv = list(sys.argv[1:] if argv is None else argv)
109
+ if argv and argv[0] not in _SUBCOMMANDS and argv[0] != "estimate" and not argv[0].startswith("-"):
110
+ argv.insert(0, "estimate")
111
+ parser = build_parser()
112
+ a = parser.parse_args(argv)
113
+ if a.command is None:
114
+ parser.print_help()
115
+ return 2
116
+ handler = {"estimate": cmd_estimate, "gpus": cmd_gpus, "bench": cmd_bench}[a.command]
117
+ try:
118
+ return handler(a)
119
+ except (ValueError, KeyError, FileNotFoundError, ImportError, RuntimeError) as exc:
120
+ msg = exc.args[0] if exc.args else str(exc)
121
+ print(f"vramcalc: {msg}", file=sys.stderr)
122
+ return 2
123
+
124
+
125
+ if __name__ == "__main__":
126
+ sys.exit(main())
vramcalc/estimate.py ADDED
@@ -0,0 +1,219 @@
1
+ """Compose per-term formulas into an Estimate for a given mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import asdict, dataclass, field
7
+
8
+ from . import memory
9
+ from .arch import Arch
10
+ from .loaders import load_arch
11
+
12
+ GB = 10**9
13
+
14
+ MODES = ("inference", "train")
15
+ ATTN_IMPLS = ("eager", "sdpa", "flash")
16
+ OPTIMIZERS = ("adamw", "sgd")
17
+ PRECISIONS = ("amp", "pure")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class EstimateParams:
22
+ arch: Arch
23
+ mode: str
24
+ dtype: str
25
+ batch: int
26
+ seq_len: int
27
+ lora_rank: int | None
28
+ grad_checkpoint: bool
29
+ optimizer: str
30
+ precision: str
31
+ attn_impl: str
32
+ overhead_gb: float
33
+
34
+ def replace(self, **changes) -> EstimateParams:
35
+ return EstimateParams(**{**asdict_shallow(self), **changes})
36
+
37
+
38
+ def asdict_shallow(params: EstimateParams) -> dict:
39
+ """asdict() without recursing into Arch, so Arch stays an Arch."""
40
+ return {f: getattr(params, f) for f in EstimateParams.__dataclass_fields__}
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Suggestion:
45
+ change: dict
46
+ changes_objective: bool
47
+ estimate: Estimate
48
+ margin_bytes: int
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class FitResult:
53
+ ok: bool
54
+ gpu_name: str
55
+ gpu_bytes: int
56
+ usable_bytes: int
57
+ required_bytes: int
58
+ margin_bytes: int
59
+ suggestions: list[Suggestion]
60
+ message: str
61
+
62
+ def to_dict(self) -> dict:
63
+ d = {k: getattr(self, k) for k in ("ok", "gpu_name", "gpu_bytes", "usable_bytes",
64
+ "required_bytes", "margin_bytes", "message")}
65
+ d["suggestions"] = [
66
+ {"change": s.change, "changes_objective": s.changes_objective,
67
+ "margin_bytes": s.margin_bytes, "total": s.estimate.total}
68
+ for s in self.suggestions
69
+ ]
70
+ return d
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Estimate:
75
+ weights: int
76
+ kv_cache: int
77
+ gradients: int
78
+ optimizer: int
79
+ activations: int
80
+ autocast_cache: int
81
+ step_workspace: int
82
+ overhead: int
83
+ params: EstimateParams = field(repr=False)
84
+
85
+ @property
86
+ def persistent(self) -> int:
87
+ return self.weights + self.kv_cache + self.gradients + self.optimizer
88
+
89
+ @property
90
+ def peak_extra(self) -> int:
91
+ return max(self.activations + self.autocast_cache, self.step_workspace)
92
+
93
+ @property
94
+ def total(self) -> int:
95
+ return self.persistent + self.peak_extra
96
+
97
+ def to_dict(self) -> dict:
98
+ d = {k: getattr(self, k) for k in _TERMS}
99
+ d.update(persistent=self.persistent, peak_extra=self.peak_extra, total=self.total)
100
+ params = asdict_shallow(self.params)
101
+ params["arch"] = asdict(self.params.arch)
102
+ d["params"] = params
103
+ return d
104
+
105
+ def fits(self, gpu=None) -> FitResult:
106
+ from . import gpus # local import keeps gpus optional for pure estimation
107
+ from .suggest import suggest
108
+
109
+ name, size = gpus.resolve(gpu)
110
+ usable = int(size * gpus.USABLE_RATIO)
111
+ required = self.total + self.overhead
112
+ margin = usable - required
113
+ ok = required <= usable
114
+ suggestions = [] if ok else suggest(self, usable)
115
+ if ok:
116
+ message = f"fits {name}: {required / GB:.1f} GB of {usable / GB:.1f} GB usable"
117
+ elif suggestions:
118
+ n = len(suggestions)
119
+ message = (f"short by {-margin / GB:.1f} GB on {name}; "
120
+ f"{n} single adjustment{'s fit' if n != 1 else ' fits'}")
121
+ else:
122
+ message = (f"short by {-margin / GB:.1f} GB on {name}; "
123
+ "no tested single adjustment fits. "
124
+ "Combine adjustments or consider quantization or distributed training")
125
+ return FitResult(ok, name, size, usable, required, margin, suggestions, message)
126
+
127
+ def breakdown(self) -> str:
128
+ rows = [(k, getattr(self, k)) for k in _TERMS if k != "overhead" and getattr(self, k)]
129
+ rows += [("total", self.total), ("overhead", self.overhead)]
130
+ width = max(len(k) for k, _ in rows)
131
+ return "\n".join(f"{k:<{width}} {v / GB:8.2f} GB" for k, v in rows)
132
+
133
+
134
+ _TERMS = ("weights", "kv_cache", "gradients", "optimizer",
135
+ "activations", "autocast_cache", "step_workspace", "overhead")
136
+
137
+
138
+ def _validate(p: EstimateParams) -> None:
139
+ if p.mode not in MODES:
140
+ raise ValueError(f"mode must be one of {MODES}, got {p.mode!r}")
141
+ if p.dtype not in memory.DTYPE_BYTES:
142
+ raise ValueError(f"dtype must be one of {tuple(memory.DTYPE_BYTES)}, got {p.dtype!r}")
143
+ if p.attn_impl not in ATTN_IMPLS:
144
+ raise ValueError(f"attn_impl must be one of {ATTN_IMPLS}, got {p.attn_impl!r}")
145
+ if p.optimizer not in OPTIMIZERS:
146
+ raise ValueError(f"optimizer must be one of {OPTIMIZERS}, got {p.optimizer!r}")
147
+ if p.precision not in PRECISIONS:
148
+ raise ValueError(f"precision must be one of {PRECISIONS}, got {p.precision!r}")
149
+ for name in ("batch", "seq_len"):
150
+ v = getattr(p, name)
151
+ if not isinstance(v, int) or isinstance(v, bool) or v <= 0:
152
+ raise ValueError(f"{name} must be a positive integer, got {v!r}")
153
+ if p.lora_rank is not None:
154
+ if p.mode != "train":
155
+ raise ValueError("lora_rank requires mode='train'")
156
+ if not isinstance(p.lora_rank, int) or p.lora_rank <= 0:
157
+ raise ValueError(f"lora_rank must be a positive integer, got {p.lora_rank!r}")
158
+ if (not isinstance(p.overhead_gb, (int, float)) or not math.isfinite(p.overhead_gb)
159
+ or p.overhead_gb < 0):
160
+ raise ValueError(f"overhead_gb must be a finite number >= 0, got {p.overhead_gb!r}")
161
+
162
+
163
+ def estimate(
164
+ model,
165
+ *,
166
+ mode: str = "inference",
167
+ dtype: str = "bf16",
168
+ batch: int = 1,
169
+ seq_len: int = 2048,
170
+ lora_rank: int | None = None,
171
+ grad_checkpoint: bool = False,
172
+ optimizer: str = "adamw",
173
+ precision: str = "amp",
174
+ attn_impl: str = "sdpa",
175
+ overhead_gb: float = 0.75,
176
+ ) -> Estimate:
177
+ params = EstimateParams(
178
+ arch=load_arch(model), mode=mode, dtype=dtype, batch=batch, seq_len=seq_len,
179
+ lora_rank=lora_rank, grad_checkpoint=grad_checkpoint, optimizer=optimizer,
180
+ precision=precision, attn_impl=attn_impl, overhead_gb=overhead_gb,
181
+ )
182
+ return estimate_from_params(params)
183
+
184
+
185
+ def estimate_from_params(p: EstimateParams) -> Estimate:
186
+ _validate(p)
187
+ B = memory.DTYPE_BYTES[p.dtype]
188
+ overhead = int(p.overhead_gb * GB)
189
+ if p.mode == "inference":
190
+ return Estimate(
191
+ weights=memory.weights(p.arch, B),
192
+ kv_cache=memory.kv_cache(p.arch, p.batch, p.seq_len, B),
193
+ gradients=0, optimizer=0,
194
+ activations=memory.inference_activations(p.arch, p.batch, p.seq_len, p.attn_impl, B),
195
+ autocast_cache=0, step_workspace=0,
196
+ overhead=overhead, params=p,
197
+ )
198
+ if p.dtype == "fp32" and p.precision == "amp":
199
+ p = p.replace(precision="pure") # nothing to autocast
200
+ S = 4 if p.precision == "amp" else B
201
+ lora = p.lora_rank is not None
202
+ trainable = memory.lora_params(p.arch, p.lora_rank) if lora else p.arch.param_count()
203
+ adapter_bytes = 4 if lora else S
204
+ weights = memory.weights(p.arch, S) + (trainable * 4 if lora else 0)
205
+ return Estimate(
206
+ weights=weights,
207
+ kv_cache=0,
208
+ gradients=memory.gradients(trainable, adapter_bytes),
209
+ optimizer=memory.optimizer_states(trainable, p.optimizer, adapter_bytes),
210
+ activations=memory.train_activations(
211
+ p.arch, p.batch, p.seq_len, p.attn_impl, B,
212
+ grad_checkpoint=p.grad_checkpoint, lora_rank=p.lora_rank,
213
+ ),
214
+ autocast_cache=memory.autocast_cache(
215
+ p.arch, p.precision, B, lora=lora, grad_checkpoint=p.grad_checkpoint,
216
+ ),
217
+ step_workspace=memory.step_workspace(trainable, adapter_bytes),
218
+ overhead=overhead, params=p,
219
+ )
vramcalc/gpus.py ADDED
@@ -0,0 +1,76 @@
1
+ """Built-in GPU capacity table and resolution of the `gpu` argument of fits()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+
8
+ GIB = 2**30
9
+
10
+ # Nominal capacity in GiB. Keys are canonical display names.
11
+ GPUS: dict[str, int] = {name: gib * GIB for name, gib in {
12
+ "RTX 3060": 12, "RTX 3070": 8, "RTX 3080": 10, "RTX 3080 Ti": 12,
13
+ "RTX 3090": 24, "RTX 3090 Ti": 24,
14
+ "RTX 4060": 8, "RTX 4060 Ti": 16, "RTX 4070": 12, "RTX 4070 Ti": 12,
15
+ "RTX 4070 Ti Super": 16, "RTX 4080": 16, "RTX 4080 Super": 16, "RTX 4090": 24,
16
+ "RTX 5070": 12, "RTX 5070 Ti": 16, "RTX 5080": 16, "RTX 5090": 32,
17
+ "T4": 16, "V100 16GB": 16, "V100 32GB": 32,
18
+ "A10": 24, "A40": 48, "A100 40GB": 40, "A100 80GB": 80,
19
+ "H100 80GB": 80, "H200": 141, "L4": 24, "L40S": 48, "B200": 192,
20
+ }.items()}
21
+
22
+ # Provisional safety reserve for an otherwise idle GPU. Confirm with bench.
23
+ USABLE_RATIO = 0.97
24
+
25
+ _STRIP = re.compile(r"[\s\-_]+")
26
+ _PREFIXES = ("nvidia", "geforce", "tesla", "rtx")
27
+ _DETECT_HINT = "cannot detect GPU; pass a GPU name or GB number"
28
+
29
+
30
+ def _key(name: str) -> str:
31
+ k = _STRIP.sub("", name.lower())
32
+ for p in _PREFIXES:
33
+ k = k.removeprefix(p)
34
+ return k
35
+
36
+
37
+ _INDEX = {_key(n): n for n in GPUS}
38
+
39
+
40
+ def lookup(name: str) -> tuple[str, int]:
41
+ k = _key(name)
42
+ if k in _INDEX:
43
+ canonical = _INDEX[k]
44
+ return canonical, GPUS[canonical]
45
+ candidates = [n for key, n in _INDEX.items() if k and (k in key or key in k)]
46
+ hint = f"; did you mean {', '.join(candidates)}?" if candidates else ""
47
+ raise KeyError(f"unknown GPU {name!r}{hint}")
48
+
49
+
50
+ def detect() -> tuple[str, int]:
51
+ try:
52
+ import pynvml
53
+ except ImportError as exc:
54
+ raise RuntimeError(_DETECT_HINT) from exc
55
+ if pynvml is None: # sys.modules poisoned in tests
56
+ raise RuntimeError(_DETECT_HINT)
57
+ try:
58
+ pynvml.nvmlInit()
59
+ handle = pynvml.nvmlDeviceGetHandleByIndex(0)
60
+ name = pynvml.nvmlDeviceGetName(handle)
61
+ total = pynvml.nvmlDeviceGetMemoryInfo(handle).total
62
+ except Exception as exc: # pynvml raises NVMLError subclasses
63
+ raise RuntimeError(_DETECT_HINT) from exc
64
+ return (name.decode() if isinstance(name, bytes) else name), int(total)
65
+
66
+
67
+ def resolve(gpu) -> tuple[str, int]:
68
+ if gpu is None:
69
+ return detect()
70
+ if isinstance(gpu, bool) or not isinstance(gpu, (int, float, str)):
71
+ raise ValueError(f"gpu must be None, a GB number or a name, got {gpu!r}") # noqa: TRY004
72
+ if isinstance(gpu, (int, float)):
73
+ if not math.isfinite(gpu) or gpu <= 0:
74
+ raise ValueError(f"gpu GB must be a positive finite number, got {gpu!r}")
75
+ return f"{gpu:g} GB", int(gpu * 10**9)
76
+ return lookup(gpu)
vramcalc/loaders.py ADDED
@@ -0,0 +1,57 @@
1
+ """Resolve the `model` argument of estimate() into an Arch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from .arch import Arch
10
+
11
+ _HF_HINT = "is not a local path; install vramcalc[hf] to load from the Hub"
12
+
13
+
14
+ def load_arch(model) -> Arch:
15
+ if isinstance(model, Arch):
16
+ return model
17
+ if isinstance(model, dict):
18
+ return Arch.from_config(model)
19
+ path = Path(model)
20
+ if path.is_dir():
21
+ config = path / "config.json"
22
+ if not config.is_file():
23
+ raise FileNotFoundError(f"no config.json in directory {path}")
24
+ return _named(Arch.from_config(_read_json(config)), path.name)
25
+ if path.is_file():
26
+ return _named(Arch.from_config(_read_json(path)), path.parent.name)
27
+ repo_id = str(model)
28
+ return _named(Arch.from_config(_download_config(repo_id)), repo_id.split("/")[-1])
29
+
30
+
31
+ def _named(arch: Arch, fallback: str) -> Arch:
32
+ """Configs without _name_or_path get the input's own name so records stay identifiable."""
33
+ return arch if arch.name else dataclasses.replace(arch, name=fallback)
34
+
35
+
36
+ def _read_json(path: Path) -> dict:
37
+ try:
38
+ return json.loads(path.read_text())
39
+ except json.JSONDecodeError as exc:
40
+ raise ValueError(f"{path} is not valid JSON: {exc}") from exc
41
+
42
+
43
+ def _download_config(repo_id: str) -> dict:
44
+ try:
45
+ import huggingface_hub
46
+ except ImportError as exc:
47
+ raise ImportError(f"{repo_id!r} {_HF_HINT}") from exc
48
+ if huggingface_hub is None: # sys.modules poisoned in tests
49
+ raise ImportError(f"{repo_id!r} {_HF_HINT}")
50
+ try:
51
+ local = huggingface_hub.hf_hub_download(repo_id, "config.json")
52
+ except Exception as exc:
53
+ raise FileNotFoundError(
54
+ f"cannot fetch config.json for {repo_id!r}: {exc}"
55
+ " (gated repo? run `huggingface-cli login`; offline? pass a local path)"
56
+ ) from exc
57
+ return _read_json(Path(local))
vramcalc/memory.py ADDED
@@ -0,0 +1,114 @@
1
+ """Per-term memory formulas. Every function returns bytes unless its name says elements.
2
+
3
+ See spec section 5. Symbols: L layers, h hidden, a heads, q = a*head_dim,
4
+ kv = kv_heads*head_dim, i intermediate, V vocab, b batch, s seq_len.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .arch import Arch
10
+
11
+ DTYPE_BYTES = {"fp32": 4, "bf16": 2, "fp16": 2}
12
+
13
+ # RMSNorm keeps fp32 intermediates for backward; count per layer. Confirm with bench.
14
+ NORM_FP32_TENSORS = 2
15
+
16
+
17
+ def weights(arch: Arch, dtype_bytes: int) -> int:
18
+ return arch.param_count() * dtype_bytes
19
+
20
+
21
+ def kv_cache(arch: Arch, batch: int, seq_len: int, dtype_bytes: int) -> int:
22
+ return 2 * arch.num_layers * batch * seq_len * arch.kv * dtype_bytes
23
+
24
+
25
+ def per_layer_act(arch: Arch, batch: int, seq_len: int, attn_impl: str, dtype_bytes: int) -> int:
26
+ """Elements saved per layer for backward (also the transient per-layer footprint in inference)."""
27
+ h, q, kv, i, a = arch.hidden_size, arch.q, arch.kv, arch.intermediate_size, arch.num_heads
28
+ tokens = seq_len * batch
29
+ elements = tokens * (2 * h + q + 2 * kv + q + h + 4 * i)
30
+ # The +2h term is empirical. A6000 runs on Llama-3.2-1B/3B and Qwen2.5-1.5B leave a
31
+ # residual of about two hidden-sized tensors per layer after the logits term is fixed.
32
+ # Split it into named tensors when a model family shows >10% peak error.
33
+ elements += tokens * 2 * h
34
+ if attn_impl in ("sdpa", "eager") and kv < q:
35
+ elements += 2 * tokens * q # repeat_kv materializes K and V at query width
36
+ if attn_impl == "eager":
37
+ # score matrix in dtype plus fp32 softmax, expressed in dtype-sized elements
38
+ elements += a * seq_len * seq_len * batch * (dtype_bytes + 4) // dtype_bytes
39
+ return elements
40
+
41
+
42
+ def inference_activations(
43
+ arch: Arch, batch: int, seq_len: int, attn_impl: str, dtype_bytes: int
44
+ ) -> int:
45
+ """Forward-only peak: prefill logits plus a small working set.
46
+
47
+ Per-layer buffers are freed as the forward proceeds, so they do not survive to the peak
48
+ (measured: kv_cache + logits explains the peak within 30 MB on 1B to 8B models).
49
+ """
50
+ tokens = seq_len * batch
51
+ logits = tokens * arch.vocab_size * dtype_bytes
52
+ working = 2 * tokens * arch.hidden_size * dtype_bytes
53
+ if attn_impl == "eager":
54
+ # score matrix in dtype plus fp32 softmax for one layer, and repeat_kv K/V expansion
55
+ working += arch.num_heads * seq_len * seq_len * batch * (dtype_bytes + 4)
56
+ working += 2 * tokens * arch.q * dtype_bytes
57
+ return logits + working
58
+
59
+
60
+ LORA_TARGETS = 4 # q_proj, k_proj, v_proj, o_proj
61
+
62
+
63
+ def lora_params(arch: Arch, rank: int) -> int:
64
+ h, q, kv = arch.hidden_size, arch.q, arch.kv
65
+ # in+out per target: q (h,q), k (h,kv), v (h,kv), o (q,h)
66
+ return arch.num_layers * rank * (4 * h + 2 * q + 2 * kv)
67
+
68
+
69
+ def cast_params(arch: Arch) -> int:
70
+ """Linear weights autocast copies; the output linear is cast once whether tied or not."""
71
+ return arch.num_layers * (arch.attn_params() + arch.mlp_params()) + arch.embed_params()
72
+
73
+
74
+ def gradients(trainable: int, state_bytes: int) -> int:
75
+ return trainable * state_bytes
76
+
77
+
78
+ def optimizer_states(trainable: int, optimizer: str, state_bytes: int) -> int:
79
+ moments = 2 if optimizer == "adamw" else 1
80
+ return trainable * moments * state_bytes
81
+
82
+
83
+ def step_workspace(trainable: int, state_bytes: int) -> int:
84
+ """Temporary buffer of the foreach optimizer implementation, alive only during step()."""
85
+ return trainable * state_bytes
86
+
87
+
88
+ def autocast_cache(
89
+ arch: Arch, precision: str, dtype_bytes: int, *, lora: bool, grad_checkpoint: bool
90
+ ) -> int:
91
+ if precision != "amp":
92
+ return 0
93
+ full = cast_params(arch) * dtype_bytes
94
+ if lora and grad_checkpoint:
95
+ # frozen casts are not cached; only the layer being recomputed holds them
96
+ return full // arch.num_layers
97
+ return full
98
+
99
+
100
+ def train_activations(
101
+ arch: Arch, batch: int, seq_len: int, attn_impl: str, dtype_bytes: int,
102
+ *, grad_checkpoint: bool, lora_rank: int | None,
103
+ ) -> int:
104
+ L, h, V = arch.num_layers, arch.hidden_size, arch.vocab_size
105
+ tokens = seq_len * batch
106
+ layer = per_layer_act(arch, batch, seq_len, attn_impl, dtype_bytes) * dtype_bytes
107
+ norm_fp32 = NORM_FP32_TENSORS * 4 * tokens * h * L
108
+ lora_branch = LORA_TARGETS * L * tokens * lora_rank * 4 if lora_rank else 0
109
+ # logits peak sits in log_softmax backward: dtype logits + saved fp32 log_softmax
110
+ # + fp32 grad_out + fp32 grad_in (the fp32 upcast itself is freed before backward)
111
+ logits = tokens * V * (dtype_bytes + 4 + 4 + 4)
112
+ if grad_checkpoint:
113
+ return L * tokens * h * dtype_bytes + layer + norm_fp32 // L + lora_branch + logits
114
+ return L * layer + norm_fp32 + lora_branch + logits
vramcalc/suggest.py ADDED
@@ -0,0 +1,45 @@
1
+ """Adjustment suggestions when an estimate does not fit. Spec section 6.3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .estimate import Estimate, EstimateParams, Suggestion, estimate_from_params
6
+
7
+ MIN_SEQ = 512
8
+ MIN_BATCH = 1
9
+
10
+
11
+ def _halving(p: EstimateParams, key: str, floor: int, usable: int):
12
+ """Return the first halved value of `key` whose estimate fits, or None."""
13
+ value = getattr(p, key)
14
+ while value > floor:
15
+ value = max(value // 2, floor)
16
+ e = estimate_from_params(p.replace(**{key: value}))
17
+ if e.total + e.overhead <= usable:
18
+ return {key: value}
19
+ return None
20
+
21
+
22
+ def _candidates(p: EstimateParams, usable: int):
23
+ train = p.mode == "train"
24
+ if train and not p.grad_checkpoint:
25
+ yield {"grad_checkpoint": True}, False
26
+ for key, floor in (("batch", MIN_BATCH), ("seq_len", MIN_SEQ)):
27
+ hit = _halving(p, key, floor, usable)
28
+ if hit:
29
+ yield hit, train
30
+ if train and p.lora_rank is None:
31
+ yield {"lora_rank": 16}, True
32
+ if train and p.optimizer == "adamw":
33
+ yield {"optimizer": "sgd"}, True
34
+ if train and p.precision == "amp":
35
+ yield {"precision": "pure"}, True
36
+
37
+
38
+ def suggest(est: Estimate, usable_bytes: int) -> list[Suggestion]:
39
+ out: list[Suggestion] = []
40
+ for change, changes_objective in _candidates(est.params, usable_bytes):
41
+ e = estimate_from_params(est.params.replace(**change))
42
+ margin = usable_bytes - (e.total + e.overhead)
43
+ if margin >= 0:
44
+ out.append(Suggestion(change, changes_objective, e, margin))
45
+ return out
@@ -0,0 +1,237 @@
1
+ Metadata-Version: 2.5
2
+ Name: vramcalc
3
+ Version: 0.1.0
4
+ Summary: Predict GPU VRAM usage of Llama-family models from config.json and check whether they fit a GPU
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
10
+ Requires-Python: >=3.10
11
+ Provides-Extra: bench
12
+ Requires-Dist: nvidia-ml-py; extra == 'bench'
13
+ Requires-Dist: peft>=0.12; extra == 'bench'
14
+ Requires-Dist: torch>=2.3; extra == 'bench'
15
+ Requires-Dist: transformers<5,>=4.46; extra == 'bench'
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == 'dev'
18
+ Requires-Dist: ruff; extra == 'dev'
19
+ Provides-Extra: hf
20
+ Requires-Dist: huggingface-hub; extra == 'hf'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # vramcalc
24
+
25
+ [English](#english) | [한국어](#한국어)
26
+
27
+ ---
28
+
29
+ ## English
30
+
31
+ Predict how much GPU memory an LLM needs before you download the weights or rent the GPU. vramcalc reads only the Hugging Face `config.json`, tells you whether the model fits a given GPU, and when it does not, which single change would make it fit. A `bench` subcommand measures real memory on your GPU and compares it with the prediction, bucket by bucket.
32
+
33
+ ```python
34
+ from vramcalc import estimate
35
+
36
+ e = estimate("meta-llama/Llama-3.1-8B", mode="train", lora_rank=16,
37
+ precision="pure", seq_len=4096)
38
+ print(e.breakdown())
39
+ r = e.fits("RTX 3090")
40
+ print(r.message)
41
+ for s in r.suggestions:
42
+ print(s.change, s.margin_bytes)
43
+ ```
44
+
45
+ ```
46
+ $ vramcalc meta-llama/Llama-3.1-8B --train --lora 16 --precision pure --seq 4096 --gpu "RTX 3090"
47
+ weights 16.12 GB
48
+ gradients 0.05 GB
49
+ optimizer 0.11 GB
50
+ activations 36.92 GB
51
+ step_workspace 0.05 GB
52
+ total 53.19 GB
53
+ overhead 0.75 GB
54
+
55
+ short by 28.9 GB on RTX 3090; 1 single adjustment fits
56
+ -> seq_len=512: 21.6 GB (changes training objective)
57
+ ```
58
+
59
+ ### Install
60
+
61
+ | Command | What you get |
62
+ |---|---|
63
+ | `pip install vramcalc` | Estimator and CLI. No dependencies. Takes a local `config.json` or a model directory |
64
+ | `pip install "vramcalc[hf]"` | Hugging Face Hub repo ids as input (`huggingface_hub`) |
65
+ | `pip install "vramcalc[bench]"` | `vramcalc bench` for real measurements (`torch`, `transformers`, `peft`, `nvidia-ml-py`) |
66
+
67
+ Offline or on-premises: pass a model directory, `estimate("/path/to/model")`. With `HF_HUB_OFFLINE=1`, Hub ids resolve from the local cache only.
68
+
69
+ ### Why
70
+
71
+ "Will this model run on this GPU with these settings?" is a question you want answered before downloading 16 GB of weights or paying for an H100 hour. Existing calculators mostly size the weights and treat activations, optimizer state and autocast copies loosely, which is where training estimates drift by several gigabytes. vramcalc keeps one formula per memory term and ships a `bench` command that measures the same configuration on a real GPU, so every term can be checked against the allocator. The formulas are fixed; there is no learned correction factor. Measurements are evidence for fixing a formula, not a fudge applied on top.
72
+
73
+ ### Scope
74
+
75
+ - Models: dense decoder-only models with `model_type` of `llama`, `mistral`, `qwen2`, `gemma` or `gemma2`. GQA, QKV bias (Qwen2), tied embeddings and Gemma 2's four norms per layer are read from the config.
76
+ - Modes: inference (with KV cache), full fine-tuning, LoRA on `q_proj`, `k_proj`, `v_proj`, `o_proj`.
77
+ - Knobs: `dtype` (fp32/bf16/fp16), `batch`, `seq_len`, `grad_checkpoint`, `optimizer` (adamw/sgd), `precision` (amp/pure), `attn_impl` (eager/sdpa/flash).
78
+
79
+ `precision="amp"` models HF Trainer with `bf16=True` (fp32 weights, bf16 autocast). `precision="pure"` models loading with `torch_dtype=bf16` and training as is. The two differ by almost 2x in memory, so pick the one you actually run.
80
+
81
+ Out of scope for 0.1: MoE, MLA, quantization (int8/int4/QLoRA), distributed training (ZeRO/FSDP), multimodal and encoder models, and sliding-window KV caches (currently counted as global attention, which is conservative).
82
+
83
+ ### What is counted
84
+
85
+ The prediction targets PyTorch's caching allocator (`allocated` memory). CUDA context and other memory outside the allocator is kept separate as `overhead` (default 0.75 GB) and only added in `fits()`.
86
+
87
+ | Term | Meaning |
88
+ |---|---|
89
+ | `weights` | parameter count x storage dtype; for LoRA, frozen base plus fp32 adapters |
90
+ | `kv_cache` | inference only, `2 x L x b x s x kv x bytes` |
91
+ | `gradients`, `optimizer` | over trainable parameters; AdamW keeps two moments, SGD one |
92
+ | `activations` | per-layer tensors saved for backward (SwiGLU, RMSNorm) plus the fp32 logits chain at the `log_softmax` backward peak |
93
+ | `autocast_cache` | bf16 weight copies alive during an amp forward pass |
94
+ | `step_workspace` | temporary buffer of the foreach optimizer during `step()` |
95
+
96
+ `total = weights + kv_cache + gradients + optimizer + max(activations + autocast_cache, step_workspace)`. The full formulas are in `docs/design.md`, section 5.
97
+
98
+ ### Validation
99
+
100
+ `vramcalc bench` loads the model with the same arguments, records `torch.cuda.max_memory_allocated()`, and compares five buckets: weights, states, peak, total, overhead.
101
+
102
+ ```
103
+ vramcalc bench /path/to/Llama-3.2-1B --train --precision pure --record benchmarks/results.jsonl
104
+ ```
105
+
106
+ Environment: NVIDIA RTX A6000 48 GB, torch 2.14.1+cu130, transformers 4.57.6, peft 0.21.2. Llama weights are gated, so the `unsloth/` mirrors of the same checkpoints were used. All 19 runs land within +/-4.3% on `total`; `weights` is exact in every run.
107
+
108
+ | model | mode | setting | GPU | predicted | measured | error |
109
+ |---|---|---|---|---|---|---|
110
+ | Llama-3.2-1B | inference | bf16 b=1 s=2048 sdpa | RTX A6000 | 3.08 GB | 3.08 GB | -0.1% |
111
+ | Llama-3.2-1B | inference | bf16 b=1 s=4096 sdpa | RTX A6000 | 3.69 GB | 3.68 GB | +0.2% |
112
+ | Llama-3.2-1B | inference | bf16 b=1 s=2048 eager | RTX A6000 | 3.90 GB | 3.95 GB | -1.2% |
113
+ | Llama-3.2-3B | inference | bf16 b=1 s=2048 sdpa | RTX A6000 | 7.21 GB | 7.21 GB | +0.0% |
114
+ | Llama-3.1-8B | inference | bf16 b=1 s=2048 sdpa | RTX A6000 | 16.89 GB | 16.88 GB | +0.0% |
115
+ | Qwen2.5-1.5B | inference | bf16 b=1 s=2048 sdpa | RTX A6000 | 3.78 GB | 3.78 GB | -0.1% |
116
+ | Llama-3.2-1B | train | bf16 b=1 s=2048 sdpa full pure | RTX A6000 | 17.52 GB | 17.51 GB | +0.0% |
117
+ | Llama-3.2-1B | train | bf16 b=1 s=2048 sdpa full pure ckpt | RTX A6000 | 13.95 GB | 13.76 GB | +1.4% |
118
+ | Llama-3.2-1B | train | bf16 b=1 s=2048 sdpa full amp | RTX A6000 | 29.88 GB | 30.55 GB | -2.2% |
119
+ | Llama-3.2-1B | train | bf16 b=1 s=2048 sdpa full amp ckpt | RTX A6000 | 26.30 GB | 25.22 GB | +4.3% |
120
+ | Llama-3.2-1B | train | bf16 b=1 s=2048 eager full pure | RTX A6000 | 30.41 GB | 30.26 GB | +0.5% |
121
+ | Llama-3.2-3B | train | bf16 b=1 s=2048 sdpa full pure ckpt | RTX A6000 | 32.13 GB | 32.15 GB | -0.1% |
122
+ | Qwen2.5-1.5B | train | bf16 b=1 s=2048 sdpa full pure | RTX A6000 | 23.17 GB | 23.40 GB | -1.0% |
123
+ | Llama-3.2-1B | train | bf16 b=1 s=2048 sdpa lora=16 pure | RTX A6000 | 10.17 GB | 10.13 GB | +0.4% |
124
+ | Llama-3.2-1B | train | bf16 b=1 s=2048 sdpa lora=16 amp | RTX A6000 | 15.11 GB | 14.52 GB | +4.1% |
125
+ | Llama-3.2-1B | train | bf16 b=1 s=2048 sdpa lora=16 amp ckpt | RTX A6000 | 9.22 GB | 9.51 GB | -3.1% |
126
+ | Llama-3.2-3B | train | bf16 b=1 s=2048 sdpa lora=16 pure | RTX A6000 | 18.84 GB | 18.98 GB | -0.8% |
127
+ | Llama-3.1-8B | train | bf16 b=1 s=2048 sdpa lora=16 pure ckpt | RTX A6000 | 20.97 GB | 20.55 GB | +2.0% |
128
+ | Qwen2.5-1.5B | train | bf16 b=1 s=2048 sdpa lora=16 pure | RTX A6000 | 13.99 GB | 13.78 GB | +1.5% |
129
+
130
+ `pure` is `torch_dtype=bf16` training, `amp` is fp32 weights with bf16 autocast, `ckpt` is gradient checkpointing. Per-bucket numbers are in `benchmarks/results.jsonl`; regenerate the table with `python benchmarks/make_table.py`.
131
+
132
+ What the measurements changed in the formulas: the inference peak is just `kv_cache + logits` (per-layer buffers are freed as the forward proceeds); the training logits peak is 14 bytes per logit, not 10, because `log_softmax` backward holds its saved output and both gradient tensors at once; and each layer saves about two more hidden-sized tensors than the textbook count.
133
+
134
+ ### Limits
135
+
136
+ - `bench` uses `torch.autocast` directly, not HF Trainer. Extra conversions done by Accelerate are not modelled.
137
+ - `fits()` assumes nothing else is using the GPU and takes 97% of the nominal capacity as usable.
138
+ - `attn_impl="sdpa"` assumes the memory-efficient or flash backend. If your environment falls back to the math backend, use `eager`.
139
+ - Overhead was measured on an A6000 with CUDA 13; other cards and driver versions may differ by a few hundred megabytes.
140
+
141
+ ### License
142
+
143
+ MIT
144
+
145
+ ---
146
+
147
+ ## 한국어
148
+
149
+ 모델 가중치를 받기 전, GPU를 빌리기 전에 LLM이 얼마만큼의 GPU 메모리를 필요로 하는지 예측하는 라이브러리입니다. Hugging Face `config.json`만 읽어 지정한 GPU에 들어가는지 판정하고, 들어가지 않으면 무엇을 하나 바꾸면 들어가는지 제안합니다. `bench` 명령은 실제 GPU에서 메모리를 측정해 예측과 항목별로 비교합니다.
150
+
151
+ ```python
152
+ from vramcalc import estimate
153
+
154
+ e = estimate("meta-llama/Llama-3.1-8B", mode="train", lora_rank=16,
155
+ precision="pure", seq_len=4096)
156
+ print(e.breakdown())
157
+ r = e.fits("RTX 3090")
158
+ print(r.message)
159
+ for s in r.suggestions:
160
+ print(s.change, s.margin_bytes)
161
+ ```
162
+
163
+ ```
164
+ $ vramcalc meta-llama/Llama-3.1-8B --train --lora 16 --precision pure --seq 4096 --gpu "RTX 3090"
165
+ weights 16.12 GB
166
+ gradients 0.05 GB
167
+ optimizer 0.11 GB
168
+ activations 36.92 GB
169
+ step_workspace 0.05 GB
170
+ total 53.19 GB
171
+ overhead 0.75 GB
172
+
173
+ short by 28.9 GB on RTX 3090; 1 single adjustment fits
174
+ -> seq_len=512: 21.6 GB (changes training objective)
175
+ ```
176
+
177
+ ### 설치
178
+
179
+ | 명령 | 포함 내용 |
180
+ |---|---|
181
+ | `pip install vramcalc` | 계산기와 CLI. 의존성 없음. 로컬 `config.json` 또는 모델 디렉터리 입력 |
182
+ | `pip install "vramcalc[hf]"` | Hugging Face Hub repo id 입력 (`huggingface_hub`) |
183
+ | `pip install "vramcalc[bench]"` | 실측 비교 `vramcalc bench` (`torch`, `transformers`, `peft`, `nvidia-ml-py`) |
184
+
185
+ 오프라인이나 온프레미스 환경에서는 `estimate("/path/to/model")`처럼 모델 디렉터리를 넘기면 됩니다. `HF_HUB_OFFLINE=1`이면 Hub id도 로컬 캐시만 조회합니다.
186
+
187
+ ### 만든 이유
188
+
189
+ "이 모델을 이 설정으로 이 GPU에서 돌릴 수 있는가"는 16GB 가중치를 받거나 H100을 한 시간 빌리기 전에 알아야 하는 질문입니다. 기존 계산기는 대부분 가중치 크기 위주이고 activation, optimizer 상태, autocast 복사본은 대략적으로만 다루기 때문에 학습 모드에서 수 GB씩 어긋납니다. vramcalc는 메모리 항목마다 식을 하나씩 두고, 같은 설정을 실제 GPU에서 측정하는 `bench` 명령을 함께 제공해 항목별로 allocator 값과 대조할 수 있게 했습니다. 식은 고정이며 보정 계수를 학습하지 않습니다. 실측은 식을 고치는 근거이지, 위에 덧씌우는 보정이 아닙니다.
190
+
191
+ ### 지원 범위
192
+
193
+ - 모델: `model_type`이 `llama`, `mistral`, `qwen2`, `gemma`, `gemma2`인 dense decoder-only 모델. GQA, QKV bias(Qwen2), 임베딩 공유, Gemma 2의 레이어당 norm 4개를 config에서 읽어 반영합니다.
194
+ - 모드: 추론(KV cache 포함), 전체 학습, LoRA(`q_proj`, `k_proj`, `v_proj`, `o_proj` 대상)
195
+ - 옵션: `dtype`(fp32/bf16/fp16), `batch`, `seq_len`, `grad_checkpoint`, `optimizer`(adamw/sgd), `precision`(amp/pure), `attn_impl`(eager/sdpa/flash)
196
+
197
+ `precision="amp"`는 HF Trainer `bf16=True` 방식(fp32 가중치, bf16 autocast)이고, `precision="pure"`는 `torch_dtype=bf16`으로 로드해 그대로 학습하는 방식입니다. 둘의 메모리는 2배 가까이 차이가 나므로 실제로 사용하는 방식을 지정해야 합니다.
198
+
199
+ 0.1에서 제외된 항목: MoE, MLA, 양자화(int8/int4/QLoRA), 분산 학습(ZeRO/FSDP), 멀티모달과 encoder 모델, sliding window KV cache(현재는 전부 global attention으로 보수적으로 계산).
200
+
201
+ ### 계산 항목
202
+
203
+ 예측 대상은 PyTorch caching allocator 기준 `allocated` 메모리입니다. CUDA context 등 allocator 밖 메모리는 `overhead`(기본 0.75GB)로 분리해 `fits()` 판정에서만 더합니다.
204
+
205
+ | 항목 | 내용 |
206
+ |---|---|
207
+ | `weights` | 파라미터 수 × 저장 dtype. LoRA는 frozen base + fp32 어댑터 |
208
+ | `kv_cache` | 추론 전용. `2 × L × b × s × kv × bytes` |
209
+ | `gradients`, `optimizer` | 학습 대상 파라미터 기준. AdamW는 상태 2개, SGD는 momentum 1개 |
210
+ | `activations` | 레이어당 역전파 저장분(SwiGLU, RMSNorm) + `log_softmax` 역전파 시점의 fp32 logits 체인 |
211
+ | `autocast_cache` | amp에서 forward 동안 살아 있는 bf16 가중치 복사본 |
212
+ | `step_workspace` | foreach optimizer의 `step()` 중 임시 버퍼 |
213
+
214
+ `total = weights + kv_cache + gradients + optimizer + max(activations + autocast_cache, step_workspace)`. 상세 식은 `docs/design.md` 5절에 있습니다.
215
+
216
+ ### 실측 검증
217
+
218
+ `vramcalc bench`는 같은 인자로 모델을 실제로 로드해 `torch.cuda.max_memory_allocated()`를 기록하고 weights, states, peak, total, overhead 다섯 묶음으로 예측과 비교합니다.
219
+
220
+ ```
221
+ vramcalc bench /path/to/Llama-3.2-1B --train --precision pure --record benchmarks/results.jsonl
222
+ ```
223
+
224
+ 측정 환경: NVIDIA RTX A6000 48GB, torch 2.14.1+cu130, transformers 4.57.6, peft 0.21.2. Llama 가중치는 gated라 동일 가중치의 `unsloth/` 미러를 사용했습니다. 19개 조합 모두 `total` 오차 ±4.3% 안이며 `weights`는 전부 0.0%입니다. 표는 위 영어 절과 같고, 항목별 수치는 `benchmarks/results.jsonl`에 있습니다. `python benchmarks/make_table.py`로 표를 다시 생성할 수 있습니다.
225
+
226
+ 실측으로 바뀐 식: 추론 peak는 `kv_cache + logits`만으로 설명되고(레이어 버퍼는 forward 진행 중 해제됨), 학습 logits peak는 logit당 10바이트가 아니라 14바이트이며(`log_softmax` 역전파가 저장 출력과 두 gradient를 동시에 보유), 레이어마다 교과서 계산보다 hidden 크기 텐서 약 2개가 더 저장됩니다.
227
+
228
+ ### 한계
229
+
230
+ - `bench`는 HF Trainer가 아니라 `torch.autocast`를 직접 사용합니다. Accelerate가 추가로 수행하는 변환은 반영하지 않습니다.
231
+ - `fits()`는 GPU에 다른 프로세스가 없다고 가정하고 표기 용량의 97%를 사용 가능 용량으로 봅니다.
232
+ - `attn_impl="sdpa"`는 memory-efficient 또는 flash backend를 전제로 합니다. math backend로 떨어지는 환경이면 `eager`로 지정해야 합니다.
233
+ - overhead는 A6000과 CUDA 13에서 측정한 값이라 다른 카드와 드라이버에서는 수백 MB 차이가 날 수 있습니다.
234
+
235
+ ### 라이선스
236
+
237
+ MIT
@@ -0,0 +1,14 @@
1
+ vramcalc/__init__.py,sha256=DUN1u42xUL8MEdOvxGS8DCqCVIIMIaZrcyDh51qOGWs,393
2
+ vramcalc/arch.py,sha256=NMnvXXJR8bAK1qdkeU_8LXCfwHgrz4QfKGhkoKYquKc,4092
3
+ vramcalc/bench.py,sha256=ri4dUM_eibBv4BJb6FF9s6Z_8Nt0TXIL7s1gXvy-yME,8764
4
+ vramcalc/cli.py,sha256=pqXR3kuepRLpCVuHv9Y9spWzr5goaumZCsl6D07zpQE,4548
5
+ vramcalc/estimate.py,sha256=B4_gVJpVLMLeu8LcmscUOr05dRL2azNb7NgasO4xwAI,7811
6
+ vramcalc/gpus.py,sha256=ONH4QdllhkX9tq2CZHZNkYuI-QAT8vAwGlzoO-_R8Qs,2709
7
+ vramcalc/loaders.py,sha256=8LZhso1SeMfIg5KMxJlS7oibMRBLcjIP_mVHVYzSge0,1948
8
+ vramcalc/memory.py,sha256=rg2bJDeuuSEHDzyfJxOZF4M1KKg4KR_gab8SC8MCgz8,4730
9
+ vramcalc/suggest.py,sha256=8quT_oHviKZCVYd3PnEzvhmmLMbfwtQJW1fDAxnQk-c,1589
10
+ vramcalc-0.1.0.dist-info/METADATA,sha256=FVI9Lor-t74oD2yro-5FQw1Uk0g2BPa0UxY3-o_I0co,14832
11
+ vramcalc-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ vramcalc-0.1.0.dist-info/entry_points.txt,sha256=9gfnZdZWjOVUTQmszl3qMLvQyUs83bskk-IiFNSwi7g,47
13
+ vramcalc-0.1.0.dist-info/licenses/LICENSE,sha256=sZhDRicBLDYEeQNMKQ5o6sRsujSsfddgm_NG8u0a5iY,1068
14
+ vramcalc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vramcalc = vramcalc.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Seonho Hong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.