The Definitive Reference: Matrix Operations, Complexity, Attention's $O(n^2)$ Wall, and Sparse Formats
A single-source technical reference for Data Scientists, ML Engineers, AI researchers, and LLM practitioners.
Table of Contentsโ
- Matrix Operations
- Why Attention is $O(n^2)$
- Sparse Matrix Formats
- Synthesis & Connections
- Common Misconceptions
- Quick-Reference Cheat Sheet
1. Matrix Operationsโ
Matrices are the atomic data structure of numerical computing. Every dense neural network layer, every attention head, and every embedding lookup reduces to matrix arithmetic. Understanding both the mechanics and the cost of these operations is the foundation for reasoning about model performance.
Notation used throughout:
- $A \in \mathbb{R}^{m \times k}$ โ a matrix with $m$ rows and $k$ columns.
- $a_{ij}$ โ the entry in row $i$, column $j$.
- $A^{\top}$ โ transpose. $A^{-1}$ โ inverse.
- We use $n$ as the shorthand square-matrix dimension when analyzing worst-case complexity ($m = k = n$).
1.1 Types of Operationsโ
1.1.1 Matrix Multiplication (Naive vs. Optimized)โ
Conceptual definition. Matrix multiplication combines two matrices by taking dot products between the rows of the first and the columns of the second. It is the workhorse of linear algebra: a linear layer $y = Wx$, a change of basis, and a batched projection are all matrix multiplications.
Formal notation. For $A \in \mathbb{R}^{m \times k}$ and $B \in \mathbb{R}^{k \times n}$, the product $C = AB \in \mathbb{R}^{m \times n}$ is defined element-wise as:
$$ c_{ij} = \sum_{p=1}^{k} a_{ip}, b_{pj} $$
The inner dimension $k$ must match: the number of columns of $A$ equals the number of rows of $B$. Matrix multiplication is associative ($(AB)C = A(BC)$) and distributive, but not commutative ($AB \neq BA$ in general).
Worked example. Multiply a $2\times 3$ by a $3\times 2$:
$$ A = \begin{bmatrix} 1 & 2 & 3 \ 4 & 5 & 6 \end{bmatrix}, \quad B = \begin{bmatrix} 7 & 8 \ 9 & 10 \ 11 & 12 \end{bmatrix} $$
Compute $c_{11}$ (row 1 of $A$ ยท column 1 of $B$):
$$ c_{11} = (1)(7) + (2)(9) + (3)(11) = 7 + 18 + 33 = 58 $$
Completing all four entries:
$$ C = \begin{bmatrix} 1\cdot7+2\cdot9+3\cdot11 & 1\cdot8+2\cdot10+3\cdot12 \ 4\cdot7+5\cdot9+6\cdot11 & 4\cdot8+5\cdot10+6\cdot12 \end{bmatrix} = \begin{bmatrix} 58 & 64 \ 139 & 154 \end{bmatrix} $$
Analogy โ Matrix Multiplication as a Spreadsheet: Think of multiplying an $(m\times k)$ matrix by a $(k\times n)$ matrix like filling out a results spreadsheet with $m$ rows and $n$ columns. Each cell requires $k$ multiply-adds โ so total work $= m \times n \times k$ operations. The "inner dimension" $k$ is the depth of the sum hidden inside every cell.
Complexity analysis (chain of thought).
First we count operations. There are $m \times n$ output cells. Each cell is a dot product of length $k$: $k$ multiplications and $k-1$ additions $\approx 2k$ FLOPs. Then we identify the dominant term. Total $\approx 2 \cdot m \cdot n \cdot k$ FLOPs. For square matrices $m = n = k$, this is $2n^3$. Therefore Big-O is $O(mnk)$, or $O(n^3)$ for the square case. Space: output is $m \times n \Rightarrow O(mn)$, or $O(n^2)$ square (inputs aside).
Code snippet.
import numpy as np
# Naive matrix multiplication โ O(nยณ) time, O(nยฒ) space
def naive_matmul(A, B):
m, k = A.shape
k2, n = B.shape
assert k == k2, "inner dimensions must match"
C = np.zeros((m, n))
for i in range(m): # m iterations
for j in range(n): # n iterations
for p in range(k): # k iterations โ O(mยทnยทk)
C[i, j] += A[i, p] * B[p, j]
return C
# In practice, ALWAYS call the optimized kernel:
C = A @ B # dispatches to BLAS (dgemm/sgemm) โ orders of magnitude faster
๐ก Practitioner Takeaway: Never hand-roll the triple loop in production.
A @ B(NumPy) ortorch.matmuldispatches to BLAS/cuBLAS, which achieves 10โ100ร the throughput of naive Python through cache blocking, SIMD vectorization, and multithreading. The naive loop is a teaching tool, not a tool.
1.1.2 Transposeโ
Conceptual definition. The transpose flips a matrix over its main diagonal, swapping rows and columns.
Formal notation. $(A^{\top}){ij} = a{ji}$. If $A \in \mathbb{R}^{m \times k}$, then $A^{\top} \in \mathbb{R}^{k \times m}$.
Worked example.
$$ A = \begin{bmatrix} 1 & 2 & 3 \ 4 & 5 & 6 \end{bmatrix} \quad\Longrightarrow\quad A^{\top} = \begin{bmatrix} 1 & 4 \ 2 & 5 \ 3 & 6 \end{bmatrix} $$
Analogy โ Transpose as Rotating a Photo: Imagine a grid of sticky notes on a wall. Transposing is relabeling so that "row 3, column 1" becomes "row 1, column 3" โ the content never moves, only how you address it.
Complexity analysis. Logically $O(mn)$ to physically move every element, but a smart library does it in $O(1)$ by just swapping the shape and stride metadata โ no data is copied. This is why A.T in NumPy/PyTorch is instantaneous and returns a view.
Code snippet.
At = A.T # O(1): returns a view, strides swapped, no copy
At_copy = A.T.copy() # O(mn): forces a contiguous physical transpose
๐ก Practitioner Takeaway:
A.Tis free, but a non-contiguous transposed tensor can silently slow down the next operation (kernels prefer contiguous memory). If a downstream op is unexpectedly slow after a transpose, call.contiguous()โ you're paying the $O(mn)$ copy once instead of fighting strided access repeatedly.
1.1.3 Inverseโ
Conceptual definition. The inverse $A^{-1}$ "undoes" $A$: multiplying by it returns the identity. Only square, non-singular matrices are invertible.
Formal notation. $A A^{-1} = A^{-1} A = I$, where $I$ is the identity matrix. Exists iff $\det(A) \neq 0$.
Worked example. For a $2\times 2$, the closed form is:
$$ A = \begin{bmatrix} a & b \ c & d \end{bmatrix}, \quad A^{-1} = \frac{1}{ad - bc}\begin{bmatrix} d & -b \ -c & a \end{bmatrix} $$
For $A = \begin{bmatrix} 4 & 7 \ 2 & 6 \end{bmatrix}$: $\det = 4\cdot6 - 7\cdot2 = 10$, so
$$ A^{-1} = \frac{1}{10}\begin{bmatrix} 6 & -7 \ -2 & 4 \end{bmatrix} = \begin{bmatrix} 0.6 & -0.7 \ -0.2 & 0.4 \end{bmatrix} $$
Analogy โ Inverse as a Reverse Recipe: If $A$ is a recipe that turns ingredients into a cake, $A^{-1}$ is the (usually impossible in cooking, but possible in linear algebra) reverse recipe that recovers the exact ingredients from the cake. If two different ingredient sets yield the same cake (singular matrix), no reverse recipe exists.
Complexity analysis. Computing an inverse via Gaussian elimination / LU is $O(n^3)$ time, $O(n^2)$ space.
๐ก Practitioner Takeaway: Almost never compute an explicit inverse. To solve $Ax = b$, use
np.linalg.solve(A, b)(an LU-based solve), notnp.linalg.inv(A) @ b. Explicit inversion is slower, numerically less stable, and amplifies floating-point error. The inverse is a conceptual object; the solve is the operation you actually want.
1.1.4 Decompositions: LU, QR, SVDโ
Conceptual definition. A decomposition factors a matrix into a product of simpler, structured matrices (triangular, orthogonal, diagonal). Decompositions are the engines behind solving systems, least squares, PCA, and low-rank approximation.
Formal notation & mechanics.
| Decomposition | Factorization | Constraint | Primary Use |
|---|---|---|---|
| LU | $A = LU$ (with pivoting $PA = LU$) | Square | Solving $Ax=b$, determinants |
| QR | $A = QR$, $Q^{\top}Q = I$, $R$ upper-triangular | Any $m \ge n$ | Least squares, orthogonalization |
| SVD | $A = U\Sigma V^{\top}$, $U,V$ orthogonal, $\Sigma$ diagonal | Any | PCA, low-rank approx, pseudo-inverse, rank |
Worked example (SVD intuition). For any $A$, SVD gives singular values $\sigma_1 \ge \sigma_2 \ge \dots \ge 0$ on the diagonal of $\Sigma$. Keeping only the top-$r$ gives the best rank-$r$ approximation (EckartโYoung theorem):
$$ A \approx \sum_{i=1}^{r} \sigma_i, u_i v_i^{\top} $$
If $A$ is $1000 \times 1000$ but $\sigma_{11} \approx 0$, you can store $\approx 10 \times (1000+1000)$ numbers instead of $10^6$ โ the basis of low-rank compression (and LoRA fine-tuning).
Analogy โ SVD as Ranking Ingredients by Flavor Impact: SVD sorts the "directions" of a matrix by how much they matter (singular values = importance). Keeping the top few is like keeping only the ingredients you can actually taste and dropping the trace amounts โ you reconstruct 95% of the dish with 5% of the components.
Complexity analysis. LU, QR, and SVD (thin) are all $O(n^3)$ for square/near-square matrices; SVD has the largest constant. Space is $O(n^2)$.
Code snippet.
import numpy as np
P, L, U = scipy.linalg.lu(A) # A = P @ L @ U
Q, R = np.linalg.qr(A) # A = Q @ R
U, S, Vt = np.linalg.svd(A, full_matrices=False) # A = U @ diag(S) @ Vt
A_rank_r = (U[:, :r] * S[:r]) @ Vt[:r] # best rank-r approximation
๐ก Practitioner Takeaway: LoRA (Low-Rank Adaptation) is SVD's intuition applied to fine-tuning: instead of updating a full $d \times d$ weight matrix ($d^2$ params), you learn two thin matrices $A \in \mathbb{R}^{d\times r}$, $B \in \mathbb{R}^{r\times d}$ with $r \ll d$, reducing trainable params from $d^2$ to $2dr$. SVD tells you why this works: weight updates are often approximately low-rank.
1.1.5 Element-wise (Hadamard) vs. Dot vs. Outer Productโ
These three "products" are constantly confused. They differ in shape rules, output shape, and cost.
Formal notation.
- Hadamard (element-wise): $C = A \odot B$, same shape, $c_{ij} = a_{ij} b_{ij}$.
- Dot product (vectors): $\mathbf{a}\cdot\mathbf{b} = \sum_i a_i b_i$ โ a scalar.
- Outer product: $\mathbf{a}\otimes\mathbf{b} = \mathbf{a}\mathbf{b}^{\top}$, with $(\mathbf{a}\mathbf{b}^{\top})_{ij} = a_i b_j$ โ a matrix.
Worked example. Let $\mathbf{a} = [1, 2, 3]$, $\mathbf{b} = [4, 5, 6]$.
- Hadamard: $\mathbf{a}\odot\mathbf{b} = [1\cdot4,\ 2\cdot5,\ 3\cdot6] = [4, 10, 18]$
- Dot: $\mathbf{a}\cdot\mathbf{b} = 4 + 10 + 18 = 32$ (a scalar)
- Outer: $$ \mathbf{a}\mathbf{b}^{\top} = \begin{bmatrix} 4 & 5 & 6 \ 8 & 10 & 12 \ 12 & 15 & 18 \end{bmatrix} $$
Analogy โ Three Ways to Combine Two Shopping Lists: Hadamard = multiply matching items (applesรapples, breadรbread) โ a new list. Dot = the single grand total after multiplying and summing โ one number. Outer = a full price grid pairing every item on list A with every item on list B โ a matrix.
Complexity analysis.
| Product | Output shape | Time | Space |
|---|---|---|---|
| Hadamard | $m \times n$ | $O(mn)$ | $O(mn)$ |
| Dot (length-$n$ vectors) | scalar | $O(n)$ | $O(1)$ |
| Outer ($m$-vec, $n$-vec) | $m \times n$ | $O(mn)$ | $O(mn)$ |
Code snippet.
A * B # Hadamard (element-wise), shapes must match/broadcast
np.dot(a, b) # or a @ b for 1-D vectors โ scalar
np.outer(a, b) # outer product โ matrix
๐ก Practitioner Takeaway: In transformers, the gating in GLU/SwiGLU FFNs is a Hadamard product ($\text{SiLU}(xW_1) \odot (xW_3)$). Getting the product type wrong is a top-5 source of silent shape bugs โ a
*where you meant@won't always error (broadcasting may "succeed" into garbage), so assert shapes explicitly.
1.1.6 Broadcasting Rules and Complexity Implicationsโ
Conceptual definition. Broadcasting lets operations combine arrays of different shapes by virtually stretching the smaller one โ without physically copying data โ as long as dimensions are compatible.
Formal rules. Align shapes from the trailing (rightmost) dimension. Two dimensions are compatible if they are equal or one of them is 1. A size-1 dimension is stretched to match.
Worked example.
A shape: (4, 3)
b shape: (3,) โ treated as (1, 3) โ stretched to (4, 3)
A + b โ (4, 3) โ (adds b to every row)
A shape: (4, 3)
c shape: (4, 1) โ stretched to (4, 3) โ (adds c to every column)
A shape: (4, 3)
d shape: (2,) โ INCOMPATIBLE (3 โ 2, neither is 1) โ
Analogy โ Broadcasting as a Rubber Stamp: A bias vector is a rubber stamp. Adding it to a matrix "stamps" the same pattern across every row. You don't manufacture $m$ physical copies of the stamp โ you reuse the one stamp $m$ times. That's broadcasting: logical replication, zero (or minimal) memory replication.
Complexity implications.
First, note broadcasting itself allocates no large intermediate for the stretched operand โ strides of 0 are used on broadcast axes. But the result is full-size. Then identify the cost: computing $A + b$ where $A$ is $m\times n$ still touches all $mn$ elements โ $O(mn)$ time and $O(mn)$ output space. Therefore broadcasting saves memory on the operand, not on the result. A naรฏve
np.tile(b, (m,1)) + Awastes $O(mn)$ extra memory materializing the tiled copy; broadcasting avoids that copy but the arithmetic cost is identical.
๐ก Practitioner Takeaway: Broadcasting can hide enormous allocations.
A[:, None] - B[None, :]on two length-$n$ vectors silently creates an $n \times n$ matrix โ the exact pattern that makes pairwise-distance and attention-score code blow up memory. When a one-line broadcast OOMs, look for an accidental $O(n^2)$ intermediate.
1.2 Complexity Analysis of Matrix Opsโ
1.2.1 Naive MatMul: $O(n^3)$โ
Chain-of-thought derivation.
First we count operations for $C = AB$ with all matrices $n\times n$. There are $n^2$ output entries $c_{ij}$. Each $c_{ij} = \sum_{p=1}^{n} a_{ip}b_{pj}$ requires $n$ multiplications and $n-1$ additions. Total multiply-adds $= n^2 \cdot n = n^3$. Counting each MAC as ~2 FLOPs gives $2n^3$. The dominant term as $n \to \infty$ is $n^3$. Therefore time is $O(n^3)$. Output space is $O(n^2)$.
The cubic scaling is why doubling matrix size makes multiplication 8ร slower, and why matrix multiply dominates the FLOP budget of dense networks.
1.2.2 Strassen's Algorithm: $O(n^{2.807})$โ
Key idea. The naive block approach to multiplying two $2\times2$ block matrices uses 8 sub-multiplications. Strassen (1969) algebraically rearranges the computation to use only 7 sub-multiplications (at the cost of more additions), then applies this recursively.
Chain-of-thought on the exponent.
Each level splits an $n\times n$ product into 7 subproblems of size $n/2$, plus $O(n^2)$ additions to combine. The recurrence is $T(n) = 7,T(n/2) + O(n^2)$. By the Master Theorem, $T(n) = \Theta(n^{\log_2 7})$. $\log_2 7 \approx 2.807$. Therefore Strassen is $O(n^{2.807})$ โ asymptotically below $n^3$.
| Method | Sub-multiplications per level | Exponent | Big-O |
|---|---|---|---|
| Naive block | 8 | $\log_2 8 = 3$ | $O(n^3)$ |
| Strassen | 7 | $\log_2 7 \approx 2.807$ | $O(n^{2.807})$ |
| CoppersmithโWinograd family (theoretical) | โ | $\approx 2.37$ | $O(n^{2.37})$ |
๐ก Practitioner Takeaway: Strassen wins asymptotically but has a large constant, worse numerical stability, and irregular memory access that fights cache hierarchies. In practice it only helps for very large matrices, and the sub-2.4 "galactic" algorithms are never used in real code. Hardware BLAS beats Strassen for the sizes ML actually uses because it optimizes the constant factor and memory movement, not the exponent.
1.2.3 BLAS / cuBLAS Optimizationsโ
BLAS (Basic Linear Algebra Subprograms) is the standardized API โ with implementations like OpenBLAS, Intel MKL, and NVIDIA cuBLAS โ that every serious numeric stack calls under the hood. Matrix multiply is GEMM (GEneral Matrix Multiply).
They keep the $O(n^3)$ exponent but crush the constant via:
- Cache blocking / tiling โ partition into sub-blocks that fit in L1/L2 cache and registers, maximizing data reuse.
- SIMD vectorization โ AVX-512 / NEON process 8โ16 floats per instruction.
- Multithreading โ split the output across cores.
- Memory-layout awareness โ pack panels contiguously to avoid strided loads.
- GPU (cuBLAS): thousands of threads + Tensor Cores performing fused $4\times4$ (or larger) matrix multiply-accumulate in a single instruction, especially in fp16/bf16/tf32.
๐ก Practitioner Takeaway: A GEMM at high arithmetic intensity (large, square, contiguous, half-precision) can hit >90% of a GPU's peak FLOPs. The same GEMM shaped as a skinny matrixโvector product (batch size 1 decode) is memory-bound and may hit <10% utilization. Shape and precision, not just FLOP count, decide your speed.
1.2.4 Memory Bandwidth & the Roofline Modelโ
The real bottleneck. Modern accelerators can compute far faster than they can fetch data from HBM/DRAM. Whether an op is compute-bound or memory-bound is captured by the Roofline Model.
Arithmetic intensity $I$ = FLOPs performed per byte of memory traffic:
$$ I = \frac{\text{FLOPs}}{\text{Bytes moved}} \quad \text{(FLOPs/byte)} $$
The achievable performance is capped by:
$$ \text{Attainable FLOP/s} = \min\big(\underbrace{P_{\text{peak}}}{\text{compute roof}},\ \underbrace{I \times BW}{\text{memory roof}}\big) $$
- If $I$ is low (few FLOPs per byte), performance $= I \times BW$ โ you're memory-bound (left of the "ridge point").
- If $I$ is high, performance saturates at $P_{\text{peak}}$ โ compute-bound.
Worked example. GEMM has $I \approx O(n)$ (reuses each element $n$ times) โ compute-bound for large $n$. Element-wise ops, softmax, LayerNorm, and attention's score-matrix read/write have $I \approx O(1)$ โ memory-bound.
Analogy โ Roofline as a Kitchen: Compute is your chef's chopping speed; bandwidth is how fast a runner brings ingredients from the pantry. If the runner is slow (low bandwidth) and each ingredient is used once (low intensity), the chef stands idle โ buying a faster chef (more FLOPs) changes nothing. You must either fetch faster or reuse each ingredient more before sending it back.
๐ก Practitioner Takeaway: This is the mental model behind kernel fusion and FlashAttention. Most transformer wall-clock time in inference is memory-bound, not compute-bound. The winning move is to move less data (fuse ops, keep tiles in SRAM, use lower precision), not to add more FLOPs.
1.3 Operation Complexity Summary Tableโ
| Operation | Shapes | Time | Space (output) | Notes |
|---|---|---|---|---|
| MatMul (naive) | $m\times k$, $k\times n$ | $O(mnk)$; $O(n^3)$ square | $O(mn)$ | BLAS GEMM in practice |
| MatMul (Strassen) | $n\times n$ | $O(n^{2.807})$ | $O(n^2)$ | Large $n$ only |
| Transpose | $m\times n$ | $O(1)$ view / $O(mn)$ copy | $O(1)$ / $O(mn)$ | Strides swapped |
| Inverse | $n\times n$ | $O(n^3)$ | $O(n^2)$ | Prefer solve |
| LU / QR / SVD | $n\times n$ | $O(n^3)$ | $O(n^2)$ | SVD largest constant |
| Hadamard | $m\times n$ | $O(mn)$ | $O(mn)$ | Element-wise |
| Dot (vectors) | $n$, $n$ | $O(n)$ | $O(1)$ | Scalar output |
| Outer | $m$, $n$ | $O(mn)$ | $O(mn)$ | Rank-1 matrix |
| Matrixโvector | $n\times n$, $n$ | $O(n^2)$ | $O(n)$ | Memory-bound |
2. Why Attention is $O(n^2)$โ
The self-attention mechanism is the defining primitive of transformers โ and its quadratic scaling in sequence length $n$ is the single biggest constraint on context length. This section derives the $O(n^2)$ result rigorously and explains what to do about it.
2.1 Mathematical Derivationโ
The definition. For a sequence of $n$ tokens with model dimension $d$ (here $d$ = per-head dimension $d_k$), self-attention computes queries, keys, and values and combines them:
$$ \text{Attention}(Q, K, V) = \text{softmax}!\left(\frac{QK^{\top}}{\sqrt{d}}\right)V $$
where $Q, K, V \in \mathbb{R}^{n \times d}$.
Step-by-step FLOP and memory accounting (chain of thought).
Step 1 โ Compute the score matrix $S = QK^{\top}$. $Q$ is $n \times d$, $K^{\top}$ is $d \times n$. The product $S$ is $n \times n$. Cost: producing $n^2$ entries, each a length-$d$ dot product โ $n^2 \cdot d$ multiply-adds. This is $O(n^2 d)$ time and โ critically โ $O(n^2)$ space to store $S$. This is where the $n^2$ is born: every token attends to every other token, forming an $n \times n$ grid.
Step 2 โ Scale by $1/\sqrt{d}$. Element-wise over $n^2$ entries โ $O(n^2)$ time. (Prevents softmax saturation from large dot products.)
Step 3 โ Row-wise softmax of $S$. Each of the $n$ rows is normalized over $n$ entries โ $O(n^2)$ time, still $O(n^2)$ space (the attention weight matrix $A$).
Step 4 โ Multiply $A V$. $A$ is $n \times n$, $V$ is $n \times d$ โ output $n \times d$. Cost: $n \cdot d$ outputs, each a length-$n$ dot product โ $n^2 \cdot d$ multiply-adds โ $O(n^2 d)$ time.
Step 5 โ Sum the dominant terms. Time $= O(n^2 d) + O(n^2) + O(n^2) + O(n^2 d) = O(n^2 d)$. Space $= O(n^2)$ for the score/weight matrix (plus $O(nd)$ for Q,K,V). Therefore attention is $O(n^2 d)$ time and $O(n^2)$ memory in the sequence length $n$.
The projection cost, for contrast. Forming $Q, K, V$ from the input $X \in \mathbb{R}^{n\times d}$ via weight matrices is $O(n d^2)$ โ linear in $n$. So for long sequences ($n \gg d$), the $O(n^2 d)$ attention core dominates the $O(nd^2)$ projections. That crossover is exactly why long context is hard.
2.2 Intuitive Explanationโ
Analogy โ Attention as a Meeting Where Everyone Talks to Everyone: Imagine $n$ people in a room, and every person must have a one-on-one conversation with every other person to decide how much to "listen" to them. With $n$ people there are $\sim n^2/2$ pairwise conversations. Add one more person and everyone already there needs an extra conversation. Double the room and the number of conversations quadruples. The $n \times n$ attention matrix is that full table of pairwise conversations.
The quadratic cost is not an implementation accident โ it is intrinsic to letting every token directly interact with every other token. Removing $O(n^2)$ means giving up some of that all-pairs interaction (that's what every efficient variant below does).
Code snippet (standard attention โ materializes the $n\times n$ matrix).
import torch, math
def attention(Q, K, V):
# Q, K, V: (n, d)
d = Q.shape[-1]
S = Q @ K.transpose(-2, -1) / math.sqrt(d) # (n, n) โ O(nยฒ) memory born HERE
A = torch.softmax(S, dim=-1) # (n, n) โ O(nยฒ) memory
return A @ V # (n, d) โ O(nยฒยทd) time
2.3 Practical Impact on Long-Context LLMsโ
The memory wall arrives before the compute wall. The attention matrix stores $n^2$ values per head, per layer. At fp32 (4 bytes), a single $n \times n$ score matrix costs:
$$ \text{Bytes} = n^2 \times 4 $$
| Context length $n$ | Single $n\times n$ matrix (fp32) | Interpretation |
|---|---|---|
| 4,096 (4K) | $4096^2 \times 4 \approx 67$ MB | Manageable per head |
| 32,768 (32K) | $32768^2 \times 4 \approx 4.3$ GB | One matrix โ a whole GPU's spare memory |
| 131,072 (128K) | $\approx 68$ GB | Exceeds a single 80 GB GPU |
| 1,048,576 (1M) | $\approx 4.4$ TB | Physically impossible to materialize |
Multiply by number of heads and layers and it is clear: you cannot naively store the attention matrix at long context. The compute ($O(n^2 d)$) is also brutal, but memory is what kills you first.
๐ก Practitioner Takeaway: At $n=32\text{K}$, a single fp32 attention matrix is ~4.3 GB โ before you count 32 layers ร 32 heads. This is precisely why FlashAttention (which never materializes the full $n\times n$ matrix) is non-negotiable for long-context models. The quadratic term is a memory problem first and a FLOP problem second.
2.4 Solutions & Variantsโ
All efficient-attention approaches attack the $O(n^2)$ term. They fall into two philosophies: compute the same thing more cleverly (exact) or compute an approximation (sparse/linear).
| Method | Core idea | Time | Memory | Exact? |
|---|---|---|---|---|
| FlashAttention | Tile Q,K,V into SRAM blocks; online-softmax; never store full $n\times n$ | $O(n^2 d)$ | $O(n)$ | โ Exact |
| Sliding Window (Longformer, Mistral) | Each token attends to $w$ local neighbors only | $O(n w)$ | $O(n w)$ | โ Approx |
| Sparse Attention (GPT-3, BigBird) | Fixed sparse pattern: local + strided + global tokens | $O(n\sqrt{n})$ | $O(n\sqrt{n})$ | โ Approx |
| Linear Attention (Performer, Linformer) | Kernel/low-rank factorization avoids forming $QK^\top$ | $O(n d^2)$ / $O(n d)$ | $O(n d)$ | โ Approx |
FlashAttention โ the most important one. It is mathematically identical to standard attention (same output, bit-for-bit up to reordering) but reorganizes computation: it splits Q, K, V into blocks, keeps them in fast on-chip SRAM, and uses the online softmax trick to accumulate results block-by-block without ever writing the full $n\times n$ matrix to HBM. This turns attention from memory-bound into compute-bound, delivering large speedups and reducing memory from $O(n^2)$ to $O(n)$.
Analogy โ FlashAttention as Streaming vs. Downloading: Standard attention downloads the entire movie (the $n\times n$ matrix) to disk before watching. FlashAttention streams it in chunks that fit in a small buffer, watching and discarding as it goes โ you never need disk space for the whole file, and it's faster because the buffer (SRAM) is right next to the player (compute cores).
# FlashAttention: same math, O(n) memory โ never materializes the (n, n) matrix
from torch.nn.functional import scaled_dot_product_attention as sdpa
out = sdpa(Q, K, V, is_causal=True) # dispatches to a fused Flash kernel on GPU
๐ก Practitioner Takeaway: FlashAttention doesn't change the $O(n^2 d)$ FLOP count โ it changes the memory complexity from $O(n^2)$ to $O(n)$ and the memory traffic dramatically. That's the Roofline lesson in action: the win came from moving less data, not doing less math. For true sub-quadratic scaling you must switch algorithms (sliding window, sparse, or linear), accepting approximation.
3. Sparse Matrix Formatsโ
When most entries of a matrix are zero, storing and multiplying them densely wastes memory and FLOPs. Sparse formats store only the nonzeros plus enough indexing to reconstruct positions. This is the data-structure counterpart to the sparse-attention algorithms above.
3.1 Format Descriptionsโ
3.1.1 Dense vs. Sparse โ When Sparsity Mattersโ
Conceptual definition. A sparse matrix has mostly zeros. Storing it sparsely pays an indexing overhead per nonzero (you must record where each value lives) in exchange for skipping the zeros.
The break-even rule of thumb. Sparse storage pays off when the matrix is roughly >70% zeros (equivalently, density < ~30%). Below that, the index overhead (typically 2โ3ร bytes per stored value) outweighs the savings, and dense + BLAS is faster and smaller.
Chain-of-thought on the threshold.
A dense $n\times n$ matrix stores $n^2$ values. A sparse format storing $\text{nnz}$ nonzeros needs roughly $\text{nnz}$ values plus $\sim!\text{nnz}$ (COO: two indices) to $\sim!(\text{nnz} + n)$ (CSR) index entries. So sparse โ $2\text{-}3 \times \text{nnz}$ storage units. Break-even: $3,\text{nnz} < n^2 \Rightarrow \text{density} = \text{nnz}/n^2 < \tfrac{1}{3}$. Therefore ~30% density (70% zeros) is the practical crossover.
Analogy โ Sparse Storage as an Address Book vs. a Seating Chart: A dense matrix is a full stadium seating chart with a marker in every seat (mostly "empty"). A sparse format is an address book: you only write down the seats that are occupied, plus each occupant's row/seat number so you can find them. If the stadium is nearly full, the address book is longer than the chart โ only worth it when the stadium is mostly empty.
3.1.2 COO (Coordinate Format)โ
Conceptual definition. The simplest format: store three parallel arrays โ row, col, and value โ one triple per nonzero.
Formal representation. For each nonzero $a_{ij} = v$, store $(i, j, v)$.
Worked example. Consider:
$$ M = \begin{bmatrix} 0 & 0 & 3 \ 4 & 0 & 0 \ 0 & 5 & 0 \end{bmatrix} $$
COO representation:
row = [0, 1, 2]
col = [2, 0, 1]
value = [3, 4, 5]
Analogy โ COO as GPS Pins: Each nonzero is a pin dropped on a map with its (latitude, longitude, label). Simple to add pins in any order, but there's no structure telling you which pins are on the same street.
Complexity. Storage $O(\text{nnz})$ (3 arrays of length nnz). Building/appending is $O(1)$ amortized. Random access and matrix-vector multiply are inefficient because entries aren't grouped by row.
Code snippet.
import scipy.sparse as sp
coo = sp.coo_matrix((value, (row, col)), shape=(3, 3))
๐ก Practitioner Takeaway: COO is the construction format: build here (fast, unordered inserts, easy to concatenate), then convert to CSR/CSC for computation. Duplicate $(i,j)$ entries are summed on conversion โ handy for finite-element assembly, dangerous if unexpected.
3.1.3 CSR (Compressed Sparse Row)โ
Conceptual definition. CSR compresses the row information of COO. Instead of storing a row index for every nonzero, it stores pointers marking where each row starts in the value array.
Formal representation. Three arrays:
valuesโ nonzeros, in row-major order (length nnz).col_indicesโ column of each nonzero (length nnz).row_ptrโ length $m+1$;row_ptr[i]is the index invalueswhere row $i$ begins. Row $i$'s nonzeros arevalues[row_ptr[i] : row_ptr[i+1]].
Worked example. Same $M$ as above:
$$ M = \begin{bmatrix} 0 & 0 & 3 \ 4 & 0 & 0 \ 0 & 5 & 0 \end{bmatrix} $$
values = [3, 4, 5]
col_indices = [2, 0, 1]
row_ptr = [0, 1, 2, 3] # row0: [0:1], row1: [1:2], row2: [2:3]
Reading row 1: row_ptr[1]=1, row_ptr[2]=2 โ values[1:2]=[4] at col_indices[1:2]=[0] โ $a_{1,0}=4$. โ
Analogy โ CSR as a Book's Table of Contents:
values+col_indicesare the words on the pages;row_ptris the table of contents telling you "Chapter (row) $i$ starts on page $p$." To read a whole row you jump straight to its start and read until the next row's start โ no scanning.
Complexity. Storage $O(\text{nnz} + m)$. Sparse matrixโvector multiply (SpMV) is $O(\text{nnz})$ and row access is $O(1)$ to locate + $O(\text{nnz in row})$ to read. Row slicing is fast; column slicing and inserting new nonzeros are slow.
Code snippet.
import scipy.sparse as sp
csr = coo.tocsr() # convert once, compute many times
y = csr @ x # SpMV in O(nnz), the hot loop of sparse solvers
๐ก Practitioner Takeaway: CSR is the default compute format for row-oriented ops and the backbone of sparse linear solvers and GNN message passing (
torch.sparse_csr_tensor,scipy). If your workload is "for each row, combine its nonzeros" (which SpMV and graph aggregation are), CSR is almost always the right choice.
3.1.4 CSC (Compressed Sparse Column)โ
Conceptual definition. CSC is CSR's mirror image: compress columns instead of rows. Store nonzeros in column-major order with a col_ptr.
Formal representation. Three arrays:
valuesโ nonzeros in column-major order.row_indicesโ row of each nonzero.col_ptrโ length $n+1$; column $j$'s nonzeros arevalues[col_ptr[j] : col_ptr[j+1]].
Worked example. Same $M$:
$$ M = \begin{bmatrix} 0 & 0 & 3 \ 4 & 0 & 0 \ 0 & 5 & 0 \end{bmatrix} $$
Column-major traversal (col 0: value 4 at row 1; col 1: value 5 at row 2; col 2: value 3 at row 0):
values = [4, 5, 3]
row_indices = [1, 2, 0]
col_ptr = [0, 1, 2, 3] # col0:[0:1], col1:[1:2], col2:[2:3]
Analogy โ CSC as a Rolodex Filed by Column: Same address book, but now filed so that everything in a given column is together. If your question is always "who is in column $j$?", this filing makes it instant โ at the cost of making "who is in row $i$?" slow.
Complexity. Storage $O(\text{nnz} + n)$. Column access is $O(1)$ to locate; column slicing is fast; row slicing is slow. Efficient for $A^{\top}x$ and column-oriented factorizations.
๐ก Practitioner Takeaway: CSC and CSR are transposes of each other's layout:
csr.Tis naturally a CSC view. Choose CSR for row-heavy access and SpMV ($Ax$), CSC for column-heavy access and $A^{\top}x$ or direct solvers (SciPy's sparse LU uses CSC). Picking the wrong one turns fast $O(1)$ slices into slow scans.
3.1.5 Block-Sparseโ
Conceptual definition. Instead of tracking individual scattered nonzeros, block-sparse formats divide the matrix into fixed-size dense blocks (e.g., $16\times16$, $32\times32$) and store only the nonzero blocks. Within each stored block, everything is dense.
Why it exists. Individual-element sparse formats (COO/CSR) have irregular memory access that GPUs and Tensor Cores hate. Block-sparse restores regularity: each block is a small dense GEMM the hardware runs at peak efficiency. It trades a little storage (some zeros inside blocks are kept) for massive throughput gains.
Formal representation. Partition $A$ into $b\times b$ blocks. Store (block_row, block_col) index pairs (like COO/CSR over blocks) plus a dense $b\times b$ tile per nonzero block.
Worked example. An $8\times8$ matrix with $2\times2$ blocks has a $4\times4$ grid of blocks. If only 3 of the 16 blocks are nonzero, you store $3 \times (2\times2) = 12$ values plus 3 block-coordinate pairs โ instead of 64 dense values or scattered element indices.
Analogy โ Block-Sparse as Shipping Full Boxes Only: Rather than mailing individual items with a separate address label on each (per-element sparsity, lots of labeling overhead), you pack items into standard-size boxes and ship only the boxes that contain something. Trucks (GPUs) love uniform boxes โ they stack and move them at full speed. A few boxes carry some padding, but throughput soars.
Complexity. Storage $O(\text{nnz-blocks} \times b^2)$. Block-sparse matmul runs at near-dense GEMM efficiency on the stored blocks, so effective speedup โ (fraction of blocks that are zero). Used in GPT-3's sparse attention, Longformer, and libraries like NVIDIA's cuSPARSE / OpenAI's block-sparse GPU kernels.
Code snippet.
# Conceptual: block-sparse attention stores only "allowed" (query_block, key_block) pairs
# Each stored pair is a dense bรb tile โ runs on Tensor Cores at peak throughput.
# e.g. triton / cuSPARSE block-sparse GEMM, or torch sparse block layouts.
๐ก Practitioner Takeaway: Block-sparse is the format that makes sparsity actually fast on GPUs. Unstructured 50% sparsity often gives zero speedup (irregular access dominates); structured block (or 2:4 semi-structured) sparsity aligned to Tensor Cores delivers real wall-clock wins. When someone says "we pruned to 90% sparse and it's not faster," the fix is almost always structure, not more pruning.
3.2 Comparison Tableโ
| Format | Stored arrays | Storage | Fast at | Slow at | Best use case |
|---|---|---|---|---|---|
| Dense | full grid | $O(n^2)$ | everything (if <70% zeros) | wasted on sparse data | Density > ~30% |
| COO | row, col, value | $O(\text{nnz})$ | construction, incremental build | random access, SpMV | Building / assembling a matrix |
| CSR | values, col_idx, row_ptr | $O(\text{nnz}+m)$ | row access, SpMV $Ax$ | column ops, inserts | Solvers, GNNs, row-wise compute |
| CSC | values, row_idx, col_ptr | $O(\text{nnz}+n)$ | column access, $A^\top x$ | row ops, inserts | Direct/LU solvers, column ops |
| Block-Sparse | block coords + dense tiles | $O(\text{nnz-blocks}\cdot b^2)$ | GPU GEMM, Tensor Cores | irregular fine-grained sparsity | Sparse attention, pruned nets on GPU |
3.3 Practical Usage Guideโ
Decision flow (chain of thought):
- Is the matrix >70% zeros? No โ stay dense, use BLAS. Yes โ continue.
- Am I still building/mutating it? Yes โ use COO (cheap appends), then convert.
- Is my hot operation row-oriented ($Ax$, per-row aggregation)? โ CSR.
- Is it column-oriented ($A^\top x$, LU factorization)? โ CSC.
- Am I on a GPU and want real speedups from sparsity? โ Block-Sparse (or 2:4 structured), aligned to Tensor Cores.
๐ก Practitioner Takeaway: The universal pattern: build in COO โ compute in CSR/CSC โ accelerate on GPU with block-sparse. Match the format to the access pattern of your hottest loop, not to how the data was born. And always benchmark: for moderate sparsity, a well-tuned dense GEMM frequently beats a "clever" sparse kernel.
4. Synthesis & Connectionsโ
How sparse formats solve the $O(n^2)$ attention problem. The chain is direct:
- Standard attention forms a dense $n \times n$ score matrix โ $O(n^2)$ memory and $O(n^2 d)$ compute (ยง2).
- Empirically, that matrix is mostly near-zero: each token meaningfully attends to only a handful of others (local neighbors + a few global anchors). It is a naturally sparse matrix (ยง3.1.1).
- Sparse attention exploits this by only computing a chosen subset of $(query, key)$ pairs โ a fixed sparsity pattern โ reducing cost to $O(n\sqrt n)$ (BigBird/GPT-3) or $O(nw)$ (sliding window).
- To make that sparsity fast on GPUs, the pattern is expressed in block-sparse form (ยง3.1.5): whole $b\times b$ tiles of the attention grid are computed as dense mini-GEMMs on Tensor Cores, skipping the "off" blocks entirely. Longformer and GPT-3 do exactly this.
- Orthogonally, FlashAttention attacks the same $O(n^2)$ memory term without approximation โ by never materializing the matrix (Roofline/tiling logic from ยง1.2.4). Sparse patterns reduce work; FlashAttention reduces memory traffic. Production long-context models combine both (e.g., block-sparse + Flash kernels).
So the three parts of this guide are one story: matrix-multiply complexity (ยง1) explains why attention costs $O(n^2 d)$ (ยง2), and sparse formats (ยง3) โ especially block-sparse โ are the data structures that turn the "reduce the $n^2$" idea into real hardware speedups.
Mental model summary.
Everything in a transformer is a matrix multiply, and matrix multiply is $O(n^3)$ in dimension but bottlenecked by memory movement, not raw FLOPs (Roofline). Attention's $O(n^2)$ arises because it multiplies $Q$ by $K^\top$ to compare every token with every other, materializing an $n\times n$ grid whose memory cost explodes before its compute does. Because that grid is mostly near-zero, we either (a) skip most of it with structured sparse/block-sparse patterns to cut the work sub-quadratically, or (b) stream it exactly with FlashAttention to cut the memory traffic โ and the best systems do both.
5. Common Misconceptionsโ
โ ๏ธ Misconception 1: "FlashAttention makes attention $O(n)$ in compute." Correction. FlashAttention is still $O(n^2 d)$ in FLOPs โ it computes the exact same attention. What it reduces is memory from $O(n^2)$ to $O(n)$ and, crucially, memory traffic to/from HBM. It's a memory-complexity and bandwidth win, not a FLOP win. For genuinely sub-quadratic compute, you need approximate methods (linear/sparse attention).
โ ๏ธ Misconception 2: "More sparsity always means faster." Correction. Unstructured sparsity (random scattered zeros) often yields no speedup on GPUs โ irregular memory access and index overhead dominate, and a dense GEMM on Tensor Cores wins. Speed comes from structure (block-sparse, 2:4 semi-structured) that maps to hardware, not from the zero-count alone. A 90%-sparse unstructured matrix can be slower than its dense version.
โ ๏ธ Misconception 3: "Strassen's algorithm is what makes GPUs fast at matmul." Correction. GPUs and BLAS do not use Strassen. They keep the $O(n^3)$ exponent and win on the constant factor โ cache blocking, SIMD, multithreading, and Tensor Cores. Strassen has poor numerical stability and cache behavior, so it's rarely used for the matrix sizes ML actually runs. Lowering the exponent โ real-world speed.
โ ๏ธ Misconception 4 (bonus): "To solve $Ax=b$, compute $A^{-1}$ then multiply." Correction. Explicit inversion is slower ($O(n^3)$ with a large constant), less numerically stable, and unnecessary. Use a factorization-based solver (
np.linalg.solve, LU/Cholesky). The inverse is a concept; the solve is the operation.
โ ๏ธ Misconception 5 (bonus): "Transpose is an expensive $O(n^2)$ copy." Correction. In NumPy/PyTorch,
A.Tis an $O(1)$ view โ it just swaps shape/stride metadata. It only becomes $O(mn)$ if you force a contiguous copy (.contiguous()/.copy()), which you sometimes need for a downstream kernel.
6. Quick-Reference Cheat Sheetโ
Matrix op complexity ($n\times n$):
| Op | Time | Space |
|---|---|---|
| MatMul (naive/BLAS) | $O(n^3)$ | $O(n^2)$ |
| MatMul (Strassen) | $O(n^{2.807})$ | $O(n^2)$ |
| Transpose (view) | $O(1)$ | $O(1)$ |
| Inverse / LU / QR / SVD | $O(n^3)$ | $O(n^2)$ |
| Matrixโvector | $O(n^2)$ | $O(n)$ |
| Hadamard / add | $O(n^2)$ | $O(n^2)$ |
| Dot product | $O(n)$ | $O(1)$ |
| Outer product | $O(n^2)$ | $O(n^2)$ |
Attention:
- Formula: $\text{softmax}(QK^\top/\sqrt d),V$
- Time $O(n^2 d)$, Memory $O(n^2)$ โ the $n^2$ comes from $QK^\top$ (all-pairs).
- fp32 score matrix bytes $= 4n^2$: 4Kโ67 MB, 32Kโ4.3 GB, 128Kโ68 GB, 1Mโ4.4 TB.
- FlashAttention: exact, $O(n)$ memory (tiling + online softmax).
- Sliding window: $O(nw)$. Sparse/BigBird: $O(n\sqrt n)$. Linear: $O(nd)$โ$O(nd^2)$.
Sparse formats:
| Format | Storage | Best for |
|---|---|---|
| COO | $O(\text{nnz})$ | Building the matrix |
| CSR | $O(\text{nnz}+m)$ | Row ops, SpMV $Ax$ |
| CSC | $O(\text{nnz}+n)$ | Column ops, $A^\top x$, LU |
| Block-Sparse | $O(\text{blocks}\cdot b^2)$ | GPU/Tensor-Core speedups, sparse attention |
Rules of thumb:
- Sparse pays off above ~70% zeros.
- Attention is a memory problem before a compute problem.
- Speed = memory movement + structure, not just FLOP count (Roofline).
- Build in COO โ compute in CSR/CSC โ accelerate with block-sparse.
- Never invert; always solve. Never hand-roll matmul; always call BLAS.
End of reference guide.
Related Guidesโ
Prerequisites: Big-O Notation & Complexity Analysis ยท Python Internals & NumPy Memory
See also: Python Internals & NumPy Memory ยท Tokenization Algorithms
Section: Domain-Specific DSA ยท All guides