Memory spaces and host offloading#
An accelerator’s device memory is fast but small; the host’s memory is slow
but large. JAX lets an array live in either one: every sharding carries a
memory kind, either "device" (the default) or "pinned_host", and
moving data between the two spaces is just jax.device_put() with a
sharding of the other kind. Host offloading uses this to trade transfer
time for device-memory capacity: park model parameters, activations, or
optimizer state in host memory, and bring them back to the device only when
computation needs them.
Note
The examples on this page are shown with outputs from an accelerator platform; memory-kind support varies by platform, so these snippets are illustrative rather than executed in place. Offloading also costs time in host-device transfers, so measure before committing to it.
Building blocks#
A sharding’s memory_kind says which space its arrays live in, and
with_memory_kind derives a sharding in the other space:
import jax
import jax.numpy as jnp
from jax.sharding import Mesh, NamedSharding, PartitionSpec as P
mesh = Mesh(jax.devices()[:1], 'x')
s_dev = NamedSharding(mesh, P('x'), memory_kind="device")
s_host = s_dev.with_memory_kind('pinned_host')
print(s_dev) # NamedSharding(..., memory_kind=device)
print(s_host) # NamedSharding(..., memory_kind=pinned_host)
jax.device_put() places (or moves) an array into the space a sharding
names:
arr = jnp.arange(8.0).reshape(2, 4)
arr_host = jax.device_put(arr, s_host)
arr_dev = jax.device_put(arr, s_dev)
print(arr_host.sharding.memory_kind) # pinned_host
print(arr_dev.sharding.memory_kind) # device
Compiled functions can consume and produce arrays in either space: jit’s
out_shardings accepts memory kinds, so a jitted function can return its
outputs directly to host memory (device-to-host) or take host-resident
inputs and produce device outputs (host-to-device):
f = jax.jit(lambda x: x, out_shardings=s_host)
out_host = f(arr_dev) # inputs on device, outputs land in host memory
g = jax.jit(lambda x: x + 1, out_shardings=s_dev)
out_dev = g(arr_host) # host-resident input, device-resident result
Inside a jitted function, jax.device_put() moves values between
spaces mid-computation. The offloading patterns below are built on this.
Offloading activations#
Residuals saved for the backward pass are often the dominant memory cost of
training. Rather than keeping them in device memory or recomputing them,
autodiff can offload them: move named residuals to host memory after the
forward pass and fetch them back for the backward pass, using
jax.checkpoint() with the
jax.checkpoint_policies.save_and_offload_only_these_names policy. That
machinery belongs to rematerialization, and is covered with the rest of the
remat story in Offloading instead of recomputing. To give a sense of the effect,
for the 10-layer scanned MLP used as a running example below, offloading the
layer activations cuts temporary memory from 17.25 MB to 6.50 MB.
Offloading parameters#
Model parameters can live in host memory, with each layer fetching its
weights to the device just in time. The pattern: initialize (or load)
parameters into host memory, and apply jax.device_put() inside the
layer function before the weights are used:
from jax.ad_checkpoint import checkpoint_name
from jax import checkpoint_policies as cp
policy = cp.save_and_offload_only_these_names(
names_which_can_be_saved=[],
names_which_can_be_offloaded=["x"],
offload_src="device",
offload_dst="pinned_host",
)
def hybrid_layer(x, w):
# Move this layer's parameters to device memory just in time.
w1, w2 = jax.tree.map(lambda w: jax.device_put(w, s_dev), w)
x = checkpoint_name(x, "x") # offload this activation (see the remat docs)
y = x @ w1
return y @ w2, None
def hybrid_scanned(w, x):
remat_layer = jax.remat(hybrid_layer, policy=policy, prevent_cse=False)
result = jax.lax.scan(remat_layer, x, w)[0]
return jnp.sum(result)
input = jnp.ones((256, 256), dtype=jnp.float32) * 0.001
w1 = jnp.ones((10, 256, 1024), dtype=jnp.float32) * 0.001
w2 = jnp.ones((10, 1024, 256), dtype=jnp.float32) * 0.001
# Parameters live in host memory...
wh1 = jax.device_put(w1, s_host)
wh2 = jax.device_put(w2, s_host)
# ...and the input stays on the device.
f = jax.jit(jax.grad(hybrid_scanned))
result = f((wh1, wh2), input)
For this example, jax.stages.Compiled.memory_analysis() reports (on
TPU):
Temp size: 4.75 MB
Argument size: 0.25 MB
Total size: 25.00 MB
against a no-offloading baseline of 17.25 MB temporary and 20.25 MB argument memory. Three effects combine:
Parameter offloading removes the weights from device argument memory (20.25 MB → 0.25 MB: only the input remains).
Activation offloading cuts temporary memory (17.25 MB → 6.50 MB).
Their interaction saves a bit more (6.50 MB → 4.75 MB): rematerialization keeps JAX from holding on-device copies of the weights alive for the backward pass.
Two limitations. The pattern depends on jax.lax.scan(): with an
explicit Python loop, the parameters would continuously occupy device
memory, giving no saving. And parameter offloading currently works only when
scanning over axis 0. Other axes insert an expensive transpose when
returning parameters to the device, and aren’t supported on all platforms.
Offloading optimizer state#
Optimizer state (like Adam’s moments) is device memory spent on values used
only briefly in each step. The same pattern applies: keep the state in host
memory between steps, move it to the device inside the step, and send the
updated state back to host memory via out_shardings:
import optax
s_dev = jax.sharding.SingleDeviceSharding(jax.devices()[0], memory_kind="device")
s_host = jax.sharding.SingleDeviceSharding(jax.devices()[0], memory_kind="pinned_host")
optimizer = optax.chain(optax.clip_by_global_norm(1.0), optax.adam(learning_rate=0.1))
# (network and loss definitions elided)
def step(params, opt_state, inputs):
grads = jax.grad(lambda p: compute_loss(p, inputs))(params)
opt_state = jax.device_put(opt_state, s_dev) # fetch state to the device
updates, new_opt_state = optimizer.update(grads, opt_state, params)
new_params = optax.apply_updates(params, updates)
return new_params, new_opt_state
params = init_params() # on device
opt_state = optimizer.init(params)
opt_state = jax.device_put(opt_state, s_host) # state lives on the host
step = jax.jit(
step,
donate_argnums=(0,),
out_shardings=(s_dev, s_host), # params to device, state back to host
)
new_params, new_opt_state = step(params, opt_state, input)
For a four-layer 7168×7168 MLP with Adam, memory analysis reports 4.59 GB total without offloading and 2.87 GB with it, a 1.72 GB saving that comes almost entirely from the optimizer state leaving device argument memory. Offloading can add temporary memory (updated state needs device buffers before it’s copied out to the host, and XLA’s latency-hiding scheduling extends buffer live ranges to overlap transfers with compute), but the argument-memory saving typically dominates.
Measuring#
jax.stages.Compiled.memory_analysis(), used throughout this page,
reports a compiled function’s memory breakdown before you run it: sum the
temporary, argument, and output sizes, minus the alias size, for the total.
For runtime measurement, including verifying that transfers overlap with
compute, see the device memory profiling and tracing tools in
Benchmarking and profiling.