AI & LLMs/LLM Architectures · Part 2 of 6
·47 min read
Neural Network Fundamentals
A walkthrough of what I learnt on Neural network fundamentals and hardware execution with a broader scope on learning about LLM architectures.
The first post stayed on the Python side of things. This one moves to the neural network itself: what a network is made of, how its parameters live in memory, how a forward pass runs on the GPU, and why inference needs so much less memory than training.
The second post covers four topics:
- What are the basic components of a neural network?
- How does a model run during inference?
- Do the optimizer and gradients matter for inference?
- Memory usage and VRAM allocation during inference
#What are the basic components of a neural network?
#What is a layer? What is a parameter?
#What are the components of a neural network model?
A neural network is a highly flexible function approximator made of many interconnected computational elements. A neuron (or node) is the fundamental processing unit. Each neuron receives one or more inputs, computes a weighted sum, and applies an activation function to the result.
Neurons are organised into layers:
- The input layer receives the raw data. It does no computation other than passing the input features through.
- Hidden layers perform complex feature extraction and transformation, through successive weighted sums and activation functions.
- The output layer produces the final predictions or classifications.
| Component | What it does |
|---|---|
| Weights | The strength of the connection between neurons in different layers. They determine how much an input influences a neuron's output, and they are adjusted during training to minimise prediction error |
| Biases | They shift the activation function, so a neuron can activate even when all its inputs are zero |
| Activation functions | Non-linear functions applied to the output of each neuron's weighted sum. They are what lets the network learn non-linear relationships |
| Loss (cost) function | Quantifies the gap between the network's predictions and the actual target values: a number that says how wrong the predictions are |
| Optimizer | The algorithm that adjusts the weights and biases during training, by minimising the loss |
- Without non-linearity, a deep network would collapse into a single linear transformation, which severely limits its expressive power.
- The goal of training is to minimise the loss over the training data, and the loss guides the parameter updates.
- Optimizers typically use gradient-based methods, such as Stochastic Gradient Descent (SGD) and its variants (Adam, RMSprop), to update the parameters step by step in the direction that reduces the loss.
You can see the parameters of a tiny model directly in PyTorch:
import torch
from torch import nn
model = nn.Sequential(
nn.Linear(4, 8), # input to hidden: an 8 x 4 weight matrix plus 8 biases
nn.ReLU(), # an activation function: it has no learnable parameters
nn.Linear(8, 2), # hidden to output: a 2 x 8 weight matrix plus 2 biases
)
for name, p in model.named_parameters():
print(name, tuple(p.shape)) # each one is a trainable parameter tensor
print(sum(p.numel() for p in model.parameters())) # 4*8 + 8 + 8*2 + 2 = 58 parameters
#Which ones are trainable parameters, and which are constants?
| Trainable parameters | Constants (hyperparameters) | |
|---|---|---|
| What they are | Numerical values adjusted by the optimizer during training. Examples: weights, biases, convolutional kernels | Elements that are fixed before or during training and aren't adjusted by gradients from the loss. They define the architecture, the operational logic, or the training process itself |
| Their role | They learn the patterns and relationships in the data and hold the network's "learned knowledge" | They set the framework and rules within which the parameters are learned: layer sizes, the learning rate, the choice of activation function |
| Who sets them | Data and gradient-based optimization | The model designer, by design choice, and then left static or adjusted by hand |
In short, trainable parameters are the adaptive elements that encode what the network has learned. Constants (hyperparameters) are the design choices around them.
#Which are used during the forward pass, and what extra is used during training?
| Used in the forward pass (inference) | Added during training |
|---|---|
| The input vector (with n features), the network's starting point | Target labels (ground truth), the benchmark for the predictions in supervised learning |
| Learnable parameters: weights and biases for each layer, which scale and shift the input signals | Loss function, which measures how far the prediction is from the true label |
| Activation functions, which transform each summed input into the output passed to the next layer | Gradients, from backpropagation |
| The output: the final result after the input has passed through all the layers | Optimizer and its state, plus the learning rate |
DataMetadata
The training phase extends the forward pass with ways to learn from errors and adjust the learnable parameters step by step.
- Loss function. A scalar-valued function that measures the gap between the prediction and the true label. It gives optimization something measurable to aim at, and a lower loss means a better model.
- Examples: Mean Squared Error (MSE) for regression, and Cross-Entropy loss for classification.
- Gradients. Vectors of partial derivatives of the loss with respect to each learnable parameter. The optimizer uses them to decide how to adjust the parameters so the loss goes down.
- They are computed efficiently by the backpropagation algorithm, which uses the chain rule of calculus.
- The core idea is to propagate the error backwards through the network, layer by layer, calculating the gradient for each parameter.
- Optimizer. Uses the computed gradients to update the network's learnable parameters step by step and reduce the loss.
- Learning rate. A crucial hyperparameter that controls the step size the optimizer takes when adjusting parameters, in other words how aggressively the model learns.
- A learning rate that is too high can cause instability and divergence. One that is too low can mean slow convergence, or getting stuck in a local minimum.
#How are parameters stored? How does PyTorch manage them?
#How are parameter tensors allocated in memory?
- Parameters are n-dimensional arrays, managed by the deep learning framework's runtime and hardware interfaces. They hold numerical data and are tied to a specific device and data type.
torch.Tensoris the fundamental data structure, a multi-dimensional matrix. It acts as a handle to a block of memory that lives either on the CPU (host memory) or on a GPU (device memory).
| Where | How memory is allocated |
|---|---|
| CPU (host) | PyTorch's C++ backend (powered by the ATen library) uses standard system memory allocation routines (such as malloc or equivalent) to reserve a contiguous block of RAM. This is typically the default device |
| GPU (device) | PyTorch uses the GPU's programming model, mainly CUDA for NVIDIA GPUs or ROCm for AMD GPUs. It calls low-level runtime APIs (such as cudaMalloc) to request dedicated video RAM (VRAM) from the GPU's memory controller, and the tensor stores a pointer to that block |
- Device transfer. Moving a tensor from CPU to GPU (with
tensor.to('cuda')ormodel.cuda()) copies its data from host RAM to device VRAM. This is a crucial step for using parallel computation, and the transfer bandwidth can be a significant bottleneck. - Memory layout and contiguity. Tensors are often stored in one contiguous block to optimise access patterns and efficiency. PyTorch uses strides to handle non-contiguous views without copying data, and an explicit
.contiguous()call forces contiguity when needed, possibly at the cost of a copy. - Data types and precision. The memory allocated is directly proportional to the number of elements and the size of the data type. Lower-precision types reduce the memory footprint and can speed up computation on compatible hardware.
| Data type | Bytes per element |
|---|---|
torch.float32 | 4 |
torch.float16, torch.bfloat16 | 2 |
torch.int8 | 1 |
- Parameter management in
nn.Module. When you define a network withtorch.nn.Module, parameters are registered withself.register_parameter()(or implicitly throughnn.Parameter). That lets the module track them, move them between devices, and include them in the optimizer's parameter list for gradient updates.
#Do all parameters have to be moved to the GPU? When does this happen?
- PyTorch operations are device-agnostic in their definition: the same operation can run on the CPU or the GPU. But all the tensors taking part must be on the same device. You can't directly combine a CPU tensor with a GPU tensor.
- Not all parameters must be on the GPU, but for a computation to use GPU acceleration, the relevant parameters and input data must sit on the same GPU as the operation.
- Parameters move to the GPU when the user asks for it explicitly, or when a framework (for example for distributed training) arranges the placement. This usually happens just before or at the point of computation.
model = model.to("cuda") # moves every parameter and buffer of the module
x = x.to("cuda") # the inputs must be on the same device as the model
y = model(x) # now the computation can run on the GPU
- Explicit control. Individual tensors move with
.to(device)or its alias.cuda(). For atorch.nn.Module,module.to(device)moves all the module's parameters and buffers (such as batch normalization's running statistics). - Placement in more advanced setups. Not every parameter has to sit on the GPU all the time. The multi-GPU strategies from post 01 (data, tensor and pipeline parallelism, and sharding with ZeRO or FSDP) spread parameters across GPUs. Offloading goes one step further: parameters that aren't needed yet wait in CPU memory and are copied to the GPU just before their layer runs. Hugging Face's
device_map="auto"does this when a model doesn't fit, trading speed for memory.
#Can multiple models share parameters?
- Yes. Parameter sharing (also called weight sharing) brings efficiency, regularisation and shared representations across different parts of a network, or across separate models.
- It is an architectural pattern in which a single set of trainable parameters (weights and biases) is used by several computational units or layers in a network, or even across model instances.
- The principle behind it is an inductive bias. If certain transformations are expected to be useful everywhere, for example at different locations in an image or across related tasks, sharing parameters lets those components learn one robust, unified representation.
- In PyTorch, sharing works through Python's object references and the module system: several
nn.Moduleinstances or sub-modules reference the sametorch.nn.Parameterobjects. If a sub-module is passed by reference to several parents, they all operate on exactly the same tensors. During backpropagation, the gradients for shared parameters accumulate correctly.
embed = nn.Embedding(1000, 64) # a 1000 x 64 embedding matrix
proj = nn.Linear(64, 1000, bias=False) # its weight is also 1000 x 64
proj.weight = embed.weight # both now share one Parameter (weight tying)
Common patterns:
| Pattern | How parameters are shared |
|---|---|
| Siamese networks | Two or more identical subnetworks share all their parameters and process different inputs, to learn a similarity metric or to embed inputs into a common space (face recognition, signature verification) |
| Multi-task learning | One model does several related tasks at once. A common "backbone" shares parameters to learn general features, and task-specific "heads" branch off with their own parameters. This tends to improve generalisation and data efficiency |
| Encoder-decoder models | The embedding layer, or parts of the encoder and decoder, can share weights. This works especially well when the input and output vocabularies are the same, and it enforces a consistent semantic space. Sharing the input and output embedding matrices in a translation model with one shared vocabulary improves performance and reduces the parameter count |
| GANs | A generator and a discriminator are usually separate, but some advanced GAN designs share components (for example partial weight sharing in spectral normalization layers, or shared feature extractors in conditional GANs) |
#How does a model run during inference?
#How is the forward pass executed on the GPU?
#How does a Linear layer → matrix multiplication run?
A Linear layer transforms an input matrix (or a batch of vectors) X into an output Y, using a weight matrix W and an optional bias vector B: Y = X · Wᵀ + B.
| Symbol | Shape |
|---|---|
| X, the input | (batch_size, in_features) |
| W, the weights | (out_features, in_features) |
| B, the bias | (out_features) |
| Y, the output | (batch_size, out_features) |
The GPU execution paradigm is massive parallelism. GPUs are designed for Single Instruction, Multiple Threads (SIMT) execution, which suits matrix multiplication perfectly, because each output element can be computed independently. Computation is organised in a hierarchy:
| Level | What it is |
|---|---|
| Grid | A collection of independent thread blocks |
| Block | A group of cooperating threads that can access fast shared memory and synchronise |
| Thread | The smallest unit of execution, responsible for a small part of the computation, typically one element or a small segment of data |
For the general matrix multiplication C = A × B, where A is M × K and B is K × N, the output C is M × N. Each element C[i][j] is the dot product of row i of A and column j of B.
- A common strategy maps portions of the output matrix C to thread blocks. A block might be responsible for computing one tile (a sub-matrix) of C, and within the block each thread computes specific elements or parts of elements.
- To maximise efficiency and minimise slow global memory access, highly optimised kernels use tiling with shared memory:
- Large matrices A and B are divided into smaller tiles that fit into the GPU's fast on-chip shared memory.
- The threads in a block load the matching tiles of A and B into shared memory together. This happens once per tile, which spreads the cost of the global memory access over many uses.
- Once the tiles are in shared memory, the threads perform many dot products (or partial dot products) on the fast shared data. This gives a high ratio of computation to memory access.
- This dramatically reduces memory latency, lets data be reused, and keeps the GPU's arithmetic logic units (ALUs) busy, reaching near peak performance. Individual threads also use fast registers for intermediate calculations, and compilers and hardware pipeline the instructions aggressively to keep execution continuous.
Deep learning frameworks hide these low-level details and rely on highly optimised libraries to run matrix multiplications:
| Library or hardware | Role |
|---|---|
| CUDA | NVIDIA's parallel computing platform and programming model, which lets developers write GPU-accelerated code. It is the low-level interface for programming GPUs |
| cuBLAS | The CUDA Basic Linear Algebra Subprograms library: highly tuned, GPU-accelerated BLAS routines, including GEMM (General Matrix Multiply). It is the workhorse for dense matrix operations |
| cuDNN | The CUDA Deep Neural Network library: highly optimised primitives for convolutions, pooling and activation functions. Many of its operations use cuBLAS internally for matrix multiplication |
| Tensor Cores | Specialised matrix-multiply-and-accumulate units on modern NVIDIA GPUs (Volta, Turing, Ampere, Hopper). They speed up mixed-precision matrix operations, especially at lower precision (FP16, BF16, INT8) |
When a linear layer is defined and run on a GPU, PyTorch, TensorFlow and JAX automatically dispatch the matrix multiplication to the most suitable cuBLAS or cuDNN routine, often using Tensor Cores when they are available.
#Is each operation a separate kernel call?
- Not necessarily for every numerical computation. Conceptually, distinct operations can map to individual GPU kernel calls, but modern frameworks use optimisation techniques to cut overhead and maximise parallelism.
- Launching a GPU kernel from the CPU has a cost. It includes CPU-GPU communication (copying parameters, setting up launch configurations), kernel enqueueing, and synchronisation if later operations depend on this kernel finishing.
- For a sequence of many small operations, the launch overhead adds up and can become a significant bottleneck, especially for memory-bound work.
- There are two ways to cut that overhead:
- Kernel fusion merges consecutive operations into one kernel (next question).
- CUDA graphs record a whole sequence of kernel launches once, then replay it with a single launch. The kernels stay separate, but the CPU cost of launching them almost disappears. vLLM captures CUDA graphs for its decode steps, where every kernel is tiny and launch overhead would otherwise dominate.
#Is there kernel fusion? Can multiple layers be merged?
- Yes. Kernel fusion lets several elementary operations (layers, or parts of layers) merge into a single GPU kernel. It combines a sequence of operations that would normally need separate kernel launches into one larger kernel.
- Standard execution model. Without fusion, each operation (matrix multiplication, ReLU, bias addition) goes through: launch a new kernel on the GPU → load the input data from global memory → compute → write the intermediate result back to global memory.
- Fused execution model. With fusion, a compiler finds chances to chain these operations directly in the GPU's faster on-chip memory (registers, shared memory) for the intermediate results, avoiding costly round trips to global memory.
#How does memory layout affect performance?
#Is PyTorch column-major or row-major?
- Row-major (C-style): the elements of the last dimension are stored next to each other in memory. When you walk along a row, the elements are adjacent.
- Column-major (Fortran-style): the elements of the first dimension are stored next to each other. When you walk down a column, the elements are adjacent.
- PyTorch tensors are row-major by default, like NumPy arrays.
- PyTorch uses strides to describe how to move through a tensor's memory. A stride is the jump in memory (in number of elements) needed to get to the next element along one dimension.
- A tensor is contiguous if its elements are stored in the same order as a row-major tensor of the same shape. Operations like
transpose()orpermute()often create non-contiguous views by changing the strides without rearranging the underlying data.- Check contiguity with
tensor.is_contiguous(). - Force contiguity with
tensor.contiguous(). Some operations (convolutions, view reshaping) perform best, or only work, on contiguous data. This creates a new tensor with the data physically rearranged in memory.
- Check contiguity with
import torch
x = torch.arange(1, 7).reshape(2, 3) # [[1, 2, 3], [4, 5, 6]]
print(x.stride(), x.is_contiguous()) # (3, 1) True
t = x.t() # a transposed view: no data is copied
print(t.stride(), t.is_contiguous()) # (1, 3) False
c = t.contiguous() # the data is now physically rearranged (a copy)
print(c.stride(), c.is_contiguous()) # (2, 1) True
Performance implications for neural networks. Memory layout matters because of cache locality and vectorisation efficiency on modern hardware such as GPUs:
- Reading contiguous regions lets the CPU or GPU use its cache hierarchy well. When data is read linearly, prefetching can bring the next data into the cache before it is asked for, which reduces latency.
- Modern processors and GPUs work on many data elements at once with SIMD instructions. Contiguous access patterns are crucial for these vector units to load and process data efficiently, without the penalties of scattered memory access.
#Why perform layer fusion?
The previous answers showed what fusion does. Here are the reasons for it, side by side:
| Motivation | Why it helps |
|---|---|
| Less memory bandwidth | Intermediate results between the fused layers stay in faster on-chip memory (registers, shared memory, L1 cache) instead of being written back to slower global memory (DRAM). This takes pressure off memory bandwidth, a common bottleneck for the many small, element-wise operations in neural networks |
| Less kernel launch overhead | Each kernel launch costs something: context switching, setting up execution parameters, synchronisation. Fewer, larger kernels mean fewer launches, which helps especially in networks with many small layers |
| Better cache locality and resource use | Keeping data on-chip saves bandwidth and also improves data locality for the next computation. That makes better use of the GPU's streaming multiprocessor (SM) resources, including registers and shared memory, and gives higher arithmetic intensity |
- Example: Convolution + ReLU fusion. Instead of running a convolution and then a separate ReLU, a fused kernel computes both in sequence for each output element of the convolution, and writes the ReLU output directly to global memory.
#How are activations stored in intermediate buffers?
- Activations are the outputs of the non-linear activation functions applied after linear transformations (convolutions, matrix multiplications) inside a layer. They are the transformed feature maps passed as input to the next layer.
- Intermediate buffers are temporary memory regions that hold activations between successive operations or layers. On GPUs they typically sit in global device memory, but the frequently used parts are often staged into faster on-chip memory such as shared memory or registers.
- Good storage uses the GPU's multi-level memory hierarchy, so data is as close as possible to the processing units (SMs and cores) when needed, which minimises expensive global memory access:
| Level | What it is |
|---|---|
| Global memory (DRAM) | The largest and slowest memory, accessible by all SMs. Most activations start here. Optimising for coalesced access (the threads of a warp reading contiguous locations) is essential to get the most bandwidth |
| Shared memory (on-chip) | A small, very fast memory shared by the threads of one thread block. It is used for tiling or blocking: sections of activations are loaded from global memory once, then reused many times by the local threads, which sharply reduces global memory traffic |
| Registers (on-chip) | The fastest memory, private to each thread. Single scalar values or small fragments of activations stay in registers during computation |
Two common layouts for 4D image tensors:
- NCHW (channels-first): Batch × Channels × Height × Width. Width varies fastest in memory, so each channel's H × W plane is stored contiguously.
- NHWC (channels-last): the channels are the innermost dimension (
Activations[N, H, W, C]). This gives better spatial locality for pixel-wise operations (element-wise additions, non-linearities), because all the channel values for one (H, W) position sit together.
Advanced buffer management techniques:
- Tiling and blocking. The same tiling as in the matrix multiplication above: activations are processed in tiles that fit in shared memory.
- Buffer reuse and in-place operations. Frameworks reuse existing buffers for intermediate results where possible, instead of allocating new ones. In-place operations (such as
relu_(x)in PyTorch) modify the buffer directly, which reduces memory traffic and allocations. - Quantization. Storing activations at lower precision (FP16, INT8) cuts the memory footprint a lot, lets more data fit in faster caches and lowers bandwidth needs. It is a common inference optimisation on specialised hardware.
- Dynamic paging and virtual memory. For extremely large models, researchers are exploring techniques where only the active parts of the model (including activations) stay in GPU memory, like CPU virtual memory, to handle models larger than the GPU's VRAM.
#Do optimizer and gradients matter for inference?
#Is gradient needed during inference?
#Does inference generate intermediate results? Where are they stored?
- Yes. The storage location of these intermediate results is a critical factor for inference performance, and it is mainly determined by the device doing the computation.
- What they are: mostly the activations (feature maps) produced by each layer as data moves through the network. A layer's output transforms the input it received and becomes the next layer's input.
- Why they exist: intermediate activations are indispensable for the layer-by-layer computation of the forward pass. Each layer applies its transformations (convolutions, matrix multiplications, non-linear activations) to its input and produces a representation with progressively more abstract features. This continues until the final layer computes the network's prediction or decision.
| Device | Where intermediate results live |
|---|---|
| GPU | Mostly in the GPU's dedicated video RAM (VRAM). This gives the GPU's many parallel cores high-bandwidth, low-latency access, which is essential for the highly parallel computation in neural networks. Frameworks like PyTorch and TensorFlow handle allocation and deallocation of these tensors in VRAM automatically |
| CPU | In the system's main RAM. Access is typically slower than VRAM, but it is used when no dedicated GPU is available, or for smaller models where the cost of transferring data to the GPU outweighs the speed gain |
#Why does .eval() change model behavior (dropout, normalization)?
.eval()turns off the stochastic parts of the model (such as Dropout) and makes statistics-dependent parts (such as Batch Normalization) use stable, learned population statistics instead of batch-specific ones. This is critical for consistent, reliable predictions.- It switches the model into evaluation mode, which differs from training mode. The difference matters for layers that behave differently in training and inference.
| Layer | In training mode | After .eval() |
|---|---|---|
| Dropout | Randomly sets a fraction p of input units to zero at each update, to prevent overfitting. The surviving activations are scaled by 1/(1 − p). This adds noise and prevents neurons from co-adapting | Passes the inputs straight through, with no dropping and no scaling. The full learned capacity of the model is used and the results are deterministic |
| Batch Normalization | Normalises inputs with the mean and variance of the current mini-batch | Uses the learned population (running) statistics instead |
model.eval() # dropout off; batch norm uses its running statistics
with torch.no_grad(): # also skip building the autograd graph (more on this below)
out = model(x)
#Does the optimizer consume resources?
#How does AdamW keep momentum for each parameter?
- AdamW doesn't keep a single "momentum" scalar. For each parameter it keeps a first moment vector (an average of past gradients), which gives a momentum-like effect, and a second moment vector (an average of squared gradients), which provides adaptive learning-rate scaling.
- Both are maintained independently for every parameter, which allows nuanced and efficient optimisation.
| State | What it stores | What it does |
|---|---|---|
| First moment (directional momentum) | An exponentially weighted average of past gradients | Plays the role of classical momentum: it accumulates the "direction" of consistent gradients, speeding up convergence in the relevant directions and damping oscillations |
| Second moment (adaptive scaling) | An exponentially weighted average of past squared gradients, an estimate of the "uncentered variance" of the gradients | Used to scale the learning rate for each parameter: parameters with consistently large gradients get smaller effective learning rates, and the reverse |
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
# After the first opt.step(), opt.state holds, for every parameter, two extra tensors of the
# same shape: "exp_avg" (the first moment) and "exp_avg_sq" (the second moment).
#How does the optimizer "scan" through all parameters?
- It is a highly parallel operation, usually run efficiently on specialised hardware such as GPUs.
- During the backward pass (backpropagation), gradients are computed for all parameters. Each parameter tensor has a matching gradient tensor with the same shape.
- The framework builds a computational graph that records all operations of the forward pass. During backpropagation the graph is traversed in reverse, applying the chain rule to compute all the gradients efficiently.
- The optimizer's main job is to take these gradients and apply a predefined update rule to each parameter. The rule changes the parameter's value based on its gradient and possibly other state (such as momentum).
- The efficiency of this "scan" comes from vectorisation and parallelisation. Instead of separate CPU loops, operations run on entire tensors at once.
- Frameworks translate these mathematical operations into highly optimised CUDA kernels (on NVIDIA GPUs) or similar low-level routines. One kernel can update millions of parameters at the same time. PyTorch's optimizers also offer foreach and fused implementations that group many tensors into few kernels.
- Conceptually an optimizer iterates over the parameters, but in practice it dispatches tensor-level operations. A simple SGD update,
param.data -= learning_rate * param.grad, runs as a single parallel element-wise subtraction and scaling on the GPU.
#Why is optimizer memory usage 2–4× the parameter size?
- Mainly because optimizers must keep extra state variables for every parameter, together with numerical-stability needs, especially in mixed-precision training.
- Modern optimizers such as Adam, AdamW and RMSprop don't store only the parameter values. They keep internal state for each parameter to make updates more effective and stable.
- The bytes are easiest to count in mixed-precision training, a common practice that reduces memory use and speeds up computation, especially on modern GPUs (for example NVIDIA Tensor Cores).
- The model's parameters may be stored in FP16 (half precision, 2 bytes) to save memory and use specialised hardware, while the optimizer's state variables (such as
mandvin Adam) are typically kept in FP32 (single precision, 4 bytes). - In many mixed-precision setups (such as NVIDIA's Apex AMP or PyTorch's Automatic Mixed Precision), an extra FP32 master copy of the weights may also be kept. Gradients are typically computed in FP16, scaled, then converted to FP32 to update these master weights, which are then cast back to FP16 for the model.
- The model's parameters may be stored in FP16 (half precision, 2 bytes) to save memory and use specialised hardware, while the optimizer's state variables (such as
Counting the bytes for one parameter with AdamW:
| Setup | Weights | Gradient | FP32 master copy | m | v | Total |
|---|---|---|---|---|---|---|
| FP32 training | 4 B | 4 B | none | 4 B | 4 B | 16 B |
| Mixed precision (FP16 or BF16) | 2 B | 2 B | 4 B | 4 B | 4 B | 16 B |
| FP16 inference | 2 B | none | none | none | none | 2 B |
- The optimizer state alone (
mandv, 8 B) is 2× the FP32 weight size. Adding the gradients makes the total 4×. That is where "2–4×" comes from. - For a 7-billion-parameter model that is
16 B × 7 × 10⁹ = 112 GBfor training, before any activations, against 14 GB to serve it in FP16.
#Memory usage and VRAM allocation during inference
#How much memory is taken by parameters, inputs, and computation graphs?
#Model weights take fixed space, but what about input tensors?
- Input tensors are dynamic and add a lot to VRAM use during inference, mainly through batch size and input dimensions. The memory footprint changes with each request, depending on the characteristics of the input.
| Factor | Effect on memory |
|---|---|
| Batch size | The number of samples processed at the same time. For a batch of N inputs, the memory is N times that of one input |
| Input dimensions | For images: height, width and channels. For sequences: sequence length and feature dimension. Larger dimensions mean larger tensors |
| Data type precision | float32, float16, int8. Lower-precision types use less memory per element, a common optimisation for VRAM-constrained deployment |
Memory = batch size × input elements per sample × bytes per element
- During inference, the input tensor memory is allocated on the GPU. It is often smaller than the model weights, but it becomes critical for:
- High-throughput scenarios that use large batch sizes.
- Models that process very high-resolution images or long sequences.
- Edge or embedded devices with limited VRAM.
#Do intermediate activations take VRAM? Are there release strategies?
- Yes. Intermediate activations are the outputs of hidden layers or operations inside a network, and they serve as inputs for later layers in the computational graph. There are several ways to release or reduce them:
| Strategy | How it helps |
|---|---|
| Automatic memory management (garbage collection) | Releases the VRAM held by intermediate tensors as soon as no later operation needs them |
| Inference contexts | Context managers stop the framework building the backward graph, which cuts activation VRAM a lot by not storing tensors that are only needed for gradients |
| In-place operations | Modify the input tensor directly, without allocating new memory for the output |
| Graph optimisation and fusion | Fusing several operations into one GPU kernel reduces the number of intermediate tensors that must be materialised and stored in VRAM |
| Quantization | Lower numerical precision of the activations directly shrinks the VRAM footprint of each activation tensor |
| Activation offloading | Activations are moved to CPU RAM and brought back to VRAM only when needed again |
#Why does VRAM fill up when batch size increases?
- The weights and biases of the network stay constant whatever the batch size.
- The memory for the input data scales linearly with batch size. For a batch of B, it needs B × S_input.
- Intermediate activations: each layer produces activations for every item in the batch. If one layer's activation for one sample has size S_act, a batch of B needs B × S_act to hold them at the same time.
Putting these together:
Total VRAM ≈ parameters + B × (input per sample) + B × (activations per sample, summed over all layers) + overhead
Only the parameters stay fixed. Everything else grows with the batch size B, so increasing B steadily raises VRAM consumption.
#Differences in memory usage between inference and training
#Does inference skip gradient storage?
- Yes, because inference runs only a forward pass, to produce predictions from the weights.
- Gradient computation is an integral part of backpropagation, which works out how much each parameter contributed to the prediction error.
- A pre-trained model is simply applied, with no parameter updates. So the intermediate values that backpropagation would need aren't kept beyond their immediate use in the forward pass.
- Without gradient storage, the VRAM footprint shrinks, and much larger models can be deployed for inference on hardware with far less memory than their training needed.
#Do dropout and batchnorm affect memory usage?
- Dropout. During training, dropout randomly sets a fraction of the inputs to zero at each update, to prevent co-adaptation of features. To keep backpropagation consistent, the specific pattern of dropped neurons (the dropout mask) must be stored. During inference there is no random dropping, so no dropout masks are generated or stored.
- Batch normalization. During training, BatchNorm normalises each batch with that batch's own statistics (which must be kept for the backward pass), and it updates running estimates of the mean and variance along the way. Those running statistics are saved as part of the model's state. During inference, BatchNorm just uses the stored running statistics, so there are no per-batch statistics to compute or keep.
#Training needs optimizer states. Does inference?
- No. Inference is a forward pass through fixed weights, so the machinery for updating them (gradients and optimizer states) is never allocated. The table in the optimizer section above shows the saving: 16 bytes per parameter for AdamW training, against 2 for FP16 inference.
- In specialised settings where models keep adapting (certain online learning systems, meta-learning), a model may do an "inference" step immediately followed by a "micro-training" step. Then a minimal set of optimizer states may be kept or re-initialised. This departs from the conventional meaning of static inference.
- Optimizer states are different from recurrent states or attention key/value caches in sequential models. Those belong to the model's architectural design, keeping context across time steps or tokens during inference, and have nothing to do with optimization. For LLMs the KV cache is the big one, and post 04 is about it.
#Causes of VRAM OOM (out-of-memory) and prediction
#Is there a way to estimate the VRAM required for a given model and batch size?
During inference, VRAM is mostly used by three categories of data, plus a baseline overhead:
| Category | What it is |
|---|---|
| Model parameters (weights and biases) | The learned coefficients of the network. The footprint is fixed once the model is loaded and doesn't scale with batch size during inference |
| Intermediate activations | The outputs of each layer during the forward pass. Unlike training, where many are kept for backpropagation, inference typically only stores the activations for the current and next operations, but the peak can still be large, especially for large batch sizes or wide, deep layers |
| Input and output buffers | The memory that holds the input data fed to the model and the predictions it produces |
| Framework overhead and CUDA context | A baseline reserved by the CUDA driver, the framework (PyTorch, TensorFlow) and other system processes. Usually a few hundred megabytes, and mostly independent of model size and batch size |
There are two ways to estimate it:
- Analytical approach: work out each component from the model architecture.
- Calculate the total number of parameters and the chosen data type. Calculate the input and output memory from the batch size and tensor shapes.
- Trace the computation graph by hand to find the largest intermediate tensors, or use introspection tools that report layer-wise output shapes.
- Empirical approach: load the model and run actual inference on the target hardware to measure memory use.
- Load the model onto the GPU and measure the base memory. Run a forward pass with a small batch size and record the maximum VRAM allocated.
- Extrapolate the parameters and activations. A simple linear scaling of the difference is often used, but a more accurate way is to test several batch sizes and watch how memory scales.
import torch
torch.cuda.reset_peak_memory_stats()
model = model.to("cuda").eval()
base = torch.cuda.memory_allocated() # the weights are loaded
x = torch.randn(8, 512, device="cuda") # a batch of 8 (adjust the shape to your model)
with torch.no_grad():
model(x) # one forward pass
peak = torch.cuda.max_memory_allocated() # the highest allocation seen so far
print(f"weights: {base / 1e9:.2f} GB, peak: {peak / 1e9:.2f} GB")
#Can torch.compile or torch.no_grad save memory?
torch.no_grad() | torch.compile | |
|---|---|---|
| How it saves memory | Disengages Autograd's graph construction and gradient tracking. Intermediate tensors that would be kept for backpropagation are never allocated for gradient purposes, or are freed immediately. Tensors produced inside have no .grad | Graph-level transformations and aggressive kernel fusion cut the number of intermediate tensors. It typically uses technologies such as Triton or Inductor to generate efficient kernel code |
| How reliable the saving is | Guaranteed and direct | Indirect and variable. It depends on the model architecture and on how well the compiler optimises that particular graph |
| Main purpose | Disabling gradient tracking | Speeding up execution. Memory savings are usually a secondary benefit, and the initial compilation can itself use temporary extra memory |
- PyTorch's automatic differentiation engine, Autograd, builds a computational graph dynamically during the forward pass. That graph retains intermediate activations and the metadata needed to compute gradients in the backward pass.
- Inside a
torch.no_grad()block, graph construction and gradient tracking are switched off. So intermediate tensors that would otherwise be kept for backpropagation are either never allocated for gradient purposes or freed at once, and no memory is allocated for gradient storage.
with torch.no_grad(): # or torch.inference_mode(): no autograd graph is built
y = model(x)
fast_model = torch.compile(model) # may fuse kernels: faster, and sometimes smaller in memory
#Can temporary intermediate tensors be freed early?
- It depends on whether the intermediate tensors are still needed for later computation, particularly for gradient calculation during training.
- During inference: with
torch.no_grad()(above), each intermediate tensor is freed as soon as the next layer has used it. - During training: activation checkpointing frees most activations during the forward pass and recomputes them during the backward pass, trading computation for memory. Splitting an
n-layer network into segments of about√nlayers and keeping only the segment boundaries cuts activation memory fromO(n)to aboutO(√n), for roughly one extra forward pass.
from torch.utils.checkpoint import checkpoint
def forward(self, x):
for block in self.blocks:
x = checkpoint(block, x, use_reentrant=False) # keep only x, recompute inside block on backward
return x
#How is VRAM managed for multiple models and users on the same GPU?
#How does PyTorch know which tensors are still alive?
- Through the interplay of Python's reference counting and PyTorch's dynamic computational graph (the autograd engine).
- For tensors with
requires_grad=True, a dynamic computation graph is built on the fly for the backward pass. A tensor that tracks gradients has a.grad_fnattribute pointing to the function that created it. Intermediate tensors are implicitly kept alive until their role in gradient computation is done. - A tensor's memory is freed when its Python object's reference count drops to zero. The tensor is then destroyed, and its destructor releases the underlying C++ and CUDA memory.
Practical implications and controls:
- Disable gradient tracking with
torch.no_grad()ortorch.inference_mode(), as above, so that the graph doesn't keep activations alive. - Detach tensors.
tensor.detach()creates a new tensor that shares the same data but is completely removed from the current computation graph. It is useful to stop gradients flowing back through part of the graph, or to save memory on intermediate results you don't need for backpropagation. - Delete explicitly. Setting a variable to
None, or usingdelon a tensor, reduces its reference count, so Python can reclaim the memory earlier, provided nothing else references it.
#What causes VRAM fragmentation?
- VRAM fragmentation is the state in which free GPU memory is scattered in small, non-contiguous pieces. A new tensor that needs one large contiguous block can't be allocated, even if the total free memory is enough.
- Unlike CPU RAM, GPU memory often prefers contiguous allocation for performance. When a program requests a block, the allocator tries to find a single contiguous region. If only small separated regions are free, a larger allocation fails.
The main causes:
- Dynamic tensor lifecycles. Tensors are allocated and freed at different times, especially during training or with variable-sized inputs.
- Varying tensor sizes. Allocating and freeing blocks of different sizes over time naturally leaves uneven free space.
- Concurrent operations and multi-tenancy. Sharing a GPU between several models or processes quickly produces an interleaved pattern of allocated and freed blocks, which speeds up fragmentation.
- No memory compaction. GPU allocators typically don't compact memory in real time because of the performance cost, so fragmentation persists.
How it shows up:
- The most common symptom is an OOM error even when
nvidia-smi(or an equivalent) shows plenty of "free" VRAM. That free VRAM just isn't contiguous enough for the next large allocation. - Allocators may also spend more time searching for suitable blocks, which can hurt performance, though OOM is the more critical problem.
#How do you manually free memory (del, torch.cuda.empty_cache()) for actual release?
It takes two steps, because there are two layers. Python's reference counting decides when a tensor dies, and PyTorch's caching allocator (post 01) decides when its memory goes back to the driver.
- Drop every reference to the tensors you no longer need, with
delor by letting them go out of scope. The block returns to PyTorch's cache, ready for the next tensor in this process. - Call
torch.cuda.empty_cache()to hand the unused cached blocks back to the CUDA driver, so that other processes can use them.
del big_tensor # drop the last reference: the block returns to PyTorch's cache
torch.cuda.empty_cache() # now hand the cached, unused blocks back to the CUDA driver
When several models or users share one GPU, two more facts matter:
- A memory cap per process.
torch.cuda.set_per_process_memory_fraction(0.5)makes this process's allocator raise an out-of-memory error rather than take more than half of the GPU. - The CUDA context is per process. Every process that touches CUDA holds its own context, a few hundred megabytes that
empty_cache()can't release. Only ending the process gives it back.
#Summary
A network is layers of weights and biases with non-linear activations between them, and training adds a loss, gradients and an optimizer on top of the forward pass. Parameters are tensors with a shape, strides, a dtype and a device, and every tensor in an operation must be on the same device. On the GPU, a Linear layer is a tiled matrix multiplication, and fusion and CUDA graphs cut the cost of many small kernels. Training with AdamW needs about 16 bytes per parameter, while FP16 inference needs 2, and torch.no_grad() drops the autograd graph. VRAM holds the weights, plus inputs and activations that grow with the batch, plus overhead, and fragmentation can cause an out-of-memory error even when the total free memory looks sufficient.
#Concepts at a glance
| Concept | In one line | Why it matters |
|---|---|---|
| Neuron and layer | A weighted sum, a bias and an activation, arranged in layers | The building block of every model |
| Parameters and hyperparameters | Learned weights, and the fixed design choices around them | What training changes, and what you choose |
| Forward pass and training | Inference is the forward pass; training adds a loss, gradients and an optimizer | Why inference is so much cheaper |
| Tensor | Shape, strides, dtype and device, pointing at a block of storage | How parameters live in memory |
| Bytes per element | 4 for FP32, 2 for FP16 and BF16, 1 for INT8 | Model memory = parameters × bytes |
| Same-device rule | Every tensor in an operation must be on one device | The most common beginner error |
| Weight tying | Two layers share one Parameter | Fewer parameters and consistent embeddings |
| Linear = matmul | Y = X·Wᵀ + B, tiled across thread blocks | Where almost all the FLOPs are |
| Kernel launch overhead | Each kernel costs CPU time to launch | Many small kernels leave the GPU idle |
| Fusion and CUDA graphs | Merge kernels, or replay recorded launches | Fewer trips to memory and fewer launches |
| Memory hierarchy | Registers, shared memory, L2, HBM, host RAM | Fast memory is tiny, and big memory is slow |
| Row-major and strides | Views change the strides; .contiguous() copies | The layout decides how fast data is read |
| Eval mode | Dropout off, batch norm on its running statistics | Deterministic inference |
| AdamW state | Two moments per parameter | 16 bytes per parameter to train, against 2 to serve |
no_grad and inference_mode | No autograd graph | Activations are freed straight away |
| Activation checkpointing | Recompute activations instead of storing them | Training memory from O(n) to about O(√n) |
| VRAM estimate | Weights + batch × (inputs + activations) + overhead | Predicting an out-of-memory error before it happens |
| Caching and fragmentation | Freed blocks stay reserved; free memory can be too scattered | Why OOM happens with "free" memory |
#Test yourself
How many parameters does nn.Linear(4, 8) have?
A weight matrix of 8 × 4 = 32 values, plus 8 biases: 40 in total.
What does training need that inference doesn't?
Target labels, a loss function, gradients from backpropagation, an optimizer with its state, and a learning rate.
How much memory do the weights of a 7-billion-parameter model take in FP16?
7 × 10⁹ × 2 bytes ≈ 14 GB, before activations or anything else.
What happens if you multiply a CPU tensor by a GPU tensor?
A RuntimeError: every tensor in an operation must be on the same device, so move both with .to("cuda") first.
Why does x.t() cost nothing, while x.t().contiguous() makes a copy?
The transpose only swaps the strides, creating a view of the same data. .contiguous() rearranges the data physically into row-major order.
Why are many small kernels slow even when each one is fast?
Each launch costs CPU time, and each kernel writes its result to HBM for the next one to read back. Fusion removes the round trips, and CUDA graphs remove most of the launch cost.
Where do the 16 bytes per parameter of AdamW mixed-precision training come from?
2 for the FP16 weights, 2 for the gradients, 4 for the FP32 master copy, and 4 each for the two moments.
What does torch.no_grad() save, and what does model.eval() change?
no_grad() skips the autograd graph, so activations aren't kept for a backward pass. eval() turns dropout off and makes batch norm use its running statistics. Inference normally uses both.
Why can an out-of-memory error happen when nvidia-smi shows free memory?
Fragmentation: the free memory is split into pieces that are each too small for the block being requested, even though together they would be enough.
Next in the series: 03 · Transformer Architecture Principles.