Sharded matrices and how to multiply them A new installment in the "How To Scale Your Model" series lays out a theory of sharded matrix multiplication for training large ML models across thousands of accelerators, using named-axis notation to describe how arrays are partitioned across a device mesh. The piece works through a concrete example: an fp32[1024, 4096] array sharded as A[I_XY, J] over a mesh of {'X': 8, 'Y': 2} yields a local shape of fp32[64, 4096], or 1MiB per device, which at H100 memory bandwidth of 3.4e12 bytes per second would take roughly 294ns to load, though overheads make it slower in practice. Part 3 of How To Scale Your Model https://jax-ml.github.io/scaling-book Part 2: TPUs ../tpus | Part 4: Transformer Math ../transformers When we train large ML models, we have to split or "shard" their parameters or inputs across many accelerators. Since LLMs are mostly made up of matrix multiplications, understanding this boils down to understanding how to multiply matrices when they're split across devices. We develop a simple theory of sharded matrix multiplication based on the cost of TPU communication primitives. When we train an LLM on ten thousand TPUs or GPUs, we’re still doing abstractly the same computation as when we’re training on one. The difference is that our arrays don’t fit in the HBM of a single TPU/GPU , so we have to split them. sharding ” or “ partitioning ” our arrays. The art of scaling is figuring out how to shard our models so computation remains efficient. Here’s an example 2D array A sharded across 4 TPUs: Note how the sharded array still has the same global or logical shape as the unsharded array, say 4, 128 , but it also has a device local shape , like 2, 64 , which gives us the actual size in bytes that each TPU is holding in the figure above, each TPU holds ¼ of the total array . Now we’ll generalize this to arbitrary arrays. We use a variant of named-axis notation to describe how the tensor is sharded in blocks across the devices: we assume the existence of a 2D or 3D grid of devices called the device mesh where each axis has been given mesh axis names e.g. X , Y, and Z. We can then specify how the matrix data is laid out across the device mesh by describing how each named dimension of the array is partitioned across the physical mesh axes. We call this assignment a sharding . Example the diagram above : For the above diagram, we have: Mesh devices= 0, 1 , 2, 3 , axis names= 'X', 'Y' , which tells us we have 4 TPUs in a 2x2 grid, with axis names $X$ and $Y$. Taken together, we know that the local shape of the array the size of the shard that an individual device holds is $ \lvert I\rvert / 2, \lvert J\rvert / 2 $, where $\lvert I\rvert$ is the size of A’s first dimension and $\lvert J\rvert$ is the size of A’s second dimension. Pop Quiz 2D sharding across 1 axis : Consider an array fp32 1024, 4096 with sharding $A I {XY}, J $ and mesh {'X': 8, 'Y': 2} . How much data is held by each device? How much time would it take to load this array from HBM on H100s assuming 3.4e12 memory bandwidth per chip ? $A I {XY}, J $ shards the first dimension I along both the X and Y hardware axes. In this example, the local shape is $ \lvert I\rvert / \lvert X\rvert \cdot \lvert Y\rvert , \lvert J\rvert $. For the given example, the global shape is fp32 1024, 4096 , so the local shape is fp32 64, 4096 . Since each GPU has 4 64 4096 = 1MiB bytes, this would take about 1e6 / 3.4e12 = 294ns , although likely significantly more due to various overheads since this is so small. Visualizing these shardings: Let’s try to visualize these shardings by looking at a 2D array of data split over 4 devices: We write the fully-replicated form of the matrix simply as $A I, J $ with no sharding assignment. This means that each device contains a full copy of the entire matrix. We can indicate that one of these dimensions has been partitioned across a mesh axis with a subscript mesh axis. For instance $A I X, J $ would mean that the I logical axis has been partitioned across the X mesh dimension, but that the J dimension is not partitioned, and the blocks remain partially-replicated across the Y mesh axis. $A I X, J Y $ means that the I logical axis has been partitioned across the X mesh axis, and that the J dimension has been partitioned across the Y mesh axis. We illustrate the other possibilities in the figure below: Here $A I {XY}, J $ means that we treat the X and Y mesh axes as a larger flattened dimension and partition the I named axis across all the devices. The order of the multiple mesh-axis subscripts matters, as it specifies the traversal order of the partitioning across the grid. Lastly, note that we cannot have multiple named axes sharded along the same mesh dimension. e.g. $A I X, J X $ is a nonsensical, forbidden sharding. Once a mesh dimension has been used to shard one dimension of an array, it is in a sense “spent”. Pop Quiz: Let A be an array with shape int8 128, 2048 , sharding $A I {XY}, J $, and mesh Mesh {'X': 2, 'Y': 8, 'Z': 2} so 32 devices total . How much memory does A use per device? How much total memory does A use across all devices? Answer: Our array A is sharded over X and Y and replicated over Z, so per device it has shape int8 128 / 2 8 , 2048 = int8 8, 2048 , with size 8 2048 = 16,384 bytes. Because it’s replicated over Z, while within a Z-plane it’s fully sharded over X and Y, there are 2 complete copies of the original array one per Z-plane . So the total size across all devices is: original array size × Z replicas = 128 2048 2 = 512 KiB total. Alternatively, we can verify this as: 32 devices × 16,384 bytes per device = 512 KiB total. So far we’ve avoided talking about code, but now is a good chance for a sneak peek. JAX uses a named sharding syntax that very closely matches the abstract syntax we describe above. We’ll talk more about this in Section 10 ../jax-stuff , but here’s a quick preview. You can play with this in a Google Colab here https://colab.research.google.com/drive/15cxw66eABwZPG-V4QFmbLfiykPFf gaP?usp=sharing and profile the result to see how JAX handles different shardings. This snippet does 3 things: python import jax import jax.numpy as jnp Create our mesh We're running on a TPU v2-8 4x2 slice with names 'X' and 'Y'. The Auto axis type tells JAX to let the XLA compiler infer intermediate shardings. assert len jax.devices == 8 Auto = jax.sharding.AxisType.Auto mesh = jax.make mesh axis sizes= 4, 2 , axis names= 'X', 'Y' , axis types= Auto, Auto A little utility function to help define our sharding. A PartitionSpec is our sharding a mapping from axes to names . def P args : return jax.NamedSharding mesh, jax.sharding.PartitionSpec args We shard both A and B over the non-contracting dimension and A over the contracting dim. A = jnp.zeros 8, 2048 , dtype=jnp.bfloat16, device=P 'X', 'Y' B = jnp.zeros 2048, 8192 , dtype=jnp.bfloat16, device=P None, 'Y' We can perform a matmul on these sharded arrays out shardings tells us how we want the output to be sharded. JAX/XLA handles the rest of the sharding for us. y = jax.jit lambda A, B: jnp.einsum 'BD,DF- BF', A, B , out shardings=P 'X', 'Y' A, B The cool thing about JAX is that these arrays behave as if they’re unsharded B.shape will tell us the global or logical shape 2048, 8192 . We have to actually look at B.addressable shards to see how it’s locally sharded. We can perform operations on these arrays and JAX will attempt to figure out how to broadcast or reshape them to perform the operations. For instance, in the above example, the local shape of A is 2, 1024 and for B is 2048, 4096 . JAX/XLA will automatically add communication across these arrays as necessary to perform the final multiplication. If you have an array of data that’s distributed across many devices and wish to perform mathematical operations on it, what are the overheads associated with sharding both the data and the computation? Obviously, this depends on the computation involved. The rest of this section will deal with how to multiply sharded matrices. To a first approximation, this involves moving chunks of a matrix around so you can fully multiply or sum each chunk. Each sharding will involve different communication. For example, $A I X, J \cdot B J, K Y \to C I X, K Y $ can be multiplied without any communication because the contracting dimension J, the one we’re actually summing over is unsharded. However, if we wanted the output unsharded i.e. $A I X, J \cdot B J, K Y \to C I, K $ , we would either need to copy $A$ and $B$ or $C$ to every device using an AllGather . These two choices have different communication costs, so we need to calculate this cost and pick the lowest one. To understand this, it can be helpful to recall the concept of a “block matrix”, or a nested matrix of matrices: $$ \begin{equation} \begin{pmatrix} a {00} & a {01} & a {02} & a {03} \\ a {10} & a {11} & a {12} & a {13} \\ a {20} & a {21} & a {22} & a {23} \\ a {30} & a {31} & a {32} & a {33} \end{pmatrix} = \left \begin{matrix} \begin{bmatrix} a {00} & a {01} \\ a {10} & a {11} \end{bmatrix} \\ \begin{bmatrix} a {20} & a {21} \\ a {30} & a {31} \end{bmatrix} \end{matrix} \begin{matrix} \begin{bmatrix} a {02} & a {03} \\ a {12} & a {13} \end{bmatrix} \\ \begin{bmatrix} a {22} & a {23} \\ a {32} & a {33} \end{bmatrix} \end{matrix} \right = \begin{pmatrix} \mathbf{A {00}} & \mathbf{A {01}} \\ \mathbf{A {10}} & \mathbf{A {11}} \end{pmatrix} \end{equation} $$ Matrix multiplication has the nice property that when the matrix multiplicands are written in terms of blocks, the product can be written in terms of block matmuls following the standard rule: $$ \begin{equation} \begin{pmatrix} A {00} & A {01} \\ A {10} & A {11} \end{pmatrix} \cdot \begin{pmatrix} B {00} & B {01} \\ B {10} & B {11} \end{pmatrix} = \begin{pmatrix} A {00}B {00} + A {01}B {10} & A {00}B {01} + A {01}B {11} \\ A {10}B {00} + A {11}B {10} & A {10}B {01} + A {11}B {11} \end{pmatrix} \end{equation} $$ What this means is that implementing distributed matrix multiplications reduces down to moving these sharded blocks over the network, performing local matrix multiplications on the blocks, and summing their results. The question then is what communication to add, and how expensive it is. Conveniently, we can boil down all possible shardings into roughly 4 cases we need to consider, each of which has a rule for what communication we need to add You can think of these as rules that simply need to be followed, but it’s also valuable to understand why these rules hold and how expensive they are. We’ll go through each one of these in detail now. Lemma: when multiplying sharded matrices, the computation is valid and the output follows the sharding of the inputs unless the contracting dimension is sharded or both matrices are sharded along the same axis. For example, this works fine with no communication whatsoever, and results in a tensor sharded across both the X and Y hardware dimensions. Try to think about why this is. Basically, the computation is independent of the sharding, since each batch entry has some local chunk of the axis being contracted that it can multiply and reduce. Any of these cases work fine and follow this rule: Because neither A nor B has a sharded contracting dimension J , we can simply perform the local block matrix multiplies of the inputs and the results will already be sharded according to the desired output shardings. When both multiplicands have non-contracting dimensions sharded along the same axis, this is no longer true see the invalid shardings case-4-both-multiplicands-have-a-non-contracting-dimension-sharded-along-the-same-axis section for details . Let’s consider what to do when one input A is sharded along the contracting J dimension and B is fully replicated: We cannot simply multiply the local chunks of A and B because we need to sum over the full contracting dimension of A , which is split across the X axis. Typically, we first “ AllGather ” the shards of A so every device has a full copy, and only then multiply against B: This way the actual multiplication can be done fully on each device. Takeaway: When multiplying matrices where one of the matrices is sharded along the contracting dimension, we generally AllGather it first so the contraction is no longer sharded, then do a local matmul. Note that when B is not also sharded along X, we could also do the local partial matmul and then sum or AllReduce the sharded partial sums, which lets us shard the compute but usually has a higher communication cost. This can be faster in some cases, although it’s usually true in practice that B will be sharded. Question 4 below some-problems-to-work works through when this is better. What is an AllGather? An AllGather is the first core MPI https://en.wikipedia.org/wiki/Message Passing Interface communication primitive we will discuss. An AllGather removes the sharding along an axis and reassembles the shards spread across devices onto each device along that axis. Using the notation above, an AllGather removes a subscript from a set of axes, e.g. We don’t have to remove all subscripts for a given dimension, e.g. $A I {XY}, J \rightarrow A I Y, J $ is also an AllGather, just over only a single axis. Also note that we may also wish to use an AllGather to remove non-contracting dimension sharding, for instance in the matrix multiply: We could either AllGather A initially to remove the input sharding, or we can do the sharded matmul and then AllGather the result C . How is an AllGather actually performed? To perform a 1-dimensional AllGather around a single TPU axis a ring , we basically have each TPU pass its shard around a ring until every device has a copy. We can either do an AllGather in one direction or both directions two directions are shown above . If we do one direction, each TPU sends chunks of size $\text{bytes} / N$ over $N - 1$ hops around the ring. If we do two directions, we have $\lfloor \frac{N}{2} \rfloor$ hops of size $2 \cdot \text{bytes} / N$. How long does this take? Let’s take the bidirectional AllGather and calculate how long it takes. Let $V$ be the number of bytes in the array, and $X$ be the number of shards on the contracting dimension. Then from the above diagram, each hop sends $V / \lvert X\rvert$ bytes in each direction, so each hop takes where $W \text{ici}$ is the bidirectional ICI bandwidth. Note that this doesn’t depend on $X$ That’s kind of striking, because it means even though our TPUs are only locally connected, the locality of the connections doesn’t matter. We’re just bottlenecked by the speed of each link. Takeaway: when performing an AllGather or a ReduceScatter or AllReduce in a throughput-bound regime, the actual communication time depends only on the size of the array and the available bandwidth, not the number of devices over which our array is sharded A note on ICI latency: Each hop over an ICI link has some intrinsic overhead regardless of the data volume. This is typically around 1us. This means when our array $A$ is very small and each hop takes less than 1us, we can enter a “latency-bound” regime where the calculation does depend on $X$. Let $T \text{min}$ be the minimum time for a single hop. Then $$ T {hop} = \max \left T {min}, \frac{2 \cdot V}{X \cdot W \text{ici}} \right $$ $$ T {total} = \max \left \frac{T {min} \cdot X}{2}, \frac{V}{W \text{ici}} \right $$ since we perform $X / 2$ hops. For large reductions or gathers, we’re solidly bandwidth bound. We’re sending so much data that the overhead of each hop is essentially negligible. But for small arrays e.g. when sampling from a model , this isn’t negligible, and the ICI bandwidth isn’t relevant. We’re bound purely by latency. Another way to put this is that given a particular TPU, e.g. TPU v5e with 4.5e10 unidirectional ICI bandwidth, sending any buffer under 4.5e10 1e-6 = 45kB will be latency bound. Here is an empirical measurement of AllGather bandwidth on a TPU v5e 8x16 slice. The array is sharded across the 16 axis so it has a full bidirectional ring. Note that we not only achieve about 95% of the peak claimed bandwidth 4.5e10 but also that we achieve this peak at about 10MB, which when 16-way sharded gives us about 625kB per device aside : this is much better than GPUs . What happens when we AllGather over multiple axes? When we gather over multiple axes, we have multiple dimensions of ICI over which to perform the gather. For instance, AllGather