feat(checkpointing): add dequantize-on-load parameter restoration - #5051
feat(checkpointing): add dequantize-on-load parameter restoration#5051snehalv2002 wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for FP8 weight-only storage and dynamic dequantization during checkpoint loading. It adds fallback logic for weight scales, implements dynamic dequantization of restored parameters when the target model expects unquantized weights, and updates dense linear layers to support block-wise quantization scales. The review feedback identifies several critical issues, including a bug in checkpoint loading for NNX models where dequantized weights are not written back, a performance bottleneck from repeated O(N) lookups on safetensors keys, incorrect unconditional conversion of float16 to bfloat16, potential AttributeErrors on older JAX versions, and numerical instability risks from initializing bias as FP8.
| restored_weights = maybe_dequantize_restored_params(restored_weights, want) | ||
| if not is_nnx: | ||
| restored_collection = restored_weights |
There was a problem hiding this comment.
In load_params_from_path, when is_nnx is True, restored_weights is retrieved from restored_collection["params"]. After dequantizing, restored_weights is reassigned to the new dequantized dictionary, but restored_collection["params"] is never updated. This means the returned restored_collection still contains the old, non-dequantized weights, completely breaking the feature for NNX models. We should update restored_collection["params"] with the new restored_weights when is_nnx is True.
| restored_weights = maybe_dequantize_restored_params(restored_weights, want) | |
| if not is_nnx: | |
| restored_collection = restored_weights | |
| restored_weights = maybe_dequantize_restored_params(restored_weights, want) | |
| if is_nnx and restore_key not in ("model_params", "model"): | |
| restored_collection["params"] = restored_weights | |
| else: | |
| restored_collection = restored_weights |
| if final_key not in f.keys(): | ||
| if final_key.endswith(".weight_scale"): | ||
| for suffix in [".scale", ".weight_scale_inv", ".scale_inv"]: | ||
| alt_key = final_key[:-len(".weight_scale")] + suffix | ||
| if alt_key in f.keys(): | ||
| final_key = alt_key | ||
| break | ||
| elif final_key.endswith(".scale"): | ||
| for suffix in [".weight_scale", ".scale_inv", ".weight_scale_inv"]: | ||
| alt_key = final_key[:-len(".scale")] + suffix | ||
| if alt_key in f.keys(): | ||
| final_key = alt_key | ||
| break |
There was a problem hiding this comment.
Calling f.keys() repeatedly in a loop or checking final_key not in f.keys() can be extremely slow because f.keys() returns a list in safetensors, making each lookup an O(N) operation. Converting f.keys() to a set once before the lookups will make them O(1) and significantly improve performance, especially for models with many keys.
| if final_key not in f.keys(): | |
| if final_key.endswith(".weight_scale"): | |
| for suffix in [".scale", ".weight_scale_inv", ".scale_inv"]: | |
| alt_key = final_key[:-len(".weight_scale")] + suffix | |
| if alt_key in f.keys(): | |
| final_key = alt_key | |
| break | |
| elif final_key.endswith(".scale"): | |
| for suffix in [".weight_scale", ".scale_inv", ".weight_scale_inv"]: | |
| alt_key = final_key[:-len(".scale")] + suffix | |
| if alt_key in f.keys(): | |
| final_key = alt_key | |
| break | |
| f_keys = set(f.keys()) | |
| if final_key not in f_keys: | |
| if final_key.endswith(".weight_scale"): | |
| for suffix in [".scale", ".weight_scale_inv", ".scale_inv"]: | |
| alt_key = final_key[:-len(".weight_scale")] + suffix | |
| if alt_key in f_keys: | |
| final_key = alt_key | |
| break | |
| elif final_key.endswith(".scale"): | |
| for suffix in [".weight_scale", ".scale_inv", ".weight_scale_inv"]: | |
| alt_key = final_key[:-len(".scale")] + suffix | |
| if alt_key in f_keys: | |
| final_key = alt_key | |
| break |
| elif t.dtype == torch.float16: | ||
| return t.to(torch.float32).numpy().astype(ml_dtypes.bfloat16) |
There was a problem hiding this comment.
In the generic get_tensor reader, unconditionally converting float16 tensors to bfloat16 is incorrect and can cause unexpected behavior or precision loss if the caller actually expects float16. Since PyTorch CPU tensors support .numpy() directly for float16, we should return the tensor in its original dtype.
| elif t.dtype == torch.float16: | |
| return t.to(torch.float32).numpy().astype(ml_dtypes.bfloat16) | |
| elif t.dtype == torch.float16: | |
| return t.numpy() |
| fp8_types = [jnp.float8_e4m3fn, jnp.float8_e5m2] | ||
| for attr in ("float8_e4m3fnuz", "float8_e5m2fnuz", "float8_e4m3b11fnuz"): | ||
| if hasattr(jnp, attr): | ||
| fp8_types.append(getattr(jnp, attr)) |
There was a problem hiding this comment.
Directly referencing jnp.float8_e4m3fn and jnp.float8_e5m2 will raise an AttributeError on older JAX versions that do not support these FP8 dtypes. We should safely check for their existence using hasattr or getattr before adding them to fp8_types.
| fp8_types = [jnp.float8_e4m3fn, jnp.float8_e5m2] | |
| for attr in ("float8_e4m3fnuz", "float8_e5m2fnuz", "float8_e4m3b11fnuz"): | |
| if hasattr(jnp, attr): | |
| fp8_types.append(getattr(jnp, attr)) | |
| fp8_types = [] | |
| for attr in ("float8_e4m3fn", "float8_e5m2", "float8_e4m3fnuz", "float8_e5m2fnuz", "float8_e4m3b11fnuz"): | |
| if hasattr(jnp, attr): | |
| fp8_types.append(getattr(jnp, attr)) |
| import flax.linen as nn | ||
|
|
||
| from maxtext.common.common_types import DecoderBlockType, ShardMode, DType, Array, Config | ||
| from maxtext.common.common_types import DecoderBlockType, ShardMode, DType, Array, Config, Shape |
There was a problem hiding this comment.
Import is_fp8_dtype from common_types to avoid duplicating the FP8 dtype checking logic in linears.py.
| from maxtext.common.common_types import DecoderBlockType, ShardMode, DType, Array, Config, Shape | |
| from maxtext.common.common_types import DecoderBlockType, ShardMode, DType, Array, Config, Shape, is_fp8_dtype |
| def _is_fp8_dtype(dtype: Any) -> bool: | ||
| """Checks whether a dtype is an FP8 data type.""" | ||
| if dtype is None: | ||
| return False | ||
| try: | ||
| canon_dtype = _canonicalize_dtype(dtype) | ||
| except (TypeError, ValueError): | ||
| return False | ||
|
|
||
| fp8_types = [jnp.float8_e4m3fn, jnp.float8_e5m2] | ||
| for attr in ("float8_e4m3fnuz", "float8_e5m2fnuz", "float8_e4m3b11fnuz"): | ||
| if hasattr(jnp, attr): | ||
| fp8_types.append(getattr(jnp, attr)) | ||
| return canon_dtype in fp8_types |
There was a problem hiding this comment.
| try: | ||
| bias_val = default_bias_init(rngs.params(), bias_shape, self.weight_dtype) | ||
| except (TypeError, ValueError): | ||
| bias_val = default_bias_init(rngs.params(), bias_shape, self.dtype).astype(self.weight_dtype) |
There was a problem hiding this comment.
If self.weight_dtype is FP8, the bias is initialized as FP8. Quantizing bias to FP8 is highly discouraged as it can cause severe numerical instability and loss of model quality. Bias should remain in the compute dtype (self.dtype or bfloat16).
bias_dtype = self.dtype if _is_fp8_dtype(self.weight_dtype) else self.weight_dtype
try:
bias_val = default_bias_init(rngs.params(), bias_shape, bias_dtype)
except (TypeError, ValueError):
bias_val = default_bias_init(rngs.params(), bias_shape, self.dtype).astype(bias_dtype)941a1de to
a838438
Compare
a8797c8 to
395dbc0
Compare
395dbc0 to
5356a58
Compare
e705f42 to
7e0a894
Compare
7e0a894 to
a3573f9
Compare
a3573f9 to
4b1be84
Compare
4b1be84 to
705fd0a
Compare
705fd0a to
c427c2c
Compare
c427c2c to
2d1ad3e
Compare
2d1ad3e to
feef8d6
Compare
feef8d6 to
0f27eb6
Compare
10a99d2 to
fffffd7
Compare
fffffd7 to
5e93173
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
5e93173 to
4e46172
Compare
4e46172 to
ad4ad4b
Compare
ad4ad4b to
806cad2
Compare
|
|
||
| augmented = {} | ||
| for k, v in want_node.items(): | ||
| scale_key = f"{k}_scale" |
There was a problem hiding this comment.
Would this work for per tensor, per-channel, & block-wise quantization?
There was a problem hiding this comment.
in terms of naming and functionality yes, the maxtext scales are always defined as f"{k}_scale" (we introduced this naming scheme in the first PR in the stack) and the dequantization method works for all three types of quantization you mentioned above using the quantizations.dequantize_weight() module which already existed in maxtext.
806cad2 to
52cc37d
Compare
52cc37d to
0e91d0e
Compare
Supports loading FP8 quantized checkpoints into unquantized models (e.g. BF16) by dynamically dequantizing weights on restore when companion scale tensors are present. Key Changes: - checkpointing: Augment target abstract shapes with companion scales discovered in checkpoint metadata. - checkpointing: Dynamically dequantize restored FP8 weights to target dtype using companion scale tensors. - checkpointing: Support both NNX models and Linen dictionary checkpoint layouts. - tests: Add comprehensive unit tests in checkpointing_test.py covering NNX, Linen, per-channel, and MoE dequantize-on-load scenarios.
Description
Upgrades
src/maxtext/common/checkpointing.pyto support restoring FP8 Orbax checkpoints and introduces automatic in-memory dequantize-on-load.Motivation & Context
Full pre-training and fine-tuning with optimizers like AdamW require unquantized BF16 master weights, as optimizer states and gradient accumulation are not supported on raw 8-bit floats. This feature enables loading a quantized FP8 checkpoint directly into an unquantized model (
weight_dtype="bfloat16"), saving ~50% disk storage and transfer bandwidth while automatically reconstructingbfloat16weights in memory upon restore without requiring an offline conversion step.How to Use Dequantize-on-Load
To use dequantize-on-load, specify the unquantized model name (or set
weight_dtype=bfloat16), but pointload_parameters_pathdirectly at an FP8 quantized Orbax checkpoint:What happens:
bfloat16(the target specificationwantexpects BF16 weights and no scale parameters).load_params_from_pathinspects the checkpoint metadata, discovers the companion scale tensors (kernel_scale,wi_0_scale, etc.), and temporarily augments the restore schema to retrieve both the FP8 weights and scales from disk.bfloat16usingquantizations.dequantize_weight, and companion scale tensors are dropped.bfloat16weights and runs without any runtime quantization overhead.Key Changes
_augment_want_with_scales):{weight}_scale) present in checkpoint metadata but omitted from the unquantized target model (want).maybe_dequantize_restored_params):bias) intact.ValueErroron shape mismatches rather than failing mid-restore.weight_dtype="float8_e4m3fn"(e.g.model_name=qwen3.5-35b-a3b-fp8), weights and scales are loaded directly without dequantization.Part 3 in the FP8 Weight-Only Dynamic Dequantization series (depends on #5053, #5052).
Tests
tests/unit/checkpointing_test.py(FP8DequantizeOnLoadTest):test_load_fp8_checkpoint_into_bf16_nnx_model: Verifies NNX dequantize-on-load to BF16, scale removal, and unquantizedbiaspreservation.test_load_fp8_checkpoint_into_fp8_nnx_model: Verifies direct FP8 restoration preserves weights and scale tensors.test_load_fp8_checkpoint_into_bf16_linen_dict: Verifies legacy Linen dictionary layout dequantization.test_load_fp8_checkpoint_shape_mismatch_raises: Verifies pre-load shape mismatch detection.PYTHONPATH=src JAX_PLATFORMS=cpu pytest tests/unit/checkpointing_test.py -k "FP8DequantizeOnLoadTest" -vResult:
4 passed, 0 failures(full test suite: 24 passed).codespell,pylint(10.00/10),pyink.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.