Register LazyTensor with the Orbax v1 leaf-handler registry - #5148
Register LazyTensor with the Orbax v1 leaf-handler registry#5148lokic233 wants to merge 1 commit into
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request introduces Orbax v1 support for saving checkpoints with LazyTensor leaves by implementing LazyTensorLeafHandler and LazyTensorPyTreeHandler, and updating checkpointing utilities to propagate a custom checkpointables_registry. The review feedback suggests inheriting from the public ocp_v1.PyTreeHandler instead of the private _src module, explicitly passing the expected dtype to np.asarray during tensor materialization to prevent dtype mismatches, and refactoring the handler initialization to robustly register LazyTensor on any provided custom registry.
| from orbax.checkpoint import v1 as ocp_v1 | ||
| from orbax.checkpoint.experimental.v1._src.handlers import pytree_handler | ||
| from orbax.checkpoint.experimental.v1._src.serialization import numpy_leaf_handler | ||
| from orbax.checkpoint.experimental.v1._src.serialization import registry as leaf_handler_registry |
There was a problem hiding this comment.
Since PyTreeHandler is publicly exposed in the Orbax v1 API, we can import and inherit from ocp_v1.PyTreeHandler directly instead of reaching into the private _src module. This reduces reliance on internal implementation details.
| from orbax.checkpoint import v1 as ocp_v1 | |
| from orbax.checkpoint.experimental.v1._src.handlers import pytree_handler | |
| from orbax.checkpoint.experimental.v1._src.serialization import numpy_leaf_handler | |
| from orbax.checkpoint.experimental.v1._src.serialization import registry as leaf_handler_registry | |
| from orbax.checkpoint import v1 as ocp_v1 | |
| from orbax.checkpoint.experimental.v1._src.serialization import numpy_leaf_handler | |
| from orbax.checkpoint.experimental.v1._src.serialization import registry as leaf_handler_registry |
| async def serialize(self, params, serialization_context): | ||
| # MATERIALIZE: trigger the lazy load (__array__) explicitly before saving. | ||
| # This ensures the parent NumpyLeafHandler receives real np.ndarrays. | ||
| params = [dataclasses.replace(param, value=np.asarray(param.value)) for param in params] |
There was a problem hiding this comment.
Explicitly pass the expected dtype (param.info.dtype) to np.asarray. When converting checkpoints from a different precision (e.g., float16 to bfloat16), LazyTensor.__array__ is called with dtype=None by default, which returns the original precision array. Passing the expected dtype ensures that the materialized array matches the expected dtype of the LazyTensor and avoids potential dtype mismatches or implicit casting issues during serialization.
| params = [dataclasses.replace(param, value=np.asarray(param.value)) for param in params] | |
| params = [dataclasses.replace(param, value=np.asarray(param.value, dtype=param.info.dtype)) for param in params] |
| return await super().serialize(params, serialization_context) | ||
|
|
||
|
|
||
| class LazyTensorPyTreeHandler(pytree_handler.PyTreeHandler): |
| def __init__(self, **kwargs): | ||
| if "leaf_handler_registry" not in kwargs: | ||
| registry = leaf_handler_registry.StandardLeafHandlerRegistry() | ||
| registry.add( | ||
| LazyTensor, | ||
| numpy_leaf_handler.NumpyShapeDtype, | ||
| LazyTensorLeafHandler, | ||
| override=True, | ||
| secondary_typestrs=["np.ndarray"], | ||
| ) | ||
| kwargs["leaf_handler_registry"] = registry | ||
| super().__init__(**kwargs) |
There was a problem hiding this comment.
To make LazyTensorPyTreeHandler more robust, retrieve the leaf_handler_registry from kwargs if it exists, and register LazyTensor on it. This ensures that LazyTensor is always supported even if a custom registry is passed to the handler.
| def __init__(self, **kwargs): | |
| if "leaf_handler_registry" not in kwargs: | |
| registry = leaf_handler_registry.StandardLeafHandlerRegistry() | |
| registry.add( | |
| LazyTensor, | |
| numpy_leaf_handler.NumpyShapeDtype, | |
| LazyTensorLeafHandler, | |
| override=True, | |
| secondary_typestrs=["np.ndarray"], | |
| ) | |
| kwargs["leaf_handler_registry"] = registry | |
| super().__init__(**kwargs) | |
| def __init__(self, **kwargs): | |
| registry = kwargs.get("leaf_handler_registry") | |
| if registry is None: | |
| registry = leaf_handler_registry.StandardLeafHandlerRegistry() | |
| kwargs["leaf_handler_registry"] = registry | |
| registry.add( | |
| LazyTensor, | |
| numpy_leaf_handler.NumpyShapeDtype, | |
| LazyTensorLeafHandler, | |
| override=True, | |
| secondary_typestrs=["np.ndarray"], | |
| ) | |
| super().__init__(**kwargs) |
`to_maxtext.py` registers its `LazyTensor` leaf type through the Orbax v0 API (`type_handlers.register_type_handler`, to_maxtext.py:297). Since c41b2ae ("Refactor MaxText's checkpointing system to primarily use the Orbax v1 API") `save_weights_to_checkpoint` saves through `ocp.training.Checkpointer.save_checkpointables`, which resolves handlers against the v1 registry only. That refactor touched no file under `checkpoint_conversion/`, and `to_maxtext.py` has not been modified since. The two registries are disjoint, and the compatibility bridge runs one way: a v1 `PyTreeHandler` builds a per-instance `StandardLeafHandlerRegistry` and *derives* its v0 registry from itself, so a v0 global registration is structurally unreachable from v1. Conversion dies at save with: NoEntryError: Could not identify a valid handler for the checkpointable: "items" and checkpointable type=<class 'flax.training.train_state.TrainState'> This is issue AI-Hypercomputer#5106. As reported there it needs `--simulated_cpu_devices_count=1`; above 1 `shard_jax_weights` materializes every leaf (utils.py:1194-1196) before the tree reaches Orbax, so no `LazyTensor` is ever handed to a handler. The fix adds the v1 counterpart of the existing v0 handler and threads an optional registry from the converter down through `save_weights_to_checkpoint` and `create_orbax_checkpoint_manager` to `build_context`. Callers that pass nothing are unaffected. Two properties are deliberate: - Both handlers masquerade as the standard numpy handler. `secondary_typestrs=["np.ndarray"]` is what the v1 registry writes as the leaf's typestr, so the checkpoint stays byte-compatible with one a plain `NumpyLeafHandler` wrote and restores in a standard MaxText instance. Without it the checkpoint records the custom handler's own typestr and a standard restore fails -- silently writing unloadable checkpoints would be worse than the current loud failure. - Materialization happens per leaf inside the handler, mirroring the v0 handler. Materializing the tree before the save would defeat the memory saving lazy loading exists for. The v0 registration is left untouched; removing it is orthogonal to this fix. Validated on CPU at simulated_cpu_devices_count=1 against a reduced-dimension dense Qwen3.5 checkpoint (model support from a companion patch; the defect and the fix are model-independent), on a tree carrying this patch rather than a monkeypatch. MaxText logits compared against the transformers reference: arm rc max|diff| cosine argmax lazy=True scan=False 0 1.66893e-06 1.00000000 100% lazy=True scan=True 0 1.57952e-06 1.00000000 100% lazy=False scan=False 0 1.66893e-06 1.00000000 100% Both lazy arms fail with the NoEntryError above without this patch. The eager arm is the unchanged reference, and the lazy result now matches it to the digit. Restore goes through the v0 `PyTreeCheckpointer`, which is what demonstrates the written checkpoint is still readable by a standard MaxText instance. At the default simulated_cpu_devices_count=16 the same tree passes with or without the patch (1.72853e-06 / 0.99999994 / 100%), matching the scope reported in AI-Hypercomputer#5106. Signed-off-by: Loki Chen <dengcchi@meta.com>
61617ca to
5d0f587
Compare
What
Registers the converter's
LazyTensorleaf type with the Orbax v1 leaf-handlerregistry, so HF→MaxText checkpoint conversion works again at
--simulated_cpu_devices_count=1.Fixes #5106.
Why
checkpoint_conversion/to_maxtext.py:297registersLazyTensorthrough the Orbaxv0 API (
type_handlers.register_type_handler). Since c41b2ae ("RefactorMaxText's checkpointing system to primarily use the Orbax v1 API", 2026-09-01) the
save path runs through
ocp.training.Checkpointer.save_checkpointables, whichresolves handlers against the v1 registry only.
The two registries are disjoint, and the compatibility bridge runs one way: a v1
PyTreeHandlerbuilds a per-instanceStandardLeafHandlerRegistryand derives itsv0 registry from itself. So a v0 global registration is structurally unreachable from
a v1 save. Conversion dies with
NoEntryError.The error names the checkpointable (
"items"/TrainState), never the leaf type,which is why this is easy to misdiagnose — the real failure is one leaf deeper.
c41b2ae touched zero files under
checkpoint_conversion/, which is why thisregressed silently.
Scope — measured, not inferred
The failure requires
simulated_cpu_devices_count=1. At 2+,shard_jax_weightsmaterializes every leaf at
checkpoint_conversion/utils/utils.py:1194-1196before thetree ever reaches Orbax. Same tree, same model, same
lazy_load_tensors=True, one flagapart:
simulated_cpu_devices_countmainNoEntryErroratsave_checkpointablesThis is also why CI is green.
tests/integration/checkpoint_conversion_test.py::test_qwen3_30b_a3b_roundtrip_conversionis a real round-trip test collected by the
tpu-integrationandgpu-integrationflavors — but it never passes
--simulated_cpu_devices_count, so it always runs at 16,the one setting that cannot fail. Not "nobody runs it"; it runs constantly, at the safe
setting.
How
Purely additive — 4 files, +68 −0. The existing v0 registration at
:297is left inplace; removing it is orthogonal.
LazyTensorLeafHandler(NumpyLeafHandler)— materializes vianp.asarrayinserialize, then defers to the numpy handler.LazyTensorPyTreeHandler(PyTreeHandler)— injects a per-instanceStandardLeafHandlerRegistrycarrying that handler. (registry.addtakes a type,not an instance, hence the subclass.)
build_lazy_tensor_checkpointables_registry()— alocal_registrywithinclude_global_registry=True, bound to checkpointable name"items".checkpoint_context.build_context/checkpointing.create_orbax_checkpoint_manager/checkpoint_conversion/utils/utils.save_weights_to_checkpointas an optionalcheckpointables_registrykwarg, defaultNone— no behavior change for anyexisting caller.
The load-bearing detail:
secondary_typestrs=["np.ndarray"]Without it, the checkpoint records the custom handler's typestr, and a standard v0
restore then fails with
Unknown type: "...LazyTensorLeafHandler". The "fix" wouldsilently write unloadable checkpoints — strictly worse than the loud failure it
replaces. An earlier prototype did exactly that, and it was caught only because the
validator restores through the v0
PyTreeCheckpointerrather than just checking thatthe save returned 0.
Reviewers: this is the line to scrutinize.
Testing
CPU logits diff against the
transformersreference at reduced dimensions(hidden 256, 8 layers, vocab 512), every parameter randomized — default init leaves
RMSNorm scales at 0, which hides a wrong norm mapping. Run on a tree carrying the
patch, not a monkeypatch, at
simulated_cpu_devices_count=1:lazy_load_tensors=True,scan_layers=Falselazy_load_tensors=True,scan_layers=Truelazy_load_tensors=False(eager reference)Both lazy arms fail with
NoEntryErrorwithout this patch; the eager arm is theunchanged reference. Lazy now matches eager to the digit.
Both
scan_layerssettings are exercised because they are different code paths andscan_layers=Trueis what training consumes.The harness is sensitive to real corruption, not just to crashes: a same-shape
gate_proj↔up_projswap in the same converter scores cosine 0.729 / argmax 18.8%.Shape and name assertions cannot see that swap; only a value diff can.
Notes
Two imports reach into
_src(
orbax...experimental.v1._src.serialization.numpy_leaf_handlerand..._src.serialization.registry). If there is a public path for these I'd rather useit — happy to switch.