Skip to content

Commit 108f993

Browse files
committed
feat(nnx): support native Flax NNX PEFT/LoRA training loop with pre-trained base weight restoration and optimized upfront conversion
1 parent fe5032a commit 108f993

12 files changed

Lines changed: 1311 additions & 371 deletions

File tree

src/maxtext/common/checkpointing.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,10 @@ def _expected_and_restored_params(abstract_nnx_state, restored_linen):
196196
Splits the abstract by Variable type (nnx.Param) so only real weights are compared --
197197
rngs/dropout/batch stats live in `nnx_aux` and are restored separately.
198198
"""
199-
want = nnx.split_state(abstract_nnx_state, nnx.Param, ...)[0].to_pure_dict().get("model", {})
199+
if hasattr(nnx, "LoRAParam") and nnx.state(abstract_nnx_state, nnx.LoRAParam):
200+
want = nnx.split_state(abstract_nnx_state, nnx.LoRAParam, ...)[0].to_pure_dict().get("model", {})
201+
else:
202+
want = nnx.split_state(abstract_nnx_state, nnx.Param, ...)[0].to_pure_dict().get("model", {})
200203
have = restored_linen.get("params", {}).get("params", {})
201204
return want, have
202205

@@ -211,6 +214,7 @@ def _raise_on_weight_mismatch(want, have):
211214
without naming the weight.
212215
"""
213216
problems = _weight_mismatches(want, have)
217+
214218
if not problems:
215219
return
216220
lines = "\n".join(f" - '{p}': {why}" for p, why in problems)
@@ -1086,9 +1090,14 @@ def maybe_save_checkpoint(checkpoint_manager, state, config, data_iterator, step
10861090
step_value = state.step.get_value() if hasattr(state.step, "get_value") else state.step
10871091
state = train_state_nnx.to_linen_checkpoint_dict({"model": state.params, "optimizer": {"step": step_value}})
10881092
else:
1089-
# rngs/dropout/batch-stats are packed under items/nnx_aux so the RNG/dropout
1090-
# stream continues across resumes instead of resetting to a base key.
1091-
state = train_state_nnx.to_checkpoint_dict(state)
1093+
if getattr(getattr(config, "lora", None), "enable_lora", False):
1094+
pure_dict = state.to_pure_dict()
1095+
pure_dict["model"] = nnx.state(state.model, nnx.LoRAParam).to_pure_dict()
1096+
state = train_state_nnx.to_linen_checkpoint_dict(pure_dict)
1097+
else:
1098+
# rngs/dropout/batch-stats are packed under items/nnx_aux so the RNG/dropout
1099+
# stream continues across resumes instead of resetting to a base key.
1100+
state = train_state_nnx.to_checkpoint_dict(state)
10921101

10931102
try:
10941103
checkpoint_saved = save_checkpoint(checkpoint_manager, actual_step, state, config, data_iterator, force_ckpt_save)

src/maxtext/layers/linears.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from maxtext.layers.quantizations import AqtQuantization as Quant
3838
from maxtext.utils import max_logging
3939
from maxtext.utils import max_utils
40+
from maxtext.utils import lora_utils
4041
from maxtext.utils.sharding import maybe_shard_with_logical
4142

4243

@@ -222,8 +223,16 @@ def __call__(self, inputs: Array, _initializing: bool = False, out_sharding: Nam
222223
if quantizations.in_serve_mode(self.quant):
223224
kernel_shape = self.in_features_shape + self.out_features_shape
224225
kernel = jnp.zeros(kernel_shape, dtype=self.dtype)
226+
restored_to_state = False
225227
else:
226-
kernel = self.kernel[...]
228+
kernel_val = self.kernel._raw_value if hasattr(self.kernel, "_raw_value") else self.kernel
229+
restored_to_state = False
230+
if isinstance(kernel_val, nnx.State):
231+
kernel = lora_utils.restore_qlora_base_weights(kernel_val)
232+
self.kernel.value = kernel
233+
restored_to_state = True
234+
else:
235+
kernel = self.kernel[...]
227236
# Move logit_dense kernel to device if parameter offloading is enabled
228237
if self.parameter_memory_host_offload:
229238
max_logging.log("linear.py: Moving parameter logits_dense kernel to device")
@@ -246,6 +255,9 @@ def __call__(self, inputs: Array, _initializing: bool = False, out_sharding: Nam
246255
out_sharding,
247256
)
248257

258+
if restored_to_state:
259+
self.kernel.value = kernel_val
260+
249261
if self.bias is not None:
250262
bias = jnp.asarray(self.bias[...], self.dtype)
251263
output += bias

src/maxtext/trainers/pre_train/train.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -391,9 +391,11 @@ def train_step(model, config, state_mesh_shardings, params_shardings, state, dat
391391
is_train=True,
392392
)
393393
else:
394+
enable_lora = getattr(getattr(config, "lora", None), "enable_lora", False)
395+
wrt = nnx.LoRAParam if enable_lora else nnx.Param
394396
owg_type = variablelib.variable_type_from_name("_overwrite_with_gradient", allow_register=True)
395397
custom_param_filter = nnx.Any(owg_type)
396-
model_graphdef, curr_params, custom_params, rest = nnx.split(state.model, nnx.Param, custom_param_filter, ...)
398+
model_graphdef, curr_params, custom_params, rest = nnx.split(state.model, wrt, custom_param_filter, ...)
397399
if config.parameter_memory_host_offload:
398400
# Params are kept on host (pinned_host) in in_shardings. Move only Param
399401
# variables to device before the forward/backward pass so that all dot_general
@@ -418,7 +420,7 @@ def train_step(model, config, state_mesh_shardings, params_shardings, state, dat
418420
def diff_wrapper(curr_params, custom_params, rest, config, data):
419421
local_model = nnx.merge(model_graphdef, curr_params, custom_params, rest, copy=True)
420422
loss, aux = loss_fn(local_model, config, data, None, None, is_train=True)
421-
_, _, _, new_rest = nnx.split(local_model, nnx.Param, custom_param_filter, ...)
423+
_, _, _, new_rest = nnx.split(local_model, wrt, custom_param_filter, ...)
422424
return loss, (aux, new_rest)
423425

424426
grad_func = jax.value_and_grad(diff_wrapper, argnums=(0, 1), has_aux=True)

src/maxtext/utils/lora_utils.py

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
import jax.numpy as jnp
2929
from orbax import checkpoint as ocp
3030
import qwix
31+
from qwix._src.core.qarray import QArray
32+
from qwix._src.providers.ptq import WithAux
3133

3234
from maxtext.common import checkpointing
3335
from maxtext.configs import pyconfig
@@ -584,7 +586,7 @@ def apply_lora_to_model(
584586
max_logging.log("MaxText LoRA adapters loaded, skipping Qwix LoRA application")
585587
return model
586588

587-
if not mt_config.lora.enable_lora:
589+
if not getattr(getattr(mt_config, "lora", None), "enable_lora", False):
588590
return model
589591

590592
# Dynamically detect and set LoRA rank before model creation if restoring
@@ -607,37 +609,36 @@ def apply_lora_to_model(
607609
)
608610

609611
if mesh is not None:
610-
with jax.set_mesh(mesh), nn_partitioning.axis_rules(mt_config.logical_axis_rules):
611-
graph_def, state = nnx.split(lora_model)
612-
613-
# We handle explicit replication for LoRA to ensure safety and efficiency.
614-
state = jax.tree_util.tree_map(
615-
lambda x: x.replace(sharding=jax.sharding.PartitionSpec(), out_sharding=None, sharding_names=None)
616-
if isinstance(x, nnx.LoRAParam)
617-
else x,
618-
state,
619-
is_leaf=lambda x: isinstance(x, nnx.Variable),
620-
)
612+
graph_def, state = nnx.split(lora_model)
613+
614+
# We handle explicit replication for LoRA to ensure safety and efficiency.
615+
state = jax.tree_util.tree_map(
616+
lambda x: x.replace(sharding=jax.sharding.PartitionSpec(), out_sharding=None, sharding_names=None)
617+
if isinstance(x, nnx.LoRAParam)
618+
else x,
619+
state,
620+
is_leaf=lambda x: isinstance(x, nnx.Variable),
621+
)
621622

622-
# Use logical_to_mesh_sharding to correctly map logical axes like 'embed'
623-
# to physical mesh axes.
624-
dst_shardings = nn.logical_to_mesh_sharding(nnx.get_partition_spec(state), mesh, mt_config.logical_axis_rules)
623+
# Use logical_to_mesh_sharding to correctly map logical axes like 'embed'
624+
# to physical mesh axes.
625+
dst_shardings = nn.logical_to_mesh_sharding(nnx.get_partition_spec(state), mesh, mt_config.logical_axis_rules)
625626

626-
def _safe_reshard(var, sharding_spec):
627-
if not isinstance(var, nnx.Variable) or not isinstance(sharding_spec, jax.sharding.Sharding):
628-
return var
629-
val = var.get_value()
630-
if not isinstance(val, jax.Array):
631-
return var
632-
# make_array_from_callback natively constructs a globally sharded array
633-
# from the local host arrays, bypassing backend-specific device_put issues
634-
# on both Pathways and McJAX.
635-
resharded_val = jax.make_array_from_callback(val.shape, sharding_spec, lambda idx: val[idx])
636-
return var.replace(value=resharded_val)
627+
def _safe_reshard(var, sharding_spec):
628+
if not isinstance(var, nnx.Variable) or not isinstance(sharding_spec, jax.sharding.Sharding):
629+
return var
630+
val = var.get_value()
631+
if not isinstance(val, jax.Array):
632+
return var
633+
# make_array_from_callback natively constructs a globally sharded array
634+
# from the local host arrays, bypassing backend-specific device_put issues
635+
# on both Pathways and McJAX.
636+
resharded_val = jax.make_array_from_callback(val.shape, sharding_spec, lambda idx: val[idx])
637+
return var.replace(value=resharded_val)
637638

638-
state = jax.tree_util.tree_map(_safe_reshard, state, dst_shardings, is_leaf=lambda x: isinstance(x, nnx.Variable))
639+
state = jax.tree_util.tree_map(_safe_reshard, state, dst_shardings, is_leaf=lambda x: isinstance(x, nnx.Variable))
639640

640-
lora_model = nnx.merge(graph_def, state)
641+
lora_model = nnx.merge(graph_def, state)
641642

642643
_verify_lora_parameters(lora_model, mt_config)
643644

@@ -666,7 +667,7 @@ def restore_lora_from_path(model: nnx.Module, mt_config: pyconfig.HyperParameter
666667

667668
if not is_lora_enabled(model):
668669
lora_module_path = _get_lora_module_path(mt_config)
669-
if not mt_config.lora.enable_lora:
670+
if not getattr(getattr(mt_config, "lora", None), "enable_lora", False):
670671
raise ValueError(
671672
"lora_restore_path is set but LoRA is not enabled on the model. "
672673
f"Set lora.enable_lora=True and verify lora_module_path ('{lora_module_path}') matches model modules."
@@ -699,37 +700,54 @@ def restore_lora_from_path(model: nnx.Module, mt_config: pyconfig.HyperParameter
699700
max_logging.log(f"Guided restore failed: {e}. Falling back to basic restore.")
700701
restored_lora_params = ocp.PyTreeCheckpointer().restore(lora_restore_path)
701702

703+
# If restoring from a full TrainState checkpoint, navigate into the model sub-tree
704+
if isinstance(restored_lora_params, dict) and "model" in restored_lora_params:
705+
restored_lora_params = restored_lora_params["model"]
706+
elif hasattr(restored_lora_params, "model"):
707+
restored_lora_params = getattr(restored_lora_params, "model")
708+
restored_count = 0
709+
702710
# Post processing
703711
def _map_to_state(path, variable):
712+
nonlocal restored_count
704713
if not isinstance(variable, nnx.Variable):
705714
return
706715

707716
str_path = [str(k.key if hasattr(k, "key") else (k.name if hasattr(k, "name") else k)) for k in path]
708717

709718
curr = restored_lora_params
710719
for p in str_path:
711-
if isinstance(curr, dict) and p in curr:
712-
curr = curr[p]
720+
if isinstance(curr, Mapping):
721+
if p in curr:
722+
curr = curr[p]
723+
elif p.isdigit() and int(p) in curr:
724+
curr = curr[int(p)]
725+
else:
726+
return
713727
elif hasattr(curr, p):
714728
curr = getattr(curr, p)
715729
else:
716730
return
717731

718-
if isinstance(curr, dict) and "value" in curr:
732+
if isinstance(curr, Mapping) and "value" in curr:
719733
matched_val = curr["value"]
720734
elif hasattr(curr, "value"):
721735
matched_val = getattr(curr, "value")
722736
else:
723737
matched_val = curr
724738

725739
variable.value = matched_val
740+
restored_count += 1
726741

727742
jax.tree_util.tree_map_with_path(
728743
_map_to_state,
729744
abstract_lora_params,
730745
is_leaf=lambda n: isinstance(n, nnx.Variable),
731746
)
732747

748+
if restored_count == 0:
749+
raise ValueError(f"No LoRA/adapter parameters were successfully restored from checkpoint at '{lora_restore_path}'.")
750+
733751
nnx.update(model, abstract_lora_params)
734752
max_logging.log(f"LoRA restore complete from '{lora_restore_path}'.")
735753
return model
@@ -918,3 +936,26 @@ def add_lora(out_node, base_node, path):
918936
opt_state={},
919937
)
920938
return unboxed_abstract_lora_state, lora_state_mesh_annotations
939+
940+
941+
def restore_qlora_base_weights(val):
942+
"""Restores qwix custom quantized types from nnx.State representation."""
943+
if isinstance(val, nnx.State):
944+
pure_dict = {k: restore_qlora_base_weights(v) for k, v in val.items()}
945+
if "array" in pure_dict:
946+
return WithAux(array=pure_dict["array"], how=None)
947+
elif "qvalue" in pure_dict and "scale" in pure_dict:
948+
return QArray(
949+
qvalue=pure_dict["qvalue"],
950+
scale=pure_dict["scale"],
951+
zero_point=pure_dict.get("zero_point", None),
952+
qtype=None,
953+
)
954+
else:
955+
return pure_dict
956+
elif isinstance(val, nnx.Variable):
957+
return restore_qlora_base_weights(val.get_value())
958+
elif isinstance(val, dict):
959+
return {k: restore_qlora_base_weights(v) for k, v in val.items()}
960+
else:
961+
return val

src/maxtext/utils/maxtext_utils.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,6 +1688,20 @@ def setup_initial_state(
16881688
# didn't carry is still an unmaterialized placeholder. Fill those from the fresh init: a
16891689
# present leaf comes from the checkpoint, an absent one keeps its init value.
16901690
overlay = restored if is_emergency else restored["items"]
1691+
1692+
if getattr(getattr(config, "lora", None), "enable_lora", False):
1693+
path_to_load = config.load_parameters_path or config.load_full_state_path
1694+
if path_to_load:
1695+
_, base_params, _ = nnx.split_state(state.model, nnx.LoRAParam, nnx.Param, ...)
1696+
loaded_base_params = checkpointing.load_params_from_path(
1697+
path_to_load,
1698+
base_params,
1699+
config.checkpoint_storage_concurrent_gb,
1700+
use_ocdbt=config.checkpoint_storage_use_ocdbt,
1701+
use_zarr3=config.checkpoint_storage_use_zarr3,
1702+
)
1703+
nnx.update(state.model, loaded_base_params)
1704+
16911705
merged = jax.tree.map(
16921706
lambda ckpt, init: init if isinstance(ckpt, jax.ShapeDtypeStruct) else ckpt,
16931707
overlay.to_pure_dict(),
@@ -1733,6 +1747,27 @@ def _merge_params(p_raw, p_init):
17331747
state = state.replace(params=merged_params)
17341748
else:
17351749
state = state.replace(params=raw_params)
1750+
if config.pure_nnx and getattr(getattr(config, "lora", None), "enable_lora", False):
1751+
# pylint: disable=import-outside-toplevel
1752+
from maxtext.utils import lora_utils
1753+
1754+
def _convert_var(v):
1755+
if isinstance(v, nnx.Variable):
1756+
raw_val = v.get_value() if hasattr(v, "get_value") else v.value
1757+
if isinstance(raw_val, nnx.State):
1758+
restored_val = lora_utils.restore_qlora_base_weights(raw_val)
1759+
if hasattr(v, "_raw_value"):
1760+
# pylint: disable=protected-access
1761+
v._raw_value = restored_val
1762+
v.set_value(restored_val)
1763+
return v
1764+
1765+
if hasattr(state, "model"):
1766+
model_to_convert = state.model
1767+
if isinstance(model_to_convert, nnx.Module):
1768+
model_to_convert = nnx.state(model_to_convert)
1769+
jax.tree_util.tree_map(_convert_var, model_to_convert, is_leaf=lambda x: isinstance(x, nnx.Variable))
1770+
17361771
if not config.pure_nnx:
17371772
state = max_utils.unbox_logicallypartioned(state)
17381773
return state, state_mesh_annotations, state_mesh_shardings, data_iterator, was_restored

src/maxtext/utils/sharding.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -598,27 +598,29 @@ def maybe_update_params_sharding_with_opt_nnx(
598598
# In TrainStateNNX, parameters are under 'model'
599599
model_shardings = state_mesh_shardings.model
600600

601+
wrt = nnx.LoRAParam if getattr(getattr(config, "lora", None), "enable_lora", False) else nnx.Param
602+
601603
def _extract_param_only(state):
602-
"""Recursively extract nnx.Param variables from an nnx.State into a nested plain dict.
604+
"""Recursively extract trainable parameters from an nnx.State into a nested plain dict.
603605
604606
Constructs nnx.State({'key': nested_dict, ...}) which produces the same pytree
605-
structure as nnx.split(model, nnx.Param, ...)[1], enabling jax.tree.map
606-
to work correctly between ga_params (Param-only) and params_shardings.
607+
structure as nnx.split(model, wrt, ...)[1], enabling jax.tree.map
608+
to work correctly between ga_params (wrt-only) and params_shardings.
607609
"""
608610
result = {}
609611
for k, v in state.items():
610-
if isinstance(v, nnx.Param):
612+
if isinstance(v, wrt):
611613
result[k] = v
612614
elif isinstance(v, nnx.Variable):
613-
pass # skip non-Param variables (RngKey, RngCount, OptVariable, etc.)
615+
pass # skip non-trainable variables (RngKey, RngCount, OptVariable, etc.)
614616
elif hasattr(v, "items"):
615617
sub = _extract_param_only(v)
616618
if sub:
617619
result[k] = sub
618620
return result
619621

620622
# prev_params_shardings must match the pytree structure of ga_params from
621-
# nnx.split(model, nnx.Param, ...) — Param variables only, no rngs.
623+
# nnx.split(model, wrt, ...) — trainable variables only, no rngs.
622624
prev_params_shardings = nnx.State(_extract_param_only(model_shardings))
623625

624626
if not config.shard_optimizer_over_data:

0 commit comments

Comments
 (0)