Skip to content

← Flax NNX step 90 of 100

Hard Primitives

NNX Distributed Gradient Reduction Simulation

Jump to the task

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.

  1. Cast seed and num_devices to Python integers. Keep lr usable as an Optax scalar rather than converting a traced value to float.
  2. Construct nnx.Linear(D_in, D_out, rngs=nnx.Rngs(seed)) using default initialization and an enabled bias. Construct nnx.Optimizer(model, optax.sgd(lr), wrt=nnx.Param) once.
  3. For each contiguous axis-0 slice of length N // num_devices, use matching x and y slices and call nnx.value_and_grad(loss_fn)(model, x_shard, y_shard).
  4. Use one tree_map to sum the gradient trees, then a second to divide each leaf by num_devices.
  5. Call optimizer.update(model, average_gradients) once.
  6. 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…