What it actually does
Fine-tuning a large model means changing every weight, which means storing a gradient and two optimizer moments for every weight — several times the model's own size in memory before a single activation is saved. The observation behind LoRA is that the CHANGE a fine-tune makes is far simpler than the model it changes: adapting a general model to legal drafting or to one company's support tone is not learning language again, it is nudging behaviour the model already has. So freeze the pretrained weight and learn a low-rank correction beside it, W0 + BA with an inner dimension of eight or sixteen instead of four thousand. Two consequences fall straight out. Training memory collapses, because optimizer state is proportional to trainable parameters and there are now a thousand times fewer. And the result is a small file that means nothing on its own, which is a feature — one frozen base can serve hundreds of tenants, each with their own adapter, if you are willing to keep it unmerged. The failure mode is the mirror image of the premise: when the task really does require capability the base does not have, a low-rank update cannot express it, and no amount of tuning reveals that the ceiling is structural rather than statistical.
- The change required lies in or near a low-rank subspace of the pretrained weights — the intrinsic-dimension hypothesis, which holds for elicitation and fails for acquisition
- The base model already has the capability, so the task is to surface it rather than to install it
- The exact base checkpoint, including its quantization, is available at serving time and stays pinned
- The fine-tuning dataset is small enough that full fine-tuning would overfit, which is what makes the capacity restriction free
- The task does not require changing anything outside the adapted modules, which is a choice made when the target-module set is picked and rarely revisited
Loss function
Read what is NOT here: the loss is identical to full fine-tuning. Nothing was added, no penalty, no auxiliary term. The only change is the feasible set — the minimization runs over a rank-r subspace passing through the pretrained weights rather than over the whole parameter space. That distinction is worth holding onto, because it predicts the failure mode. A regularizer biases a search that could still reach the answer; a constraint removes the answer from the search entirely. When a task needs a full-rank change, this objective has no minimum near it, and the symptom is a loss curve that flattens early and stays there while every hyperparameter sweep comes back unhelpful. Two details in the parameterization matter more than they look. B is initialized to zero, so BA starts at zero and the adapted model starts EXACTLY at the pretrained model — there is no random perturbation to recover from, which is why these runs need no warm-up. And the alpha-over-r scaling exists so that raising the rank adds capacity without also raising the magnitude of the update; teams that hold alpha fixed while sweeping r are sweeping two things at once and usually conclude that rank does not matter.
- the pretrained weight, frozen — it receives no gradient and no optimizer state
- the learned update, a product of two thin matrices and therefore of rank at most r
- the rank: the entire capacity of the adaptation, and the one number that decides what cannot be learned
- a fixed scale on the update; dividing by r is what keeps a rank sweep from also sweeping the effective step size
- the ordinary task loss — cross-entropy, unchanged from full fine-tuning
How it is minimized
AdamW over A and B only, with the frozen base contributing activations to the backward pass but never a weight gradient
- the gradient arriving at the layer output — still computed, at every layer, for the full depth
- the r-dimensional projection, cached from the forward pass and reused in both gradients
- the d-by-k weight gradient that full fine-tuning materializes and this one skips
- the same fixed scale as the forward pass, so it appears in both gradients and folds into B once in practice
The saving is in memory, not in arithmetic, and conflating the two is the most common misunderstanding of this technique. Backpropagation still traverses the full depth of the frozen network, because the adapter in layer three needs the gradient that arrived from layer forty, so every activation gradient is computed exactly as in full fine-tuning. What disappears is the per-weight terms: the d-by-k weight gradient is never materialized, and Adam's two moment buffers shrink with the trainable-parameter count rather than the model size. Since those moments are the dominant training-memory cost for a large model, removing them is what lets a seven-billion-parameter model fine-tune on one consumer accelerator — and it is also why the observed speedup is modest, often well under two-fold, which surprises people who expected the parameter ratio to show up as a time ratio. Activation memory is untouched and frequently becomes the new binding constraint, which is why gradient checkpointing usually appears alongside this rather than as an alternative to it. QLoRA pushes further by holding the frozen base in four-bit and dequantizing per block during the forward pass, trading arithmetic for memory in the opposite direction from everything else here, and introducing a coupling worth naming: the adapter learns against a specific quantization, so it is no longer a clean delta on the original weights.
rank r
The whole capacity of the adaptation. Undersized, it caps quality structurally; oversized, it mostly wastes memory rather than overfitting (4 to 64, occasionally 128 or higher for continued pretraining)
alpha
Scales the update as alpha over r. Reported alongside r or the number is meaningless, and holding it fixed across a rank sweep confounds the sweep (commonly r, 2r, or a fixed 16 or 32)
target modules
Which projections get an adapter. The most consequential choice after rank and the least examined — attention-only versus attention-plus-MLP changes results more than doubling r (q,v projections up to every linear layer)
learning rate
An order of magnitude or two above full fine-tuning, because the update starts at exactly zero and has far fewer parameters to move (1e-5 to 1e-3)
adapter dropout
Dropout on the adapter path only. Small datasets need it; on continued pretraining it usually does nothing (0 to 0.1)
base quantization
QLoRA's bit width for the frozen weights. Buys memory, and permanently ties the adapter to that quantization (4-bit NF4, 8-bit, or none)
Four characteristic failures, and only the first is the one people expect. Rank starvation: the task requires a change the subspace cannot represent, training loss flattens early, and every sweep comes back flat because the ceiling is structural. The tell is that full fine-tuning on the same data clears the plateau immediately, which is the one experiment that settles it and the one nobody runs because it is the expensive one. Scale confounding: sweeping r while holding alpha fixed changes the effective magnitude of the update at the same time as its capacity, and the usual conclusion — "rank did not matter" — is an artefact of the two moving together. Residual forgetting: the constraint reduces catastrophic forgetting relative to full fine-tuning but does not remove it, and nothing in the objective protects a capability the fine-tuning data never mentions; the signature is an in-domain metric improving while a general benchmark nobody ran drops several points. Quantization entanglement, specific to QLoRA: the frozen base carries a fixed quantization error, the adapter is trained to compensate for it, and merging that adapter into an unquantized base produces a measurably different model from the one that was evaluated — a silent discrepancy between the artefact tested and the artefact shipped.
Trainable parameters per adapted matrix drop from d·k to r(d + k), which for a 4096-by-4096 projection at rank 8 is roughly 65,000 against 16.8 million. Optimizer state falls in proportion and is the saving that matters, since Adam holds two fp32 moments per trainable parameter. Forward cost adds O(r(d + k)) per adapted matrix, negligible beside the O(dk) base matmul. Backward cost is essentially unchanged, because activation gradients still propagate through every frozen layer. Activation memory is identical to full fine-tuning and typically becomes the binding constraint once optimizer state is gone.
How, where, and why it is used
Time-Series Forecasting
AdaptedTake a pretrained time-series foundation model — trained on a broad panel of series — and adapt it to one organization's data by learning low-rank corrections on the attention and projection weights while the backbone stays frozen. The inputs and targets are ordinary forecasting windows; what changes is that a few hundred series no longer have to support a full fine-tune.
- Adapting a pretrained forecaster to a domain with idiosyncratic seasonality the general model never saw
- Per-tenant forecasting where every customer needs their own model and none of them has enough history for one
- Continual adaptation after a regime change, where a small adapter is retrained rather than the backbone
- Retail and demand panels where the base model is strong on shape but wrong on the local calendar
A reasonable fit, and the reason is the data-size mismatch rather than anything about time series specifically: these backbones are large and a single organization's panel is small, so full fine-tuning overfits before it adapts. The per-tenant story is the strongest case — one frozen backbone with a few megabytes per customer is a serving architecture that a fleet of full fine-tunes is not. Two honest limits, and the first is the one that decides the project. Classical baselines are unusually strong here; a seasonal-naive or exponential-smoothing model frequently beats an adapted foundation model on a single well-behaved panel, so the comparison must be run before the adaptation work is justified, not after. And low-rank adaptation cannot install structure the backbone lacks — if the base model has no notion of an intermittent-demand series, an adapter will not give it one, and the flat loss curve will be misread as a data problem for a week before anyone checks.
- Normalize per series exactly as the base model's preprocessing did, since the adapter inherits every assumption in that pipeline
- Keep the context length the backbone was trained at; changing it is not something a low-rank update can absorb
- Split strictly by time, and hold out whole series as well, because per-tenant adapters overfit to the tenants they saw
- Encode local calendar and holiday effects as covariates rather than hoping the adapter discovers them
Rolling-origin backtesting against two baselines that must both be present: the un-adapted foundation model, which says whether the adapter did anything, and a classical seasonal method, which says whether any of it was worth doing. Report per-series as well as pooled error — a pooled improvement frequently hides a handful of large series carrying the average while the tail degrades.
- No un-adapted baseline, so an adapter that changed nothing looks like a success
- No classical baseline, which is the comparison that most often ends the project
- Assuming a flat loss curve is a data problem when the rank is simply too small for the change required
- Adapting on a panel small enough that the adapter memorizes individual series
Anomaly Detection
AdaptedContinue training a pretrained sequence model on one system's normal data — logs, telemetry, transaction narratives — with a low-rank adapter, then score by per-token surprisal under the adapted model. The adaptation is what makes the score domain-calibrated: a general model finds every service's logs surprising, which is useless.
- Log anomaly detection where each service has its own vocabulary and a shared model has no threshold that works for all of them
- Per-tenant detectors over a frozen backbone, swapped by adapter at request time rather than deployed as separate models
- Narrative or free-text fraud review, where the base model supplies language and the adapter supplies the institution's normal
- Drift-triggered re-adaptation, cheap enough to rerun weekly as normal behaviour shifts
Genuinely useful, with the contribution in an unglamorous place: the adapter makes the score comparable within a domain. Surprisal under a general model reflects how unusual text is in general, which is a property of the corpus rather than of the system being watched, so thresholds set on it are arbitrary. Adapt to one service's normal and surprisal starts to mean "unlike this service", which is what the alert claims. The per-tenant economics are the other half — hundreds of adapters against one frozen backbone is affordable in a way that hundreds of fine-tunes is not. Three limits worth stating plainly. Surprisal is not a probability of anomaly and the threshold is still a quantile of observed normal scores, so it does not transfer across adapter versions. Training data contamination hurts more here than in most settings, because the adapter's entire job is to learn what normal looks like and a few percent of anomalies in that data teaches it that they are normal. And for structured numeric telemetry, a purpose-built detector usually wins outright — this fits where the signal is language.
- Adapt on verified-normal data only; contamination is learned directly as normal and the metrics will not show it
- Template or mask high-cardinality identifiers before adaptation, or the adapter spends its rank memorizing request ids
- Recalibrate the threshold from a fresh normal-score quantile after every adapter retrain, since scores do not transfer across versions
- Keep a per-tenant normal sample for calibration rather than a pooled one, which is the reason for adapting in the first place
Precision at the alert volume the on-call rotation can actually absorb, not AUC — the operating point is a staffing constraint and AUC averages over volumes nobody would run. Track the alert rate on known-normal traffic as a stability check between adapter versions, because a shifted score distribution moves every threshold whether or not detection improved.
- Contaminated normal data, which teaches the adapter that the anomaly is ordinary
- Reusing a threshold across adapter versions, when the score distribution moved underneath it
- Letting identifiers into the training text, which consumes the rank on memorization
- Choosing this over a purpose-built detector on structured numeric telemetry, where it has no advantage
Optimization
Primary fitThe technique IS an optimization decision. The task loss is untouched; what changes is the feasible set, reparameterized as a rank-r subspace so that the optimizer state — the dominant memory term in large-model training — shrinks by three orders of magnitude while the objective stays the same.
- Trading representational capacity for memory explicitly, with r as the dial and the trade written down rather than implied
- Constrained optimization as a practical tool: the constraint is what makes the problem fit on the hardware
- Reparameterization as a training-dynamics choice, since zero-initialized B means the run starts exactly at the pretrained model
- Quantization plus adaptation as coupled decisions, where QLoRA trades arithmetic for memory in the opposite direction
The clearest illustration in this reference of a principle worth carrying elsewhere: changing where you are allowed to search can be more useful than changing what you are searching for. Several lessons generalize. Memory and compute are separate budgets and an optimization that helps one may not touch the other — here the parameter count falls a thousandfold and the wall-clock time falls by rather less than half, which is exactly what the arithmetic predicts and consistently not what people expect. The alpha-over-r scaling is a small masterclass in experimental hygiene: without it, the capacity knob and the step-size knob are the same knob and every sweep is confounded. Zero-initializing one factor so the perturbation starts at exactly zero is a reparameterization trick that recurs well beyond adapters. And QLoRA is a clean example of a second-order coupling, where an optimization made for memory silently changes what the trained artefact is.
- Sweep r with alpha proportional to r, or the sweep measures step size and capacity at once and resolves neither
- Profile activation memory before and after, since it is untouched here and usually becomes the new binding constraint
- Measure wall-clock separately from parameter count; the backward pass still traverses the full depth
- Record the target-module set with every result, because it moves quality more than rank does and is rarely reported
Report peak memory, wall-clock per step and task quality together, since the technique trades between them and any one number alone is a selective quote. The decisive comparison is against a full fine-tune on the same data at least once — it is the only way to tell a capacity ceiling from a data ceiling, and skipping it is how teams spend weeks tuning a rank that was never the problem.
- Expecting a speedup proportional to the parameter reduction, when the backward pass is unchanged
- Sweeping rank with alpha held fixed, which confounds capacity with effective learning rate
- Never running the full fine-tuning comparison, leaving a structural ceiling indistinguishable from a data limit
- Reporting quality without the memory and latency numbers the technique exists to improve
Natural Language
Primary fitAdapt a pretrained language model to an instruction format, a domain register, or a task-specific output schema by learning low-rank updates on the attention and MLP projections. The data is ordinary supervised pairs or continued pretraining text; the adapter is what makes the run affordable and the artefact small.
- Instruction tuning and style or tone adaptation on a few thousand examples
- Domain adaptation to legal, clinical or financial registers where vocabulary and conventions differ from general text
- Per-customer adapters over one frozen base in multi-tenant products, selected per request
- Structured-output conformance, where the task is making a capable model emit a schema reliably
The default way to specialize a large language model, and the argument is economic before it is technical: a few megabytes per task against tens of gigabytes per full fine-tune, with quality close enough on elicitation tasks that the gap rarely decides anything. The distinction that actually predicts success is elicitation versus acquisition. Teaching a model to answer in a house style, follow a schema, or prefer a domain's conventions is rearranging behaviour it already has, and a rank of eight is plenty. Teaching it a language it barely saw in pretraining, or a genuinely new capability, is acquisition, and low-rank updates are the wrong tool — the honest move there is continued pretraining at full rank or a different base model. The other caveat is quiet: adapters do not compose. Two adapters that each work do not reliably work stacked, because nothing in either objective knew about the other, and teams discover this after building a product around the assumption.
- Match the base model's chat template and special tokens exactly; a mismatch degrades output with no error anywhere
- Mask the prompt tokens out of the loss for instruction tuning, or the model spends capacity learning to generate prompts
- Adapt the MLP projections as well as attention when the task is domain knowledge rather than format
- Hold out a general capability benchmark, because the in-domain metric cannot see what was forgotten
Paired in-domain quality and a general-capability benchmark, reported together — the second is the one that catches the regression the first is blind to. For instruction tuning, human or model-graded preference on a held-out prompt set beats perplexity, which moves for reasons unrelated to whether the output got better.
- A chat template or tokenizer mismatch between adaptation and serving, silent and substantial
- Choosing low-rank adaptation for acquisition tasks, where the ceiling is structural
- Stacking adapters and assuming their effects compose, which nothing in the training guaranteed
- Shipping without a general benchmark, so forgetting is discovered by users
Computer Vision
Primary fitAttach low-rank updates to the attention and projection weights of a vision transformer or a diffusion model's cross-attention, then train on a small image set. For diffusion, the adapter typically encodes a subject or a style and is applied at generation time alongside a trigger token.
- Style and subject adapters for image generation, trained on tens of images
- Adapting a pretrained vision backbone to a specialized imaging domain — medical, industrial, satellite
- Cross-attention-only adaptation for text-conditioned control, which is cheaper and often sufficient
- Fine-grained classification on a small labelled set where a full fine-tune would overfit immediately
Standard practice, and in generative image models it is the dominant customization mechanism — the adapter ecosystem is large precisely because the artefacts are small and shareable. Two domain-specific points worth knowing. Which modules get adapters matters unusually much here: adapting only the text cross-attention changes what a model responds to, while adapting the backbone changes what it can render, and the two are frequently confused when results disappoint. And subject adapters trained on a handful of images overfit in a characteristic and visible way — the subject appears correctly but the model loses the ability to place it in contexts it handled before, which is forgetting that shows up as a composition failure rather than as a metric. The usual mitigations are regularization images and a deliberately low rank, and both are capacity restrictions applied on purpose.
- Caption training images consistently, since the adapter binds behaviour to the phrasing it saw
- Include regularization images of the broader class to limit the collapse of everything toward the subject
- Prefer cross-attention adapters for conditioning changes and backbone adapters for appearance, and know which one the task needs
- Keep the rank deliberately small on tiny subject sets, where extra capacity buys overfitting rather than fidelity
Fidelity to the subject and prompt-following measured separately, because they trade against each other and a single score hides the trade. Generate a fixed prompt suite before and after, including prompts unrelated to the adaptation, since the unrelated ones are where collapse shows first.
- Over-training on a small subject set until the model can render nothing else
- Adapting the wrong modules for the effect wanted, then blaming the rank
- Inconsistent captions, which make the adapter's behaviour unreliable to trigger
- Evaluating only on prompts resembling the training set, which cannot show what was lost
Recommendation & Ranking
AdaptedFreeze a pretrained sequence or language backbone used for content and item understanding, and learn per-surface or per-market low-rank adapters against interaction data. The backbone supplies semantics; the adapter supplies the local population's behaviour.
- Per-market or per-surface adapters over a shared content encoder
- Cold-start item understanding, where a text backbone is adapted to a catalogue's vocabulary
- Rapid re-adaptation after a catalogue or policy shift, retraining megabytes rather than a full model
- Sharing one backbone across teams that each need different behaviour from it
A real but secondary fit, and the boundary is worth being clear about. The ranking models that decide what a user sees are usually trained from scratch on interaction data, where there is no large pretrained artefact to adapt and this technique has nothing to attach to. Where it earns its place is the content-understanding half of the system — item text, descriptions, reviews — where a pretrained backbone exists and each surface needs a different slice of its behaviour. The serving argument is the same one as everywhere else here: one frozen backbone with small per-surface adapters is operationally simpler than a fleet of fine-tunes. The domain-specific caution is that interaction data is confounded by exposure, so an adapter trained on logged interactions partly learns the incumbent ranker's behaviour, and adaptation being cheap makes it easy to run that loop far more often than anyone examines it.
- Adapt on content and text signals where a pretrained backbone exists; interaction-only models have nothing to adapt
- Keep the exposure confound explicit, since cheap retraining tightens the feedback loop rather than breaking it
- Version adapters against the backbone, because a backbone upgrade invalidates every surface's adapter at once
- Hold out whole surfaces or markets, not just rows, when the adapters are per-surface
Online tests decide, as always here; offline gains on logged data measure agreement with the incumbent ranker as much as quality. Between tests, watch catalogue coverage of the retrieved or ranked set per surface — an over-adapted surface narrows to a small slice of the catalogue before any accuracy metric moves.
- Reaching for this on interaction-only models where there is no pretrained backbone
- Retraining adapters so often that the exposure feedback loop tightens unobserved
- Leaving adapters pinned to a backbone version nobody is willing to upgrade
- Trusting offline lift on logged data as evidence of an improvement to users
What it costs to run
The reason the technique exists: a seven-billion-parameter model that needs roughly eighty gigabytes of accelerator memory to fine-tune fully fits on a single consumer card once optimizer state is gone, and under four-bit quantization fits with room to spare. Wall-clock is a different story and consistently disappoints — the backward pass still traverses every frozen layer, so a run is typically tens of percent faster rather than the thousandfold the parameter ratio suggests. Illustrative, not a measured benchmark.
Two serving modes that are different systems, and choosing between them is an architecture decision rather than a flag. Merged: fold the scaled update into the weight once and the adapted model is byte-for-byte an ordinary model with exactly zero added latency — but it is now one model per adapter, and the multi-tenant economics vanish. Unmerged: keep the adapter as a side path, and a single batch can carry requests for different adapters, at the cost of an extra thin matmul per adapted matrix and the throughput lost to gathering per-request adapter weights. The second is what makes hundreds of tenants on one base possible, and it is the mode that never appears in single-adapter benchmarks.
Cheap enough that cost stops being the constraint, which creates a different problem: adapter sprawl. Organizations accumulate dozens of adapters pinned to a base model nobody will upgrade, because upgrading invalidates all of them at once and no single team owns the re-adaptation. Plan the base-model upgrade path before the adapter count grows, not after.
- A general-capability benchmark on a fixed prompt set alongside the in-domain metric, since forgetting is invisible to the metric that was optimized
- The base-model fingerprint each adapter was trained against, including its quantization, verified at load rather than assumed
- Effective rank of BA from its singular values — a rank-64 adapter using four directions is telling you the rank sweep was never run
- Score or output distribution shift between adapter versions, which moves every downstream threshold that was calibrated on the old one
- Serving-mode drift: which requests are served merged and which unmerged, because the two paths can and do diverge numerically
- An adapter is meaningless without its exact base. A different checkpoint, a different quantization of the same checkpoint, or a different tokenizer produces silent degradation rather than an error
- The alpha-over-r scale is part of the weights' meaning and is stored separately from them. Load an adapter with the wrong alpha and it applies a different update with no indication
- Merging is not free under quantization: the adapter learned against quantization error, so merging into an unquantized base gives a model that differs from the one evaluated
- Adapters do not compose. Stacking two that each work is unsupported by anything in either objective, and the failures are subtle rather than obvious
- Unmerged multi-adapter serving has a real throughput cost that single-adapter benchmarks cannot show, and it is the mode the multi-tenant business case depends on
- The chat template, prompt format and special tokens used during adaptation are part of the adapter. Serving with a different template degrades output with no error
- Activation memory is unchanged, so a run that fits on paper by parameter count can still fail at the first long batch
Pros and cons, in context
Training memory falls by roughly the ratio of trainable parameters, because optimizer state dominates and it scales with what is trained rather than with model size
This is the whole reason the technique exists, and it is what puts a seven-billion-parameter fine-tune on one consumer card. It says nothing about wall-clock, which improves by far less
The artefact is a few megabytes, so hundreds of task- or tenant-specific variants cost what one full fine-tune would
Decisive for multi-tenant products and for any team shipping many small specializations. It requires keeping the adapter unmerged at serving time, which has its own throughput cost
Zero-initialized B means the adapted model starts exactly at the pretrained model, with no random perturbation to recover from
Removes the warm-up and instability that full fine-tuning of a large model often needs, and makes an untrained adapter a provable no-op rather than an approximate one
Forgetting is reduced relative to full fine-tuning, because most of the network cannot move at all
Reduced, not eliminated — the constrained update can still damage a capability the fine-tuning data never mentions, so the general benchmark is still required
Merging folds the update into the weights, giving an adapted model with exactly zero added inference latency
Available whenever one adapter per deployment is acceptable. Choosing it forfeits the per-request adapter switching that the multi-tenant case depends on
The objective is unchanged, so everything known about full fine-tuning on the same data still applies
Makes the technique easy to reason about and easy to compare against. The only difference to account for is what the rank-r subspace cannot express
A task needing a change outside the low-rank subspace has a structural ceiling that no hyperparameter reaches
The failure looks exactly like a data problem — a flat loss curve and unhelpful sweeps — and the only experiment that distinguishes them is a full fine-tune, which is the one teams avoid running
The speedup is far smaller than the parameter reduction suggests, because the backward pass still traverses every frozen layer
Consistently surprises teams who planned capacity from the parameter ratio. The saving is memory; treat time as roughly unchanged until measured
Rank and alpha interact, so a rank sweep with alpha held fixed measures step size and capacity simultaneously
The most common measurement error with this technique, and it reliably produces the conclusion that rank does not matter
The adapter is inseparable from its exact base, including the quantization the base was held in during training
Turns every base-model upgrade into a fleet-wide re-adaptation, and makes a mismatched load a silent quality regression rather than an error
Adapters do not compose; stacking two that each work is unsupported
Nothing in either objective knew about the other, so interference is expected. Products built on the assumption that adapters combine discover this late
Activation memory is untouched, so it becomes the binding constraint once optimizer state is gone
A run sized by parameter count alone still fails at the first long sequence, which is why gradient checkpointing usually appears alongside this rather than instead of it
Under QLoRA the adapter is trained against quantization error and partly learns to compensate for it
Merging into an unquantized base then yields a different model from the one that was evaluated — a discrepancy between the artefact tested and the artefact shipped, with nothing to signal it
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""LoRA, transcribed from the objective.
No ML library. The point of writing it this way is that three claims
about this technique become things you can read rather than things you
are told:
h = W0 @ x + (alpha / r) * B @ (A @ x)
First, W0 never appears on the left of an assignment after setup. It is
frozen, and freezing it is not a flag on an optimizer -- it is the
absence of any code that updates it.
Second, dL/dW0 is never formed. Look at backward() below: it computes
the gradient flowing BACK through the layer (needed, because layers
below this one still have adapters), and it computes dL/dA and dL/dB.
The d-by-k weight gradient that full fine-tuning would materialize is
simply not there. That single missing array is the entire memory
argument.
Third, B starts at zero, so the adapted model starts EXACTLY at the
pretrained model. Not approximately. There is nothing to warm up.
"""
import math
import random
Matrix = list[list[float]]
Vector = list[float]
def zeros(rows: int, cols: int) -> Matrix:
return [[0.0 for _ in range(cols)] for _ in range(rows)]
def matvec(matrix: Matrix, vector: Vector) -> Vector:
out = []
for row in matrix:
total = 0.0
for index in range(len(vector)):
total += row[index] * vector[index]
out.append(total)
return out
def matvec_transposed(matrix: Matrix, vector: Vector) -> Vector:
"""matrix.T @ vector, without building the transpose."""
cols = len(matrix[0])
out = [0.0] * cols
for row_index, row in enumerate(matrix):
scale = vector[row_index]
if scale == 0.0:
continue
for col_index in range(cols):
out[col_index] += row[col_index] * scale
return out
def add_outer(target: Matrix, left: Vector, right: Vector, scale: float) -> None:
"""target += scale * outer(left, right), in place."""
for row_index, left_value in enumerate(left):
if left_value == 0.0:
continue
row = target[row_index]
for col_index, right_value in enumerate(right):
row[col_index] += scale * left_value * right_value
class LoRALinear:
"""One adapted projection: a frozen W0 plus a learned rank-r update.
The shapes are the whole story. W0 is d-by-k and holds d*k numbers
that will never change. A is r-by-k and B is d-by-r, and together
they hold r*(d+k), which for d = k = 4096 and r = 8 is about 65
thousand against 16.8 million.
"""
def __init__(self, d_out, d_in, rank, alpha, rng):
if rank <= 0 or rank > min(d_out, d_in):
raise ValueError("rank must be in 1..min(d_out, d_in)")
# Frozen. Stands in for a pretrained weight; in reality this is
# loaded, and it is the only large array in this class.
self.w0 = [
[rng.gauss(0.0, 1.0 / math.sqrt(d_in)) for _ in range(d_in)]
for _ in range(d_out)
]
# A is initialized randomly and B at zero, so B @ A == 0 at step
# zero and the layer is exactly W0. Reversing which one is zero
# would work too; what must not happen is both being random,
# which perturbs a pretrained model before training starts.
self.a = [
[rng.gauss(0.0, 1.0 / math.sqrt(d_in)) for _ in range(d_in)]
for _ in range(rank)
]
self.b = zeros(d_out, rank)
self.rank = rank
self.alpha = alpha
# alpha / r, not alpha. Dividing by the rank is what lets you
# raise r without also raising the magnitude of the update --
# the difference between sweeping capacity and sweeping two
# things at once.
self.scale = alpha / rank
self.grad_a = zeros(rank, d_in)
self.grad_b = zeros(d_out, rank)
self.cached_x = None
self.cached_ax = None
def forward(self, x: Vector) -> Vector:
base = matvec(self.w0, x)
projected = matvec(self.a, x) # r numbers
update = matvec(self.b, projected) # d numbers
self.cached_x = x
self.cached_ax = projected
return [base[i] + self.scale * update[i] for i in range(len(base))]
def backward(self, grad_h: Vector) -> Vector:
"""Accumulate dL/dA and dL/dB; return dL/dx.
dL/dx still needs W0, because layers below this one have
adapters of their own and their gradients have to arrive. This
is why LoRA saves memory and not much time: the backward pass
walks the full depth exactly as it would otherwise.
"""
# dL/dB = scale * grad_h (A x)^T
add_outer(self.grad_b, grad_h, self.cached_ax, self.scale)
# dL/dA = scale * (B^T grad_h) x^T
back_through_b = matvec_transposed(self.b, grad_h)
add_outer(self.grad_a, back_through_b, self.cached_x, self.scale)
# dL/dx = W0^T grad_h + scale * A^T B^T grad_h.
# Note what is absent: no outer product against W0 is ever
# formed. That array is dL/dW0, and it is the one full
# fine-tuning cannot avoid.
through_base = matvec_transposed(self.w0, grad_h)
through_adapter = matvec_transposed(self.a, back_through_b)
return [
through_base[i] + self.scale * through_adapter[i]
for i in range(len(through_base))
]
def zero_grad(self) -> None:
for row in self.grad_a:
for index in range(len(row)):
row[index] = 0.0
for row in self.grad_b:
for index in range(len(row)):
row[index] = 0.0
def trainable_parameters(self) -> int:
return self.rank * (len(self.a[0]) + len(self.b))
def frozen_parameters(self) -> int:
return len(self.w0) * len(self.w0[0])
def merge(self) -> Matrix:
"""W0 + (alpha / r) B A, materialized.
Zero added latency afterwards, because the result is an ordinary
weight matrix. And the adapter is gone: one merged model serves
one adapter, which is exactly the property the multi-tenant case
is built on not having.
"""
merged = [row[:] for row in self.w0]
for out_index in range(len(self.b)):
b_row = self.b[out_index]
target = merged[out_index]
for rank_index in range(self.rank):
weight = self.scale * b_row[rank_index]
if weight == 0.0:
continue
a_row = self.a[rank_index]
for in_index in range(len(a_row)):
target[in_index] += weight * a_row[in_index]
return merged
class Adam:
"""Two moment buffers per trainable parameter, allocated here.
Written out because this is the cost LoRA removes. Every buffer
below is sized to the adapter, not to W0, and that ratio -- not the
forward arithmetic -- is why the technique exists.
"""
def __init__(self, shapes, lr, beta1=0.9, beta2=0.999, eps=1e-8):
self.lr = lr
self.beta1 = beta1
self.beta2 = beta2
self.eps = eps
self.step_count = 0
self.m = [zeros(rows, cols) for rows, cols in shapes]
self.v = [zeros(rows, cols) for rows, cols in shapes]
def step(self, params, grads):
self.step_count += 1
bias1 = 1.0 - self.beta1 ** self.step_count
bias2 = 1.0 - self.beta2 ** self.step_count
for slot, (param, grad) in enumerate(zip(params, grads)):
m_buffer = self.m[slot]
v_buffer = self.v[slot]
for row_index, row in enumerate(param):
grad_row = grad[row_index]
m_row = m_buffer[row_index]
v_row = v_buffer[row_index]
for col_index in range(len(row)):
gradient = grad_row[col_index]
m_row[col_index] = (
self.beta1 * m_row[col_index] + (1.0 - self.beta1) * gradient
)
v_row[col_index] = (
self.beta2 * v_row[col_index]
+ (1.0 - self.beta2) * gradient * gradient
)
m_hat = m_row[col_index] / bias1
v_hat = v_row[col_index] / bias2
row[col_index] -= self.lr * m_hat / (math.sqrt(v_hat) + self.eps)
def squared_error(prediction: Vector, target: Vector) -> float:
return sum((p - t) ** 2 for p, t in zip(prediction, target)) / len(target)
def squared_error_grad(prediction: Vector, target: Vector) -> Vector:
scale = 2.0 / len(target)
return [scale * (p - t) for p, t in zip(prediction, target)]
def train(layer: LoRALinear, samples, epochs: int, lr: float):
"""The ordinary loop. The loss is the ordinary loss.
Nothing in here is LoRA-specific, which is the point: the objective
did not change, only the set of parameters the optimizer was handed.
"""
optimizer = Adam(
shapes=[(len(layer.a), len(layer.a[0])), (len(layer.b), len(layer.b[0]))],
lr=lr,
)
history = []
for _ in range(epochs):
total = 0.0
layer.zero_grad()
for x, target in samples:
prediction = layer.forward(x)
total += squared_error(prediction, target)
layer.backward(squared_error_grad(prediction, target))
optimizer.step([layer.a, layer.b], [layer.grad_a, layer.grad_b])
history.append(total / len(samples))
return history
def rank_ceiling_demo(d: int, target_rank: int, adapter_rank: int, seed: int = 0):
"""The experiment that distinguishes a capacity ceiling from a data
problem, run small enough to read.
A target update of rank target_rank is planted, then fitted with an
adapter of rank adapter_rank. When adapter_rank < target_rank the
loss flattens at a positive floor and stays there -- not because the
data is bad, not because the learning rate is wrong, but because the
answer is outside the feasible set. In production this presents as a
week of unhelpful sweeps.
"""
rng = random.Random(seed)
layer = LoRALinear(d, d, adapter_rank, alpha=adapter_rank, rng=rng)
# Plant a rank-target_rank delta and generate data through W0 + it.
left = [[rng.gauss(0.0, 0.4) for _ in range(target_rank)] for _ in range(d)]
right = [[rng.gauss(0.0, 0.4) for _ in range(d)] for _ in range(target_rank)]
samples = []
for _ in range(64):
x = [rng.gauss(0.0, 1.0) for _ in range(d)]
base = matvec(layer.w0, x)
delta = matvec(left, matvec(right, x))
samples.append((x, [base[i] + delta[i] for i in range(d)]))
history = train(layer, samples, epochs=400, lr=0.05)
return {
"final_loss": history[-1],
"trainable": layer.trainable_parameters(),
"frozen": layer.frozen_parameters(),
"ratio": layer.frozen_parameters() / layer.trainable_parameters(),
}
Per sample: one O(d·k) base matvec, two adapter matvecs at O(rk) and O(dr), and a backward pass that repeats all three plus two outer-product accumulations at O(rk) and O(dr). Illustrative, not a measured benchmark: the shape to read off is that the adapter terms are linear in r while the base term is quadratic in the layer width, so the forward cost is essentially unchanged and the arrays that vanish — dL/dW0 and its two Adam moments — are the ones that were never allocated here.
How this language actually wants it written.
"""LoRA with the three silent failures made loud.
The literal version accepted all of these without complaint:
* an adapter loaded against a base it was not trained on, which
degrades quality and raises nothing;
* an alpha that differs from the one the adapter was trained with,
which applies a different update with no indication;
* a merge left in place after an evaluation raised, so the next
caller gets a model that is not the one they asked for.
Each becomes an exception or a context manager here. The fourth thing
that is added is diagnostic rather than defensive: effective_rank() and
retained_capability() are the two measurements that distinguish this
technique working from this technique appearing to work.
"""
from __future__ import annotations
import contextlib
import json
from collections.abc import Iterator, Sequence
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import numpy.typing as npt
FloatArray = npt.NDArray[np.float32]
class AdapterError(Exception):
"""Base for every recoverable failure in adaptation."""
class InvalidAdapterConfig(AdapterError):
"""Rank, alpha or target modules cannot describe a usable adapter."""
class BaseModelMismatch(AdapterError):
"""The adapter was trained against a different base than this one.
Includes quantization: an adapter trained against a four-bit base
learned to compensate for that base's quantization error, so the
same checkpoint in full precision is a different model as far as
this adapter is concerned.
"""
class MergeNotSupported(AdapterError):
"""Merging would silently change the model that was evaluated."""
@dataclass(frozen=True)
class BaseFingerprint:
"""What an adapter is pinned to. All three parts, not just the name."""
checkpoint_id: str
quantization: str
tokenizer_id: str
def describe(self) -> str:
return f"{self.checkpoint_id}[{self.quantization}]/{self.tokenizer_id}"
@dataclass(frozen=True)
class AdapterConfig:
"""Validated once, at construction, rather than trusted per call.
Frozen because these values are part of the artefact's meaning:
changing alpha after training does not adjust the adapter, it
applies a different update with the same weights.
"""
rank: int
alpha: float
target_modules: tuple[str, ...]
dropout: float = 0.0
def __post_init__(self) -> None:
if self.rank <= 0:
raise InvalidAdapterConfig(f"rank must be positive, got {self.rank}")
if self.alpha <= 0.0:
raise InvalidAdapterConfig(f"alpha must be positive, got {self.alpha}")
if not self.target_modules:
raise InvalidAdapterConfig(
"no target modules: an adapter attached to nothing trains nothing"
)
if not 0.0 <= self.dropout < 1.0:
raise InvalidAdapterConfig(f"dropout must be in [0, 1), got {self.dropout}")
@property
def scale(self) -> float:
"""alpha / r. Read from the config, never recomputed by a caller.
Sweeping rank with alpha fixed sweeps this too, which is why a
rank study that holds alpha constant measures two things and
resolves neither.
"""
return self.alpha / self.rank
@dataclass
class LoRAAdapter:
"""A frozen projection plus its rank-r update.
The weight is a view onto the base model's array, never a copy. That
is not only memory: a copy would let the adapter and the base drift
apart, and an adapter whose frozen half has drifted is the hardest
version of this bug to find.
"""
name: str
base_weight: FloatArray
config: AdapterConfig
fingerprint: BaseFingerprint
down: FloatArray = field(init=False)
up: FloatArray = field(init=False)
_merged: bool = field(default=False, init=False)
def __post_init__(self) -> None:
out_features, in_features = self.base_weight.shape
if self.config.rank > min(out_features, in_features):
raise InvalidAdapterConfig(
f"rank {self.config.rank} exceeds min dimension "
f"{min(out_features, in_features)} of {self.name}; "
"a full-rank update is just fine-tuning with extra steps"
)
rng = np.random.default_rng(abs(hash(self.name)) % (2**32))
self.down = rng.normal(
0.0, in_features**-0.5, size=(self.config.rank, in_features)
).astype(np.float32)
# Zero, so the adapted model starts exactly at the pretrained
# one. An untrained adapter is a provable no-op, not an
# approximate one.
self.up = np.zeros((out_features, self.config.rank), dtype=np.float32)
@property
def trainable_parameters(self) -> int:
return int(self.down.size + self.up.size)
@property
def frozen_parameters(self) -> int:
return int(self.base_weight.size)
def delta(self) -> FloatArray:
"""The update alone, scaled. Materialized only for diagnostics."""
return self.config.scale * (self.up @ self.down)
def forward(self, activations: FloatArray) -> FloatArray:
if activations.shape[-1] != self.base_weight.shape[1]:
raise ValueError(
f"{self.name}: expected {self.base_weight.shape[1]} input features, "
f"got {activations.shape[-1]}"
)
base = activations @ self.base_weight.T
if self._merged:
return base
return base + self.config.scale * (activations @ self.down.T) @ self.up.T
@contextlib.contextmanager
def merged(self) -> Iterator[None]:
"""Temporarily fold the update into the weight.
A context manager rather than a merge()/unmerge() pair because
the evaluation inside will eventually raise, and an unmerge that
depends on an exception not happening is an unmerge that will be
skipped exactly when it matters. On exit the base weight is
restored bit-for-bit.
"""
if self.fingerprint.quantization != "none":
raise MergeNotSupported(
f"{self.name}: base is {self.fingerprint.quantization}; the adapter "
"was trained against that quantization error, so merging into a "
"dequantized weight yields a different model than was evaluated"
)
original = self.base_weight.copy()
self.base_weight += self.delta()
self._merged = True
try:
yield
finally:
self.base_weight[...] = original
self._merged = False
def effective_rank(adapter: LoRAAdapter, energy: float = 0.99) -> int:
"""Directions the update actually uses, from its spectrum.
A rank-64 adapter whose delta has four meaningful singular values is
reporting that the rank sweep was never run. Cheap to compute and
almost never computed, which is why rank is usually a number someone
copied from a blog post.
"""
if not 0.0 < energy <= 1.0:
raise ValueError(f"energy must be in (0, 1], got {energy}")
singular_values = np.linalg.svd(adapter.delta(), compute_uv=False)
total = float(singular_values.sum())
if total == 0.0:
return 0
cumulative = np.cumsum(singular_values) / total
return int(np.searchsorted(cumulative, energy) + 1)
def retained_capability(
before: Sequence[float], after: Sequence[float], tolerance: float = 0.02
) -> tuple[bool, float]:
"""Did adaptation cost something the in-domain metric cannot see?
Guard clauses rather than nesting, because the interesting path is
the comparison and the uninteresting paths are all early exits.
"""
if len(before) != len(after):
raise ValueError(f"score counts differ: {len(before)} vs {len(after)}")
if not before:
raise ValueError("no held-out capability scores; forgetting is unmeasured")
drop = float(np.mean(before) - np.mean(after))
return drop <= tolerance, drop
class AdapterSet:
"""Adapters attached to one base, sharing one fingerprint."""
def __init__(self, fingerprint: BaseFingerprint, config: AdapterConfig) -> None:
self.fingerprint = fingerprint
self.config = config
self._adapters: dict[str, LoRAAdapter] = {}
def attach(self, name: str, base_weight: FloatArray) -> LoRAAdapter:
if name in self._adapters:
raise InvalidAdapterConfig(f"{name} already has an adapter attached")
if not any(pattern in name for pattern in self.config.target_modules):
raise InvalidAdapterConfig(
f"{name} matches none of {self.config.target_modules}; "
"the target-module set moves quality more than rank does"
)
adapter = LoRAAdapter(
name=name,
base_weight=base_weight,
config=self.config,
fingerprint=self.fingerprint,
)
self._adapters[name] = adapter
return adapter
@property
def trainable_parameters(self) -> int:
return sum(item.trainable_parameters for item in self._adapters.values())
@property
def frozen_parameters(self) -> int:
return sum(item.frozen_parameters for item in self._adapters.values())
def save(self, directory: Path) -> None:
"""Weights and the scale that gives them meaning, together.
Storing alpha and rank beside the tensors is the whole point.
An adapter file without them is a set of numbers that can be
applied at any magnitude, and it will be.
"""
directory.mkdir(parents=True, exist_ok=True)
manifest = {
"rank": self.config.rank,
"alpha": self.config.alpha,
"target_modules": list(self.config.target_modules),
"base": {
"checkpoint_id": self.fingerprint.checkpoint_id,
"quantization": self.fingerprint.quantization,
"tokenizer_id": self.fingerprint.tokenizer_id,
},
"modules": sorted(self._adapters),
}
(directory / "adapter_config.json").write_text(json.dumps(manifest, indent=2))
tensors = {}
for name, adapter in self._adapters.items():
tensors[f"{name}.lora_down"] = adapter.down
tensors[f"{name}.lora_up"] = adapter.up
np.savez(directory / "adapter_model.npz", **tensors)
@classmethod
def load(
cls, directory: Path, serving_fingerprint: BaseFingerprint
) -> tuple[AdapterConfig, dict[str, FloatArray]]:
"""Refuse a mismatched base rather than degrade against it."""
manifest = json.loads((directory / "adapter_config.json").read_text())
trained_against = BaseFingerprint(**manifest["base"])
if trained_against != serving_fingerprint:
raise BaseModelMismatch(
f"adapter trained against {trained_against.describe()}, "
f"serving {serving_fingerprint.describe()}"
)
config = AdapterConfig(
rank=manifest["rank"],
alpha=manifest["alpha"],
target_modules=tuple(manifest["target_modules"]),
)
with np.load(directory / "adapter_model.npz") as archive:
tensors = {key: archive[key].astype(np.float32) for key in archive.files}
return config, tensors
What changed: The configuration becomes a frozen dataclass validated once at construction, because rank, alpha and the target-module set are the three numbers that decide what the run can learn and every one of them is silently accepted by the literal version. The alpha-over-r scale travels with the weights rather than living in whoever launched the job, which is the fix for the most common silent misload of this technique: an adapter loaded with a different alpha applies a different update and reports nothing. The base model gets a fingerprint — checkpoint identity and quantization together — checked when an adapter is attached, so an adapter meeting a base it was not trained against raises instead of quietly degrading. Merging becomes a context manager, so the unmerge always happens even when the evaluation inside raises, and the merged and unmerged paths are verified to agree numerically rather than assumed to. Every recoverable failure names the values that caused it: a rank exceeding the matrix it adapts, a mismatched base, a merge attempted against a quantized weight the adapter was trained to compensate for. And the diagnostics that actually catch this technique failing are here as functions rather than as advice — effective rank from the update's spectrum, which says whether the rank sweep was ever run, and a retained-capability check, which is the only thing that sees the forgetting the in-domain metric cannot.
Asymptotically identical to the literal version — the same base matmul and the same two thin adapter matmuls — with the per-sample Python loop replaced by array operations, so the constant factor falls by orders of magnitude without any change in what is computed. Illustrative, not a measured benchmark: the substantive change is that a mismatched base, a rank exceeding the matrix it adapts, a merge against a quantized weight and an unmerge skipped by an exception are all failures a caller must now handle, where the literal version produced a plausible model for each.
Faster, and the justification for each change.
"""LoRA serving, shaped for the case the technique exists for.
One frozen base, many adapters, mixed in a single batch. The layout
follows from that: adapters stacked contiguously and selected by index,
the scale folded into the weights at load, the update accumulated into
the base output in place, and scratch allocated once.
What this does NOT change is worth stating first, because it is the
thing people expect it to change. The backward pass still walks the
full depth of the frozen model, so training throughput here is close to
full fine-tuning's. The saving was always memory.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
FloatArray = npt.NDArray[np.float32]
IndexArray = npt.NDArray[np.int32]
@dataclass(frozen=True)
class ServingShape:
max_batch: int
rank: int
in_features: int
out_features: int
class StackedAdapters:
"""Every tenant's adapter for one module, in two contiguous tensors.
down is (n_adapters, rank, in_features) and up is
(n_adapters, out_features, rank), both C-contiguous float32. One
allocation each, so selecting an adapter is a strided read rather
than a pointer chase through n_adapters separate arrays -- which is
the difference between a gather that streams and one that stalls.
The cost of this layout is that the set is immutable: adding a
tenant reallocates both tensors. That is the right trade for a
serving process that reloads on a schedule, and the wrong one for a
system that wants to register adapters at runtime.
"""
def __init__(self, shape: ServingShape, n_adapters: int) -> None:
self.shape = shape
self.down = np.zeros(
(n_adapters, shape.rank, shape.in_features), dtype=np.float32
)
self.up = np.zeros(
(n_adapters, shape.out_features, shape.rank), dtype=np.float32
)
# Scratch at the largest batch shape, sliced per call. A serving
# loop that allocates per request spends a measurable share of a
# cheap layer inside the allocator.
self._projection = np.empty((shape.max_batch, shape.rank), dtype=np.float32)
self._gathered_down = np.empty(
(shape.max_batch, shape.rank, shape.in_features), dtype=np.float32
)
self._gathered_up = np.empty(
(shape.max_batch, shape.out_features, shape.rank), dtype=np.float32
)
def load(self, slot: int, down: FloatArray, up: FloatArray, scale: float) -> None:
"""Fold alpha/r into up once, here, and never again.
The scale is a constant of the adapter, so applying it per
forward pass is a full out_features-wide multiply repeated for
the life of the deployment. Folding it in at load costs one
multiply per weight, total.
It also means the stored tensors are no longer the trained ones,
so the manifest's alpha must not be re-applied downstream --
exactly the kind of bookkeeping that has to be written down.
"""
np.copyto(self.down[slot], down)
np.multiply(up, np.float32(scale), out=self.up[slot], casting="same_kind")
def forward(
self, activations: FloatArray, base_weight: FloatArray, slots: IndexArray
) -> FloatArray:
"""Mixed-adapter batch in two batched matmuls.
activations is (batch, in_features); slots[i] names the adapter
for row i. The base matmul is shared across the whole batch --
one frozen weight, which is the entire operational argument for
this technique -- and only the thin update varies per row.
"""
batch = activations.shape[0]
if batch > self.shape.max_batch:
raise ValueError(
f"batch {batch} exceeds scratch shape {self.shape.max_batch}"
)
# Shared across every tenant in the batch, at full BLAS
# throughput. This is the dominant term and it is paid once.
out = activations @ base_weight.T
# Gather into preallocated scratch rather than fancy-indexing
# into a fresh array, which would allocate per call.
np.take(self.down, slots, axis=0, out=self._gathered_down[:batch])
np.take(self.up, slots, axis=0, out=self._gathered_up[:batch])
# (batch, 1, in) @ (batch, in, rank) -> (batch, 1, rank).
# einsum keeps the shape contract readable; the arithmetic is
# the same batched GEMM either way.
projection = np.einsum(
"bi,bri->br",
activations,
self._gathered_down[:batch],
out=self._projection[:batch],
optimize=True,
)
# Accumulate in place. The out_features-wide intermediate the
# earlier stages returned is never allocated; at batch scale it
# is the largest short-lived array in the layer.
out += np.einsum("br,bor->bo", projection, self._gathered_up[:batch])
return out
class HomogeneousBatch:
"""The faster path when a batch happens to share one adapter.
Worth having separately because it is common -- a single-tenant
deployment, or a scheduler that groups by adapter -- and because the
gather disappears entirely: one (rank, in) matmul for the whole
batch instead of a per-row slab. Grouping requests by adapter before
serving turns the general path into this one, at the cost of holding
requests until a group forms.
"""
def __init__(self, down: FloatArray, up: FloatArray, scale: float) -> None:
self.down = np.ascontiguousarray(down, dtype=np.float32)
self.up = np.ascontiguousarray(up * np.float32(scale), dtype=np.float32)
def forward(self, activations: FloatArray, base_weight: FloatArray) -> FloatArray:
out = activations @ base_weight.T
# Two matmuls, no gather, no per-row slab. Fused into out so the
# intermediate never lands.
out += (activations @ self.down.T) @ self.up.T
return out
def group_by_adapter(slots: IndexArray) -> list[tuple[int, IndexArray]]:
"""Turn a mixed batch into homogeneous runs.
A sort plus a boundary scan, vectorized rather than a Python loop
over requests. Whether this pays depends on the tenant distribution:
a batch of sixty-four distinct adapters gains nothing and has paid
for a sort, while skewed traffic -- which is what real multi-tenant
traffic looks like -- collapses to a handful of groups.
"""
order = np.argsort(slots, kind="stable")
ordered = slots[order]
boundaries = np.flatnonzero(np.diff(ordered)) + 1
runs = np.split(order, boundaries)
return [(int(ordered[run[0]]), run.astype(np.int32)) for run in runs]
def memory_breakdown(
n_parameters: int, trainable: int, bytes_per_parameter: int = 4
) -> dict[str, float]:
"""Where the memory actually went, in gigabytes.
Included because the number people plan capacity from -- the
trainable-parameter ratio -- is not the number that decides whether
a run fits. Optimizer state scales with trainable parameters and
collapses; frozen weights and activations do not move at all, and
activations are usually what fails first.
"""
gigabyte = 1024**3
frozen = n_parameters * bytes_per_parameter / gigabyte
gradients = trainable * bytes_per_parameter / gigabyte
# Adam keeps two fp32 moments per trainable parameter. This is the
# term the technique removes, and the only one.
optimizer = trainable * 2 * 4 / gigabyte
return {
"frozen_weights_gb": frozen,
"gradients_gb": gradients,
"optimizer_state_gb": optimizer,
"trainable_fraction": trainable / n_parameters,
"note": "activation memory is unchanged and is excluded here on purpose",
}
What changed: The optimization that matters here is not the single-adapter forward pass — that was already two thin matmuls beside a large one — it is serving many adapters in one batch, which is the mode the whole multi-tenant argument depends on and the mode that never appears in a single-adapter benchmark. Adapters are stacked into one contiguous (n_adapters, rank, in_features) tensor per module and selected by index, so a mixed batch becomes a gather plus one batched matmul instead of a Python loop over tenants; the gather is a strided read out of a single allocation rather than a walk over per-adapter arrays scattered across the heap. The alpha-over-r scale is folded into the up-projection once at load time, removing a full-width multiply from every forward pass and from every backward pass, where it otherwise appears twice. The two adapter matmuls are fused into the base output with in-place accumulation, so the d-wide intermediate that the literal version returned is never materialized; at batch scale that intermediate is the largest short-lived allocation in the layer. Scratch for the r-wide projection is allocated once at the maximum batch shape and sliced, so a serving loop performs no per-request allocation. Everything is float32 and C-contiguous end to end, which is what keeps BLAS on its fast path rather than silently copying for every call.
Batch work to amortize interpreter overhead
A mixed-tenant batch becomes one gather and one batched matmul rather than a Python loop that pays dispatch per request
Trade-off: Requests must be grouped before they can be served, so batching adds queueing latency to every request in exchange for throughput
Fuse adjacent operations so intermediates are never materialized
The adapter output accumulates into the base result in place, so the full-width intermediate is never allocated
Trade-off: The base output is destroyed as it is updated, so anything needing the unadapted activations must copy them explicitly
Pre-allocate output arrays and use in-place operations
The r-wide projection scratch is sized once at the maximum batch shape and sliced per call, so serving allocates nothing per request
Trade-off: Scratch is held at peak shape for the process lifetime and the buffers are not reentrant, so a worker cannot share them across threads
Ensure contiguous memory layout and a single dtype
Stacked adapters in one C-contiguous float32 tensor let the per-request gather be a strided read and keep every matmul on the BLAS fast path
Trade-off: Adding or removing a tenant reallocates the whole stack, so the adapter set is effectively immutable between reloads
Vectorize to NumPy/BLAS (@, np.dot, einsum)
The per-adapter update becomes one batched matmul over the gathered slices instead of a matmul per request
Trade-off: The batched form gathers a rank-by-in_features slab per row, so memory traffic grows with batch size even though the arithmetic does not
Per batched forward: one (batch x in x out) GEMM shared across every tenant, plus a gather of batch rank-by-in and out-by-rank slabs and two batched matmuls at O(batch·rank·in) and O(batch·rank·out). Illustrative, not a measured benchmark: the shape worth reading is that arithmetic in the adapter path is linear in rank while the gather's memory traffic is linear in batch size, so the mixed-adapter path is bandwidth-bound where the homogeneous path is not — which is why grouping by adapter, when traffic allows it, is the larger win than anything done to the matmuls.