Download docs/SPEC.md from mlanvvs/fitcheck: direct link, hf CLI and curl.
- Browser
- Download file 117 kB
-
https://huggingface.co/spaces/mlanvvs/fitcheck/resolve/main/docs/SPEC.md
- Command line
-
hf download hf://spaces/mlanvvs/fitcheck/docs/SPEC.md
-
curl -L -o SPEC.md https://huggingface.co/spaces/mlanvvs/fitcheck/resolve/main/docs/SPEC.md
A newer version of the Gradio SDK is available: 6.30.0
fitcheck β Specification
PRD + Technical Design + Definition of Done β One Document
Version: 0.3.1 beta Β· Author: Anas Β· Date: September 2026
Section 1: Problem & Users
The Pain Point
Every ML practitioner who fine-tunes LLMs has hit the same wall:
- Pick a model, set training config (batch size, LoRA rank, sequence length, precision).
- Launch training. Wait 2β5 minutes for the model to load.
CUDA OutOfMemoryError.- Guess a smaller config. Relaunch. Wait again. Repeat.
This trial-and-error loop wastes 10β30 minutes per attempt and provides zero insight into why it doesn't fit or how close you are. The information needed to answer "will it fit?" exists β it's pure math from the model's config.json β but nobody has packaged it into a tool that gives a precise, component-level breakdown with actionable advice.
Target Users
| Persona | What they need | How they use fitcheck |
|---|---|---|
| Solo GPU owner (RTX 3090/4090) | Know if a QLoRA job fits before launching | fitcheck <model> --gpu 4090 --lora-r 64 ... |
| Cloud ML engineer (A100/H100) | Pick the cheapest instance that fits | fitcheck <model> --gpu a100-40 ... vs --gpu a100-80 |
| ML student / beginner | Understand where GPU memory goes during training | Interactive REPL β explain command |
| Framework developer (Axolotl, Unsloth) | Pre-validate user configs before launching jobs | JSON output mode for CI/CD integration |
| Inference deployer (Ollama, vLLM) | Know if a model fits for serving | fitcheck infer <model> --gpu 4090 β v0.2, see Β§2 |
Why Existing Tools Don't Solve This
| Tool | Gap |
|---|---|
accelerate estimate-memory |
No LoRA/QLoRA. No activations, optimizer, gradients. Weights only. |
| HF Model Memory Calculator | Inference only. No training components. |
| LLM-Calc | Napkin math. No component breakdown. No LoRA. |
| vram.asmirnov.xyz | No GQA-aware formulas. No CLI. Limited architectures. |
fitcheck fills every gap simultaneously: component-level breakdown, LoRA/QLoRA-native, GQA-aware, architecture-specific (reads config.json), actionable advice, CLI-first, and two interaction modes (one-liner + interactive REPL).
Section 2: Feature Scope
MVP (v0.1 β Days 1β5)
| Feature | Priority | Notes |
|---|---|---|
CLI with click: fitcheck <model> [flags] |
P0 | Power-user one-liner mode |
Interactive REPL: fitcheck (no args) |
P0 | Commands: model, gpu, memory, infer, explain, optimize, compare, help, exit |
Fetch HuggingFace config.json via huggingface_hub |
P0 | No weight download β config only |
| Compute all 6 memory components | P0 | Weights, LoRA, optimizer, gradients, activations, overhead |
| GPU database (hard-coded) | P0 | 22 entries β consumer, older/cloud, workstation, datacenter. Roster in Β§3.4 |
| Rich terminal output | P0 | Colored table, pass/fail verdict, headroom %, max batch suggestion |
| Precision support: FP32, FP16, BF16, INT8, INT4 | P0 | |
| Optimizer support: AdamW, SGD, Adam8bit | P0 | |
| LoRA + QLoRA support | P0 | GQA-aware LoRA param counting |
Read intermediate_size from config |
P0 | Never assume 4h |
--json output for CI/CD |
P0 | Machine-readable MemoryReport |
--explain + savings hints |
P0 | The "where did my VRAM go" teaching path |
pip install fitcheck-llm |
P0 | PyPI published on day 5 |
Unit tests with pytest |
P0 | β₯80% coverage on memory/ modules |
| GitHub Actions CI | P0 | Three jobs: lint (ruff check . + mypy --strict fitcheck/ β see Β§3.10), test (pytest on 3.10/3.11/3.12, coverage badge) and floor (lowest declared dependency versions on 3.10 β see Β§3.9) |
Stretch β v0.2+ (Post-MVP)
| Feature | Phase | Notes |
|---|---|---|
fitcheck infer <model> β inference mode |
1.5 | KV cache math, concurrent request estimation β done, v0.2 (Β§3.1 Component 7, Β§3.5 Mode C) |
fitcheck advise β config advisor |
2 | Sweep of (batch_size, lora_r, seq_len) β per-axis prices, per-axis ceilings, and the frontier at the edge of what fits β done, v0.3 (Β§3.5 Mode D for the CLI, Β§3.5 Mode B for the session command, docs/ADVISOR.md for the derivation). Dominance is over two objectives (maximise tokens/step, maximise rank) with total_mib β€ usable as the constraint, not a third objective: MiB is a monotone function of the other two, so minimising it filters nothing (measured 60 of 60 fitting points surviving on a 150-point Llama-3.1-8B grid) |
| Calibration mode | 3 | 1 real forward pass β correction factor |
| HuggingFace Gradio Space | 3 | Web UI for non-CLI users β live at https://huggingface.co/spaces/mlanvvs/fitcheck |
| Cost estimator (RunPod/Lambda pricing) | 3 | |
| ZeRO / FSDP sharding | 4 | Multi-GPU memory modeling |
| Axolotl/Unsloth YAML integration | 4 | Read their config, output estimate |
Section 3: Technical Design
3.1 β The 6 Memory Components and Their Formulas
All training estimates use the master equation:
Each component below uses the same order: what it counts, formula, why, and limits. All results are converted to MiB at the boundary.
Component 1: Base Model Weights ($W_{base}$)
What it counts. The resident base-model weights and quantization scales.
A quantized base has a packed slice and an unquantized slice. The latter includes
embed_tokens, lm_head, and layernorms.
Formula.
Unquantized base:
Quantized base:
Quantization-scale overhead:
Double quantization:
The factor relative to single quantization is $\mathbf{0.254}$. Double quantization removes about three quarters of scale overhead.
Parameter count:
Attention parameter count:
| Attention type | Condition | $P_{attn}$ |
|---|---|---|
| MHA | $n_{kv}=n_h$ | $4h^2$ |
| GQA | $1<n_{kv}<n_h$ | $2h^2 + 2h \cdot n_{kv} \cdot d_k$ |
| MQA | $n_{kv}=1$ | $2h^2 + 2h \cdot d_k$ |
Here $P$ is the total parameter count, $P_q=P-P_{skip}$, and $B_q$ is the
quantization block size. The default $B_q$ is 64. bytes_per_param is 4 for
FP32, 2 for FP16/BF16, 1 for INT8, and 0.5 for INT4/NF4.
The skipped count is:
The $2Lh$ term is the two per-layer RMSNorms. The final $+h$ is the final norm. The unquantized slice is billed at 4 bytes per parameter during quantized training.
Why. A flat $P \times \text{bytes_per_param}$ is too low for quantized training. For Llama-3.1-8B, $P_{skip}$ is 1.05B parameters. That slice costs 4,009 MiB at FP32, while a flat 0.5 bytes/param charges 501 MiB. A T4 Mistral-7B-v0.3 run measured 3,328 MiB of NF4 linears, 416 MiB of scales, and 1,129 MiB of unquantized weights. The prediction was identical.
The QLoRA scale is one FP32 absmax per 64 weights. It is charged only on the quantized slice. Two SmolLM2-1.7B NF4 T4 runs measured 96 MiB without double quantization and 24 MiB with it. The quantized slice had 1,610,612,736 parameters. The derived double-quantization cost is 24.4 MiB. The old 0.5 factor was wrong by 2Γ. The shipped factor is 0.25390625. The golden number set does not use double quantization. DQ stores first-level FP32 scales as 8-bit integer codes and adds one FP32 second-level scale per 256 blocks.
The general attention formula uses $d_k$ from the model config. Do not replace it with $h/n_h$. Gemma-2-9B has $n_h d_k=4096$ and $h=3584$.
Limits.
config_parser.pygets the parameter count from Hub metadata and cross-checks the count derived fromconfig.json. It never downloads weights.- The flat formula is valid only for an unquantized base.
- The skipped slice is FP32 in the quantized training path because peft's
prepare_model_for_kbit_trainingupcasts it. - The implementation is
memory/weights.py:estimate_weight_memory(num_params, precision, quantization_config).
Component 2: LoRA Adapter Weights ($W_{lora}$)
What it counts. Trainable LoRA adapter weights.
Formula.
For GQA, $d_{out}^{(k)}=d_{out}^{(v)}=n_{kv}d_k$, not $h$.
Why. peft keeps LoRA adapters in FP32 on every CLI path. On a quantized
base, prepare_model_for_kbit_training upcasts trainable parameters. On an
unquantized FP16 or BF16 base, get_peft_model(autocast_adapter_dtype=True)
does the same. An FP32 base is already FP32.
All 14 measured LoRA runs showed the same result. Adapter memory was exactly
twice the lower-precision prediction. A lower-precision calculation under-counted
gradient memory by exactly 50%. Nine runs used --quant none. The resident
weight breakdown places the upcast at load time, before the optimizer is built.
Limits.
- There is no CLI path that produces half-precision adapters.
- Adapter precision is separate from compute precision. Activations use the compute dtype, not $\gamma_{adapter}$.
- The implementation is
memory/lora.py:estimate_lora_memory(config, rank, targets, precision, adapter_precision=None). The orchestrator suppliesadapter_precisionfromestimator._adapter_precision.
Component 3: Optimizer States ($S_{optim}$)
What it counts. Optimizer state for trainable parameters only. Full fine-tuning may also need an FP32 master-weight copy.
Formula.
| Optimizer | $\beta_{optim}$ (bytes/param) |
|---|---|
| AdamW with FP32 states | 8 |
| AdamW with BF16 states | 4 |
| AdamW 8-bit | 2 |
| SGD with momentum | 4 |
| SGD without momentum | 0 |
For full fine-tuning in mixed precision:
Why. A lower-precision full-fine-tuning parameter needs an FP32 shadow. An FP32 parameter does not. Its base-weight term already paid for the FP32 storage. With AdamW, both correct full-fine-tuning paths total 16 bytes per parameter:
| Precision | $W_{base}$ | $G_{grad}$ | State | Master copy | Total |
|---|---|---|---|---|---|
| BF16/FP16 mixed | 2 | 2 | 8 | +4 | 16 |
| FP32 | 4 | 4 | 8 | +0 | 16 |
Billing the master copy unconditionally over-reports Llama-3.1-8B FP32
full fine-tuning by 30,633 MiB. The condition depends on precision, not
the optimizer. Master weights also apply to mixed-precision SGD.
Limits.
- Optimizer states never apply to frozen base parameters in a LoRA run.
- Pure BF16 with BF16 states and no master copy is over-counted. Such stochastic-rounding setups are rare and are not modeled in v0.1.
- The implementation is
memory/optimizer.py:estimate_optimizer_memory(trainable_params, optimizer, is_lora, optimizer_dtype, precision).
Component 4: Gradients ($G_{grad}$)
What it counts. .grad tensors for trainable parameters.
Formula.
The parameter dtype determines this factor:
| Trainable parameters | Bytes/param | Applies to |
|---|---|---|
| FP32 | 4 | Every LoRA run; full fine-tuning at FP32 |
| FP16/BF16 | 2 | Full fine-tuning at FP16/BF16 |
Why. A gradient matches its parameter in shape and dtype. LoRA parameters
are FP32, so LoRA gradients are FP32 too. The golden configuration uses
$54{,}525{,}952 \times 4=208$ MiB. Reading this term from --precision
alone under-counts every LoRA run by half. Fourteen measured LoRA rows showed
exactly this error. Nine used --quant none.
The measurement harness now keeps the compute weights at the requested
precision and stores the FP32 master copy in measure.MasterWeightOptimizer.
This keeps the weight, optimizer, and gradient terms separate. The optimizer
step can briefly hold a 4-byte-per-parameter gradient copy. FitCheck does not
model that transient.
Limits.
- Gradient accumulation reuses the same gradient tensor. It does not increase this term.
grad_accum_stepsmust not appear in the formula.- The harness uses no
autocast. It measures the pure compute dtype. - Rows are rejected when observed parameter, gradient, activation, or optimizer dtypes contradict their flags. Quantized rows are exempt from base-weight and activation dtype checks because peft intentionally upcasts the skipped slice.
- The implementation is
memory/gradients.py:estimate_gradient_memory(trainable_params, precision, param_precision=None).
Component 5: Activations ($A_{act}$)
What it counts. Saved tensors, attention score matrices, checkpoint storage, and the LM-head logits hump during training.
Let $\gamma$ be the compute activation size: 2 bytes for FP16/BF16 and
4 bytes for FP32. Under --quant int8, $\gamma=4$ regardless of
--precision.
Formula.
The exact per-layer bracket is:
When $n_h d_k=h$, this becomes:
The per-layer transient peak is:
The exact code form uses the bracket above with $n_h d_k$ and $n_{kv}d_k$.
The score-matrix copy count is $c=9$ with checkpointing for NF4 and INT8,
$c=7.4$ with checkpointing for --quant none, and $c=2.9$ without
checkpointing.
The LM-head hump is:
Here $\lambda=3.5$ for --quant none and $\lambda=4$ for --quant nf4
and --quant int8.
The logits tensor is always FP32. This term is independent of compute dtype,
gradient checkpointing, and Flash Attention.
With gradient checkpointing:
For --quant none, $\kappa=1$ and $\gamma_{ckpt}=\gamma$. For --quant nf4,
the checkpoint store is one FP32 tensor per layer. For --quant int8, the shipped
profile pins it to two FP32-equivalent tensors.
Without gradient checkpointing:
The retained eager score-matrix rate is $2.9\gamma$. The transient checkpointed rate is $9\gamma$. Flash Attention removes the score-matrix term in both branches.
| Constant | --quant none |
--quant nf4 |
--quant int8 |
|---|---|---|---|
| Checkpoint tensors/layer ($\kappa$) | 1 | 1 | 2 (pinned) |
| Checkpoint dtype | $\gamma$ | FP32 | $\gamma$ (already FP32) |
| $A_{logits}$ FP32 copies | 3.5 | 4 | 4 |
| Score-matrix copies ($c$) | 7.4 | 9 | 9 |
The score-matrix profile therefore uses $c=7.4$ for checkpointed
--quant none, $c=9$ for checkpointed NF4/INT8, and $c=2.9$ in the
no-checkpointing branch.
Why. The saved-tensor derivation contains twelve named tensors:
| # | Saved tensor | Shape | Size | Reason |
|---|---|---|---|---|
| 1 | Layer input | $(b,s,h)$ | $\gamma bsh$ | RMSNorm backward |
| 2 | Attention-norm output | $(b,s,h)$ | $\gamma bsh$ | Input to q/k/v projections |
| 3 | Q after RoPE | $(b,n_h,s,d_k)$ | $\gamma bs,n_h d_k$ | Attention backward |
| 4 | Attention output | $(b,s,n_h d_k)$ | $\gamma bs,n_h d_k$ | Attention and o-projection input |
| 5 | Post-attention residual | $(b,s,h)$ | $\gamma bsh$ | MLP-norm backward |
| 6 | MLP-norm output | $(b,s,h)$ | $\gamma bsh$ | Input to gate and up projections |
| 7 | K after RoPE | $(b,n_{kv},s,d_k)$ | $\gamma bsh,n_{kv}/n_h$ | GQA reduction |
| 8 | V after RoPE | $(b,n_{kv},s,d_k)$ | $\gamma bsh,n_{kv}/n_h$ | GQA reduction |
| 9 | Attention score matrix | $(b,n_h,s,s)$ | $9\gamma bn_hs^2$ | Removed by Flash Attention |
| 10 | Gate output | $(b,s,d_{ff})$ | $\gamma bs,d_{ff}$ | SiLU backward |
| 11 | Up output | $(b,s,d_{ff})$ | $\gamma bs,d_{ff}$ | Element-wise product backward |
| 12 | Down-projection input | $(b,s,d_{ff})$ | $\gamma bs,d_{ff}$ | Down projection backward |
The table derives six hidden-width tensors. Measurement of 11 no-checkpoint rows across 9 models gives a total of 15 hidden-width tensors. Whole-number candidates have a clean minimum there: 14 scores 7.8%, 15 scores 6.4%, and 16 scores 7.2%. The split between $h$ and $n_h d_k$ is not fully resolved: only Gemma-2 has different values, and it also has four layer norms per layer. The implementation uses $12h+3n_h d_k$ because it scores best. The $3d_{ff}$ term is exact. The $n_{kv}d_k$ term is one copy, not two.
In the golden QLoRA/FP16 profile, checkpoint storage is $2L\gamma bsh$. It is 4,096 MiB here, or one FP32 $(b,s,h)$ tensor per layer in the quantized profile. The checkpointed branch uses a max, not a sum: the logits hump and one layer's recompute do not overlap. This is why the golden configuration keeps $A_{act}=20{,}128$ MiB even after its layer term changes from 1,088 to 1,648 MiB.
Eager attention has two different measured rates. With checkpointing, nine score-matrix copies are the transient peak of one live layer. Without it, 2.9 copies are what each layer retains. Charging nine copies in every layer caused a 98.3% worst error on SmolLM2-135M. Three equal-token SDPA runs with a 4Γ sequence-length range had the same 3,001 MiB activation result, which rules out another hidden $s^2$ term.
The 2026-09-15 T4 sweep had 32 distinct rows. Fitting the two checkpoint
profiles gave 0.90L / 3.56 copies / $c=7.30$ for none and
1.93L / 4.04 copies / $c=9.15$ for nf4; the shipped structure is the
rounded profile above. Against measured $A_{act}$, the none profile has
worst error β3.3% and mean 1.1%. The unchanged nf4 profile has
worst error β4.8% and mean 1.1%. The old shared profile had
+32.1% worst and 23.3% mean on none.
Limits.
- $b$ is micro-batch size, not effective batch size. Gradient accumulation does not increase activation memory.
- Read $d_{ff}$ from
intermediate_size. Never assume $4h$. - Read $d_k$ from
config.json. Never assume $h/n_h$. - Under checkpointing, the LM-head and layer humps use
max, not sum. - The three profile constants depend on base storage. They are not determined by $\gamma$ alone.
- The INT8 checkpoint pin is derived from an archived row because no INT8 sweep row exists. That row already under-predicts by 9.3% because LLM.int8() outlier buffers are not modeled.
- The FP32 NF4 checkpoint interpretation is derived, not measured. All 32 sweep rows used FP16.
- The no-checkpointing retained score constant has no unquantized measurement.
- The 15-tensor count is measured, not derived tensor by tensor. Nine tensors remain unnamed. Gemma-2 scored β5.9% on SDPA and β6.8% on eager because it has four layer norms and attention-logit softcapping, neither modeled here.
- All archived measurements are on a Tesla T4. There is no real FlashAttention-2 measurement; the flash path uses SDPA's memory-efficient backend.
- The implementation is
memory/activations.py:estimate_activation_memory(config, batch_size, seq_len, grad_checkpoint, flash_attn, precision). It keeps transient and retained layer memory separate.
Component 6: CUDA Overhead ($C_{overhead}$)
What it counts. CUDA context, library workspace, and PyTorch caching allocator over-reservation.
Formula.
The profile key is (GPU, attention kernel, quantization).
B is measured. $S$, the sequence slope, is zero.
Why. Under checkpointing, one of the two activation humps loses the
max. Its allocations remain in allocator segments that the winning hump
cannot reuse. The loser is therefore the right regressor.
B is torch.cuda.mem_get_info() minus
torch.cuda.memory_reserved(). It measured 140.875 MiB on 69 archived rows.
The twelve final holdout rows report 141.0 MiB. Fitting B caused
collinearity with the size column. Leave-one-out moved $F$ by up to 172%
and produced 697 MiB intercepts for a 141 MiB context. The current model pins B.
Quantization belongs in the key. On the same card and kernel, --quant none
held 99β154 large-pool segments. NF4 held 15β25. The resulting $F$ values
differ by 2β3Γ.
Shipped T4 profiles:
| Kernel | Quant | Hump wins | $F$ | Β± se | n | $R^2$ |
|---|---|---|---|---|---|---|
| Flash | None | Logits | 1.3181 | 0.106 | 10 | 0.75 |
| Flash | NF4 | Logits | 1.9683 | 0.112 | 15 | 0.88 |
| Eager | None | Logits | 0.5808 | 0.042 | 6 | 0.93 |
| Eager | None | Layer | 0.8468 | 0.109 | 4 | 0.78 |
| Eager | NF4 | Logits | 0.6442 | 0.043 | 6 | 0.93 |
| Eager | NF4 | Layer | 0.7518 | 0.060 | 8 | 0.88 |
The profiles are generated from data/measurements/manifest.json by:
python -m fitcheck.calibrate data/measurements/manifest.json --emit-python
tests/test_manifest.py checks the generated OVERHEAD_DB literal.
Forty-nine rows are fitted. Eight bit-identical repeats and ten pre-9.3 rows
are declared repeat or excluded.
Flash profiles have no measured layer-win coefficient. They fall back to the logits-win value. No measured model has the required roughly $V<3.75h$ condition for a layer win under Flash Attention.
Current fitted accuracy over 57 scored rows is mean absolute process error 2.4%, worst over-prediction +13.9%, and worst under-prediction β6.4%. Zero rows under-predict by more than 8%. The one row beyond Β±8% is an over-prediction.
The frozen final holdout has 12 rows. Tensor error is Β±1.4%, process MAE
is 5.4%, and worst under-prediction is β15.3%. A separate boundary
file has 12/12 safe/unsafe verdicts matching the 14,000 MiB T4 safety
budget. The reserve can turn a point-safe case into uncertain; that is
intentional.
Limits.
- The hump formula is valid only with gradient checkpointing. Without it, activation memory is $L A_{layer}+A_{logits}$, so the two humps do not represent leftover allocations.
- The estimator falls back to the default 500 MiB + 5% profile for other GPUs, INT8, checkpointing off, and inference.
- The default profile is deliberate.
usable_mibalready includes driver and display reserve. This component covers process runtime overhead. The overlap biases the estimate high to avoid unsafe fits. - The fit is narrow: every measurement is a Tesla T4. The final holdout is NF4, FP16 compute, LoRA rank 16, sequence length 1024, and checkpointing on.
- The one INT8 measurement under-predicts because its FP16 outlier buffers are not modeled.
- The implementation is
memory/overhead.py:estimate_overhead(weight_memory, activation_memory, profile=None, seq_len=None, logits_mib=None, layer_mib=None). Passing both humps selects the measured form. Omitting them selects the default proportional form.
Component 7: Inference Serving ($M_{infer}$) β v0.2
What it counts. Resident model weights and KV-cache memory for serving. It does not count backward-pass state.
Formula.
The leading 2 is one K tensor plus one V tensor.
Why. The cache has one K/V pair for every layer and every concurrent request. Four requests at 2,048 tokens cost the same cache bytes as one request at 8,192 tokens. GQA uses $n_{kv}d_k$, not $h$.
precision is the compute dtype. It prices the KV cache and the float slice
of the weights. quantization is the base storage dtype. Keeping these axes
separate prevents a 4-bit deployment from incorrectly using 4-bit KV cache
bytes.
In serving, the unquantized weight slice is billed at the compute dtype.
Serving does not call prepare_model_for_kbit_training, so the slice is not
upcast to FP32. NF4 scales remain FP32 per 64 quantized weights. Double
quantization reduces them to about 25%, as measured.
The component function returns weights and KV cache separately. The serving
orchestrator adds estimate_overhead(weights, kv) before checking a GPU.
The model-side subtotal must not be rendered as the final verdict.
Limits.
- PagedAttention and block allocation are not modeled. The formula assumes every request holds all $s$ tokens.
- FP8 or INT8 KV cache is not modeled. It needs a third dtype axis.
- Decode-time transient memory is not modeled. It includes cache attention, logits, and runtime buffers.
- Four measured serving runs had exact cache error 0.0% and weight error within 0.1%. Total tensor error was β2.8% at concurrency 1, β3.4% at 4, β8.9% at 8, and β23.2% at 16. At 16, the peak was 3,650 MiB versus 2,812 MiB resident, leaving 838 MiB of transient work.
- No transient coefficient is shipped. Its mechanism is not identified.
The warning threshold is
_SERVING_CONCURRENCY_WARN_AT= 4. A proper fix needs a 1/4/8/16/32 concurrency sweep with prefill and decode peaks. - Reference: Llama-3.1-8B, FP16, $s=2048$, one request, unquantized, RTX 4090: weights 15,316.51 MiB, KV 256.00 MiB, overhead 1,278.63 MiB, total 16,851.13 MiB, and 6,648.87 MiB headroom. The model-side subtotal is 15,572.51 MiB. The maximum is 25 concurrent requests. The cache is 0.125 MiB/token. NF4 changes weights to 5,748.51 MiB and leaves the cache at 256.00 MiB.
- The implementation is
memory/inference.py:estimate_inference_memory(config, precision, seq_len, num_concurrent, quantization, double_quant). It returnsInferenceMemory(weight_mib, kv_cache_mib). Serving has its ownInferenceReport; it is not part of the training master equation.
3.2 β Module / File Structure and Data Flow
fitcheck/
βββ __init__.py # version, public API
βββ __main__.py # python -m fitcheck entry point
βββ cli.py # click commands & option groups
βββ repl.py # Interactive REPL (Mode B)
βββ config_parser.py # HuggingFace config.json β ModelConfig; refuses MoE / nested multimodal
βββ estimator.py # Orchestrators: estimate() -> MemoryReport (the 6 training
β # components) and estimate_inference() -> InferenceReport (7 + 6)
βββ memory/
β βββ __init__.py # re-exports all estimate_* functions
β βββ weights.py # Component 1
β βββ lora.py # Component 2
β βββ optimizer.py # Component 3
β βββ gradients.py # Component 4
β βββ activations.py # Component 5
β βββ overhead.py # Component 6
β βββ inference.py # Component 7 β serving (v0.2), not in the training equation
βββ gpu_db.py # GPU name β GpuSpec(name, vram_mib, usable_mib)
βββ overhead_db.py # (GPU, kernel, quant) β OverheadProfile β fitted C_overhead constants
βββ safety.py # final-holdout reserve envelope for safe/uncertain verdicts
βββ display.py # rich tables, panels, verdicts, explain text
βββ advisor.py # Config advisor (v0.3): sweep, frontier, per-axis ceilings
βββ calibrate.py # fits overhead_db.py from measure.py --json
βββ validation.py # the shared input contract: which compute dtypes, storage
β # formats and combinations of the two are accepted at all
βββ utils.py # bytesβMiB, precisionβbytes lookup
tests/
βββ conftest.py # shared fixtures (Llama, Mistral, Qwen configs)
βββ test_config_parser.py
βββ test_gpu_db.py
βββ test_weights.py
βββ test_lora.py
βββ test_optimizer.py
βββ test_gradients.py
βββ test_activations.py
βββ test_overhead.py
βββ test_overhead_db.py
βββ test_calibrate.py
βββ test_inference.py
βββ test_advisor.py
βββ test_validation.py # the shared contract, and interface parity against it
βββ test_end_to_end.py # full pipeline: config β report β verdict
scripts/ # NOT part of the installed package
βββ measure.py # ground-truth harness (Β§3.8) β imports torch/peft/bitsandbytes
βββ measure_infer.py # the serving-side equivalent (Component 7)
βββ calibration_sweep.py # drives measure.py over the 9.3 grid, one process per row
βββ requirements-measure.txt # its deps, deliberately separate from pyproject.toml
data/measurements/ # NOT packaged β archived measure.py --json rows, the only
# evidence behind overhead_db.py
The dependency runs one way.
scripts/measure.pyimportsfitcheck;fitchecknever importstorch,peftorbitsandbytesβ not lazily, not inside atry. An estimate must cost a few KB ofconfig.jsonand no GPU, and that is the constraint the whole product rests on.
Data flow:
graph LR
A["CLI / REPL<br/>(user input)"] --> B["config_parser<br/>fetch config.json<br/>refuse unsupported"]
B --> C["estimator.py<br/>orchestrator"]
C --> D["memory/*.py<br/>6 components"]
C --> E["gpu_db.py<br/>GPU specs"]
D --> F["MemoryReport<br/>dataclass"]
E --> F
F --> G["display.py<br/>rich output"]
F --> H["repl.py<br/>explain / optimize / compare"]
Key dataclasses:
@dataclass
class ModelConfig:
name: str
num_params: int
hidden_size: int
num_layers: int
num_attention_heads: int
num_kv_heads: int
intermediate_size: int
vocab_size: int
head_dim: int
tie_word_embeddings: bool
@dataclass
class TrainingConfig:
precision: str # COMPUTE dtype: "fp32" | "fp16" | "bf16"
# drives LoRA weights, gradients, and activations (Ξ³)
quantization: str # BASE MODEL storage: "none" | "nf4" | "int8"
double_quant: bool # NF4 double quantization
optimizer: str # "adamw" | "adam8bit" | "sgd" | "sgd-momentum"
optimizer_dtype: str # AdamW state dtype: "fp32" | "bf16"
batch_size: int # MICRO-batch
seq_len: int
lora_rank: int | None # None => full fine-tuning
lora_targets: list[str]
grad_checkpoint: bool
flash_attn: bool
grad_accum_steps: int
@dataclass
class MemoryReport:
weight_mib: float
lora_mib: float
optimizer_mib: float
gradient_mib: float
activation_mib: float
overhead_mib: float
total_mib: float
gpu_capacity_mib: float
headroom_mib: float
fits: bool
max_batch_size: int
effective_batch_size: int # batch_size Γ grad_accum_steps β DISPLAY ONLY, costs no memory
savings_hints: list[str] # see Β§3.5 --explain
warnings: tuple[str, ...] = () # caveats that apply to this config β see Β§3.7
warningscarries the caveats, it does not change the number. Empty means every formula the estimate used has a measured row behind it. Since measurement closed the no-checkpointing branch, the only training-side entry comes from--quant int8(Β§3.7);estimate_warnings(training)computes it and is public, so the REPL and the advisor can ask the same question without building a full report.InferenceReportcarries the same field, filled byinference_warnings(serving), which fires above four concurrent requests.
precisionis the compute dtype only. Base-model storage precision is a separate axis (quantization), because they genuinely vary independently: QLoRA is a 4-bit base with BF16 compute. Collapsing them into one flag leaves the activation and gradient dtype undefined whenever the base is quantized, and silently under-counts activations by 2Γ under FP32.
One input contract,
fitcheck/validation.py. The two axes are only two axes if something enforces it, so every surface βestimate(),estimate_inference(), the advisor, the CLI, the REPL β validates through the same three functions instead of its own copy:
validate_precisiontakes the compute dtypes only:fp32,fp16,bf16. A storage format (nf4,int8,int4,fp8) asprecisionis refused, not silently priced β it would rescale $W_{lora}$, $G_{grad}$ and $A_{act}$ by 4Γ.validate_quantizationtakes the storage formats only:none,nf4,int8.validate_double_quantallowsdouble_quantonly undernf4. It is bitsandbytes'bnb_4bit_use_double_quant, a second level of quantization for the NF4 absmax scales:nonehas no scales to shrink, andint8has none either, so charging the ~75% saving (Component 1) on either would report a saving the run never gets.scripts/measure.pyhas always refused this pair; before the shared validation layer, the library andfitcheck inferdid not, which is the drift the shared layer removes.The message is one string, returned by
double_quant_conflictso the CLI and REPL can raise it as aclick.UsageErrorwhile the library raises the same sentence as aValueError.
The shape axes have ceilings too.
MAX_SEQ_LEN(2^24 = 16,777,216 tokens, past every published context window) andMAX_SEQUENCES(2^20 = 1,048,576, the estimator's own_MAX_SEARCH_CEILING, so the max-batch bisection can still probe its whole range) live beside the dtype axes invalidation.py.estimate_activation_memoryboundsbatch_sizeandseq_lenagainst them;estimate_inference_memoryboundsseq_lenandnum_concurrent. They are not a claim about what a GPU can run. They are the point past which a number is a typo: the eager score matrix is $O(s^2)$ and the KV cache is $O(s \cdot n)$, so a large enough value leaves the byte count outside the float range and the user gets anOverflowErrortraceback instead of a sentence. For the same reasonestimate_overheadrefuses NaN and infinity βvalue < 0is false for both, so the non-negative check alone let them through and every downstream verdict came backnan.
3.3 β How Config Fetching Works (No Weight Download)
from huggingface_hub import HfApi, hf_hub_download
import json
def fetch_model_config(model_id: str) -> ModelConfig:
path = hf_hub_download(repo_id=model_id, filename="config.json")
with open(path) as f:
raw = json.load(f)
_reject_unsupported(raw, model_id) # architectures the formulas cannot estimate
fields = _parse_fields(raw)
return ModelConfig(
name=model_id.split("/")[-1],
# the Hub's own count, with _count_params(fields) as cross-check and fallback
num_params=_reconcile_param_count(
model_id, _reported_param_count(model_id, token), _count_params(fields)
),
hidden_size=raw["hidden_size"],
num_layers=raw["num_hidden_layers"],
num_attention_heads=raw["num_attention_heads"],
num_kv_heads=raw.get("num_key_value_heads", raw["num_attention_heads"]),
intermediate_size=raw["intermediate_size"],
vocab_size=raw["vocab_size"],
# explicit field wins; h // n_h is only the fallback
head_dim=raw.get("head_dim") or raw["hidden_size"] // raw["num_attention_heads"],
# absent β untied β see below
tie_word_embeddings=_tie_word_embeddings(raw), # model_type lookup, False if unknown
)
This downloads only config.json (2KB), never the model weights (4β140GB).
The refusal gate runs before any field is parsed. _reject_unsupported(raw, model_id) raises
UnsupportedModelError β a ValueError subclass, so every existing caller still catches it, and a
distinct type so cli.py and repl.py print the message without the "could not read config.json"
prefix. The file read fine; it is the architecture that is refused. Both refusals exit 2.
| Trigger | Keys | Why refusing beats estimating |
|---|---|---|
| Mixture-of-Experts | any of num_experts_per_tok, num_local_experts, num_experts, n_routed_experts β the expert-count key varies by family (Mixtral and gpt-oss use num_local_experts, Qwen3-MoE num_experts, DeepSeek-V2 n_routed_experts), while num_experts_per_tok is common to all four |
The dense-FFN count sees one expert out of 8β128: Mixtral-8x7B reads as 7.24B against a true 46.70B (β84.5%), Qwen3-30B-A3B β89.1%, gpt-oss-20b β88.6%, DeepSeek-V2-Lite β82.9%. Every one of those is in the direction that says "fits" for a run that OOMs |
| Nested multimodal | text_config present and hidden_size absent |
Previously raised config.json field 'hidden_size' must be a positive integer, which is true of the top level and misleading about the file. The dimensions are nested, and the vision tower is not modelled |
Error text must name the direction of the error, not just the fact of it. "Would under-count by 80-90%, in the direction that reports a fit where the run would OOM" is the sentence that stops someone trusting a number the tool printed before this gate existed. A refusal a user reads as a mere inconvenience gets worked around; a refusal that explains the failure mode does not.
Three ways the Hub can fail, and they are not one failure. fetch_model_config sorts them at the
point of the call, because that is the last place the difference is visible:
| What happened | Raised | What the user is told |
|---|---|---|
| The repo is gated | RuntimeError |
Accept the licence, then hf auth login or HF_TOKEN β a different sentence depending on whether a token was found at all |
| The Hub answered and said no (404, 401, 5xx) | HfHubHTTPError, re-raised untouched |
The Hub's own message, under "Could not read config.json for 'X'" |
| No answer came back (timeout, dropped connection, proxy) | HubUnavailableError |
"Could not reach the Hugging Face Hub for 'X' (ReadTimeout). Check the connection and try again, or set HF_HUB_OFFLINE=1 to estimate from a config.json already in the local cache" |
HubUnavailableError is a RuntimeError subclass, and a distinct type for the same reason
UnsupportedModelError is one: cli.py and repl.py print its message as it stands, with no
"could not read config.json" prefix. Nothing was wrong with config.json; it was never fetched.
Both exit 2, and the REPL keeps the session.
The transport failure is caught as huggingface_hub.errors.HTTPError β the Hub's re-export of
whichever HTTP library it ships with (httpx from 1.0, requests below it). Taking it from there
instead of importing httpx keeps the runtime dependencies at three (Β§3.9), and it is the only
spelling that covers both. Under httpx this is not cosmetic: httpx.ReadTimeout is not an
OSError, so nothing downstream was catching it and a timed-out estimate printed a traceback and no
message at all. Under requests the same failure was already an OSError and always reported
cleanly. Order matters: HfHubHTTPError derives from both, so it is re-raised first β otherwise
a plain 404 would come back dressed as a network problem. Nothing here catches Exception, so a
programmer error still surfaces as itself.
A model that parses is not thereby endorsed. The gate catches shapes that are detectable from
config.json keys, and that is not the same as the shapes the formulas cover. Qwen2.5-VL keeps its
text dimensions at the top level beside vision_config, so nothing in the file marks it as
multimodal. What catches it is the parameter cross-check below, not this gate.
The parameter count comes from the Hub, and the formula grades it
_count_params is a Llama-shaped formula: a 3-matrix SwiGLU MLP (gate/up/down) and no biases
anywhere. It is exact for the family it was derived on and drifts silently for everything else β
microsoft/phi-2 has a 2-matrix fc1/fc2 MLP plus biases and comes out +30.1%, and any model
with a component config.json does not describe (a vision tower) comes out low. Deriving a number
the Hub already knows is the wrong trade.
def _reported_param_count(model_id: str, token: str | None) -> int | None:
"""Exact parameter count from the Hub's safetensors metadata, or None."""
try:
info = HfApi(token=token).model_info(model_id, expand=["safetensors"])
except Exception: # any Hub failure means "fall back", never "crash"
return None
total = getattr(getattr(info, "safetensors", None), "total", None)
return total if isinstance(total, int) and not isinstance(total, bool) and total > 0 else None
One metadata call, no weight bytes, so Β§3.8's "an estimate costs a few KB and no GPU" still
holds. expand= and
ModelInfo.safetensors.total both exist at the declared floor of huggingface-hub==0.25.0
(Β§3.9), so this needs no dependency change.
Precedence and tolerance. The Hub count wins whenever it is available β including inside the
tolerance band, where it is the truth and the formula is the approximation. _PARAM_COUNT_TOLERANCE
is 2%, relative and not absolute, because exact equality is the wrong test:
| Model | Derived | Hub | Relative gap | Cause | Outcome |
|---|---|---|---|---|---|
meta-llama/Llama-3.1-8B |
8,030,261,248 | 8,030,261,248 | 0% | β | β Hub count used |
HuggingFaceTB/SmolLM2-1.7B |
1,711,376,384 | 1,711,376,384 | 0% | β | β |
TinyLlama/TinyLlama-1.1B |
1,100,048,384 | 1,100,048,384 | 0% | β | β |
Qwen/Qwen2.5-1.5B |
1,543,656,960 | 1,543,714,304 | β0.004% | q/k/v biases the formula ignores | β |
microsoft/phi-2 |
3,617,753,600 | 2,779,683,840 | +30.1% | 2-matrix MLP charged as 3-matrix | β refused, exit 2 |
Qwen/Qwen2.5-VL-7B-Instruct |
7,615,487,488 | 8,292,166,656 | β8.2% | vision tower not modelled | β refused, exit 2 |
A relative tolerance is what makes the harmless rows harmless. Absolute equality would reject Qwen2.5-1.5B over 57,344 bias parameters, and gpt-oss-20b differs from its own safetensors count by 704 params (0.000003%) purely on tied-tensor accounting. Neither says anything about the memory model; +30.1% does.
A disagreement past the tolerance is refused, not averaged, and not silently corrected. This is the point of the whole check. The parameter count is not an isolated number: $A_{act}$ charges $3 d_{ff}$ for a 3-matrix MLP (Component 5) and the LoRA target dimensions assume the same shape (Component 2). If $P$ is 30% out, those are out too β so taking the Hub's $P$ and keeping the rest would buy a correct $W_{base}$ sitting next to a wrong $A_{act}$, which is the "silently wrong" failure this document refuses everywhere else. The error text names both numbers, the signed gap, and the direction: an over-count over-states the memory needed, an under-count is the direction that reports a fit where the run would OOM.
The formula stays as the offline fallback. When the Hub cannot answer β no network, a repo with
no safetensors weights, a cached config.json in an air-gapped run β _reconcile_param_count
returns the derived count and prints a warning to stderr naming it as derived and naming the
assumption behind it. That keeps Β§3.8's offline promise without pretending the fallback is exact.
Note what the fallback costs: with no Hub count there is nothing to grade against, so phi-2 and
Qwen2.5-VL are estimated rather than refused when offline.
The refusal gate runs first, so a MoE or nested-multimodal model costs no metadata call.
Two fields that are not what they look like. Both are silent, both are wrong on the same model family, and Gemma-2-9B is a row in the validation matrix:
| Field | NaΓ―ve rule | Why it breaks | Correct rule |
|---|---|---|---|
head_dim |
$h / n_h$ | Gemma-2-9B declares head_dim: 256 against $3584/16 = 224$; Gemma-2-27B declares 128 against $4608/32 = 144$ |
Use the field when present; fall back to $h/n_h$ |
tie_word_embeddings |
absent β False |
Gemma-2 omits the key and ties. Defaulting to untied counts a $256000 \times 3584$ embedding twice | Per-architecture default table, False for unknown model_type |
Getting both wrong compounds: Gemma-2-9B comes out at 9.93B parameters against a true 9.24B β a 7.5% over-count, β1.3 GiB of phantom BF16 weights, on a model the README promises to validate.
Two consequences worth stating outright:
- Divisibility is a rule about the fallback, not about the model. Rejecting a config because
$h \bmod n_h \ne 0$ is only valid when $d_k$ is being derived. A config that declares
head_dimis free to violate it, and must be accepted. Falsefor an unknownmodel_typeis a deliberate divergence fromtransformers, whose ownPretrainedConfigdefault isTrue. Assuming untied over-counts the embedding, and for a tool whose job is avoiding OOM, over-counting is the direction that costs the user nothing. Known tying families are listed explicitly so the conservative default only ever applies to architectures fitcheck has not seen.- A declared
tie_word_embeddingsmust be a real JSON boolean, not anything truthy.bool(...)reads the string"false"asTrue, which ties the embeddings and drops a whole $V imes h$ LM head from the count β an under-count, the direction that reports a fit where the run would OOM. A non-boolean is refused rather than coerced. The same rule applies one level up:config.jsonmust have a JSON object at its root, or there are no fields to read andraw.getwould fail with anAttributeErrorinstead of a message.
3.4 β GPU Database Design
Hard-coded in v0.1. User-extensible in v0.3+.
The database ships 22 entries. gpu_db.py is the authority; this is the current roster:
| Class | Keys |
|---|---|
| Consumer | 3060-12, 4070ti, 5070, 5070ti, 5080, 3090, 4090, 5090 |
| Older / cloud | t4, v100-16, l4, a10 |
| Workstation | a6000, rtx6000-ada, l40, l40s |
| Datacenter | a100-40, a100-80, h100, h100-80, h200, b200 |
h100 and h100-80 are intentional aliases for the same card β users reach for both spellings.
--list-gpus prints this table at runtime, and --vram-mib N synthesizes a GpuSpec for anything unlisted
(usable defaults to 95% of the value given).
The T4 entry was wrong in the dangerous direction, and is now fixed.
gpu_db.pyused to list the Tesla T4 asvram_mib=16_384, usable_mib=15_360, but the card in every measurement reports 14,912 MiB total totorch.cuda.get_device_properties()β so the usable figure exceeded the card's entire capacity by 448 MiB and could produce a false βfitsβ. The cause was treating a vendor β16 GBβ as 16 GiB; a T4 is 16 GB = 15,258 MiB, and less again with ECC on. It now readsvram_mib=14_912, usable_mib=14_000, the measured total with a normal driver allowance.The same audit was applied to the two entries where the vendor unit is ambiguous:
h200(β141 GBβ) andb200(β192 GBβ) now take theusable_mibthat is safe under the pessimistic reading β 134,000 and 176,000 β because under-stating capacity costs a conservative estimate while over-stating it costs an OOM. Neither is measured.t4is the only row in the table with a measured total; every other value is an estimate, and a measured row for any of them is a useful contribution (see.github/ISSUE_TEMPLATE/measurement.yml).
Usable vs. advertised: Usable β advertised Γ 0.91β0.97 (CUDA context, driver overhead, display if desktop
GPU). This is a rough per-card allowance, not a fixed formula β older/consumer cards (T4, V100, L4, RTX
30/40/50-series) sit lower in the range (as low as 0.91β0.94), while newer datacenter cards without a display
attached sit at the top (0.96β0.97, e.g. A100, H100, H200, B200). GPU_DB values are current estimates, not
measured constants β treat any entry as approximate until benchmarked.
3.5 β CLI Interface Design
Mode A: One-Liner (Power User)
fitcheck <model_id> [OPTIONS]
Arguments:
MODEL_ID HuggingFace model ID (e.g., meta-llama/Llama-3.1-8B)
Model / quantization:
--quant TEXT none|nf4|int8 β BASE MODEL storage (default: none)
--double-quant NF4 double quantization (cuts scale overhead ~75%)
--qlora Shorthand: --quant nf4 --precision bf16 --grad-checkpoint
Training Options:
--precision TEXT fp32|fp16|bf16 β COMPUTE dtype: LoRA weights,
gradients, activations (default: bf16)
--lora-r INT LoRA rank (default: 16)
--no-lora Full fine-tuning (all params trainable)
--lora-targets TEXT Comma-separated modules, or a preset:
minimal (q,v) | standard (q,k,v,o) | full (+gate,up,down)
(default: standard)
--batch-size INT MICRO-batch size (default: 1)
--grad-accum INT Accumulation steps β display only, costs no memory (default: 1)
--seq-len INT Sequence length (default: 2048)
--optimizer TEXT adamw|adam8bit|sgd|sgd-momentum (default: adamw)
--optimizer-dtype TEXT fp32|bf16 β AdamW state dtype (default: fp32)
--grad-checkpoint Enable gradient checkpointing
--flash-attn Enable Flash Attention
GPU Options:
--gpu TEXT GPU name from database (default: 4090)
--vram-mib INT VRAM override for a GPU not in the database
--list-gpus Print the GPU database and exit
Output Options:
--json Output as JSON (for CI/CD) β schema below
--no-color Disable colored output
--verbose Show per-layer breakdown
--explain Plain-English breakdown + savings hints
-V, --version Print the installed fitcheck version and exit
--json output contract
The machine-readable surface is a published contract, not a dump of whatever MemoryReport
happens to hold β the framework-developer persona in Β§1 gates CI on it. Top-level keys:
| Key | Type | Contents |
|---|---|---|
fitcheck_version |
str |
Installed package version β pin CI assertions against it |
model |
object |
ModelConfig fields verbatim (incl. num_params, from the Hub when reachable β Β§3.3) |
gpu |
object |
GpuSpec: name, vram_mib, usable_mib |
training |
object |
TrainingConfig as resolved β after --qlora expansion and preset lookup |
trainable_params |
int |
LoRA param count, or num_params under --no-lora |
memory_mib |
object |
weights, lora, optimizer, gradients, activations, overhead, total |
activations_per_layer_mib |
float |
$A_{layer}$ β the per-layer figure --verbose renders |
verdict |
object |
fits, safe_fits, status, gpu_capacity_mib, headroom_mib, safe_total_mib, safe_headroom_mib, headroom_pct, max_batch_size, estimated_max_batch_size, recommended_batch_size, recommendation_basis, effective_batch_size |
savings_hints |
list[str] |
The Β§3.5 hint lines, unformatted |
warnings |
list[str] |
Caveats that apply to this configuration β empty when every formula used is measured. A CI job can branch on it being non-empty to refuse an estimate that rests on a derived branch |
Every *_mib value is a float rounded to 2 dp. max_batch_size (also exposed as
estimated_max_batch_size) is the point-estimate ceiling; recommended_batch_size is the
conservative power-of-two batch after the validated reserve; recommendation_basis says whether
that reserve was available or the recommendation is only a point-estimate margin. safe_fits and
status are the fields a CI job should branch on. Keys may be added in a minor version, never renamed or
removed.
Exit codes (Mode A): 0 the config passes the safety policy (validated reserve where the exact
holdout scope applies, generous point-estimate headroom otherwise) Β· 1 it does not fit or is
uncertain Β· 2 the estimate could not be run (bad flags, unknown GPU, unreachable config,
or a model the memory model refuses β Β§3.3).
A CI job can therefore gate on the exit status alone and never parse the JSON. The REPL is the exception and always exits 0 β inside a session a
doesn't-fit is a verdict on screen, not the status of the shell you came from.
Validation: reject --quant nf4 --no-lora β a fitcheck scope limitation, not a universal claim.
Quantized models can be trained (QAT, and quantized-training methods that keep master weights or
straight-through estimators); fitcheck simply does not model those memory profiles. Its quantized path
assumes the base stays frozen while only adapters train, which is what the $W_{base}$ and $S_{optim}$
formulas are derived for. Error messages must say "not modelled", never "not possible". Reject
--optimizer-dtype with a non-AdamW optimizer. --qlora sets defaults, so an explicit later flag wins.
--explain output contract
--explain must name the largest component and say why, then price each toggle by re-running the estimator
with that one flag flipped β no new math, just a second call:
Largest component: activations (20,128 MiB, 66%) β 16,032 MiB of that is four FP32
copies of the (b, s, V) logits tensor, which a 128k vocabulary makes enormous.
adamw -> adam8bit ......... saves 312 MiB
--flash-attn OFF .......... costs 0 MiB (currently ON)
--grad-checkpoint OFF ..... costs +32,256 MiB (currently ON)
--grad-accum 8 ............ costs 0 MiB (accumulation is free)
Each figure is a total-memory delta, not a single-component delta β flipping gradient checkpointing off
adds the layer activations plus the 5% of that which $C_{overhead}$ picks up. Compute every hint as the
difference of two full estimate() calls; never by summing component deltas by hand.
Two lines here are load-bearing. "Gradient accumulation costs memory" is the single most common
misconception this tool can correct, and a hard 0 MiB corrects it faster than a paragraph. The
Flash Attention line is the second: at this shape it really does save nothing, because the LM-head
hump wins the $\max$ in $A_{act}$ either way. A hint that promised a saving here would be wrong.
The largest component is computed, never assumed. This has now been wrong in both directions. Earlier drafts named activations as the leader; the v0.1.1 numbers made it the NF4 base (4,068 MiB against 3,136 of activations); and with the corrected $A_{act}$ it is activations again, by a wide margin. Rank the components from the report and never hard-code the answer.
Mode B: Interactive REPL
fitcheck # no MODEL_ID β enters REPL
fitcheck --qlora --gpu 4090 # flags without a MODEL_ID seed the session
Commands:
model <model_id> Load a model config from HuggingFace
gpu <name> [--vram-mib N] Set target GPU
memory [OPTIONS] Compute training memory breakdown (same flags as CLI)
infer [OPTIONS] Serving breakdown: weights + KV cache (Mode C's flags)
advise [OPTIONS] Sweep batch/seq/rank: axis prices and the wall (Mode D)
explain Explain the last memory result in plain English
optimize Suggest best config for current model + GPU
compare <gpu> [...] [--infer]
Compare the current config across other GPUs;
--infer compares the serving config instead
show Current model, GPU, both flag sets, swept axes, last estimates
reset Training, serving and sweep flags back to defaults
gpus Print the GPU database
help Show available commands
exit / quit Exit the REPL
Aliases: mem, serve/inference/kv, sweep, q, ?, h, config/state, list-gpus.
Mode selection is the presence of MODEL_ID, not the absence of flags. cli.estimate_command takes
MODEL_ID as an optional argument; when it is missing, it builds the TrainingConfig exactly as it would for a
one-liner and hands it to run_repl(console, training=..., gpu=...). Three consequences:
- Estimate flags seed the session.
fitcheck --qlora --lora-r 64thenmodel <id>reaches the same state as entering the bare REPL and typing those flags at thememoryprompt β the flags are sticky either way, so honoring them at entry is the only reading that is not silent data loss. - Only an explicit
--gpu/--vram-mibpresets the session GPU. Mode A defaults to the 4090 when--gpuis absent; carrying that default in would set a session GPU the user never named, so the seeded session leaves it unset andgpu <name>is still required. - The output-only flags are a usage error without a
MODEL_ID.--json,--verbose, and--explainformat one estimate; with no model there is nothing to format, and the error namesmemory --jsonetc. as the in-session equivalent. Validation (--no-lora --quant nf4, bad--lora-targets) runs before entry, so a contradictory line fails at the shell rather than three commands later.
The banner echoes any seeded GPU and flags. Seeded state that nothing shows is state the user has forgotten by the third command. The REPL always exits 0 β a config that does not fit is a verdict inside the session, not the session's exit status.
REPL state: The REPL maintains a session object holding the current ModelConfig, GpuSpec, the
training flags in force, and the last MemoryReport. Commands like explain, optimize, and compare read
from the last computed report β and compute one from the session's state if none exists yet, rather than
refusing. Only a missing model or GPU is a hard error ("Run model <id> and gpu <name> first").
"Same flags as CLI mode" is enforced structurally, not by hand. repl.py builds its memory command
from cli.estimate_command.params β the same click.Option objects β minus the four that make no sense in a session
(MODEL_ID, --list-gpus, --no-color, --version). A flag added to Mode A appears in Mode B for free, and
the two surfaces cannot drift.
Flags are sticky. memory --qlora --lora-r 64 --batch-size 4 --seq-len 2048 --flash-attn followed by
memory --batch-size 8 re-uses everything else; retyping a fifteen-flag line to move one dial is what makes
people abandon a REPL. Only options the user actually typed are folded in (ParameterSource.COMMANDLINE),
and the report header echoes the config in force, so the state is never invisible. Consequences:
- Sticky booleans need an undo, so the REPL adds
--no-flash-attn,--no-grad-checkpoint, and--no-double-quant;resetrestores every default at once. An on/off pair on one line is an error. --lora-ror--lora-targetsre-enables LoRA after--no-lora(naming a rank means you want adapters).- A
--quantthat cannot use--double-quant(none,int8) silently clears a sticky one, instead of failing on a flag set three lines ago. Typing both on one line is still an error. --gpu/--vram-miboverride one estimate; onlygpu <name>moves the session GPU.
infer is Mode C inside the session, built from cli.infer_command.params exactly as memory is
built from cli.estimate_command.params β minus MODEL_ID and --no-color, plus a --no-double-quant
undo. It renders the same render_inference_report panel, honours --json, and takes --gpu /
--vram-mib as a one-shot override. Two things are deliberate:
- The serving flags are their own sticky set, held as a
ServingConfigbeside theTrainingConfig. They share four names (--quant,--precision,--seq-len,--double-quant) but not their values: serving compute defaults to fp16 and training to bf16 (Β§3.1 Component 7, note 4), so folding one line's--seq-leninto both would silently move a number the user was not editing.resetclears both,showprints both, andmodel/gpuinvalidate both cached reports. - A non-NF4
--quantclears a sticky--double-quant, and typing the pair on one line is the same usage error as Mode A β literally the same function,cli._validate_serving_combination, over the same rule infitcheck.validation.
advise is Mode D inside the session, built from cli.advise_command.params the same way, minus
MODEL_ID and --no-color, and it renders the same render_advisor_report panel. It differs from
infer in one deliberate way:
- It does not get a third sticky flag set. The fixed axes of the sweep are the session's
TrainingConfigβ the very flagsmemoryholds β soadvise --qlora --flash-attnsets them formemorytoo, and there is no pair of values to keep in sync.inferneeded its ownServingConfigbecause serving defaults differ (fp16 vs bf16);advisehas no such conflict. Only the sweep bounds are its own state:batch_sizes,seq_lens,lora_ranksand--max-seq-len. - The bounds stick too, and
--seq-lensstops being required. Mode A demands it on every line because there is nothing to remember it with; a session remembers, so the copied option dropsrequired(repl._optional, a shallow copy β the Mode A option is untouched and still required there). Give--seq-lensonce and every later line can be a bareadvise. Until it is given once,advisesays so rather than inventing a range. showprints the axes being swept, ornot setwith the line to type;resetclears the bounds along with the training and serving flags.- A session whose
TrainingConfigis a full fine-tune (--no-lora) has no rank axis to sweep, soadvisesays that and names the fix, rather than passing an impossible config to the advisor.
compare takes several GPUs and prices each one separately: columns are usable VRAM, peak,
headroom, % used, max micro-batch, and the verdict. The peak is usually the same everywhere β the
model does not know what card it is on β but C_overhead is keyed by GPU (Component 6), so a
calibrated card and an uncalibrated one do not agree. The footer says "peak is identical on every
card, only the ceiling moves" only when the rendered peaks really are identical, and otherwise gives
the range. With --infer it is the one table again with the serving config in the header and max
concurrent requests in place of max micro-batch.
optimize recommends, it does not just report the ceiling. It suggests the largest power-of-two
micro-batch within ~75% of max_batch_size, plus the --grad-accum steps that restore an effective batch of
at least 16 (free, per Component 4) β and says why the ceiling itself is the wrong thing to run. When even
batch_size=1 does not fit, it applies levers in ascending order of what they cost the user
(--flash-attn β --grad-checkpoint β --quant nf4 --double-quant β halve --seq-len β
--optimizer adam8bit), stopping at the first configuration that fits and printing the command to run. If
the ladder is exhausted it names the smallest card in the database that would hold the result.
Mode C: Inference Serving (fitcheck infer)
fitcheck infer <model_id> [OPTIONS]
Arguments:
MODEL_ID HuggingFace model ID (e.g., meta-llama/Llama-3.1-8B)
Serving Options:
--quant TEXT none|nf4|int8 β BASE MODEL storage (default: none).
Embeddings, LM head and norms are never quantized.
--double-quant NF4 double quantization (cuts scale overhead ~75%)
--precision TEXT fp32|fp16|bf16 β COMPUTE dtype: the KV cache and the
unquantized weight slice (default: fp16)
--seq-len INT Context length one request holds (default: 2048)
--concurrent INT Requests in flight (default: 1). Alias --num-concurrent.
GPU Options:
--gpu TEXT GPU name from database (default: 4090)
--vram-mib INT VRAM override for a GPU not in the database
Output Options:
--json Output as JSON (for CI/CD) β schema below
--no-color Disable colored output
fitcheck <model> still means training. infer is a subcommand; anything whose first token is
not a registered subcommand name β a model id, a flag, nothing at all β is routed to the training
command unchanged, so the v0.1 command line and the bare-fitcheck REPL are untouched and no usage
line ever names the internal estimate command. A model literally named infer is reachable as
fitcheck estimate infer.
The two axes are not interchangeable, and the flags say so. --precision is the compute dtype and
--quant is base-model storage, exactly as in training (Β§3.1 Component 7, notes 4β5). --quant nf4 --precision fp16 is the real 4-bit deployment: packed linears, fp16 embeddings, fp16 cache. There is
no way to spell "4-bit cache", because that is not modelled.
--double-quant outside --quant nf4 is a usage error, same wording and same reason as Mode A.
fitcheck infer --json output contract
| Key | Type | Contents |
|---|---|---|
fitcheck_version |
str |
Installed package version |
model |
object |
ModelConfig fields verbatim |
gpu |
object |
GpuSpec: name, vram_mib, usable_mib |
serving |
object |
ServingConfig as resolved |
memory_mib |
object |
weights, kv_cache, overhead, total |
kv_cache_mib_per_request |
float |
Cache one request holds at this seq_len |
kv_cache_mib_per_token |
float |
Cache per token, 6 dp β the number to plan capacity with |
verdict |
object |
fits, gpu_capacity_mib, headroom_mib, headroom_pct, max_concurrent |
warnings |
list[str] |
Caveats that apply to this serving config β empty at low concurrency, non-empty above four concurrent requests, where the unmodelled decode transient starts to matter (Β§3.7). Same contract as Mode A's warnings |
memory_mib.total includes overhead. It is the only figure a verdict may be drawn from; the
Component 7 model-side subtotal (weights + kv_cache) is ~500 MiB optimistic and is deliberately not
a key here. Exit codes and the add-only key policy match Mode A.
Inside the REPL this is the infer command (Β§3.5 Mode B), with the same flags, its own sticky
ServingConfig, and compare <gpu> ... --infer for the max concurrent requests per card.
max_concurrent is found by bisection over the full estimate and floored, for the same reason
max_batch_size is: $C_{overhead}$ is 5% of a total that itself grows with the cache, so
headroom / kv_per_request over-counts. Both use the one _largest_fitting helper in estimator.py.
Mode D: Config Advisor (fitcheck advise)
fitcheck advise <model_id> [OPTIONS]
Arguments:
MODEL_ID HuggingFace model ID (e.g., meta-llama/Llama-3.1-8B)
Sweep Axes (ranges, not one config):
--seq-lens TEXT REQUIRED. Comma-separated lengths, e.g. '1024,2048,4096'
--max-seq-len INT The model's real context length. Any --seq-lens value
above it is rejected instead of priced.
--batch-sizes TEXT Comma-separated micro-batch sizes (default: 1,2,4,8,16)
--lora-ranks TEXT Comma-separated LoRA ranks (default: 8,16,32,64,128,256)
Held Fixed Across the Sweep:
--quant TEXT none|nf4|int8 β BASE MODEL storage (default: none)
--double-quant NF4 double quantization (cuts scale overhead ~75%)
--qlora Shorthand: --quant nf4 --precision bf16 --grad-checkpoint
--precision TEXT fp32|fp16|bf16 β COMPUTE dtype (default: bf16)
--lora-targets TEXT Preset (minimal | standard | full) or modules (default: standard)
--optimizer TEXT adamw|adam8bit|sgd|sgd-momentum (default: adamw)
--optimizer-dtype TEXT fp32|bf16 β AdamW state dtype (default: fp32)
--grad-checkpoint Enable gradient checkpointing
--flash-attn Enable Flash Attention
GPU Options:
--gpu TEXT GPU name from database (default: 4090)
--vram-mib INT VRAM override for a GPU not in the database
Output Options:
--json Output as JSON (for CI/CD) β schema below
--no-color Disable colored output
advise says what each axis costs and where the wall is; the REPL's optimize says what to
run. They are deliberately different questions β see docs/ADVISOR.md Β§1. advise ignores
whatever single batch size and rank you are holding, sweeps the grid, and reports three things:
the frontier (the edge of what fits), the ceilings (bisected with the same
_largest_fitting that max_batch_size uses), and the price of each axis around the
recommended point.
There is no --no-lora. advise sweeps the LoRA rank, so a full fine-tune has no axis to
sweep; use Mode A for that. There is no --grad-accum either, because accumulation costs no
memory and so cannot move a frontier.
--seq-lens is required, and that is the rule enforced by the type. ModelConfig does not
carry max_position_embeddings (config_parser.py does not parse it), so no default sequence
range can be honest β it would sweep past a model's real context length in silence. advise
therefore has DEFAULT_BATCH_SIZES and DEFAULT_LORA_RANKS but deliberately no
DEFAULT_SEQ_LENS. --max-seq-len is the optional guard rail: pass the model's real context
length and any longer swept value is a usage error, not a priced recommendation.
Ties are grouped, never hidden. Under grad checkpointing with --flash-attn, A_act depends
only on b x s, so 8 x 512, 4 x 1024, 2 x 2048 and 1 x 4096 cost exactly the same. One
frontier row lists every tied split, because seq_len is usually fixed by the user's data and is
not the tool's to trade away.
fitcheck <model> still means training. advise is a subcommand registered on the same
group as infer, so routing is unchanged and no usage line names the internal estimate
command. A model literally named advise is reachable as fitcheck estimate advise.
fitcheck advise --json output contract
| Key | Type | Contents |
|---|---|---|
fitcheck_version |
str |
Installed package version |
model |
object |
ModelConfig fields verbatim |
gpu |
object |
GpuSpec: name, vram_mib, usable_mib |
sweep |
object |
batch_sizes, seq_lens, lora_ranks β sorted, deduplicated |
anchor |
object |
The full TrainingConfig the prices and ceilings were measured at: the recommended point, or the smallest grid point when nothing fits. Everything in it that is not a sweep key is a held-fixed axis |
grid_size |
int |
Points evaluated = len(batch_sizes) x len(seq_lens) x len(lora_ranks) |
fitting_count |
int |
How many of them fit |
frontier |
list[object] |
The edge of what fits, best first β see the row schema below |
recommended |
object | null |
The head of the frontier, or null when nothing fits |
ceilings |
list[object] |
axis (batch_size | tokens_per_step | lora_rank), max_value (bisected and floored; 0 when not even 1 fits), total_mib_at_max |
prices |
list[object] |
axis, from_value, to_value, delta_mib (positive costs, negative saves) |
verdict |
object |
fits (i.e. fitting_count > 0), gpu_capacity_mib |
Each frontier row, and recommended:
| Key | Type | Contents |
|---|---|---|
batch_size, seq_len, lora_rank |
int |
The canonical split β the largest batch among the tied ones |
tokens_per_step |
int |
batch_size x seq_len β work per optimizer step, not a speed |
total_mib |
float |
Predicted peak, 2 dp |
fits |
bool |
Always true: only fitting points reach the frontier |
command |
str |
A runnable fitcheck ... line, written with the MODEL_ID as typed |
equivalent_splits |
list[[int, int]] |
Every [batch_size, seq_len] at the same cost |
There is no throughput or speed key, and there never will be. fitcheck has no FLOPs count
and no timing data, so tokens_per_step is the honest second axis: the work done before one
optimizer update. See docs/ADVISOR.md Β§2.
Exit codes (Mode D): 0 at least one swept config fits Β· 1 none of them fits Β· 2 the
sweep could not be run. Same contract as Modes A and C, and the same add-only key policy.
3.6 β Key Architecture Decisions
| Decision | Rationale |
|---|---|
| Static estimation (no GPU required) | The whole point β predict before you spend money/time. Pure math from config.json. |
| One formula per file | Each memory/*.py module has one formula. Testable, debuggable, swappable independently. |
Read config.json not weights |
Downloads ~2KB vs. ~4β140GB. Works offline after first fetch. No GPU needed. |
| Practical grad checkpointing (every layer) | This is what HuggingFace transformers actually does, and it stores $2L\gamma bsh$ = 4,096 MiB for the golden config. The academic $\sqrt{L}$ formula models a different algorithm, so it does not describe the memory profile this tool predicts. See Component 5 above. |
| GQA-aware by default | Most modern models use GQA. Ignoring it gives 20β25% error on LoRA and activation estimates. |
click not argparse |
Better UX: auto-generated help, option groups, composable commands. Standard for Python CLIs. |
rich for display |
Screenshot-worthy terminal output drives organic sharing. Tables, colors, panels, emojis. |
| Separate REPL module | Mode B has its own state machine. Cleaner than cramming it into cli.py. |
3.7 β Edge Cases and Known Limitations
| Edge Case | How fitcheck Handles It |
Status |
|---|---|---|
| MoE models (Mixtral, Qwen3-MoE, gpt-oss, DeepSeek) | Refused at parse time, exit 2. Active experts Γ per-expert FFN changes both the parameter count and the activation formula; estimating anyway under-counts by 80β90% and reports a fit for a run that OOMs. _reject_unsupported fires on num_experts_per_tok / num_local_experts / num_experts / n_routed_experts β see Β§3.3. |
β refused |
| Nested multimodal configs (SmolVLM / Idefics3, Llama-4) | Refused at parse time, exit 2. text_config present and hidden_size absent means the decoder dimensions are nested, not missing, and the vision tower is unmodelled either way. The message says nested, so the user is not sent looking for a broken file. |
β refused |
| Flat multimodal configs (Qwen2.5-VL) | Refused, exit 2 β by the parameter cross-check, not the key gate. Text dimensions sit at the top level next to vision_config, so _reject_unsupported sees nothing wrong. The Hub reports 8,292,166,656 params against the formula's 7,615,487,488, a β8.2% gap past the 2% tolerance, and the omitted vision tower is refused there β see Β§3.3. Offline, with no Hub count to grade against, it is estimated 8% low instead. |
β refused (online) |
Non-SwiGLU MLPs (phi-2 fc1/fc2, models with biases) |
Refused, exit 2. _count_params assumes a 3-matrix gated MLP and no biases; phi-2 comes out +30.1%. The Hub cross-check (Β§3.3) catches the gap, and refuses rather than adopting the Hub count, because $A_{act}$'s $3d_{ff}$ and the LoRA target dimensions assume that same shape. Bias-only gaps (Qwen2.5's q/k/v biases, β0.004%) sit far inside the tolerance and are unaffected. |
β refused (online) |
| Models with tied embeddings | Detected via tie_word_embeddings in config. Count embedding params once. |
β MVP |
| Gated vs. non-gated FFN | Detect mlp_type or presence of gate_proj in config. If intermediate_size is missing, fall back to 4h and print a warning to the user that this is an approximation (can be 10β30% off β see Component 5 above). |
β MVP |
Non-standard head_dim (Gemma-2/3) |
head_dim read from config when present, $h/n_h$ only as fallback; the divisibility rule applies only when the value is derived. $P$, LoRA dims and the activation bracket all use the exact $n_hd_k$ / $n_{kv}d_k$ form. Sliding-window attention is still not modelled β see the row below. |
β MVP |
tie_word_embeddings absent from config |
Architecture default table (Gemma family ties), False for unknown model_type. |
β MVP |
| Custom attention patterns (sliding window, local) | Not modeled. Treated as standard attention. Note Gemma-2 alternates sliding/full layers, so its non-Flash path is approximate even once the two rows above are fixed. | β v0.3 |
| FSDP / DeepSpeed ZeRO | Not supported. Memory is split across GPUs β requires sharding-aware formulas. | β v0.4 |
torch.compile |
Changes which tensors are saved (kernel fusion). Not modeled. | β v0.4 |
| Multi-GPU (tensor parallel) | Not supported. Single-GPU estimation only. | β v0.4 |
| Very long sequences ($s > 8192$) | The $9\gamma$ eager coefficient is measured at $s \le 4096$ only. It is the term that grows as $s^2$, so extrapolation error grows with it. | β οΈ Known |
| Gated linear units (GLU variants: SiLU, GELU) | Treated uniformly β all save same intermediate shapes. | β MVP |
| Private / gated HF models | huggingface_hub handles auth via HF_TOKEN env var. |
β MVP |
| Hub unreachable (timeout, DNS failure, proxy refusal) | HubUnavailableError, exit 2: one line naming the failure and pointing at a retry or HF_HUB_OFFLINE=1. Under huggingface_hub 1.x the transport error is an httpx exception, which is not an OSError, so it used to escape every handler and reach the terminal as a traceback with no message β Β§3.3. |
β handled |
| Offline mode | If config.json is cached locally, works without internet. The Hub parameter count is unavailable, so num_params falls back to the config.json formula with a warning on stderr β and the architectures that only the cross-check catches (phi-2, Qwen2.5-VL) are estimated rather than refused. |
β MVP |
C_overhead fragmentation model |
Rebuilt and shipped on 2026-09-16: C_overhead = B + F x min(A_logits, A_layer), keyed per (GPU, kernel, quantization). B is the CUDA context, now measured (140.875 MiB on 69 archived T4 rows; 141.0 MiB on the twelve final holdout rows) rather than fitted -- fitting it was the main defect, because the constant column is collinear with the size column and leave-one-out moved F by up to 172%. The sequence slope S is measured to be zero (t = +0.4, -0.1, +0.5, -0.2). Over-reservation tracks the hump that loses the checkpointed max(), because segments cached for one allocation pattern cannot serve the other; R^2 on none/eager went -0.04 -> 0.80. Four T4 profiles ship; everything else (other cards, int8, checkpointing off, inference) keeps the 500 MiB + 5% default. Re-fitted from data/measurements/manifest.json on 2026-09-21, which declares the role of every archived row so repeats, holdout, and earlier rows cannot enter the fit: 49 rows fitted, scored over 57 calibration/repeat rows, plus 12 final holdout rows kept separate; the holdout reports tensor Β±1.4%, process MAE 5.4%, and worst under-prediction -15.3%. (SS3.8, Component 6). |
[x] Shipped |
T4 / ECC entries in gpu_db |
Fixed: T4 is now 14_912 / 14_000, the measured total. h200 and b200 take the usable_mib that is safe under the pessimistic reading of their vendor GB. Only the T4 is measured; the rest of the table is estimates. |
β fixed, rest unmeasured |
| No-checkpointing branch | Measured 2026-09-12 over 11 rows and 9 models: worst-case error 98.3% β 5.9%. The bracket is now $12h + 3n_hd_k + 1n_{kv}d_k + 3d_{ff}$ and the score matrix is charged at the retained rate ($2.9\gamma$), not the transient one ($9\gamma$) β see Component 5. The 8.3 warning is removed: keeping a caveat that says the branch is unmeasured would now be false. | β measured |
--quant int8 activations |
Billed at $\gamma = 4$ whatever --precision says: prepare_model_for_kbit_training upcasts the layer norms to FP32 and LLM.int8() takes that FP32 input at every linear β the run prints MatMul8bitLt: inputs will be cast from torch.float32 hundreds of times. On the one measured int8 row (TinyLlama, bs=2, seq=1024) that takes $A_{act}$ from 1,620 predicted against 3,729 measured ($-56.6%$) to 3,382 ($-9.3%$), and the tensors tier from $-38.9%$ to $-5.7%$. The residual is LLM.int8()'s own FP16 outlier buffers, which are not modelled, so estimate_warnings attaches a caveat calling the figure a lower bound. One model, one run β a second int8 row on a different model is owed before the mechanism can be called general. |
β οΈ Partly measured, warned |
Serving activations (fitcheck infer) |
Not modelled at all: $M_{infer}$ is resident memory only. Measured $-2.8%$ at 1 concurrent request and $-23.2%$ at 16, the unsafe direction, growing with concurrency. inference_warnings attaches a caveat above 4 concurrent naming both numbers. No coefficient is fitted, because 838 MiB of transient at 16 concurrent is far larger than any identified mechanism and one data point cannot settle it β see Component 7. |
β οΈ Unmeasured, warned |
| Full fine-tuning term split | fitcheck keeps the FP32 master copy in $S_{optim}$ (12 B/param). measure.py used to upcast in place so it landed in weights β +50% / β50% / β50% across three terms with a sum exact to the MiB (2,479 vs 2,479) β and that cast also made an fp16 row measure FP32 activations. Harness fixed 2026-09-20 (Component 4); the fitcheck terms are unchanged. |
β οΈ harness fixed, full-FT row not re-measured |
| Non-T4 hardware, BF16, real Flash Attention | All 79 archived measurement rows are from one Tesla T4 (sm_75) in FP16. BF16 and FA2 need sm_80+; the flash-like path is validated only via SDPA's memory-efficient backend as a stand-in. | β οΈ Unmeasured |
| Unknown GPU | Error message listing available GPUs. Flag to pass custom VRAM: --vram-mib 24000. |
β MVP |
3.8 β Ground Truth: how the numbers are checked (scripts/measure.py)
Everything above is arithmetic. This section is how we find out whether the arithmetic is true. It is the part of the project that turned a plausible formula into a measured one, so it belongs in the spec even though it ships no user-facing feature.
Why the harness lives outside the package
scripts/measure.py imports torch, peft, transformers and bitsandbytes. The fitcheck
package must never import any of them β that is the hard constraint the whole product rests on (an
estimate costs a few KB of config.json, one Hub metadata call for the parameter count, and no
GPU). So the dependency runs one way only:
scripts/measure.py ββimportsββ> fitcheck β
fitcheck ββimportsββ> torch β never
The harness is not a runtime dependency and is not installed by pip install fitcheck-llm. It runs
on a machine that has a GPU, and it prints a markdown row you paste into the README matrix.
What one run does
python scripts/measure.py <model_id> --qlora --precision fp16 --lora-r 32 \
--batch-size 2 --seq-len 1024 --gpu t4
- Ask
fitcheckfor a prediction (pure arithmetic, no GPU). - Load the real model with the real quantization config, apply real LoRA adapters.
- Run
--warmup-stepsfull training steps. Warmup must be β₯ 1: AdamW allocates itsexp_avg/exp_avg_sqbuffers lazily on the first.step(), so a zero-warmup peak would miss $S_{optim}$ entirely. - Reset the peak counters, run
--measure-stepsmore steps, read the peaks. - Run one extra instrumented step that resets the counter between phases, with forward hooks on the decoder layers reading back the dtype of the hidden states.
- Refuse the row if the observed dtypes contradict the flags. Parameters, gradients, activations and optimizer states are all read off a real step and checked against what the run claims to be; a row measured at one dtype and filed under another corrupts every per-param term downstream of it. Component 4 has the checks and the quantized-base exemptions.
- Print prediction vs measurement at three tiers, plus per-component spot-checks.
Step 5 happens after the headline peaks are read, so adding the instrumentation did not change any number the harness had already reported. The dtype hooks keep nothing but a set of strings and are installed for that step only, for the same reason.
The three tiers β and why comparing the wrong one is meaningless
PyTorch exposes two memory counters, and neither of them is "how much VRAM the process uses". Getting this wrong makes a correct formula look broken and a broken one look correct.
| counter | what it counts | what it misses |
|---|---|---|
max_memory_allocated() |
bytes in live tensors | the allocator's spare pool, the CUDA context |
max_memory_reserved() |
bytes the caching allocator holds from the driver | the CUDA context |
nvidia-smi |
everything the process holds | β |
So the harness compares like with like, three times:
| tier | predicted side | measured side | what it tests |
|---|---|---|---|
| tensors | six-component total minus $C_{overhead}$ | max_memory_allocated() |
the five physical formulas |
| allocator | total minus the 500 MiB context constant | max_memory_reserved() |
the formulas + the fragmentation model |
| process | the full fitcheck total |
max_memory_reserved() + CUDA context |
what a user actually sees |
The tensors tier is the one that grades the physics: it is deterministic, and it excludes $C_{overhead}$, which is a heuristic rather than a derivation. The process tier is the one that grades the product, because the fits / doesn't-fit verdict is computed from the full total. A validation table that shows only the tensors tier is technically true and quietly flattering; publish both.
Peak by phase β peak memory is a max over time, not a sum
This is the single most important idea in the whole project, and getting it wrong is what produced a 36% error in v0.1.1.
max_memory_allocated() is the highest the water level ever reached. A training step is not one
moment β memory rises and falls:
memory
β β±β² β hump B: backward, one layer recomputed
β β±β² β± β² (holds the sΒ² score matrix under eager attention)
β β± β² β± β²
β β± β² β± β²
β β± hump A β²
β β± logits + loss β²
ββββββββββββββββββββββββββΊ time
forward backward optimizer step
Hump A and hump B never exist at the same time: the FP32 logits are freed as the backward pass moves down from the LM head, long before it reaches a decoder layer's recompute. Adding them over-counts a peak that never happened. Think of a room where ten people arrive in the morning and ten different people arrive in the evening β you need ten chairs, not twenty.
That is why $A_{act}$ under checkpointing is resident + max(A_logits, A_layer) and not a sum
(Component 5). The harness proves it by resetting the peak counter between forward, backward and
optimizer step, and printing all three:
PEAK BY PHASE (which part of the step actually is the peak)
forward (logits + loss) 4,081 MiB
backward (recompute + attn) 6,655 MiB
optimizer step 1,793 MiB
Across every measured run the peak is in the backward phase β never the forward, never the
optimizer step. But which hump dominates inside the backward changes with shape, and that is
exactly the behaviour a max() reproduces and a sum cannot.
Component spot-checks β measuring $A_{act}$ instead of inferring it
Four of the six components can be observed directly, so the harness does not have to guess which one is wrong:
| component | measured as |
|---|---|
| $W_{base} + W_{lora}$ | memory_allocated() right after the model is built |
| $S_{optim}$ | sum over optimizer.state tensors (also reports their dtype) |
| $G_{grad}$ | sum over p.grad after backward, before zero_grad |
| $A_{act}$ | peak allocated β after load β S_optim β G_grad |
The last line is the important one. Activations cannot be read from a counter, but everything else in the peak can, so whatever is left over is the activation memory. This turns "the total is 12% off" into "the activation term is 16% off and the other four are exact", which is the difference between guessing and debugging.
The harness also prints a resident-weight breakdown (NF4-packed bytes, quantization scales, unquantized FP32 bytes). That is what confirmed the $P_{skip}$ rule in Component 1: embeddings and the LM head really are held in FP32, and the number matches $2Vh \times 4$ to the MiB.
Attention kernels β and how to measure the $s^2$ term
"Attention" is the math; a kernel is the code that runs it. Same result, very different memory.
| kernel | how it works | builds the $(b, n_h, s, s)$ matrix? |
|---|---|---|
eager |
plain PyTorch ops, one at a time | yes β about nine times over (Component 5) |
sdpa β MATH |
SDPA's fallback backend | yes |
sdpa β EFFICIENT_ATTENTION |
tiled, running softmax | no |
sdpa β FLASH_ATTENTION |
Flash Attention 2, tiled | no |
flash_attention_2 |
the standalone library | no |
fitcheck does not really have a "Flash Attention" flag; it has an "is the $s^2$ matrix resident or
not" flag. Flash is simply the best-known way of answering no. Any tiled kernel β including SDPA's
memory-efficient backend β has the same memory profile, because all of them recompute tiles in the
backward pass instead of storing the matrix.
That equivalence is what makes the $s^2$ term measurable by difference:
eager peak β tiled-kernel peak = the true cost of the score matrix
Run the same config twice, change only the kernel, and everything else β weights, optimizer states, gradients, logits, checkpoints β cancels. It is a controlled A/B test, and it is how the $9\gamma$ coefficient stopped being a guess. Measured on TinyLlama at $b{=}2$:
| seq | eager $A_{act}$ | tiled $A_{act}$ | difference = real matrix cost | $\gamma bn_hs^2$ predicts |
|---|---|---|---|---|
| 512 | 672 | 670 | 2 MiB | 32 |
| 1024 | 1,699 | 1,351 | 348 MiB | 128 |
| 2048 | 5,478 | 2,720 | 2,758 MiB | 512 |
At 512 the matrix is effectively free β hump A wins the max and the matrix is invisible. At 2048 it
costs 5.4Γ what a single copy would. One coefficient cannot fit both, which is what proved the
structure was wrong and not just the number.
Why SDPA and not Flash Attention 2. Flash Attention 2 requires sm_80 (Ampere: RTX 30-series,
A100 and newer). The Tesla T4 every measurement was taken on is sm_75 (Turing). SDPA's
memory-efficient backend runs on Turing and has the same memory behaviour, so it stands in for flash
on hardware that cannot run flash. --flash-attn --attn-impl sdpa therefore means predict as though
Flash Attention were on, and measure with a kernel that genuinely has no score matrix.
The backend is pinned, not requested. Plain sdpa is free to fall back to MATH, which does build
the matrix, and that would silently void the comparison β so the harness forces
EFFICIENT_ATTENTION and fails loudly if it is unavailable. It prints which backend was used; a run
that does not say EFFICIENT_ATTENTION is not a valid control.
The GQA shim β why grouped-query models needed extra work
Under MHA, every query head owns a K and a V head ($n_{kv} = n_h$). Under GQA, several query heads share one K/V head ($n_{kv} < n_h$) β that is what shrinks rows 7 and 8 of the saved-tensor table. But the attention math still needs K and V at every query position, so someone must widen them. There are two ways:
- copy β
repeat_kvduplicates 4 heads into 32. Costs memory; works everywhere. - broadcast β PyTorch 2.5+ accepts
enable_gqa=Trueand re-reads the narrow tensor. Cheaper.
When no attention mask is passed, transformers chooses broadcast. The memory-efficient backend does
not support it β it requires Q, K and V to have matching head counts β so every GQA model died with
RuntimeError: No available kernel. Only SmolLM2 survived, because it is MHA.
The harness therefore installs a small shim that widens K/V with repeat_interleave before calling
SDPA, and drops enable_gqa. This is exactly what the eager path does anyway, so the eager-vs-tiled
comparison stays fair β the only remaining difference between the two runs is the score matrix, which
is the whole point.
Measuring the CUDA context at the right moment
_cuda_context_mib() computes total β free β reserved: device memory the process holds that the
caching allocator does not account for. When you read it matters. Straight after
torch.cuda.init() it reports ~105 MiB, but cuBLAS/cuDNN workspaces and lazily-loaded kernel images
land during the first real matmul, and by peak it is ~141 MiB on this T4. Reading it early
under-states the process total and makes fitcheck look better than it is, so the harness reads it
after the measured steps and prints the init-time value only for reference.
Measured status
79 rows are archived, all on one Tesla T4 (sm_75), FP16 compute. data/measurements/manifest.json
declares the role of each: 49 fitted, 57 calibration/repeat rows scored, 12 final holdout rows,
10 legacy rows excluded. The current accuracy
figures come from one command and are not maintained by hand:
python -m fitcheck.calibrate data/measurements/manifest.json --check --role calibration --role repeat
worst process-tier error 13.9%, mean 2.4%; re-scored with the current code the tensors tier
is worst 4.6%, mean 0.7%. tests/test_docs_claims.py fails if any document drifts from that.
Two stacks, four sessions. t4-2026-09-01 (10 rows) and t4-2026-09-16 (17 rows) ran torch
2.10.0+cu128 / transformers 5.0.0 / peft 0.19.1; t4-2026-09-15 (40 rows) ran torch 2.14.0+cu130 /
transformers 5.17.0 / peft 0.20.0; the final t4-2026-09-21 session adds the twelve holdout rows
on the first stack. Python 3.12 throughout. The measured CUDA context is close across sessions,
but the holdout remains out of fit by design.
Everything below in this subsection is historical, recording the 33-run population that v0.1.1
through v0.2 were validated on (2026-09-01 to 2026-09-12). Only its first ten rows are in the
committed archive, and they are the ten marked excluded β they carry no embedded model_config,
so they cannot be re-predicted offline. The other twenty-three were never committed at all.
The original ten β QLoRA r=32 [q,k,v,o], AdamW FP32 states, gradient checkpointing on, three models, three sequence lengths, both attention kernels:
| tier | max abs error | mean abs error | worst run |
|---|---|---|---|
| tensors | 2.8% | 0.7% | TinyLlama bs2 seq2048 eager |
| process | 14.7% | 5.5% | SmolLM2 bs4 seq1024 eager |
| allocator | 20.2% | 9.5% | SmolLM2 bs4 seq1024 eager |
Two of those ten moved under the wider bracket, both for the better (TinyLlama seq-1024 eager $-3.4% \to -1.0%$, seq-2048 eager $+0.7% \to +2.8%$); the other eight are unchanged, because $A_{logits}$ wins the $\max()$ and absorbs the change.
The thirteen new rows, which are where the formula was actually wrong:
| branch | before correction | after correction |
|---|---|---|
| tensors tier, checkpointing off (11 rows) | 33.7% worst | 4.4% worst |
| $A_{act}$ alone, checkpointing off | 98.3% worst | 6.8% worst |
| $G_{grad}$, every LoRA row | β50.0%, all 14 | +3.1% worst |
| tensors tier, checkpointing on (2 rows) | 15.6% worst | 17.4% worst |
Read that as: the five physical formulas are right to a few percent on every branch that ships, and $C_{overhead}$ is not. The allocator and process tiers differ from the tensors tier only by the overhead model. The one row still above 10% on the tensors tier β SmolLM2-360M at seq 4096, +17.4% β is also an overhead row: its allocator reserved 7,304 MiB against 4,909 allocated, the worst fragmentation this project has measured. See Component 6.
What has still never been measured, stated as plainly as the results: any GPU other than this T4,
therefore no BF16 and no real FlashAttention-2 (sm_75 supports neither β the flash path is validated
only through SDPA's memory-efficient backend); --quant int8 on more than one model; and any serving
run above 16 concurrent requests.
3.9 β The dependency contract
Runtime dependencies stay at three. A fourth is a decision, not a detail.
| Dependency | Floor | Why this floor and not a lower one |
|---|---|---|
click |
>=8.1 |
Option groups and the command style cli.py is written in |
rich |
>=13.0 |
Tables, panels and the verdict styling in display.py |
huggingface-hub |
>=0.25 |
config_parser.py does from huggingface_hub.errors import GatedRepoError. That module does not exist before 0.22, and it does not export GatedRepoError until 0.25. The same import also takes HfHubHTTPError and HTTPError (Β§3.3); both are in 0.25's errors.py, so neither raises the floor β HTTPError is the re-export of the HTTP library the hub ships with, requests at this floor and httpx from 1.0. The Hub parameter count (Β§3.3) needs HfApi.model_info(..., expand=[...]) and ModelInfo.safetensors.total; both are present in 0.25.0, so it does not raise the floor |
The dev extra is pytest>=7.4, pytest-cov>=4.1, httpx>=0.27, ruff>=0.16 and mypy>=2.3.
httpx is there because tests/test_config_parser.py builds a fake 403 response with it. It used
to work undeclared, purely because huggingface-hub 1.x happens to pull it in; at the declared
floor the hub still uses requests, so without the explicit entry the suite would not even collect.
The two tool floors are the versions the configuration in Β§3.10 was verified against: a linter that
drifts below them reports a different set of findings, which makes "clean" mean something else.
A declared floor is a promise, so CI tests it. Each floor above was found by installing that exact version in a clean environment and importing, not by guessing:
huggingface-hub |
Result of from huggingface_hub.errors import GatedRepoError |
|---|---|
0.20.0 |
ModuleNotFoundError: No module named 'huggingface_hub.errors' |
0.23.0 |
ImportError: cannot import name 'GatedRepoError' |
0.24.0 |
ImportError: cannot import name 'GatedRepoError' |
0.25.0 |
works β this is the true floor |
The test matrix job cannot catch a wrong floor, because a resolver always picks the newest release
that satisfies the pin. So a second CI job, floor, installs the lowest version of every direct
dependency on Python 3.10 and runs pytest -m "not network" against it:
- run: uv pip install --system -e ".[dev]" --resolution lowest-direct
- run: pytest -m "not network"
--resolution lowest-direct is a uv flag; pip has no equivalent, which is why the job installs
uv first. "Direct" and not "lowest" on purpose: this project's floors are the contract, and
pinning the floor of every transitive package as well would fail on other people's metadata rather
than on ours.
3.10 β Lint and type checking
ruff check . and mypy --strict fitcheck/ are both clean, and the lint CI job runs them on every
push and PR. The annotations were always there β from __future__ import annotations at the top of
every module, frozen dataclasses for all three configs, _validate_* helpers on every public
estimator. This is what makes them binding.
Configuration lives in pyproject.toml, not in separate dotfiles:
| Block | Setting | Why |
|---|---|---|
[tool.ruff] |
line-length = 100, target-version = "py310" |
The width the source was already written to, and the declared floor |
[tool.ruff] |
extend-exclude = ["*.ipynb"] |
The notebooks are dated measurement artifacts β they record what one GPU did on one day, and their saved outputs are the evidence. Linting invites edits that invalidate them |
[tool.ruff.lint] |
select = ["E", "W", "F", "I", "UP", "B", "C4", "SIM", "RUF"] |
Errors, imports, modern syntax, bugbear, comprehensions, simplifications, and ruff's own checks |
[tool.ruff.lint] |
ignore = ["RUF001", "RUF002", "RUF003"] |
The formulas are written with the same symbols this document uses (Ξ³, Γ). Renaming them to ASCII lookalikes would make the code and the spec disagree |
[tool.ruff.lint.isort] |
combine-as-imports = true |
estimator.py imports one private helper under a readable alias; it should not be torn into a second statement from the same module |
[tool.mypy] |
python_version = "3.10", strict = true, files = ["fitcheck"] |
Check against the declared floor, not the CI runner's interpreter |
--strict covers fitcheck/ only. scripts/measure.py imports torch, peft and
bitsandbytes, which are not installed in CI and must never become dependencies of the package
(Β§3.9). Type-checking the harness would require them in the lint environment, which is exactly the
coupling the project forbids.
What the first pass actually found. No bug, which is the honest result to report β the
annotations were accurate. What --strict did surface was three places where a type was wider than
the invariant it stood for:
advisor.pyreadsanchor.lora_rank, declaredint | None, as anintthroughout_ceilingsand_prices.advise()refuses a full-fine-tuning config at the door and_anchor_configalways fills the rank from the validated sweep, so the value is neverNonethere. A private_anchor_rank()now states that invariant instead of leaving it implicit.estimator.py's_validate_positive_intand_validate_flagwere annotated as takingintandbool. They exist to check values that may be anything β their bodies testisinstanceβ soobjectis the honest parameter type, and the narrowing is the return.repl.py's_cli_module()had no return annotation (the lazy import that breaks thecliβreplcycle) and itssticky()helpers returnedobject, which cannot be passed toint(). The values come fromclick'sctx.params, so they areAny.
Everything else was mechanical: import order, typing.Iterable β collections.abc, trailing
whitespace, and zip(..., strict=True) on two pairings in calibrate.py that are length-equal by
construction.
Section 4: Definition of Done
v0.1 (MVP) β done when all 6 bullets are true
pip install fitcheck-llmworks β published to PyPI, installs cleanly on Python 3.10+,fitcheck --helpruns.Both interaction modes functional β Mode A (CLI one-liner) and Mode B (interactive REPL with
model,gpu,memory,explain,optimize,compare,exit) produce correct output. v0.2 addsinferandcompare --inferto Mode B.Estimates are analytical and labelled as such β every component reproduces its row in the Appendix, and the README states exactly what is and is not measured. This was written when nothing had been measured; as of v0.1.2 the physical formulas are validated to 3.4% and $C_{overhead}$ is not, so the honesty requirement now means publishing both tiers, not disclaiming everything.
pytestpasses with β₯80% line coverage on allmemory/modules, including at least one end-to-end test (known config β expected MiB Β± tolerance).CI is green β GitHub Actions runs
pytest --covon 3.10 / 3.11 / 3.12 for every push and PR, badge in the README. Β§2 marks this P0; a red badge on day one costs more trust than a missing feature. A second job,floor, resolves every direct dependency to its lowest declared version and runs the offline suite there (Β§3.9) β the matrix job always resolves to the newest release, so onlyfloorcan catch a wrong pin. A third job,lint, runsruff check .andmypy --strict fitcheck/(Β§3.10); all three gate a PR.README is complete β includes: what it does (with screenshot of terminal output), comparison table vs. existing tools, installation instructions, usage examples (both modes), the validation matrix with its columns still TBD, and "how it works" linking to this spec.
v0.2 β the accuracy gate
Estimates within Β±10% of measured ground truth for β₯3 configurations, measured with
scripts/measure.pyand filled into the README matrix.Status (2026-09-02): met on the tensors tier, not yet on the process tier. Ten runs across three models, three sequence lengths and both attention kernels land within 3.4% on the tensors tier (mean 0.8%). The process tier β the full total, which is what the fits/doesn't-fit verdict uses β reaches 14.7%, and every bit of that gap is $C_{overhead}$. Treat the gate as half-passed: the physics is validated, the overhead heuristic is not. The remaining hardware gap is a second GPU β all twenty measurements to date are one Tesla T4.
Why the split. Requiring measured rows before the first publish would block PyPI on owning a 4090. Shipping unvalidated with a loud banner is the honest trade; shipping unvalidated quietly, or launching to an audience that checks numbers before the matrix has rows, is not.
v0.3 β the advisor gate
fitcheck adviseanswers the two questions a breakdown cannot β what each axis costs, and where the wall on each axis is. Done when all four are true:a. Both modes carry it. Mode D (
fitcheck advise <model> --seq-lens ..., Β§3.5) and the session commandadvise/sweep(Β§3.5 Mode B) are built from the samecli.advise_command.params, so a flag added to one appears in the other for free. The session drops only the--seq-lensrequirement, because a session remembers it.b. The wall is bisected, never read off the grid. The grid gives the shape; each per-axis ceiling comes from re-running the whole estimator (
_largest_fitting), for the same reasonmax_batch_sizedoes β see the Appendix. A grid that stops at rank 256 hides a real ceiling of 330.c. Every frontier row prints a command you can paste. It carries the model id the user typed, not
ModelConfig.name(which is only the last path segment and does not resolve on the Hub).d. The golden sweep reproduces to the digit. Llama-3.1-8B, QLoRA r=64 [q,k,v,o], 4090, ckpt + flash, seq lens 512β8192: grid 150, fitting 60, frontier
4,096 tok/step r=256 @ 22,535.87 MiB, rank ceiling 330 @ 23,497.87 MiB, axis prices β416 / +832 / β5,283.60 / +10,567.20 MiB. The grid size, the bisected rank ceiling and the axis prices are asserted intests/test_advisor.py(advisor.pyat 100% coverage); the rendered screen is verified by hand in both modes, the way 4.5/4.6 verifycli.pyanddisplay.py.
Historical v0.3 accuracy note. When the advisor gate shipped,
advisere-ran the same estimator on a grid and inherited the v0.2 evidence β 3.4% on the tensors tier, 14.7% on the full total, and twenty measurements from one Tesla T4. A sweep of an unvalidated heuristic did not add accuracy evidence. The current beta evidence is reported in Β§3.8.
Appendix: Worked Example (Reference Implementation Check)
Config: Llama-3.1-8B, QLoRA r=64, targets=[q,k,v,o], bs=4, seq=2048, BF16 compute, NF4 base (no double quant), AdamW FP32 states, grad ckpt ON, Flash Attn ON, RTX 4090.
This is the single golden number set for the whole project. Every doc, and every test, cites these values and no others.
Derived inputs: $P = 8{,}030{,}261{,}248$, $h=4096$, $L=32$, $n_h=32$, $n_{kv}=8$, $d_{ff}=14336$, $\gamma=2$.
bracket = 15h + 1hΒ·(n_kv/n_h) + 3Β·d_ff
= 61,440 + 1,024 + 43,008 = 105,472
A_layer = Ξ³Β·bΒ·sΒ·bracket = 2Β·4Β·2048Β·105,472 = 1,727,983,616 B = 1,648 MiB
2LΒ·Ξ³bsh = 2 Β· 32 Β· 2Β·4Β·2048Β·4096 = 4,294,967,296 B = 4,096 MiB
Let $P_{skip}$ be the parameters bitsandbytes does not quantize -- embeddings, LM head, layernorms -- which peft then upcasts to FP32: $P_{skip} = 2Vh + (2Lh + h) = 1{,}050{,}673{,}152 + 266{,}240 = 1{,}050{,}939{,}392$ for the untied Llama-3.1-8B ($V = 128{,}256$). The quantized slice is $P_q = P - P_{skip} = 6{,}979{,}321{,}856$.
| Component | Formula | Bytes | Result (MiB) |
|---|---|---|---|
| $W_{base}$ | $P_q(0.5 + \frac{4}{64}) + P_{skip} \times 4$ | 8,129,626,112 | 7,753.02 |
| $W_{lora}$ | $32 \times 1{,}703{,}936 \times 4$ bytes | 218,103,808 | 208.00 |
| $S_{optim}$ | $54{,}525{,}952 \times 8$ bytes | 436,207,616 | 416.00 |
| $G_{grad}$ | $54{,}525{,}952 \times 4$ bytes | 218,103,808 | 208.00 |
| $A_{act}$ | $2L\gamma bsh + \max(A_{logits},\ A_{layer})$ | 21,105,344,512 | 20,128.00 |
| $C_{overhead}$ | $500 + 0.05 \times (7{,}753.02 + 20{,}128)$ | β | 1,894.05 |
| Total | 30,607.07 |
$A_{act} = 4{,}096 + \max(16{,}032,\ 1{,}648) = 4{,}096 + 16{,}032$, where $A_{logits} = 4 \times 4 \times bsV = 4 \times 4 \times 8{,}192 \times 128{,}256 = 16{,}810{,}573{,}824$ B $= 16{,}032$ MiB β four FP32 copies of the logits tensor, reduced by neither gradient checkpointing nor Flash Attention.
Note that $A_{logits}$ wins the $\max$ by a wide margin here, so at this shape Flash Attention saves 0 MiB: with it off, $A_{layer}$ rises from 1,648 to 10,864 MiB and is still the smaller of the two. That is a property of the 128k vocabulary, not a bug.
RTX 4090 usable: 23,500 MiB β β DOES NOT FIT β headroom β7,107 MiB (β30%) β max micro-batch 2. The same configuration at $b = 1$ costs 14,756 MiB and fits comfortably.
Displayed rounded as 30,607 MiB. Tests assert the unrounded total within a tolerance, never the display string.
Unchanged by the 2026-09-12 correction, and that is the point. The bracket widened from 69,632 to 105,472, so $A_{layer}$ went 1,088 β 1,648 MiB β and every number in the table above stayed exactly where it was, because 1,648 still loses the $\max()$ to $A_{logits}$. A correction that moves an intermediate by 51% and the headline total by zero is the clearest possible demonstration of why this config's peak is an LM-head problem, not an attention problem. The adapter-precision widening in Components 2 and 4 also leaves it alone: this config is QLoRA, so the adapters were already being billed at FP32.
Revised 2026-09-01 (v0.1.2). The previous set β $A_{act}$ 19,168 and total 29,599.07, headroom β6,099 (β26%), $b{=}1$ at 14,504 β came from summing the LM-head and layer humps and from storing one $(b,s,h)$ tensor per checkpoint. Twenty measured T4 runs showed both were wrong; see Component 5. Every verdict is unchanged (still does not fit, max micro-batch still 2, $b{=}1$ still fits) β only the MiB figures moved, by +3.4%. Worst-case prediction error across those 20 runs fell from 36.1% to 4.8%.
This appendix changed on 2026-08-31. v0.1 published 8,688.67 MiB here and claimed the config fits a 4090 with 63% headroom. The first real measurement β Mistral-7B-v0.3, QLoRA r=32 bs=2 seq=1024 fp16 no-FA on a Kaggle T4 β came back 35.6% above the v0.1 prediction. It had four causes. Three of them ($P_{skip}$, FP32 absmax, FP32 adapters) were confirmed to the MiB against the measured storage breakdown. The fourth, $A_{logits}$, is now confirmed out-of-sample: Qwen2.5-7B and Qwen2.5-1.5B (152k vocabulary, where logits are 85-93% of $A_{act}$) predict to +0.5% and +0.0%. The open item that replaced it is a second GPU -- every measurement so far is one Tesla T4, in FP16, without Flash Attention.
Units discipline: all values are MiB ($1024^2$), never MB ($10^6$). $W_{base}$ is 8,130 MB but 7,753 MiB; quoting the former as the latter is a 4.9% error, which is enough on its own to flip a fits/doesn't-fit verdict near the boundary. GPU vendors advertise in GB, PyTorch reports in MiB β convert once, at the edge.
max_batch_size is defined by search, not extrapolation: the largest integer $b$ with
$\text{total_mib}(b) \le \text{usable_mib}$, found by re-running the whole estimator (bisection).
Every activation term is linear in $b$, so within one branch of the max the total is linear too:
Note the two slopes: activations grow at 5,032 MiB per batch unit but the total grows at 5,283.60, the extra 251.60 being $C_{overhead}$ following $A_{act}$ upward. Dividing headroom by the activation slope over-estimates how many batches fit β in the optimistic direction.
And note $2.655$: always floor, never round. Rounding hands the user a config that OOMs on the first step, and an optimistic error is the only kind this tool actually costs anyone.
Why bisection rather than this algebra. Inside one branch the extrapolation above is exact. But $\text{total}(b)$ is piecewise linear β it has a kink wherever the
maxflips from the LM-head hump to the layer hump, which happens as $b$ and $s$ grow (the eager $s^2$ term flips it sooner). Extrapolating across that kink gives the wrong answer; re-running the estimator is correct regardless of the curve's shape, and stays correct as components are added.
Cross-check (Flash Attention OFF): the eager score matrix adds
$9\gamma bn_hs^2 = 9 \cdot 2 \cdot 4 \cdot 32 \cdot 2048^2 = 9{,}216$ MiB per layer, taking $A_{layer}$
from 1,648 to $\mathbf{10{,}864}$ MiB. That is still below $A_{logits} = 16{,}032$, so the max picks
the same branch and $A_{act}$ stays at $\mathbf{20{,}128}$ MiB. Flash Attention saves 0 MiB at this
shape β a property of the 128k vocabulary, not a bug, and the kind of result only a max reveals.
Every memory/*.py module must reproduce its row in this table for the corresponding inputs, and
test_end_to_end.py must assert the Total.