Course: Course 3 — LLM Fine-Tuning Masterclass Module: FT01 — VRAM Math: Can I Actually Run This? Duration: 45–60 minutes Environment: Python 3.10+. No GPU required. This is a pure-Python estimation lab — it runs on a laptop, Colab T4 (free), or any machine. Optional: run the validation against a real loaded model on a GPU/MPS box to feel the estimate vs. reality.
By the end of this lab you will have:
This lab is deliberately GPU-free. The math is the point. Once you can estimate, the lab's stretch goal checks your estimate against nvidia-smi (or Activity Monitor on Apple Silicon) on a real load.
python3 -m venv ft01-env && source ft01-env/bin/activate
# No ML deps needed for the core lab. Optional GPU validation (Phase 4) needs torch.
pip install -q torch # optional — only for the real-model check in Phase 4
No other dependencies. The estimator is pure Python.
Implement this function in a file called vram_calc.py:
def vram_estimate(model_params_b, method, context_len, batch_size=1,
gradient_checkpointing=True, flash_attention=True):
"""Estimate training VRAM in GB from the three consumers.
Args:
model_params_b: model size in billions of params (e.g. 7, 70)
method: "qlora" | "lora16" | "full"
context_len: max sequence length in tokens (use your 99th pctile)
batch_size: PHYSICAL micro-batch size (effective batch uses grad accum)
gradient_checkpointing: discard-and-recompute activations (default on)
flash_attention: fused attention, linear memory (default on)
Returns:
total training VRAM in GB (float).
"""
The estimator sums three consumers. Implement them exactly as below — the constants are calibrated against the field rules of thumb (Introl Dec 2025, Spheron PEFT guide, QLoRA paper).
Consumer 1 — Weights (the frozen/loaded base):
| Method | Base precision | Bytes/param |
|---|---|---|
qlora |
4-bit (NF4) frozen | 0.5 |
lora16 |
FP16/BF16 frozen | 2.0 |
full |
FP16 trainable | 2.0 |
weights_gb = bytes_per_param × params_b
Consumer 2 — Optimizer states + gradients (only over trainable params):
qlora / lora16: only the adapter trains. Assume adapter ≈ 1% of params. Per trainable param: FP16 weight (2) + FP16 gradient (2) + 8-bit AdamW states (~2) ≈ 6 bytes/trainable-param.full: all params trainable. Per trainable param: FP16 gradient (2) + FP32 master copy (4) + AdamW m (4) + AdamW v (4) = 14 bytes/trainable-param (on top of the 2-byte FP16 weight counted in Consumer 1, for 16 bytes/param total).opt_gb = bytes_per_trainable_param × trainable_params_b
Consumer 3 — Activations (scales with your data):
Activations scale with layers, hidden dim, batch, and context. Derive architecture from params using transformer scaling (params ≈ 12 × layers × hidden², with layers ≈ hidden/120), giving:
hidden = round((10 * params_n) ** (1/3) / 64) * 64 # snapped to 64
layers = round(hidden / 120)
Then:
BASE_K = 2.9e-8 # calibrated constant (FlashAttention-on baseline)
act_gb = BASE_K * layers * batch_size * context_len * hidden
if gradient_checkpointing: act_gb *= 0.35 # ~65% activation-memory cut
if not flash_attention: act_gb *= 1.7 # naive N×N attention penalty
total_gb = weights_gb + opt_gb + act_gb
Why derive
hidden/layersfrom params? Because activation memory islayers × batch × seq × hidden, and larger models have bigger hidden dims — so activation memory does not scale linearly with parameter count. This is why a 70B is not 10× a 7B in activation cost. Thehidden = (10·params)^(1/3)relation reproduces real architectures to within ~10% (7B→~4096/34, 70B→~8900/74).
Write the full file. A reference skeleton:
"""vram_calc.py — FT01 lab: bottom-up training VRAM estimator."""
def _arch(params_b):
"""Derive (hidden, layers) from param count via transformer scaling."""
params_n = params_b * 1e9
hidden = round((10 * params_n) ** (1 / 3) / 64) * 64
layers = max(1, round(hidden / 120))
return hidden, layers
def vram_breakdown(model_params_b, method, context_len, batch_size=1,
gradient_checkpointing=True, flash_attention=True):
"""Return the per-consumer breakdown as a dict."""
assert method in ("qlora", "lora16", "full"), f"unknown method: {method}"
hidden, layers = _arch(model_params_b)
params_n = model_params_b * 1e9
# --- Consumer 1: weights ---
base_bytes = {"qlora": 0.5, "lora16": 2.0, "full": 2.0}[method]
weights_gb = base_bytes * model_params_b
# --- Consumer 2: optimizer states + gradients (trainable params only) ---
if method in ("qlora", "lora16"):
trainable_b = 0.01 * model_params_b # adapter ~1% of params
bytes_per_trainable = 6 # FP16 wt+grad + 8-bit opt
else: # full
trainable_b = model_params_b # all params trainable
bytes_per_trainable = 14 # grad + FP32 master + m + v
opt_gb = bytes_per_trainable * trainable_b
# --- Consumer 3: activations ---
BASE_K = 2.9e-8
act_gb = BASE_K * layers * batch_size * context_len * hidden
if gradient_checkpointing:
act_gb *= 0.35
if not flash_attention:
act_gb *= 1.7
return {
"method": method,
"params_b": model_params_b,
"context_len": context_len,
"batch_size": batch_size,
"arch": {"hidden": hidden, "layers": layers},
"weights_gb": round(weights_gb, 1),
"optimizer_gradients_gb": round(opt_gb, 1),
"activations_gb": round(act_gb, 1),
"total_gb": round(weights_gb + opt_gb + act_gb, 1),
}
def vram_estimate(model_params_b, method, context_len, batch_size=1,
gradient_checkpointing=True, flash_attention=True):
"""Headline API: return total training VRAM in GB (float)."""
return vram_breakdown(model_params_b, method, context_len, batch_size,
gradient_checkpointing, flash_attention)["total_gb"]
def recommend_gpu(total_gb):
"""Map a VRAM budget to a GPU class (the three-question framework, step 4).
Boundaries leave ~25% headroom: a 15 GB estimate lands on a 24 GB card,
not a 16 GB card, because CUDA context + fragmentation + optimizer buffers
eat into the nominal budget. Buy headroom; don't squeeze onto the ceiling.
"""
if total_gb <= 12: return "Laptop / Apple Silicon / Colab T4 (free)"
if total_gb <= 18: return "RTX 4090 24GB ($1,500) — 7B QLoRA sweet spot"
if total_gb <= 30: return "RTX 4090 24GB with checkpointing, or A100 40GB"
if total_gb <= 60: return "1× A100 80GB"
if total_gb <= 120: return "2× A100 80GB"
if total_gb <= 160: return "multi-A100 80GB (~$50K of GPUs)"
if total_gb <= 640: return "8× A100/H100 single node"
return "multi-node H100 cluster (8–16× H100)"
if __name__ == "__main__":
import json
cases = [
(7, "qlora", 4096, 2),
(70, "qlora", 2048, 1),
(7, "full", 4096, 1),
]
for params, method, ctx, bs in cases:
bd = vram_breakdown(params, method, ctx, bs)
print(f"\n=== {params}B {method} @ {ctx} ctx, bs {bs} ===")
print(json.dumps(bd, indent=2))
print(f" -> recommend: {recommend_gpu(bd['total_gb'])}")
Run it:
python vram_calc.py
Add this validation block to the bottom of vram_calc.py (or a separate test_vram.py run with pytest), then run it. All three asserts must pass.
def validate():
"""Three real jobs. Each must land within ~20% of the field rule of thumb."""
TOL = 0.20
# Job 1: 7B QLoRA, 4K context, batch 2. Rule of thumb: 10-16 GB. Fits RTX 4090 24GB.
j1 = vram_estimate(7, "qlora", 4096, batch_size=2)
assert 10 <= j1 <= 16, f"7B QLoRA: expected 10-16 GB, got {j1}"
assert j1 <= 24, f"7B QLoRA must fit RTX 4090 24GB, got {j1}"
# Job 2: 70B QLoRA, 2K context, batch 1. Rule of thumb: 48-60 GB. Fits 1× A100 80GB.
j2 = vram_estimate(70, "qlora", 2048, batch_size=1)
assert 48 <= j2 <= 60, f"70B QLoRA: expected 48-60 GB, got {j2}"
assert j2 <= 80, f"70B QLoRA must fit 1× A100 80GB, got {j2}"
# Job 3: 7B full FT, 4K context, batch 1, checkpointing on. Rule of thumb: 100-160 GB.
j3 = vram_estimate(7, "full", 4096, batch_size=1, gradient_checkpointing=True)
assert 100 <= j3 <= 160, f"7B full FT: expected 100-160 GB, got {j3}"
assert j3 > 80, f"7B full FT needs multi-A100 (>80GB), got {j3}"
print("\nAll three validation jobs PASS.")
print(f" 7B QLoRA : {j1} GB -> {recommend_gpu(j1)}")
print(f" 70B QLoRA: {j2} GB -> {recommend_gpu(j2)}")
print(f" 7B Full : {j3} GB -> {recommend_gpu(j3)}")
if __name__ == "__main__":
validate()
Expected output (your numbers should match to within rounding):
All three validation jobs PASS.
7B QLoRA : 15.5 GB -> RTX 4090 24GB ($1,500) — 7B QLoRA sweet spot
70B QLoRA: 52.9 GB -> 1× A100 80GB
7B Full : 117.8 GB -> 2× A100 80GB
(7B Full at 118 GB lands on 2× A100 80GB = 160 GB capacity — that is the "multi-A100 ($50K)" class the teaching doc describes; recommend_gpu just names the minimum card count.)
If an assert fails, debug which consumer is off. The most common student bugs:
params_b directly instead of deriving hidden/layers. Activations need the architecture.If you have a GPU (or Apple Silicon), load a small model and compare your estimate to reality. This is the moment the numbers stop being abstract.
import torch
from transformers import AutoModelForCausalLM
# Pick a small open-data model (from FT00): MiniCPM5-1B
MODEL_ID = "openbmb/MiniCPM5-1B"
# Step 1: PREDICT before loading (inference floor = 4-bit weights for QLoRA-style load)
params_b = 1.0
pred_weights_gb = 0.5 * params_b # 4-bit
print(f"Predicted 4-bit weight footprint: {pred_weights_gb} GB")
# Step 2: LOAD and measure
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.float16, device_map="auto", trust_remote_code=True,
)
if torch.cuda.is_available():
actual = torch.cuda.memory_allocated() / 1e9
print(f"Actual FP16 loaded VRAM: {actual:.2f} GB")
print(f"Predicted FP16: {2.0 * params_b} GB (factor ~{actual/(2.0*params_b):.2f}x = overhead)")
elif torch.backends.mps.is_available():
print("On Apple Silicon: watch Activity Monitor GPU memory while loading.")
The inference footprint (Consumer 1 only) will be ~1.5–2× the bare weight footprint because of CUDA context, KV cache, and activation buffers. For training you add Consumers 2 and 3 on top — which is exactly why training costs 3–10× inference for the same model. Your estimator captures this; nvidia-smi confirms it.
Submit ft01-lab-report.md containing:
vram_calc.py (the function + validation).python vram_calc.py showing all three validation jobs PASS, with the per-consumer breakdown printed.The lesson, stated bluntly: for Job 3, the single decision "full FT vs QLoRA" is worth ~$48,500 and ~10× the hardware. That is the entire economic argument for PEFT, and it falls out of the math.
lora16. Validate: 7B LoRA @ 4K should land ~18–30 GB (2–3× the FP16 model). Which GPU class? (A100 40GB, or 4090 with aggressive checkpointing.)inference_estimate(params_b, quant_bits, context_len) that returns weights + KV cache. Validate: Q4_K_M 7B at 4K context runs in ~6 GB; Q4 70B at 4K in ~40 GB.python vram_calc.py --params 7 --method qlora --ctx 4096 --batch 2 prints the breakdown and GPU recommendation. Make it a tool you actually use before renting GPUs.# Lab Specification — Module FT01: The VRAM Calculator
**Course**: Course 3 — LLM Fine-Tuning Masterclass
**Module**: FT01 — VRAM Math: Can I Actually Run This?
**Duration**: 45–60 minutes
**Environment**: Python 3.10+. **No GPU required.** This is a pure-Python estimation lab — it runs on a laptop, Colab T4 (free), or any machine. Optional: run the validation against a real loaded model on a GPU/MPS box to feel the estimate vs. reality.
---
## Learning objectives
By the end of this lab you will have:
1. **Built a bottom-up VRAM estimator** from the three consumers (weights, optimizer states + gradients, activations) rather than trusting a single multiplier.
2. **Validated it against three real jobs** — 7B QLoRA, 70B QLoRA, 7B full fine-tuning — and confirmed each lands within ~20% of the field rules of thumb.
3. **Turned the three-question framework into code** — model size × method × context length → a GPU-class recommendation.
4. **Internalized the numbers** so you never again open a model loader without first knowing whether it will fit.
This lab is deliberately GPU-free. The math is the point. Once you can estimate, the lab's stretch goal checks your estimate against `nvidia-smi` (or Activity Monitor on Apple Silicon) on a real load.
---
## Phase 0 — Environment setup (2 min)
```bash
python3 -m venv ft01-env && source ft01-env/bin/activate
# No ML deps needed for the core lab. Optional GPU validation (Phase 4) needs torch.
pip install -q torch # optional — only for the real-model check in Phase 4
```
No other dependencies. The estimator is pure Python.
---
## Phase 1 — The estimator spec
Implement this function in a file called `vram_calc.py`:
```python
def vram_estimate(model_params_b, method, context_len, batch_size=1,
gradient_checkpointing=True, flash_attention=True):
"""Estimate training VRAM in GB from the three consumers.
Args:
model_params_b: model size in billions of params (e.g. 7, 70)
method: "qlora" | "lora16" | "full"
context_len: max sequence length in tokens (use your 99th pctile)
batch_size: PHYSICAL micro-batch size (effective batch uses grad accum)
gradient_checkpointing: discard-and-recompute activations (default on)
flash_attention: fused attention, linear memory (default on)
Returns:
total training VRAM in GB (float).
"""
```
### The model (the rules of thumb, made executable)
The estimator sums three consumers. Implement them exactly as below — the constants are calibrated against the field rules of thumb (Introl Dec 2025, Spheron PEFT guide, QLoRA paper).
**Consumer 1 — Weights** (the frozen/loaded base):
| Method | Base precision | Bytes/param |
| --- | --- | --- |
| `qlora` | 4-bit (NF4) frozen | 0.5 |
| `lora16` | FP16/BF16 frozen | 2.0 |
| `full` | FP16 trainable | 2.0 |
`weights_gb = bytes_per_param × params_b`
**Consumer 2 — Optimizer states + gradients** (only over *trainable* params):
- `qlora` / `lora16`: only the adapter trains. Assume **adapter ≈ 1% of params**. Per trainable param: FP16 weight (2) + FP16 gradient (2) + 8-bit AdamW states (~2) ≈ **6 bytes/trainable-param**.
- `full`: **all** params trainable. Per trainable param: FP16 gradient (2) + FP32 master copy (4) + AdamW `m` (4) + AdamW `v` (4) = **14 bytes/trainable-param** (on top of the 2-byte FP16 weight counted in Consumer 1, for 16 bytes/param total).
`opt_gb = bytes_per_trainable_param × trainable_params_b`
**Consumer 3 — Activations** (scales with your data):
Activations scale with layers, hidden dim, batch, and context. Derive architecture from params using transformer scaling (`params ≈ 12 × layers × hidden²`, with `layers ≈ hidden/120`), giving:
```python
hidden = round((10 * params_n) ** (1/3) / 64) * 64 # snapped to 64
layers = round(hidden / 120)
```
Then:
```python
BASE_K = 2.9e-8 # calibrated constant (FlashAttention-on baseline)
act_gb = BASE_K * layers * batch_size * context_len * hidden
if gradient_checkpointing: act_gb *= 0.35 # ~65% activation-memory cut
if not flash_attention: act_gb *= 1.7 # naive N×N attention penalty
```
`total_gb = weights_gb + opt_gb + act_gb`
> **Why derive `hidden`/`layers` from params?** Because activation memory is `layers × batch × seq × hidden`, and larger models have bigger hidden dims — so activation memory does *not* scale linearly with parameter count. This is why a 70B is not 10× a 7B in activation cost. The `hidden = (10·params)^(1/3)` relation reproduces real architectures to within ~10% (7B→~4096/34, 70B→~8900/74).
---
## Phase 2 — Implement it (15 min)
Write the full file. A reference skeleton:
```python
"""vram_calc.py — FT01 lab: bottom-up training VRAM estimator."""
def _arch(params_b):
"""Derive (hidden, layers) from param count via transformer scaling."""
params_n = params_b * 1e9
hidden = round((10 * params_n) ** (1 / 3) / 64) * 64
layers = max(1, round(hidden / 120))
return hidden, layers
def vram_breakdown(model_params_b, method, context_len, batch_size=1,
gradient_checkpointing=True, flash_attention=True):
"""Return the per-consumer breakdown as a dict."""
assert method in ("qlora", "lora16", "full"), f"unknown method: {method}"
hidden, layers = _arch(model_params_b)
params_n = model_params_b * 1e9
# --- Consumer 1: weights ---
base_bytes = {"qlora": 0.5, "lora16": 2.0, "full": 2.0}[method]
weights_gb = base_bytes * model_params_b
# --- Consumer 2: optimizer states + gradients (trainable params only) ---
if method in ("qlora", "lora16"):
trainable_b = 0.01 * model_params_b # adapter ~1% of params
bytes_per_trainable = 6 # FP16 wt+grad + 8-bit opt
else: # full
trainable_b = model_params_b # all params trainable
bytes_per_trainable = 14 # grad + FP32 master + m + v
opt_gb = bytes_per_trainable * trainable_b
# --- Consumer 3: activations ---
BASE_K = 2.9e-8
act_gb = BASE_K * layers * batch_size * context_len * hidden
if gradient_checkpointing:
act_gb *= 0.35
if not flash_attention:
act_gb *= 1.7
return {
"method": method,
"params_b": model_params_b,
"context_len": context_len,
"batch_size": batch_size,
"arch": {"hidden": hidden, "layers": layers},
"weights_gb": round(weights_gb, 1),
"optimizer_gradients_gb": round(opt_gb, 1),
"activations_gb": round(act_gb, 1),
"total_gb": round(weights_gb + opt_gb + act_gb, 1),
}
def vram_estimate(model_params_b, method, context_len, batch_size=1,
gradient_checkpointing=True, flash_attention=True):
"""Headline API: return total training VRAM in GB (float)."""
return vram_breakdown(model_params_b, method, context_len, batch_size,
gradient_checkpointing, flash_attention)["total_gb"]
def recommend_gpu(total_gb):
"""Map a VRAM budget to a GPU class (the three-question framework, step 4).
Boundaries leave ~25% headroom: a 15 GB estimate lands on a 24 GB card,
not a 16 GB card, because CUDA context + fragmentation + optimizer buffers
eat into the nominal budget. Buy headroom; don't squeeze onto the ceiling.
"""
if total_gb <= 12: return "Laptop / Apple Silicon / Colab T4 (free)"
if total_gb <= 18: return "RTX 4090 24GB ($1,500) — 7B QLoRA sweet spot"
if total_gb <= 30: return "RTX 4090 24GB with checkpointing, or A100 40GB"
if total_gb <= 60: return "1× A100 80GB"
if total_gb <= 120: return "2× A100 80GB"
if total_gb <= 160: return "multi-A100 80GB (~$50K of GPUs)"
if total_gb <= 640: return "8× A100/H100 single node"
return "multi-node H100 cluster (8–16× H100)"
if __name__ == "__main__":
import json
cases = [
(7, "qlora", 4096, 2),
(70, "qlora", 2048, 1),
(7, "full", 4096, 1),
]
for params, method, ctx, bs in cases:
bd = vram_breakdown(params, method, ctx, bs)
print(f"\n=== {params}B {method} @ {ctx} ctx, bs {bs} ===")
print(json.dumps(bd, indent=2))
print(f" -> recommend: {recommend_gpu(bd['total_gb'])}")
```
Run it:
```bash
python vram_calc.py
```
---
## Phase 3 — Validate against three real jobs (15 min)
Add this validation block to the bottom of `vram_calc.py` (or a separate `test_vram.py` run with `pytest`), then run it. All three asserts must pass.
```python
def validate():
"""Three real jobs. Each must land within ~20% of the field rule of thumb."""
TOL = 0.20
# Job 1: 7B QLoRA, 4K context, batch 2. Rule of thumb: 10-16 GB. Fits RTX 4090 24GB.
j1 = vram_estimate(7, "qlora", 4096, batch_size=2)
assert 10 <= j1 <= 16, f"7B QLoRA: expected 10-16 GB, got {j1}"
assert j1 <= 24, f"7B QLoRA must fit RTX 4090 24GB, got {j1}"
# Job 2: 70B QLoRA, 2K context, batch 1. Rule of thumb: 48-60 GB. Fits 1× A100 80GB.
j2 = vram_estimate(70, "qlora", 2048, batch_size=1)
assert 48 <= j2 <= 60, f"70B QLoRA: expected 48-60 GB, got {j2}"
assert j2 <= 80, f"70B QLoRA must fit 1× A100 80GB, got {j2}"
# Job 3: 7B full FT, 4K context, batch 1, checkpointing on. Rule of thumb: 100-160 GB.
j3 = vram_estimate(7, "full", 4096, batch_size=1, gradient_checkpointing=True)
assert 100 <= j3 <= 160, f"7B full FT: expected 100-160 GB, got {j3}"
assert j3 > 80, f"7B full FT needs multi-A100 (>80GB), got {j3}"
print("\nAll three validation jobs PASS.")
print(f" 7B QLoRA : {j1} GB -> {recommend_gpu(j1)}")
print(f" 70B QLoRA: {j2} GB -> {recommend_gpu(j2)}")
print(f" 7B Full : {j3} GB -> {recommend_gpu(j3)}")
if __name__ == "__main__":
validate()
```
**Expected output** (your numbers should match to within rounding):
```
All three validation jobs PASS.
7B QLoRA : 15.5 GB -> RTX 4090 24GB ($1,500) — 7B QLoRA sweet spot
70B QLoRA: 52.9 GB -> 1× A100 80GB
7B Full : 117.8 GB -> 2× A100 80GB
```
(7B Full at ~118 GB lands on 2× A100 80GB = 160 GB capacity — that *is* the "multi-A100 (~$50K)" class the teaching doc describes; `recommend_gpu` just names the minimum card count.)
If an assert fails, debug *which consumer* is off. The most common student bugs:
- **Off by ~10× on full FT?** You forgot that *all* params are trainable, not 1%. Recheck Consumer 2.
- **Activations tiny for 70B?** You used `params_b` directly instead of deriving `hidden`/`layers`. Activations need the architecture.
- **7B QLoRA too high?** Make sure gradient checkpointing (×0.35) and FlashAttention (×1.7 penalty *off*) are applied.
---
## Phase 4 — Feel it: estimate vs. a real load (optional, needs a GPU/MPS) (10 min)
If you have a GPU (or Apple Silicon), load a small model and compare your estimate to reality. This is the moment the numbers stop being abstract.
```python
import torch
from transformers import AutoModelForCausalLM
# Pick a small open-data model (from FT00): MiniCPM5-1B
MODEL_ID = "openbmb/MiniCPM5-1B"
# Step 1: PREDICT before loading (inference floor = 4-bit weights for QLoRA-style load)
params_b = 1.0
pred_weights_gb = 0.5 * params_b # 4-bit
print(f"Predicted 4-bit weight footprint: {pred_weights_gb} GB")
# Step 2: LOAD and measure
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.float16, device_map="auto", trust_remote_code=True,
)
if torch.cuda.is_available():
actual = torch.cuda.memory_allocated() / 1e9
print(f"Actual FP16 loaded VRAM: {actual:.2f} GB")
print(f"Predicted FP16: {2.0 * params_b} GB (factor ~{actual/(2.0*params_b):.2f}x = overhead)")
elif torch.backends.mps.is_available():
print("On Apple Silicon: watch Activity Monitor GPU memory while loading.")
```
The *inference* footprint (Consumer 1 only) will be ~1.5–2× the bare weight footprint because of CUDA context, KV cache, and activation buffers. For *training* you add Consumers 2 and 3 on top — which is exactly why training costs 3–10× inference for the same model. Your estimator captures this; `nvidia-smi` confirms it.
---
## Deliverables
Submit `ft01-lab-report.md` containing:
- [ ] Your `vram_calc.py` (the function + validation).
- [ ] The output of `python vram_calc.py` showing all three validation jobs PASS, with the per-consumer breakdown printed.
- [ ] A one-paragraph answer: **for each of the three validation jobs, which consumer dominates, and what is the single cheapest knob that would shrink it?** (e.g., for 7B full FT: optimizer states dominate → the knob is "switch to QLoRA so only 1% of params carry optimizer states.")
- [ ] (If you did Phase 4) the predicted-vs-actual inference footprint and the overhead factor.
---
## Solution key
- **Job 1 (7B QLoRA, bs 2, 4K):** ~15.5 GB. Dominant consumer: activations (~11.6 GB). Cheapest knob: lower physical batch to 1 (halves activations) and use gradient accumulation to keep effective batch 2. GPU: RTX 4090 24GB.
- **Job 2 (70B QLoRA, bs 1, 2K):** ~52.9 GB. Dominant consumer: 4-bit weights (35 GB). Cheapest knob: none that's cheap — the weights are the floor; you cannot shrink frozen 4-bit weights further without going to 2-bit (quality cost). GPU: 1× A100 80GB.
- **Job 3 (7B full FT, bs 1, 4K, ckpt on):** ~117.8 GB. Dominant consumer: optimizer states + gradients (~98 GB). Cheapest knob: switch from full FT to QLoRA — trainable params drop from 100% to 1%, and optimizer states collapse from ~98 GB to <0.5 GB. Total falls from ~118 GB to ~10–16 GB. GPU without the knob: multi-A100 (~$50K). GPU with the knob: RTX 4090 ($1,500).
The lesson, stated bluntly: **for Job 3, the single decision "full FT vs QLoRA" is worth ~$48,500 and ~10× the hardware.** That is the entire economic argument for PEFT, and it falls out of the math.
---
## Stretch goals
1. **Add LoRA (16-bit base).** Extend the estimator to `lora16`. Validate: 7B LoRA @ 4K should land ~18–30 GB (2–3× the FP16 model). Which GPU class? (A100 40GB, or 4090 with aggressive checkpointing.)
2. **Model the KV cache at inference.** Add an `inference_estimate(params_b, quant_bits, context_len)` that returns weights + KV cache. Validate: Q4_K_M 7B at 4K context runs in ~6 GB; Q4 70B at 4K in ~40 GB.
3. **Build a CLI.** `python vram_calc.py --params 7 --method qlora --ctx 4096 --batch 2` prints the breakdown and GPU recommendation. Make it a tool you actually use before renting GPUs.
4. **Reconcile the shorthand.** The field says "QLoRA ≈ 1.5–2× the 4-bit model size." Show in code that this holds *if* you use the deployed 4-bit footprint (~6 GB for 7B, including runtime overhead) rather than the bare NF4 weights (3.5 GB) — and explain in a comment why the bottom-up estimator is more trustworthy than any single multiplier.