We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Flax NNX step 90 of 100
NNX Distributed Gradient Reduction Simulation
Why this matters
In data-parallel training, model replicas compute gradients from different parts of a batch. Combining those gradients before an update lets the replicas follow a shared objective. This exercise focuses on the reduction: first sum corresponding gradient arrays, then divide by the number of equally sized shards.
You will implement this with a local loop and one NNX Linear layer. It is a simulation of the gradient arithmetic, not an actual distributed training run. It has no mesh, collective communication, parameter sharding, or optimizer-state sharding.
One update from many shards
Create one model and one SGD optimizer. Every shard must evaluate the same initial kernel and bias. For each slice of the batch, calculate mean squared error and its parameter gradients, saving both without changing the model.
The loss is jnp.mean((model(x) - y) ** 2). For a shard with B
examples and K output features, this divides by B * K. Equal
shard sizes make the mean of these losses equal to the full-batch
loss. The mean of the gradients likewise matches the full-batch
gradient, up to floating-point rounding.
This equivalence depends on the model and loss. The Linear here has no batch-dependent state or randomness in its forward pass. Adding BatchNorm, dropout, uneven shard sizes, or masked examples requires reconsidering statistics, randomness, and weighting.
Sum and divide the gradient tree
An NNX gradient tree contains separate kernel and bias arrays. Sum matching leaves across shards, then divide every summed leaf by the shard count:
summed = tree_map(sum corresponding leaves, shard gradients)
average = tree_map(divide by shard count, summed)
Do not add the kernel gradient to the bias gradient. Do not apply SGD until the reduction is complete. For plain SGD, updating with the sum alone multiplies the intended step by the shard count.
This runnable example shows a two-shard reduction with named arrays:
import jax
import jax.numpy as jnp
import numpy as np
gradients = [
{"kernel": jnp.array([[2., 4.]]), "bias": jnp.array([1., 3.])},
{"kernel": jnp.array([[6., 8.]]), "bias": jnp.array([5., 7.])},
]
summed = jax.tree_util.tree_map(lambda *parts: sum(parts), *gradients)
average = jax.tree_util.tree_map(lambda array: array / 2, summed)
np.testing.assert_allclose(summed["kernel"], [[8., 12.]])
np.testing.assert_allclose(average["kernel"], [[4., 6.]])
np.testing.assert_allclose(average["bias"], [3., 5.])
In a real manual data-parallel program, lax.psum sums over a bound
collective axis and lax.pmean computes its mean. A local Python
sum does not perform that communication. Sum-then-divide describes
the mathematics, not a guarantee about a communication library's
implementation or bitwise reduction order.
Which loss is returned?
Save each shard's loss before the update. After applying SGD, average those saved values and return the result. Computing this average later does not turn it into a post-update loss: the values were measured with the original parameters.
Consequently, changing lr changes the updated weights but not the returned value. The output alone cannot establish that an optimizer update occurred. The task still requires exactly one update of both kernel and bias. A fresh model is constructed for every entrypoint call; updated weights are not returned or reused across calls.
Problem
Implement distributed_train_step(seed, x_batch, y_batch, num_devices, lr).
Inputs have shapes (N, D_in) and (N, D_out), with matching N.
Assume positive dimensions, positive num_devices dividing N, and
a nonnegative scalar learning rate.
- Cast seed and num_devices to Python integers. Keep lr usable as an Optax scalar rather than converting a traced value to float.
- Construct
nnx.Linear(D_in, D_out, rngs=nnx.Rngs(seed))using default initialization and an enabled bias. Constructnnx.Optimizer(model, optax.sgd(lr), wrt=nnx.Param)once. - For each contiguous axis-0 slice of length
N // num_devices, use matching x and y slices and callnnx.value_and_grad(loss_fn)(model, x_shard, y_shard). - Use one tree_map to sum the gradient trees, then a second to divide each leaf by num_devices.
- Call
optimizer.update(model, average_gradients)once. - Average the saved pre-update loss scalars and return that JAX
scalar reshaped to
(1,).
One shard uses the entire batch. N shards use one example each. Both yield the same objective and update within numerical tolerance. Unequal shards would need a weighted mean; padding without masking and corrected normalization would change the objective.
This exercise makes the sum-then-divide steps explicit. It computes the same update as averaging each matching gradient leaf directly. Neither version simulates network traffic, measures speedups, or requires the proposed shard count to match the available devices.
The diagram below illustrates batch partitioning on a separate mesh. The unused model axis replicates the batch shards. It does not show gradient synchronization or this function's actual array placement.
Loading visualization…
Understand the ideas behind this exercise. The Problem tab contains the exact implementation requirements.
Why this matters
In data-parallel training, model replicas compute gradients from different parts of a batch. Combining those gradients before an update lets the replicas follow a shared objective. This exercise focuses on the reduction: first sum corresponding gradient arrays, then divide by the number of equally sized shards.
You will implement this with a local loop and one NNX Linear layer. It is a simulation of the gradient arithmetic, not an actual distributed training run. It has no mesh, collective communication, parameter sharding, or optimizer-state sharding.
One update from many shards
Create one model and one SGD optimizer. Every shard must evaluate the same initial kernel and bias. For each slice of the batch, calculate mean squared error and its parameter gradients, saving both without changing the model.
The loss is jnp.mean((model(x) - y) ** 2). For a shard with B
examples and K output features, this divides by B * K. Equal
shard sizes make the mean of these losses equal to the full-batch
loss. The mean of the gradients likewise matches the full-batch
gradient, up to floating-point rounding.
This equivalence depends on the model and loss. The Linear here has no batch-dependent state or randomness in its forward pass. Adding BatchNorm, dropout, uneven shard sizes, or masked examples requires reconsidering statistics, randomness, and weighting.
Sum and divide the gradient tree
An NNX gradient tree contains separate kernel and bias arrays. Sum matching leaves across shards, then divide every summed leaf by the shard count:
summed = tree_map(sum corresponding leaves, shard gradients)
average = tree_map(divide by shard count, summed)
Do not add the kernel gradient to the bias gradient. Do not apply SGD until the reduction is complete. For plain SGD, updating with the sum alone multiplies the intended step by the shard count.
This runnable example shows a two-shard reduction with named arrays:
import jax
import jax.numpy as jnp
import numpy as np
gradients = [
{"kernel": jnp.array([[2., 4.]]), "bias": jnp.array([1., 3.])},
{"kernel": jnp.array([[6., 8.]]), "bias": jnp.array([5., 7.])},
]
summed = jax.tree_util.tree_map(lambda *parts: sum(parts), *gradients)
average = jax.tree_util.tree_map(lambda array: array / 2, summed)
np.testing.assert_allclose(summed["kernel"], [[8., 12.]])
np.testing.assert_allclose(average["kernel"], [[4., 6.]])
np.testing.assert_allclose(average["bias"], [3., 5.])
In a real manual data-parallel program, lax.psum sums over a bound
collective axis and lax.pmean computes its mean. A local Python
sum does not perform that communication. Sum-then-divide describes
the mathematics, not a guarantee about a communication library's
implementation or bitwise reduction order.
Which loss is returned?
Save each shard's loss before the update. After applying SGD, average those saved values and return the result. Computing this average later does not turn it into a post-update loss: the values were measured with the original parameters.
Consequently, changing lr changes the updated weights but not the returned value. The output alone cannot establish that an optimizer update occurred. The task still requires exactly one update of both kernel and bias. A fresh model is constructed for every entrypoint call; updated weights are not returned or reused across calls.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
JAX reference solution
Sign in to track your attempts toward unlocking the reference solution.