@@ -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
0 commit comments