Skip to content

Commit 87331f6

Browse files
Merge pull request AI-Hypercomputer#4255 from AI-Hypercomputer:fix/nnx-linen-parity-gaps
PiperOrigin-RevId: 938838397
2 parents c666fb8 + 9ce8edd commit 87331f6

15 files changed

Lines changed: 659 additions & 65 deletions

File tree

src/maxtext/common/metric_logger.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,23 @@ def _prepare_metrics_for_json(metrics, step, run_name):
6464

6565

6666
def record_activation_metrics(output_metrics, intermediate_outputs, config):
67-
"""Adds the activation metrics to the metrics dict"""
67+
"""Adds the activation metrics to the metrics dict.
6868
69-
if config.scan_layers:
70-
metrics_dict = intermediate_outputs["intermediates"]["decoder"]["decoder"]
71-
72-
for layer_num in range(config.num_decoder_layers):
73-
output_metrics["scalar"][f"activ_fraction_zero/layer_{layer_num:03d}"] = metrics_dict["activation_fraction_zero"][
74-
0
75-
][layer_num]
76-
output_metrics["scalar"][f"activ_mean/layer_{layer_num:03d}"] = metrics_dict["activation_mean"][0][layer_num]
77-
output_metrics["scalar"][f"activ_stdev/layer_{layer_num:03d}"] = metrics_dict["activation_stdev"][0][layer_num]
78-
else:
69+
Collects each metric by path suffix rather than a hardcoded path, so it works for
70+
both the Linen ("intermediates"-prefixed) and NNX (model-rooted) layouts and for
71+
both scanned (one stacked leaf) and unscanned (one leaf per layer) decoders.
72+
"""
73+
for label, key in (
74+
("activ_fraction_zero", "activation_fraction_zero"),
75+
("activ_mean", "activation_mean"),
76+
("activ_stdev", "activation_stdev"),
77+
):
78+
vals = maxtext_utils.collect_intermediates_by_suffix(intermediate_outputs, key)
79+
if not vals:
80+
continue
81+
per_layer = jax.numpy.concatenate(vals)
7982
for layer_num in range(config.num_decoder_layers):
80-
layer = intermediate_outputs["intermediates"]["decoder"][f"layers_{layer_num}"]
81-
output_metrics["scalar"][f"activ_fraction_zero/layer_{layer_num:03d}"] = layer["activation_fraction_zero"][0]
82-
output_metrics["scalar"][f"activ_mean/layer_{layer_num:03d}"] = layer["activation_mean"][0]
83-
output_metrics["scalar"][f"activ_stdev/layer_{layer_num:03d}"] = layer["activation_stdev"][0]
83+
output_metrics["scalar"][f"{label}/layer_{layer_num:03d}"] = per_layer[layer_num]
8484

8585

8686
class MetadataKey(enum.Enum):

src/maxtext/common/train_state_nnx.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,20 @@ def __init__(
4040
self.model = model
4141
self.optimizer = optimizer
4242

43-
def apply_gradients(self, grads: Any):
43+
def apply_gradients(self, grads: Any, **kwargs):
4444
"""Mimics the Linen apply_gradients function.
4545
4646
Updates the optimizer state, applies updates to parameters, and increments
47-
the step counter. Only updates `self.model`.
47+
the step counter. Only updates `self.model`. Extra kwargs (e.g. loss/grad_norm
48+
for the skip-step-on-spikes optimizer) are forwarded to the optax update.
4849
"""
4950
if self.optimizer is None:
5051
raise RuntimeError(
5152
"Cannot call apply_gradients on a TrainStateNNX initialized without"
5253
" an optimizer. This usually happens when the state was created for"
5354
" inference only."
5455
)
55-
self.optimizer.update(self.model, grads)
56+
self.optimizer.update(self.model, grads, **kwargs)
5657

5758

5859
# On-disk checkpoint format.

src/maxtext/configs/types.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2082,6 +2082,16 @@ class RLHardware(BaseModel):
20822082
description="Tensor parallelism per replica for rollout. If not specified, it will be auto-determined.",
20832083
)
20842084
rollout_expert_parallelism: int = Field(1, description="Expert parallelism per replica for rollout")
2085+
inference_replicas: int = Field(1, description="Legacy experimental GRPO: number of inference (sampler) replicas.")
2086+
inference_devices_per_replica: int = Field(
2087+
4, description="Legacy experimental GRPO: devices per inference replica (single-controller device split)."
2088+
)
2089+
inference_rollouts: int = Field(
2090+
1, description="Legacy experimental GRPO: refresh rollouts every N steps (step % inference_rollouts)."
2091+
)
2092+
use_pathways_reshard: bool = Field(
2093+
True, description="Legacy experimental GRPO: use Pathways resharding to move policy params to the sampler."
2094+
)
20852095

20862096

20872097
class VLLM(BaseModel):
@@ -2700,6 +2710,14 @@ def validate_and_set_hlo_dump_defaults():
27002710
if not self.enable_nnx:
27012711
raise ValueError("a value of self.distill_beta > 0.0 requires self.enable_nnx = True")
27022712

2713+
if self.pure_nnx and not self.pure_nnx_decoder and self.use_qwix_quantization and not self.use_batch_split_schedule:
2714+
if self.quantization:
2715+
raise ValueError(
2716+
f"quantization='{self.quantization}' with use_qwix_quantization=True under pure_nnx=True requires "
2717+
"pure_nnx_decoder=True. The bridged Linen decoder (pure_nnx_decoder=False) is invisible to Qwix, "
2718+
"so quantization (and weight sparsity) would silently have no effect. Set pure_nnx_decoder=True."
2719+
)
2720+
27032721
# Validate distillation schedule parameters
27042722
if self.distill_alpha_end is not None and not 0.0 <= self.distill_alpha_end <= 1.0:
27052723
raise ValueError(f"distill_alpha_end must be in [0, 1], got {self.distill_alpha_end}")

src/maxtext/experimental/rl/grpo_trainer.py

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -471,23 +471,15 @@ def _train_step_nnx(model_graphdef, config, state_mesh_shardings, state, data):
471471
Args:
472472
model_graphdef: NNX `GraphDef` of the `TrainStateNNX`.
473473
config: Training configuration object.
474-
state_mesh_shardings: Sharding spec for the train state. Unused on this
475-
path; kept for signature parity with `train_step`.
474+
state_mesh_shardings: Sharding spec for the train state; used to move the
475+
optimizer state to device when `optimizer_memory_host_offload` is set.
476476
state: Flat `nnx.State` matching `model_graphdef`.
477477
data: A batch dict produced by the GRPO input pipeline.
478478
479479
Returns:
480480
A tuple `(new_state, metrics)`. `new_state` is filtered to exclude
481481
`nnx.Intermediate`. `metrics` is a dict shaped like the Linen path's.
482482
"""
483-
del state_mesh_shardings # Host-offload paths are not yet wired up here.
484-
485-
if config.gradient_accumulation_steps > 1:
486-
raise NotImplementedError(
487-
"GRPO + pure_nnx + gradient_accumulation_steps>1 not supported yet. "
488-
"Set gradient_accumulation_steps=1 or pure_nnx=False."
489-
)
490-
491483
state = nnx.merge(model_graphdef, state) # Reconstruct the TrainStateNNX.
492484
policy_graphdef, curr_params, rest = nnx.split(state.model, nnx.Param, ...)
493485
# Split the reference model into (graphdef, state) so we pass `ref_state` as
@@ -505,13 +497,61 @@ def diff_wrapper(param, rest, ref_state, config, data):
505497
return loss, (aux, new_rest)
506498

507499
grad_func = jax.value_and_grad(diff_wrapper, argnums=0, has_aux=True)
508-
(loss, (aux, new_rest)), raw_grads = grad_func(curr_params, rest, ref_state, config, data)
500+
501+
if config.gradient_accumulation_steps <= 1:
502+
(loss, (aux, new_rest)), raw_grads = grad_func(curr_params, rest, ref_state, config, data)
503+
else:
504+
# Mirror the pre-train NNX gradient-accumulation loop and the Linen GRPO one:
505+
# params stay fixed across microbatches while the non-param state (rest, e.g.
506+
# RNGs) advances in the scan carry. Grads are accumulated weighted by each
507+
# microbatch's total_weights, then normalized once after the scan.
508+
def reshape_to_microbatch_accumulations(batch_arr):
509+
microbatches = config.gradient_accumulation_steps
510+
microbatch_shape = (microbatches, batch_arr.shape[0] // microbatches) + batch_arr.shape[1:]
511+
return jnp.reshape(batch_arr, microbatch_shape)
512+
513+
ga_data = jax.tree_util.tree_map(reshape_to_microbatch_accumulations, data)
514+
515+
def accumulate_gradient(carry, microbatch):
516+
(_, (aux, new_rest)), cur_grad = grad_func(curr_params, carry["rest"], ref_state, config, microbatch)
517+
carry["loss"] += aux.total_loss
518+
carry["grad"] = jax.tree_util.tree_map(lambda x, y: x * aux.total_weights + y, cur_grad, carry["grad"])
519+
carry["total_weights"] += aux.total_weights
520+
carry["rest"] = new_rest
521+
return carry, aux
522+
523+
init_carry = {
524+
"loss": 0.0,
525+
"grad": jax.tree_util.tree_map(jnp.zeros_like, curr_params),
526+
"total_weights": 0.0,
527+
"rest": rest,
528+
}
529+
carry, aux = jax.lax.scan(accumulate_gradient, init_carry, ga_data, length=config.gradient_accumulation_steps)
530+
# total_loss is already a per-batch mean (and includes moe_lb), so the full-batch
531+
# loss is the mean across the equal-sized microbatches.
532+
loss = carry["loss"] / config.gradient_accumulation_steps
533+
raw_grads = jax.tree_util.tree_map(lambda arr: arr / carry["total_weights"], carry["grad"])
534+
aux = jax.tree.map(lambda x: jnp.sum(x, axis=0), aux)
535+
new_rest = carry["rest"]
536+
509537
nnx.update(state.model, new_rest)
510538

511539
if config.gradient_clipping_threshold > 0:
512540
grads = maxtext_utils.apply_gradient_clipping(raw_grads, None, config.gradient_clipping_threshold)
513541
else:
514542
grads = raw_grads
543+
if config.optimizer_memory_host_offload:
544+
# Mirror the pre-train NNX path: move the optimizer state from pinned_host to
545+
# device before the in-place optimizer update. (The Linen GRPO path also casts
546+
# params/reference to bf16 under this flag; NNX host-offload moves the memory
547+
# kind without casting, matching the pre-train NNX convention.)
548+
device_opt_shardings = jax.tree_util.tree_map_with_path(
549+
maxtext_utils_nnx.move_memory_to_device,
550+
state_mesh_shardings.optimizer,
551+
is_leaf=lambda x: isinstance(x, jax.sharding.NamedSharding),
552+
)
553+
opt_state = nnx.state(state.optimizer)
554+
nnx.update(state.optimizer, jax.device_put(opt_state, device_opt_shardings))
515555
state.apply_gradients(grads)
516556
new_state = state
517557

@@ -524,11 +564,14 @@ def diff_wrapper(param, rest, ref_state, config, data):
524564
"learning/completion_length": aux.completion_length,
525565
"learning/moe_lb_loss": aux.moe_lb_loss,
526566
"learning/total_weights": aux.total_weights,
527-
"learning/grad_norm": max_utils.l2norm_pytree(grads),
528-
"learning/raw_grad_norm": max_utils.l2norm_pytree(raw_grads),
529567
}
530-
new_policy_params = nnx.state(new_state.model, nnx.Param)
531-
scalar_metrics["learning/param_norm"] = max_utils.l2norm_pytree(new_policy_params)
568+
# These norms pull host-resident tensors back to device, defeating the offload,
569+
# so skip them when offloading (matches the Linen GRPO path).
570+
if not config.optimizer_memory_host_offload:
571+
scalar_metrics["learning/grad_norm"] = max_utils.l2norm_pytree(grads)
572+
scalar_metrics["learning/raw_grad_norm"] = max_utils.l2norm_pytree(raw_grads)
573+
new_policy_params = nnx.state(new_state.model, nnx.Param)
574+
scalar_metrics["learning/param_norm"] = max_utils.l2norm_pytree(new_policy_params)
532575
metrics = {"scalar": scalar_metrics, "scalars": {}}
533576

534577
return nnx.state(new_state, nnx.Not(nnx.Intermediate)), metrics

src/maxtext/experimental/rl/grpo_utils.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,14 @@ def pathways_reshard_nnx(
244244
245245
Splits the policy `nnx.Param` state out of the training-side TrainStateNNX
246246
model substate, reshards it onto the inference mesh, and pushes the
247-
resharded params into the inference engine. Requires `scan_layers=True`;
248-
the Linen `unscan_train_state_params` helper has no NNX equivalent yet.
247+
resharded params into the inference engine.
248+
249+
Unlike Linen — where the policy is always scanned and must be unrolled via
250+
`unscan_train_state_params` when the inference side is unscanned — the NNX
251+
policy model is built per `config.scan_layers` (scanned: a single stacked
252+
`decoder/layers` subtree; unscanned: per-layer `decoder/layers/{i}`). The
253+
inference-side model is built from the same config, so both layouts already
254+
match and `reshard_pytree` maps them directly without an explicit unscan.
249255
250256
Args:
251257
config: Training configuration object.
@@ -255,10 +261,6 @@ def pathways_reshard_nnx(
255261
because the same shardings are already attached to the params.
256262
destination_shardings_model: Shardings for the inference-side model.
257263
"""
258-
if not config.scan_layers:
259-
raise NotImplementedError(
260-
"GRPO + pure_nnx + scan_layers=False not supported yet. " "Use scan_layers=True or pure_nnx=False."
261-
)
262264
policy_params = nnx.state(policy_state_model, nnx.Param)
263265
source_param_shardings = nnx.state(source_shardings_model, nnx.Param)
264266
dest_param_shardings = nnx.state(destination_shardings_model, nnx.Param)

src/maxtext/inference/maxengine/maxengine.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -399,19 +399,15 @@ def _load_params_nnx(self, params, rng):
399399
# axis metadata but no physical .sharding. Resolve logical to physical here so
400400
# device_put actually reshards instead of being a no-op.
401401
with nn_partitioning.axis_rules(self.config.logical_axis_rules):
402-
target_shardings = sharding.nnx_construct_named_sharding(
403-
params_abs, self._mesh
404-
)
402+
target_shardings = sharding.nnx_construct_named_sharding(params_abs, self._mesh)
405403
params_state = jax.device_put(params, target_shardings)
406404
# We only need a concrete `rest` (RNG vars) for nnx.merge. create_nnx_sharded_model
407405
# builds the model with a jitted out_shardings so params are produced already
408406
# sharded, avoiding a single-device allocation of the full model (an OOM risk for
409407
# large models). self.model is abstract with no .sharding, so pass an explicit one.
410408
_, full_abs = nnx.split(self.model)
411409
with nn_partitioning.axis_rules(self.config.logical_axis_rules):
412-
full_sharding = sharding.nnx_construct_named_sharding(
413-
full_abs, self._mesh
414-
)
410+
full_sharding = sharding.nnx_construct_named_sharding(full_abs, self._mesh)
415411
concrete_model = maxtext_utils_nnx.create_nnx_sharded_model(
416412
self.model, self._create_model_fn, mesh=self._mesh, named_sharding=full_sharding
417413
)
@@ -2047,11 +2043,18 @@ def set_engine_vars_from_base_engine(
20472043
"""Set internal vars from base_engine, which has already loaded the checkpoint and has sharding,
20482044
mesh, and kv cache related vars set.
20492045
"""
2050-
if base_engine.model.quant:
2046+
if not engine.config.pure_nnx and base_engine.model.quant:
2047+
# NNX bakes the quant mode in at construction (via _nnx_quant_mode_str) rather
2048+
# than mutating model.quant.quant_mode, so there's nothing to copy on that path.
20512049
engine.model.quant.quant_mode = base_engine.model.quant.quant_mode
20522050
engine.state_mesh_annotations = base_engine.state_mesh_annotations
20532051
engine.abstract_params = base_engine.abstract_params
2054-
engine.kv_cache_annotations = maxtext_utils.get_kv_cache_annotations(engine.model, engine.config, rng, engine.mesh) # pylint: disable=protected-access
2052+
if engine.config.pure_nnx:
2053+
# Linen's get_kv_cache_annotations calls model.init(); NNX modules have no
2054+
# .init, so use the abstract-model variant (mirrors _load_params_nnx).
2055+
engine.kv_cache_annotations = maxtext_utils.get_kv_cache_annotations_nnx(engine.model_ar, engine.config, engine.mesh)
2056+
else:
2057+
engine.kv_cache_annotations = maxtext_utils.get_kv_cache_annotations(engine.model, engine.config, rng, engine.mesh) # pylint: disable=protected-access
20552058
engine.kv_cache_shardings = jax.tree_util.tree_map(
20562059
lambda x: jax.sharding.NamedSharding(engine.mesh, x),
20572060
engine.kv_cache_annotations, # pylint: disable=protected-access

src/maxtext/layers/nnx_decoders.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1891,12 +1891,12 @@ def pure_layer_fn(graphdef, state_in, y_in, kv_in):
18911891
# for efficiency, as the main model is frozen and the LM loss is not needed.
18921892
elif (
18931893
cfg.use_indexer and cfg.indexer_loss_scaling_factor > 0.0 and not cfg.indexer_sparse_training
1894-
) and self.model_mode == MODEL_MODE_TRAIN:
1894+
) and model_mode == MODEL_MODE_TRAIN:
18951895
logits = None
18961896

18971897
# When vocab tiling is enabled in training mode, full logits won't generate to reduce memory
18981898
# Instead, we keep track on the hidden states, which has smaller size compared to full logits
1899-
elif cfg.num_vocab_tiling > 1 and self.model_mode == MODEL_MODE_TRAIN:
1899+
elif cfg.num_vocab_tiling > 1 and model_mode == MODEL_MODE_TRAIN:
19001900
logits = None
19011901
self.sow(nnx.Intermediate, "hidden_states", hidden_state)
19021902

src/maxtext/layers/nnx_wrappers.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,14 @@ def nnx_attrs_to_linen_vars(nnx_attrs: dict) -> dict:
126126
"""Convert a dict of NNX variables (or variable states) to Linen-style variables."""
127127
linen_structured = {}
128128
for kp, v in nnx.traversals.flatten_mapping(nnx_attrs).items():
129-
if isinstance(v, variablelib.Variable):
130-
col_name = variablelib.variable_name_from_type(v.type)
131-
v = to_linen_var(v)
132-
else:
133-
raise ValueError(f"Cannot infer collection name from value: {v}")
129+
if not isinstance(v, variablelib.Variable):
130+
# Plain (non-Variable) attributes aren't Linen collections, so they have no
131+
# place in the variables dict passed to the wrapped module's apply(). Qwix
132+
# attaches bookkeeping attrs like qwix_path/qwix_rngs/disable_quant_stats_update
133+
# to the module during interception; leave them on the module and skip them here.
134+
continue
135+
col_name = variablelib.variable_name_from_type(v.type)
136+
v = to_linen_var(v)
134137
linen_structured[(col_name, *kp)] = v
135138
variables = nnx.traversals.unflatten_mapping(linen_structured)
136139
return variables

0 commit comments

Comments
 (0)