TransformerCPP: Building a Transformer from Scratch in C++
Loading...
Loading...
I wrote transformer.cpp to understand how much code sits behind a Transformer layer. I wanted to be able to start at an attention calculation and follow it through the matrix multiplication, the array indexing, and the backward pass. That meant writing the tensor library as well as the model.
The resulting implementation runs on the CPU and includes an encoder-decoder Transformer, automatic differentiation, and a thread pool for tensor operations. Most of this article concerns the tensor library. To explain why it is organized the way it is, I'll first go through the model that uses it.
I used the encoder-decoder architecture from Attention Is All You Need. 1 The encoder reads a source sequence; the decoder processes a target sequence with access to the encoder's output. For a batch of examples, the two inputs contain and token IDs per example. Each side has its own embedding layer and positional encoding, after which every token is represented by floating-point values.
An encoder layer applies self-attention and a feed-forward network. A decoder layer adds cross-attention between its self-attention and feed-forward sublayers. In the cross-attention call, I pass the decoder representation as the query input and the encoder output as both the key and value inputs. The attention implementation can therefore handle both kinds of attention through the same interface.
Each sublayer returns a tensor with the same shape as its input. After dropout, I add the input to the result and apply layer normalization:
Normalization is performed over a token's features, with learned scale and bias parameters. 2 This follows the post-normalization layout of the original Transformer. Preserving the feature width across sublayers makes the residual addition possible without another projection.
At the end of the decoder, a linear layer converts each token's features into vocabulary scores. For example, with a vocabulary of 100 tokens, there are 100 scores at every target position, regardless of the internal feature width. forward returns the resulting tensor to the caller, which computes the loss during training.
For a query , attention compares it with every key by taking their dot product. A larger score gives the corresponding value vector more influence over the output. Softmax turns the scores into coefficients that sum to one, so a row with coefficients , , and would produce
The same calculation for all queries can be expressed with two matrix products: 1
The first product computes the scores; the second combines the values using their probabilities. Here is the width of one head and is the attention mask. In softmax, the reduction runs along the last axis of , which contains the keys. Each row then describes how one query divides its attention among them.
Multi-head attention requires some rearrangement before these products. The query, key, and value projections each produce a tensor of shape . With heads and , I reshape and transpose each projection as follows:
My matrix multiplication kernel treats the final two axes as a matrix and everything before them as batch dimensions. Putting the heads ahead of the sequence axis lets the kernel process them as additional batches. If the query and key lengths are and , respectively, the matrix dimensions within each head are
These lengths can differ in cross-attention, so the code tracks them separately. Once the heads have been evaluated, I transpose their outputs to , reshape to , and apply the output projection.
The look-ahead mask contains zero on and below the diagonal and negative infinity above it. Adding it to the scores makes future positions contribute zero after exponentiation. The remaining entries need to be normalized without overflowing the exponential, so I subtract the largest score in each row:
Subtracting multiplies every exponential by the same factor, which cancels in the fraction. All exponent arguments are now at most zero. Blanchard, Higham, and Higham examine the numerical accuracy of this shifted formulation. 3
There is an unresolved edge case when every entry in a row is masked. The maximum is then negative infinity, and the subtraction produces NaNs. A caller using this kernel needs at least one finite score in each row, or a separate policy for fully masked rows.
The reshapes above share data with their inputs. The transposes copy it. This follows from a restriction in my tensor representation: every tensor is interpreted as a contiguous, row-major array.
Tensor stores its dimensions in a std::vector<int> and its values in a shared std::vector<float>. Consider an array with shape . Each row contains four values, and each of the two outer slices contains three rows. To reach element , skip slices, rows, and values:
The coefficients are the strides. I calculate them from the shape whenever indexing requires them. A reshape changes the dimensions used to interpret this array while preserving the order of its values. Its result shares the original data buffer and allocates a separate gradient buffer.
Now consider transposing a matrix:
Both matrices must be stored row by row under the contiguous-storage convention:
Reinterpreting the first buffer with shape would put 1 and 2 in the first row. The transpose needs 1 and 4 there. My implementation allocates a second buffer and writes each input value at its transposed offset, including when attention splits and recombines heads.
I could avoid the copy by storing arbitrary strides on the tensor. The transposed matrix would use strides against the original buffer, and reading its first row would visit offsets 0 and 3. Every kernel consuming that tensor would then have to support this access pattern. Keeping inputs contiguous simplified the kernels at the expense of copying transposed data.
Reshape has a different consequence: its input and output alias the same storage. Calling set through either tensor changes the values visible through both. If backward needs one of those values, modifying it after the forward pass changes the derivative being computed. The API currently leaves that constraint to the caller.
A layer's local variables disappear when its forward method returns. Some of their values are still needed to differentiate the result. For an elementwise product , for instance, the derivative with respect to requires the forward value of :
A bar denotes the derivative of the loss with respect to that variable. To evaluate these expressions later, the result tensor stores references to both operands and records OperationType::Mul. Other operations save whatever their derivatives require, such as the permutation used by a transpose.
This is an operator-overloading implementation of reverse-mode automatic differentiation. 4 Calling backward on a result accumulates the incoming gradient and selects a derivative routine using the operation tag. That routine computes the parent gradients and recursively calls backward on each parent.
For a matrix product , the routines compute
I use the existing transpose and matrix multiplication operations for these calculations. As a result, the backward pass also incurs their allocations and copies. When is shared across a batch, the contributions from different batch elements are reduced to a single gradient with the shape of .
The same reduction is necessary for a broadcast bias. Suppose a projection adds one bias vector to every token in a batch. Increasing its th entry changes the th feature at every one of those positions, so the derivative includes all of them:
reduce_gradient sums over the dimensions introduced by broadcasting. Without that sum, the backward result would still have separate entries for each use of the parameter.
For softmax, the probability of one element depends on every score in its row through the denominator. After differentiating and collecting terms, the gradient is
The sum can be evaluated once per row and reused for every element. My backward kernel assumes softmax ran along the final axis, as it does in attention. The forward API accepts other axes too; supporting them in backward requires recording the selected axis alongside the operation.
Backward must add contributions whenever a tensor is used more than once. If and the gradient arriving at is 1, each use of contributes 1. The addition routine calls backward on twice, leaving 2 in its gradient buffer.
On that second call, only the newly received contribution should continue to 's parents. Sending the accumulated value of 2 would propagate the first contribution again. The implementation therefore keeps accumulation and propagation separate: it stores the total gradient, then passes the incoming contribution to the local derivative routine.
This recursive traversal can evaluate the derivative of a shared ancestor repeatedly. A traversal in reverse topological order would allow each node to collect all its contributions before running its derivative once. That is one change I would make before using the library with larger, heavily branched graphs.
The graph also determines when memory can be released. A result holds shared_ptr references to its parents, keeping their forward values alive until the result is released. Tensor::create establishes shared ownership before an operation calls shared_from_this() to obtain a reference to itself. Parameters remain alive through a separate static registry populated by the factory. Moving that registry into the model would give each model control over its parameter lifetime.
For matrix multiplication, I assign each task a range of output elements. A task computes the full dot product for each element in its range, so workers never need to combine partial results or write to the same output location. The operation submits these tasks to a persistent thread pool and waits for the batch to complete.
This introduces synchronization at every operation boundary. On small tensors, the queue, worker notification, and completion barrier may account for a substantial part of the work. Increasing the worker count can only help when the tasks contain enough computation to cover that overhead.
The matrix multiplication loop also has poor locality in one operand: it reads across a row of the left matrix and down a column of the right. Column reads are separated by the row width in memory. Goto and van de Geijn describe how packing operands and blocking matrix multiplication improves reuse through the memory hierarchy. 5 Those changes concern the loop within each task, so they remain relevant even after the computation has been divided among workers.
Attention has a further storage cost. My implementation allocates the entire score matrix and another tensor for its probabilities. In self-attention, each contains floats. At long sequence lengths, avoiding those intermediates becomes as important as speeding up individual matrix products. FlashAttention addresses this with tiled exact attention designed around GPU memory transfers. 6 A blocked CPU implementation would need its own measurements to establish the benefit on the hardware used here.