The base weights are frozen. The loss changes. The adapter learns. What actually travels between those three statements?
That is the part I want to make visible. In LoRA & QLoRA: Inside the Update, we placed the adapter inside attention and the MLP. Here we follow the gradients back through it, first with ordinary floating-point weights, then with a quantized base.
We will keep one small example open throughout: one rank-one projection, two outputs and a known target. Then we will put that local calculation back inside a transformer block and a training pipeline. The way I approach this in runtime work is to check a small forward result, check its derivatives, and only then worry about packing weights or fusing kernels.
1. What are we training against?
We do not give LoRA a target matrix and ask it to copy those weights. In supervised language-model fine-tuning, we give the model examples of the behaviour we want: a prompt and an answer, or a conversation. The objective usually asks it to predict the correct next token at selected positions.
{
"messages": [
{
"role": "user",
"content": "Return the issue category for: motor overheats."
},
{
"role": "assistant",
"content": "thermal"
}
]
}The word “thermal” may become one token or several; that depends on the tokenizer. The chat template adds the model’s expected role and boundary tokens. After batching, we commonly have input_ids [batch, length], an attention mask, and labels of the same sequence shape. Token IDs are integer indices, not the continuous hidden vectors that enter a LoRA projection.
Two masks have different jobs. The attention mask controls which positions may be attended to. The loss mask controls which predictions contribute to the objective. A prompt token can be visible to the model while being excluded as a supervised target. A common label convention uses −100 for ignored positions.
For causal language modelling, the logit at position t predicts the token at t+1. Some model/trainer combinations do the shift internally. Check that contract; manually shifting again can silently train the wrong alignment. The TRL SFT documentation describes dataset formats and completion-only or assistant-only supervision. Inspect a tokenized batch rather than assuming the configured mask is correct.
For N supervised positions, the usual averaged loss is L = −(1/N) Σ log p(correct token). The labels and masks determine what “correct” means. If padding is scored accidentally, or answers are truncated, the gradients can be numerically correct while teaching the wrong task. Reject batches with no supervised tokens instead of allowing an undefined average.
2. Start with an unquantized projection
Take one selected linear projection inside one transformer block. A block here means attention, an MLP, normalization and residual connections. It is not the same thing as one GEMM. We will zoom into the attention output projection; the local derivatives also apply to other adapted linear projections.
Use row-vector notation and omit bias and dropout for now. X contains n token-position vectors. The base W is frozen. A and B are trainable. The scalar s is fixed by the adapter configuration; for ordinary LoRA, s = α/r.
X : n × d W : d × m
A : d × r B : r × m
Z = XA # n × r
Y = XW + sZB # n × m
D = ∂L/∂Y # n × m, arriving from downstreamThink of D as a message from the rest of the model: how would a small change to each entry of Y affect the loss? D is not generally the language-model probability error at this internal projection. It has already passed backward through whatever lies between Y and the output loss.
First, the gradient for B
Holding Z fixed, each output column is a weighted combination of its r columns. The matrix multiplication’s backward rule gives ∇B = sZᵀD. Check the dimensions: [r × n][n × m] → [r × m]. That is exactly B’s shape. Summing over n accumulates contributions from all local token positions.
Then back through B into A
The gradient reaching Z is dZ = sDBᵀ, with shape n-by-r. Since Z = XA, the gradient for A is ∇A = XᵀdZ. Substituting gives ∇A = sXᵀDBᵀ. We multiply by the current B from this forward/backward pass. Updating B before finishing the backward calculation would mix two parameter states.
Finally, the gradient for X has two paths
X feeds both the base and the adapter. Their contributions add:
∇B = s Zᵀ D # r × m
dZ = s D Bᵀ # n × r
∇A = Xᵀ dZ # d × r
∇X = D Wᵀ + dZ Aᵀ # n × d
No optimizer update to W.
No need to materialize ∇W when W is frozen.Frozen does not mean absent from backpropagation. W is needed for the base contribution DWᵀ. If earlier layers contain trainable adapters, cutting this path loses part of their learning signal. If no earlier trainable quantity requires X’s gradient, an implementation may avoid calculating it; that is dependency analysis, not permission to detach every frozen module.
In framework terms, freezing W’s parameter gradient is different from running its entire computation without gradient tracking. A blanket detach can also prevent upstream adapters from learning. The LoRA paper defines the frozen-base, trainable-factor parameterization; these equations spell out its local matrix calculus in our row-vector convention.
3. Walk one rank-one example backward
To keep the numbers small, the interactive example takes one projection and treats its two outputs directly as classification logits. This is a deliberate teaching simplification, not an execution of the whole transformer block. The target is the second output, class index 1.
The eight steps
- Compute logits and cross-entropy for target class 1.
- Compute D = softmax(Y) − [0, 1].
- Compute ∇B = ZᵀD.
- Compute dZ = DBᵀ.
- Compute ∇A = XᵀdZ.
- Add both input-gradient paths: ∇X = DWᵀ + dZAᵀ.
- Update A and B with the chosen optimizer; keep W frozen.
- Recompute the loss. This is training-example loss, not held-out quality.
The numerical matrices and first-step results are given below. JavaScript enables Previous/Next controls and alternate cases.
The initial values are X = [1, 2], A = [0.1, −0.2]ᵀ, B = [0, 0], and W = [[0.63, −0.47], [0.28, 0.91]]. Rank and alpha are both one, so s = 1. The base is “unquantized” here in the sense that it has no low-bit encoding; the browser uses double-precision numbers for readable arithmetic, not a simulated BF16 kernel.
The first forward pass gives Z = −0.3, Y = [1.19, 1.35], and probabilities approximately [0.460085, 0.539915]. The target is the second output, so D ≈ [0.460085, −0.460085]. Consequently ∇B ≈ [−0.138026, 0.138026]. A gradient-descent step pushes B’s first entry positive and its second entry negative. Since Z is negative, that lowers the first output and raises the second.
“Why is A’s gradient zero at first? Is it broken?”
No. With B initialized to zero, dZ = DBᵀ = 0, so the first loss gradient for A is zero. B’s gradient can still be nonzero. After B changes, the next pass can produce a nonzero gradient for A. The PEFT initialization documentation describes a common nonzero-A, zero-B initialization. Other initializers exist.
Zeroing both factors would make both loss gradients zero in this plain setup. That is why “start the correction at zero” does not mean “set every adapter parameter to zero.” Also distinguish loss gradients from weight decay: an optimizer can change a parameter through decay even when its loss gradient is zero. The walkthrough sets decay to zero so that distinction cannot obscure the first step.
The curves above repeat one example. They demonstrate the arithmetic, not language-model quality. The quantized toy starts with different logits and happens to start with a lower loss on this target. That is not evidence that quantization improves a real model.
Run the same two steps in PyTorch
Let’s check the browser’s numbers with an actual autograd engine. This small CPU example trains the same rank-one projection with AdamW. No model download or GPU is needed. It uses float64 so we can inspect the arithmetic; it is not a full transformer or a QLoRA training run.
The training loop has three distinct operations: compute the loss, ask autograd for gradients, then let the optimizer change A and B. X also requests a gradient so we can inspect the signal going toward earlier layers. X is not in the optimizer. W neither requests a parameter gradient nor receives an update.
optimizer.zero_grad(set_to_none=True)
X.grad = None
Z = X @ A
logits = X @ W + Z @ B
loss = F.cross_entropy(logits, target)
loss.backward() # fills A.grad, B.grad and X.grad
print(A.grad, B.grad, X.grad)
optimizer.step() # updates only A and BActual output from the CPU run: watch A’s gradient go from zero to nonzero on step two. The same AdamW instance is kept across steps, preserving its moment estimates.
PyTorch 2.15.0+cpu | CPU | rank 1 | alpha 1
Step 1: loss before = 0.616344
dA: [0.0, 0.0]
dB: [-0.138026, 0.138026]
dX: [0.506094, -0.289854]
A after: [0.1, -0.2]
B after: [0.01, -0.01]
loss after = 0.613588; W unchanged = True
Step 2: loss before = 0.613588
dA: [0.009172, 0.018344]
dB: [-0.137579, 0.137579]
dX: [0.505372, -0.290749]
A after: [0.092559, -0.207441]
B after: [0.019999, -0.019999]
loss after = 0.610433; W unchanged = True
PASS: factor/input gradients, first-step behavior, frozen baseThe loss moves from 0.616344 → 0.613588 → 0.610433. That is learning on this one example, not evidence of generalization. A gradient is a local slope, not necessarily the final update direction or size after AdamW’s moment normalization.
Copy and run the complete PyTorch demo
Save the following as lora_torch_demo.py. A clean CPU-only environment is enough:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install torch==2.15.0 --index-url https://download.pytorch.org/whl/cpu
python lora_torch_demo.py"""CPU reference: one rank-one projection, not a whole language model."""
import torch
import torch.nn.functional as F
torch.set_printoptions(precision=6, sci_mode=False)
dtype = torch.float64 # readable reference arithmetic, not a BF16 benchmark
# Shapes: X [tokens=1, width=2], W [2,2], A [2,rank=1], B [1,2].
# Row-vector convention; torch.nn.Linear stores its weight transposed.
X = torch.tensor([[1.0, 2.0]], dtype=dtype, requires_grad=True)
# W defaults to requires_grad=False: frozen parameters, live input path.
W = torch.tensor([[0.63, -0.47], [0.28, 0.91]], dtype=dtype)
A = torch.nn.Parameter(torch.tensor([[0.1], [-0.2]], dtype=dtype))
# Nonzero A + zero B starts with zero correction, but lets B learn.
B = torch.nn.Parameter(torch.zeros((1, 2), dtype=dtype))
target = torch.tensor([1]) # the second output is the correct class
frozen_copy = W.clone()
# Only adapter factors belong to the optimizer. No decay in this demo.
optimizer = torch.optim.AdamW([A, B], lr=0.01, weight_decay=0.0,
betas=(0.9, 0.999), eps=1e-8)
def values(t):
return [round(v, 6) for v in t.detach().flatten().tolist()]
print("PyTorch", torch.__version__, "| CPU | rank 1 | alpha 1")
for step in range(1, 3):
# Gradients accumulate by default; start this step with fresh buffers.
optimizer.zero_grad(set_to_none=True)
X.grad = None # observe input gradients; X is not optimized
# Rank-one bottleneck, then expand and add to the frozen-base result.
Z = X @ A
logits = X @ W + Z @ B
# Cross entropy takes raw logits, not precomputed probabilities.
loss = F.cross_entropy(logits, target)
loss.backward()
# Check autograd against the local matrix equations before updating.
with torch.no_grad():
D = logits.softmax(dim=-1) - F.one_hot(target, 2).to(dtype)
dZ = D @ B.T
torch.testing.assert_close(B.grad, Z.T @ D)
torch.testing.assert_close(A.grad, X.T @ dZ)
torch.testing.assert_close(X.grad, D @ W.T + dZ @ A.T)
assert W.grad is None
if step == 1:
assert torch.count_nonzero(A.grad) == 0
assert torch.count_nonzero(B.grad) > 0
else:
assert torch.count_nonzero(A.grad) > 0
print(f"\nStep {step}: loss before = {loss.item():.6f}")
print("dA:", values(A.grad))
print("dB:", values(B.grad))
print("dX:", values(X.grad))
# All derivatives above use the old factors; update only afterwards.
optimizer.step()
with torch.no_grad():
after = F.cross_entropy(X @ W + (X @ A) @ B, target)
assert torch.equal(W, frozen_copy)
print("A after:", values(A))
print("B after:", values(B))
print(f"loss after = {after.item():.6f}; W unchanged = True")
print("\nPASS: factor/input gradients, first-step behavior, frozen base")The assertions compare autograd’s gradients with the matrix equations for A, B and X, check the zero-B first step, and require W to remain exactly unchanged. PyTorch’s autograd guide explains gradient tracking and accumulation; the AdamW reference describes the optimizer update. Clearing gradients matters: repeated backward calls otherwise accumulate into the leaf gradients.
Experiment 1: check a gradient without trusting backward
Append this to the main script. Move one entry of B a little in each direction and measure the change in loss. The slope should agree with autograd. We recompute backward first because the stored gradients belong to the parameters before the last optimizer update.
# Append after the main demo: A and B now contain two optimizer updates.
# Recompute gradients at these current values; the old .grad is stale.
optimizer.zero_grad(set_to_none=True)
loss = F.cross_entropy(X @ W + (X @ A) @ B, target)
loss.backward()
analytic = B.grad[0, 0].item()
epsilon = 1e-6 # suited to this float64 toy, not a universal tolerance
# Perturb only a COPY of B: leave model parameters and optimizer untouched.
with torch.no_grad():
plus, minus = B.clone(), B.clone()
plus[0, 0] += epsilon
minus[0, 0] -= epsilon
loss_plus = F.cross_entropy(X @ W + (X @ A) @ plus, target)
loss_minus = F.cross_entropy(X @ W + (X @ A) @ minus, target)
numeric = ((loss_plus - loss_minus) / (2 * epsilon)).item()
print(f"autograd={analytic:.9f}, finite difference={numeric:.9f}")
assert abs(analytic - numeric) < 1e-8
print("PASS: one B entry agrees with an independent loss perturbation")This checks one entry, not the entire model. A runtime test should extend it to all small-test entries in A, B and X, including nonzero factors, multiple tokens and a non-unit scale. Tiny epsilon values can amplify floating-point noise; large values measure a slope over too wide an interval.
Experiment 2: save the adapter and reproduce its output
Append this after the main script, with or without experiment 1. The adapter consists of learned factors plus configuration and a reference to its base. The example serializes and reloads those tensors in memory, then checks the output.
# Append after the main demo. This is an inference adapter, not a resume checkpoint.
import io
# Serialize actual tensors, using memory so this example overwrites no files.
# Replace the BytesIO buffer with a filename to keep your own checkpoint.
checkpoint = {
"base_id": "article-toy-w-v1", "rank": 1, "alpha": 1,
"A": A.detach().clone(), "B": B.detach().clone(),
}
buffer = io.BytesIO()
torch.save(checkpoint, buffer)
buffer.seek(0)
loaded = torch.load(buffer, map_location="cpu", weights_only=True)
assert loaded["base_id"] == "article-toy-w-v1"
assert loaded["rank"] == 1 and loaded["alpha"] == 1
assert loaded["A"].shape == (2, 1) and loaded["B"].shape == (1, 2)
with torch.no_grad():
expected = X @ W + (X @ A) @ B
restored = X @ W + (X @ loaded["A"]) @ loaded["B"]
torch.testing.assert_close(restored, expected, rtol=0, atol=0)
print("PASS: reloaded adapter reproduces the same CPU logits exactly")Here the base identity is a fixed teaching label and W stays in memory. A real adapter needs a verified base revision or hash, target-module names, rank, scale, dtype and compatible architecture. The same-shaped weights from another base are not interchangeable. For an equivalent training resume, also save optimizer moments, counters, data position and relevant random state. The PyTorch saving/loading guide distinguishes model weights from a training checkpoint.
Two changes worth trying
- Switch to SGD: replace the AdamW construction with
torch.optim.SGD([A, B], lr=0.1), then rerun the script from the start. Compare with SGD in the interactive walkthrough. The backward rules stay the same; the first update changes, so subsequent gradients can differ. - Try a reconstructed low-bit base: immediately after constructing W, insert
W = (W / 0.2).round() * 0.2. Then run the same checks. This is a dense float64 reconstruction of the toy INT4 weights—not packed storage, NF4 or a QLoRA memory benchmark.
What CKE needs for this same experiment
The next step I want to show is this exact experiment running through C-Kernel-Engine. Start with the unquantized adapter, keep the test small, then move to a supported quantized representation. Here is what we need to demonstrate along that path:
| Part | Required behaviour | Evidence |
|---|---|---|
| Adapter in the graph | Named target projection, explicit A/B shapes, rank and scale; frozen base. | The forward logits and loss match the reference within a stated tolerance. |
| Backward and buffers | Compute ∇A, ∇B and required ∇X; accumulate and clear gradients at the intended boundaries. | Matrix-equation and finite-difference checks, including nonzero B and multiple tokens. |
| Optimizer ownership | Update only adapter parameters; preserve AdamW moments across steps. | Multiple updates match a pinned reference; base weights remain unchanged. |
| Quantized base | Declare the exact encoding and compute dtype; support the needed transposed activation-gradient operation. | Compare forward and backward against that format’s reconstructed weights, not the original pre-quantization values. |
| Artifact lifecycle | Export factors/configuration bound to a base; reload for inference and save additional state for resume. | Reloaded outputs and resumed updates reproduce their reference results. |
| Useful training | Correct labels, masks, data split and evaluation. | Held-out results and saved examples, separately from numerical correctness. |
This is the acceptance path for the CKE demonstration, not a claim that every item is missing or already delivered. We have not checked the current CKE implementation in this article. Once that run is verified, we can publish its command, revision, tensor comparisons and results alongside the PyTorch version. Until then, the runnable reference here is PyTorch.
Our comparison contract
This gives us a concrete reference for a later CKE demo: use the same inputs, weights, factors, target and optimizer settings, then compare logits, loss, ∇A, ∇B, ∇X and the updated factors. For float64 we can check closely; lower-precision kernels need explicit numerical tolerances. The eventual quantized-base demo should also identify its exact format and reconstruction rules. That CKE comparison has not been run here—we now have the small PyTorch experiment to compare it against.
4. How does this reconnect to a whole transformer block?
For the attention output projection, the local input X is the concatenated attention-head output. Its ∇X is reshaped back into the head layout. Backpropagation then traverses attention and the Q/K/V projections. Any LoRA factors attached to those projections receive their own local D and use the same factor-gradient equations.
For a single attention head, write S = QKᵀ / √dₕ + mask, P = softmax(S) and C = PV. Given dC, the useful local rules are:
dV = Pᵀ dC
dP = dC Vᵀ
dS = P ⊙ (dP − row_sum(dP ⊙ P))
dQ = dS K / √dₕ
dK = dSᵀ Q / √dₕThe row sum is broadcast across each row. Masked positions contribute zero probability and the corresponding softmax gradient is zero. Head rearrangement, positional transforms and normalization each have their own backward rules. The adapter is not bypassing any of them.
In a gated MLP, H = SiLU(G) ⊙ U. The down projection first returns dH. Then dG = dH ⊙ U ⊙ SiLU′(G) and dU = dH ⊙ SiLU(G). Those feed the gate and up projections, where their respective adapters receive factor gradients. The two MLP input-gradient branches add. At a residual addition, the incoming gradient is passed to both operands; contributions reunite where they share an earlier tensor.
This is why I start from the computational graph. “Train only the adapter” says which parameters move. It does not say that only adapter operations execute during backward. With multiple adapted blocks, later frozen operations can sit directly on the route to earlier trainable factors.
5. Can AdamW or Muon update the adapter?
Backpropagation computes gradients. The optimizer chooses the parameter update. A and B can be optimized without changing the derivative rules above. The optimizer must receive the intended trainable parameters, not the frozen base.
AdamW: a straightforward baseline
AdamW maintains first and second moment estimates for each trainable entry, uses bias correction, and applies decoupled weight decay when configured. Its learning rate, epsilon, moment coefficients and decay affect the update; the gradient alone does not specify the step. The PyTorch AdamW reference gives the update equations.
Our interactive AdamW mode uses learning rate 0.01, betas (0.9, 0.999), epsilon 10⁻⁸ and zero decay. The second-step option preserves the first step’s moments. Its loss curve should not be compared with SGD as an optimizer benchmark: two updates on one artificial example, with different learning rates, cannot establish that.
Muon: possible, but ask what matrix you are optimizing
A and B are two-dimensional matrices, so they can satisfy the shape requirement of a matrix optimizer. Muon forms a momentum-based matrix update and approximately orthogonalizes it, with implementation-specific scaling. The PyTorch Muon documentation describes its intended use on hidden-layer matrices. That establishes an optimizer contract, not that vanilla Muon on LoRA factors is the best adaptation recipe.
Applying Muon separately to A and B is not the same as applying it to a dense ΔW. If the optimizer changes the factors by δA and δB, the effective correction changes by:
δ(ΔW) = s[(A + δA)(B + δB) − AB]
= s(δA B + A δB + δA δB)There is also a parameterization issue: (cA)(B/c) = AB for nonzero c, but the factor gradients and optimizer states need not behave identically under that rescaling. Rank-one factors are extremely rectangular, not square weight matrices. Evaluate the actual factor optimizer, its zero-gradient handling, scaling and task metrics rather than transferring a dense-model result by name.
I would use a documented AdamW adapter recipe as the baseline, then compare a Muon-factor experiment under a controlled budget. Keep unsupported parameter classes on a suitable optimizer. Do not give the two optimizers overlapping parameter ownership. The walkthrough deliberately does not label an ad-hoc orthogonalization routine as a faithful Muon implementation. For the broader matrix-update intuition, see Muon: from wx+b to matrix updates and SGD to AdamW.
6. What changes when W is quantized?
Replace W with a reconstructed operator Ŵ = dequantize(codes, metadata). For standard frozen-base QLoRA, those codes and their quantization representation do not receive optimizer updates. A and B remain higher-precision trainable factors.
Y = XŴ + s(XA)B
∇B = s(XA)ᵀD
∇A = sXᵀDBᵀ
∇X = DŴᵀ + sDBᵀAᵀThe factor formulas have the same structure, but their numerical values can change. Quantization changes the forward activations, which changes the prediction, loss and D. It can also change X when earlier projections are quantized. Saying “the gradient formula is unchanged” does not mean that the training run is identical.
“Does backward need to differentiate rounding?”
Not to train A and B against a fixed quantized base. We are differentiating the activation path through a fixed reconstructed linear map. We are not asking for a derivative of the frozen code-selection decision. The kernel can reconstruct tiles when needed; it does not necessarily materialize a full dense model.
Quantization-aware training is a different case: a trainable underlying weight is passed through a simulated quantizer in the forward pass. Rounding is not ordinarily differentiable in the useful sense required for gradient descent, so a surrogate such as a straight-through estimator may be used. Do not import that requirement into frozen-base QLoRA without checking what is actually trainable.
“Which low-bit formats are normally used?”
| Representation | Role in this discussion | What must be supported |
|---|---|---|
| BF16 / FP16 base | Unquantized LoRA baseline | Forward and needed activation gradients; adapter optimizer state |
| NF4 | The original QLoRA base-storage recipe | NF4 reconstruction, scales and compatible backward computation |
| bitsandbytes FP4 or 8-bit base | Other documented low-bit adapter configurations | The specific implementation’s training path, dtypes and model support |
| GPTQ-based checkpoint | A quantization method and representation family | A documented compatible training integration, not merely an inference loader |
| Q4_K / other GGUF tensor types | Encoding inside a file container | A supported decode or packed operator plus backward and adapter integration |
The QLoRA paper combines NF4, double quantization and paged optimizers. Double quantization compresses quantization constants; it is not a gradient trick. Paged optimizers address memory management, not whether derivatives exist. The PEFT quantization guide documents training integrations, while the bitsandbytes linear-layer reference distinguishes four-bit storage and compute settings.
The interactive INT4 switch is deliberately simpler: code = round(W/0.2), then Ŵ = 0.2 × code. All example codes fit a signed four-bit range. This is not NF4, Q4_K, MXFP4 or NVFP4. It lets us isolate the effect of a changed frozen operator without hiding it behind a real format’s block metadata.
7. Can GGUF models have backpropagation?
GGUF is a container, not an autograd engine and not one quantization format. The GGUF specification describes model metadata and tensors, including tensor types. A file can store floating-point tensors or various quantized encodings. Its extension does not make gradients mathematically impossible—or provide them automatically.
Route one: load and dequantize into a training framework
A compatible loader can reconstruct tensors and expose a model through ordinary differentiable operations. LoRA can then be applied if the architecture and modules are supported. But fully dequantizing the base gives up much of the low-bit resident-memory advantage. The reconstructed weights also retain the information loss from quantization; loading does not restore the original checkpoint exactly.
The Transformers GGUF documentation describes loader paths that may retain packed weights on supported combinations or fall back to dequantization. This is version-, architecture- and device-dependent. A successful forward load, including a packed path, is not proof that the required backward kernels exist.
Route two: keep the base packed and implement the backward contract
For our frozen linear operator, we need DŴᵀ when an input gradient is required, along with the ordinary adapter derivatives. That can be implemented with appropriate reconstruction and multiplication. The challenge is engineering: transposed access to packed blocks, metadata interpretation, numerical checks, saved activations, dtype rules and framework integration. An inference kernel tuned for XŴ does not automatically implement the transposed backward operation.
For a full model, every operation on a route to a trainable parameter needs the relevant backward support: attention, normalization, activation functions, residual connections and the linear operators. Special-case demonstrations are not a blanket guarantee for every GGUF model or quantization type.
“Then why not always train LoRA from GGUF?”
Teams often choose the checkpoint and backend already supported by their training stack: target-module discovery, dataset tooling, optimizer state, checkpointing and evaluated low-bit backward kernels. GGUF is frequently the deployment artifact at the end of that pipeline. That is a practical convention, not a theorem forbidding training from GGUF.
A sensible route is to train against a pinned base in a supported training representation, export the adapter, and validate it with the exact serving base and runtime. If serving uses another quantization, measure the resulting quality and output differences. If merging and requantizing, validate that converted artifact too. “Same architecture” is weaker than “the same compatible base.”
8. The data structures behind one training step
| State | Example shape / contents | Lifetime |
|---|---|---|
| Frozen base | W or packed codes, scales, tensor shape and format | Loaded across steps; no base optimizer state in adapter-only training |
| Adapter parameters | A [d,r], B [r,m], rank, alpha, target-module identity | Persistent; updated by optimizer |
| Saved activations | X [n,d], Z [n,r] and other operation-specific tensors | Until their backward consumers finish; recomputable with checkpointing |
| Gradients | ∇A, ∇B; activation gradients as required | Accumulate until optimizer step, then cleared according to policy |
| Optimizer state | Adam moments and step counters, or optimizer-specific state | Persistent across steps; save for an equivalent resume |
| Batch | Token IDs, masks, labels and source/example identifiers | One microbatch, with provenance retained separately |
Gradient accumulation can process several microbatches before one optimizer update. Normalize consistently: if microbatches have different numbers of supervised tokens, averaging their already-averaged losses equally can weight short examples more heavily than intended. Decide whether the objective is token-weighted or example-weighted and implement that deliberately.
Activation checkpointing trades additional forward computation for lower activation storage. It does not remove the need for backward. Mixed precision adds another contract: parameter dtype, multiplication dtype, accumulation dtype and optimizer-state dtype are not necessarily identical. Loss scaling, where used, must be undone before interpreting or clipping gradients.
A practical sequence for the first real run
- Define a task and a baseline. Keep held-out examples that the training loop will not consume. Record current model quality and resource use.
- Version the data. Track source, permissions, transformations, splits and near-duplicate handling. Render a few conversations after templating and inspect which tokens are scored.
- Pin the base and representation. Save the model revision, tokenizer, quantization configuration and library/runtime versions. Do not rely on a mutable model name alone.
- Choose an explicit adapter recipe. Target modules, ranks, alpha, dropout, initialization, trainable auxiliary parameters, optimizer, learning rate and schedule belong in one reproducible configuration.
- Run a small preflight. Check trainable counts, shapes, finite losses, gradients and unchanged frozen weights. Overfit a tiny batch to find pipeline mistakes; do not mistake that for generalization.
- Train within a resource budget. Record tokens processed, loss, evaluation metrics, memory, throughput and checkpoint identity. A process exiting successfully is not a model-quality result.
- Export and reload. Adapter-only export is not a resumable training checkpoint. Preserve optimizer/scheduler state, random-number state and data progress when resume matters.
- Evaluate the serving artifact. Test task quality, regressions and latency after conversion, merging or quantization. Retain a known-good version and a rollback route.
9. How I would check the kernel implementation
The small example gives us a useful test boundary. For each A or B entry, perturb it by ±ε and recompute the loss. Compare the central difference (L(θ+ε) − L(θ−ε))/(2ε) with the analytic gradient. Do the same for X while keeping the reconstructed quantized base fixed. This checks derivatives of the frozen-operator computation, not derivatives of the code-selection procedure.
Check zero-B initialization and a nonzero-B case. Otherwise, a broken ∇A implementation could appear correct simply because the expected first-step gradient is zero. Also check multiple tokens, rectangular input/output widths, non-unit s, and both base/input gradient branches. A rank-one example is the starting point, not a complete kernel certification.
For the numerical examples here, an independent NumPy calculation checks A, B and X gradients against central differences for both the dense and toy-quantized base. The browser implementation is checked against those numerical fixtures. These checks validate the teaching arithmetic; they do not certify any CKE, CUDA or GGUF training backend.
For a real runtime, I would then compare forward values, gradients and optimizer updates against a trusted implementation using dtype-appropriate tolerances. Verify that frozen codes and scales do not change. Save and reload the adapter; compare the result again. Then test the intended end-to-end task.
Keep following the computation
LoRA’s trainable state is small, but the learning signal still travels through a model. QLoRA changes how the frozen base is represented; it does not remove the activation-gradient path. Once the tensors, masks and optimizer ownership are explicit, it becomes much easier to see what a training runtime must actually implement.
- LoRA & QLoRA: where the adapters attach
- Muon: from wx+b to matrix updates
- Four-bit formats: Q4_K, MXFP4 and NVFP4
If this is the kind of detail you want to work through, subscribe to ShivasNotes. I write about the math, kernels and runtime decisions behind models, starting with examples we can inspect.
Comments
Join the conversation. Replies can go three levels deep.