fitcheck / docs /SPEC.md
mlanvvs's picture
Link the live FitCheck demo
cb7eab1 verified
|
Raw History Blame Contribute Delete
117 kB

A newer version of the Gradio SDK is available: 6.30.0

Upgrade

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:

  1. Pick a model, set training config (batch size, LoRA rank, sequence length, precision).
  2. Launch training. Wait 2–5 minutes for the model to load.
  3. CUDA OutOfMemoryError.
  4. 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:

Peak VRAM=Wbase+Wlora+Soptim+Ggrad+Aact+Coverhead\boxed{\text{Peak VRAM} = W_{base} + W_{lora} + S_{optim} + G_{grad} + A_{act} + C_{overhead}}

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:

Wbase=PΓ—bytes_per_paramW_{base} = P \times \text{bytes\_per\_param}

Quantized base:

Wbase=PqΓ—(bytes_per_param+4Bq)+PskipΓ—4W_{base} = P_q \times \left(\text{bytes\_per\_param} + \frac{4}{B_q}\right) + P_{skip} \times 4

Quantization-scale overhead:

Qoverhead=PqΓ—4Bq bytesQ_{overhead} = P_q \times \frac{4}{B_q}\text{ bytes}

Double quantization:

OverheadDQ=8 bits64+32 bits64Γ—256=0.125+0.00195β‰ˆ0.127 bits/paramβ‰ˆ0.0159 bytes/param\text{Overhead}^{DQ} = \frac{8\text{ bits}}{64} + \frac{32\text{ bits}}{64 \times 256} = 0.125 + 0.00195 \approx \mathbf{0.127}\text{ bits/param} \approx \mathbf{0.0159}\text{ bytes/param}

The factor relative to single quantization is $\mathbf{0.254}$. Double quantization removes about three quarters of scale overhead.

Parameter count:

P=Vh+L[Pattn+3hβ‹…dff+2h]+h+Vhβ‹…1[untied]P = V h + L \left[P_{attn} + 3h \cdot d_{ff} + 2h\right] + h + V h \cdot \mathbb{1}[\text{untied}]

Attention parameter count:

Pattn=2hβ‹…nhβ‹…dk+2hβ‹…nkvβ‹…dkP_{attn} = 2h \cdot n_h \cdot d_k + 2h \cdot n_{kv} \cdot d_k

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:

Pskip=2Vh (or Vh when tied)+2Lh+hP_{skip}=2Vh\text{ (or }Vh\text{ when tied)} + 2Lh + h

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.py gets the parameter count from Hub metadata and cross-checks the count derived from config.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_training upcasts 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.

Wlora=LΓ—rΓ—Ξ³adapterΓ—βˆ‘t∈targets(din(t)+dout(t))W_{lora} = L \times r \times \gamma_{adapter} \times \sum_{t \in \text{targets}}\left(d_{in}^{(t)} + d_{out}^{(t)}\right)

Ξ³adapter=4 bytes (FP32) on every LoRA path\gamma_{adapter}=4\text{ bytes (FP32) on every LoRA path}

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 supplies adapter_precision from estimator._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.

Soptim=PtrainableΓ—Ξ²optimS_{optim}=P_{trainable}\times\beta_{optim}

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:

Soptim=PtrainableΓ—(Ξ²optim+4β‹…1[not LoRA]β‹…1[precisionβ‰ fp32])S_{optim}=P_{trainable}\times \left(\beta_{optim} +4\cdot\mathbb{1}[\text{not LoRA}] \cdot\mathbb{1}[\text{precision}\ne\text{fp32}]\right)

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.

Ggrad=PtrainableΓ—Ξ³adapterG_{grad}=P_{trainable}\times\gamma_{adapter}

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_steps must 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:

bracket=12h+3 nhdk+1 nkvdk+3 dff\text{bracket}=12h+3\,n_h d_k+1\,n_{kv}d_k+3\,d_{ff}

When $n_h d_k=h$, this becomes:

15h+1hnkvnh+3dff15h+1h\frac{n_{kv}}{n_h}+3d_{ff}

The per-layer transient peak is:

Alayer=Ξ³bs[15h+1hnkvnh+3dff]+cΞ³bnhs21[no Flash Attn]A_{layer}= \gamma bs\left[15h+1h\frac{n_{kv}}{n_h}+3d_{ff}\right] +c\gamma bn_hs^2\mathbb{1}[\text{no Flash Attn}]

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:

Alogits=λ×4 bytesΓ—bsVA_{logits}=\lambda\times4\text{ bytes}\times bsV

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:

Aact=κLγckptbsh+max⁑(Alogits,Alayer)A_{act}=\kappa L\gamma_{ckpt}bsh+\max(A_{logits},A_{layer})

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:

Aact=LΓ—Alayerretained+AlogitsA_{act}=L\times A_{layer}^{retained}+A_{logits}

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.

Coverhead=B+FΓ—min⁑(Alogits,Alayer)\boxed{C_{overhead}=B+F\times\min(A_{logits},A_{layer})}

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_mib already 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.

Minfer=Wbase+KV+CoverheadM_{infer}=W_{base}+\text{KV}+C_{overhead}

KV=2Γ—LΓ—nkvΓ—dkΓ—sΓ—nconcurrentΓ—Ξ³\text{KV}=2\times L\times n_{kv}\times d_k\times s\times n_{concurrent}\times\gamma

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 returns InferenceMemory(weight_mib, kv_cache_mib). Serving has its own InferenceReport; 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.py imports fitcheck; fitcheck never imports torch, peft or bitsandbytes β€” not lazily, not inside a try. An estimate must cost a few KB of config.json and 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

warnings carries 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. InferenceReport carries the same field, filled by inference_warnings(serving), which fires above four concurrent requests.

precision is 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_precision takes the compute dtypes only: fp32, fp16, bf16. A storage format (nf4, int8, int4, fp8) as precision is refused, not silently priced β€” it would rescale $W_{lora}$, $G_{grad}$ and $A_{act}$ by 4Γ—.
  • validate_quantization takes the storage formats only: none, nf4, int8.
  • validate_double_quant allows double_quant only under nf4. It is bitsandbytes' bnb_4bit_use_double_quant, a second level of quantization for the NF4 absmax scales: none has no scales to shrink, and int8 has none either, so charging the ~75% saving (Component 1) on either would report a saving the run never gets. scripts/measure.py has always refused this pair; before the shared validation layer, the library and fitcheck infer did not, which is the drift the shared layer removes.

The message is one string, returned by double_quant_conflict so the CLI and REPL can raise it as a click.UsageError while the library raises the same sentence as a ValueError.

The shape axes have ceilings too. MAX_SEQ_LEN (2^24 = 16,777,216 tokens, past every published context window) and MAX_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 in validation.py. estimate_activation_memory bounds batch_size and seq_len against them; estimate_inference_memory bounds seq_len and num_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 an OverflowError traceback instead of a sentence. For the same reason estimate_overhead refuses NaN and infinity β€” value < 0 is false for both, so the non-negative check alone let them through and every downstream verdict came back nan.


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_dim is free to violate it, and must be accepted.
  • False for an unknown model_type is a deliberate divergence from transformers, whose own PretrainedConfig default is True. 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_embeddings must be a real JSON boolean, not anything truthy. bool(...) reads the string "false" as True, 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.json must have a JSON object at its root, or there are no fields to read and raw.get would fail with an AttributeError instead 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.py used to list the Tesla T4 as vram_mib=16_384, usable_mib=15_360, but the card in every measurement reports 14,912 MiB total to torch.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 reads vram_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”) and b200 (β€œ192 GB”) now take the usable_mib that 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. t4 is 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 64 then model <id> reaches the same state as entering the bare REPL and typing those flags at the memory prompt β€” 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-mib presets the session GPU. Mode A defaults to the 4090 when --gpu is absent; carrying that default in would set a session GPU the user never named, so the seeded session leaves it unset and gpu <name> is still required.
  • The output-only flags are a usage error without a MODEL_ID. --json, --verbose, and --explain format one estimate; with no model there is nothing to format, and the error names memory --json etc. 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; reset restores every default at once. An on/off pair on one line is an error.
  • --lora-r or --lora-targets re-enables LoRA after --no-lora (naming a rank means you want adapters).
  • A --quant that 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-mib override one estimate; only gpu <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 ServingConfig beside the TrainingConfig. 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-len into both would silently move a number the user was not editing. reset clears both, show prints both, and model / gpu invalidate both cached reports.
  • A non-NF4 --quant clears 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 in fitcheck.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 flags memory holds β€” so advise --qlora --flash-attn sets them for memory too, and there is no pair of values to keep in sync. infer needed its own ServingConfig because serving defaults differ (fp16 vs bf16); advise has no such conflict. Only the sweep bounds are its own state: batch_sizes, seq_lens, lora_ranks and --max-seq-len.
  • The bounds stick too, and --seq-lens stops being required. Mode A demands it on every line because there is nothing to remember it with; a session remembers, so the copied option drops required (repl._optional, a shallow copy β€” the Mode A option is untouched and still required there). Give --seq-lens once and every later line can be a bare advise. Until it is given once, advise says so rather than inventing a range.
  • show prints the axes being swept, or not set with the line to type; reset clears the bounds along with the training and serving flags.
  • A session whose TrainingConfig is a full fine-tune (--no-lora) has no rank axis to sweep, so advise says 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
  1. Ask fitcheck for a prediction (pure arithmetic, no GPU).
  2. Load the real model with the real quantization config, apply real LoRA adapters.
  3. Run --warmup-steps full training steps. Warmup must be β‰₯ 1: AdamW allocates its exp_avg / exp_avg_sq buffers lazily on the first .step(), so a zero-warmup peak would miss $S_{optim}$ entirely.
  4. Reset the peak counters, run --measure-steps more steps, read the peaks.
  5. 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.
  6. 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.
  7. 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_kv duplicates 4 heads into 32. Costs memory; works everywhere.
  • broadcast β€” PyTorch 2.5+ accepts enable_gqa=True and 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.py reads anchor.lora_rank, declared int | None, as an int throughout _ceilings and _prices. advise() refuses a full-fine-tuning config at the door and _anchor_config always fills the rank from the validated sweep, so the value is never None there. A private _anchor_rank() now states that invariant instead of leaving it implicit.
  • estimator.py's _validate_positive_int and _validate_flag were annotated as taking int and bool. They exist to check values that may be anything β€” their bodies test isinstance β€” so object is the honest parameter type, and the narrowing is the return.
  • repl.py's _cli_module() had no return annotation (the lazy import that breaks the cli ↔ repl cycle) and its sticky() helpers returned object, which cannot be passed to int(). The values come from click's ctx.params, so they are Any.

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

  1. pip install fitcheck-llm works β€” published to PyPI, installs cleanly on Python 3.10+, fitcheck --help runs.

  2. 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 adds infer and compare --infer to Mode B.

  3. 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.

  4. pytest passes with β‰₯80% line coverage on all memory/ modules, including at least one end-to-end test (known config β†’ expected MiB Β± tolerance).

  5. CI is green β€” GitHub Actions runs pytest --cov on 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 only floor can catch a wrong pin. A third job, lint, runs ruff check . and mypy --strict fitcheck/ (Β§3.10); all three gate a PR.

  6. 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

  1. Estimates within Β±10% of measured ground truth for β‰₯3 configurations, measured with scripts/measure.py and 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

  1. fitcheck advise answers 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 command advise / sweep (Β§3.5 Mode B) are built from the same cli.advise_command.params, so a flag added to one appears in the other for free. The session drops only the --seq-lens requirement, 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 reason max_batch_size does β€” 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 in tests/test_advisor.py (advisor.py at 100% coverage); the rendered screen is verified by hand in both modes, the way 4.5/4.6 verify cli.py and display.py.

Historical v0.3 accuracy note. When the advisor gate shipped, advise re-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:

Aact(b)=1,024b⏟2LΞ³sh+4,008b⏟Alogits=5,032 bA_{act}(b) = \underbrace{1{,}024b}_{2L\gamma sh} + \underbrace{4{,}008b}_{A_{logits}} = 5{,}032\,b

total(b)=8,585.02⏟W+Wlora+S+G+5,032b+500+0.05(7,753.02+5,032b)⏟Coverhead(b)=9,472.67+5,283.60 b\text{total}(b) = \underbrace{8{,}585.02}_{W+W_{lora}+S+G} + 5{,}032b + \underbrace{500 + 0.05(7{,}753.02 + 5{,}032b)}_{C_{overhead}(b)} = 9{,}472.67 + 5{,}283.60\,b

9,472.67+5,283.60 b≀23,500β€…β€Šβ‡’β€…β€Šb≀2.655β€…β€Šβ‡’β€…β€Šbmax=29{,}472.67 + 5{,}283.60\,b \le 23{,}500 \;\Rightarrow\; b \le 2.655 \;\Rightarrow\; b_{max} = \mathbf{2}

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 max flips 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.