feat(checkpoint_conversion): support direct FP8 and scale tensor ingestion in to_maxtext - #5052
feat(checkpoint_conversion): support direct FP8 and scale tensor ingestion in to_maxtext#5052snehalv2002 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 in MaxText, including changes to checkpoint conversion, linear layers, and decoder layers to support FP8 weights alongside scalar, per-channel, and block-wise scales. Feedback on these changes includes addressing a potential lossy double conversion of float16/bfloat16 tensors during checkpoint loading, extracting duplicated scale key resolution logic in to_maxtext.py into a helper function, and removing the redundant local _is_fp8_dtype helper in linears.py in favor of the existing utility in common_types.py.
| elif t.dtype == torch.bfloat16: | ||
| return t.to(torch.float32).numpy().astype(ml_dtypes.bfloat16) | ||
| elif t.dtype == torch.float16: | ||
| return t.to(torch.float32).numpy().astype(ml_dtypes.bfloat16) |
There was a problem hiding this comment.
Converting torch.float16 and torch.bfloat16 unconditionally to ml_dtypes.bfloat16 inside get_tensor can lead to a lossy double conversion when save_dtype is configured as float32 (i.e., float16 -> bfloat16 -> float32).
Since LazyTensor.__array__ already handles casting the retrieved numpy array to the target save_dtype (via arr.astype(dtype)), get_tensor should simply return the closest native numpy representation (e.g., float32 for bfloat16 to avoid PyTorch conversion issues, and native float16 for float16).
| elif t.dtype == torch.bfloat16: | |
| return t.to(torch.float32).numpy().astype(ml_dtypes.bfloat16) | |
| elif t.dtype == torch.float16: | |
| return t.to(torch.float32).numpy().astype(ml_dtypes.bfloat16) | |
| elif t.dtype == torch.bfloat16: | |
| return t.to(torch.float32).numpy() | |
| elif t.dtype == torch.float16: | |
| return t.numpy() |
| if shard_name is None: | ||
| # Check fallback for .weight_scale vs .scale and inverse scales | ||
| if resolved_key.endswith(".weight_scale"): | ||
| for suffix in [".scale", ".weight_scale_inv", ".scale_inv"]: | ||
| alt_key = resolved_key[:-len(".weight_scale")] + suffix | ||
| if alt_key in self.shard_map: | ||
| resolved_key = alt_key | ||
| shard_name = self.shard_map[resolved_key] | ||
| break | ||
| elif resolved_key.endswith(".scale"): | ||
| for suffix in [".weight_scale", ".scale_inv", ".weight_scale_inv"]: | ||
| alt_key = resolved_key[:-len(".scale")] + suffix | ||
| if alt_key in self.shard_map: | ||
| resolved_key = alt_key | ||
| shard_name = self.shard_map[resolved_key] | ||
| break |
There was a problem hiding this comment.
The fallback logic for resolving .weight_scale vs .scale and inverse scales is duplicated three times in this file (here, in get_tensor's second block, and in _eager_getter).
To improve maintainability and reduce redundancy, consider extracting this logic into a helper function at the module level:
def resolve_scale_key(key: str, container) -> str:
"""Resolves fallback keys for weight scales and inverse scales."""
if key in container:
return key
if key.endswith(".weight_scale"):
for suffix in [".scale", ".weight_scale_inv", ".scale_inv"]:
alt_key = key[:-len(".weight_scale")] + suffix
if alt_key in container:
return alt_key
elif key.endswith(".scale"):
for suffix in [".weight_scale", ".scale_inv", ".weight_scale_inv"]:
alt_key = key[:-len(".scale")] + suffix
if alt_key in container:
return alt_key
return keyYou can then simplify this block to:
resolved_key = resolve_scale_key(key, self.shard_map)
shard_name = self.shard_map.get(resolved_key)| 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.
56098db to
3d13d99
Compare
3d13d99 to
4e38337
Compare
cb0ebb5 to
f54bfa9
Compare
|
|
||
| if "HF_HOME" not in os.environ and os.path.exists("/dev/shm"): | ||
| os.environ["HF_HOME"] = "/dev/shm/hf_cache" | ||
|
|
There was a problem hiding this comment.
remove this it was only for local development
f54bfa9 to
44cc3a7
Compare
| checkpointing.wait_until_finished(checkpoint_manager) | ||
|
|
||
| max_logging.log(f"Elapse for checkpoint save: {(time.time() - start) / 60:.2f} min") | ||
|
|
There was a problem hiding this comment.
delete this new line addition
| with safe_open(local_path, framework=framework, device="cpu") as f: | ||
| final_key = resolve_scale_key(resolved_key, f.keys()) | ||
| t = f.get_tensor(final_key) | ||
| if torch is not None and isinstance(t, torch.Tensor): | ||
| if hasattr(torch, "float8_e4m3fn") and t.dtype == torch.float8_e4m3fn: | ||
| return t.view(torch.uint8).numpy().view(ml_dtypes.float8_e4m3fn) | ||
| elif hasattr(torch, "float8_e5m2") and t.dtype == torch.float8_e5m2: | ||
| return t.view(torch.uint8).numpy().view(ml_dtypes.float8_e5m2) | ||
| elif t.dtype == torch.bfloat16: | ||
| if self.save_dtype in ("float32", DType.FLOAT32): | ||
| return t.to(torch.float32).numpy() | ||
| return t.view(torch.int16).numpy().view(ml_dtypes.bfloat16) | ||
| elif t.dtype == torch.float16: | ||
| if self.save_dtype in ("float32", DType.FLOAT32): | ||
| return t.to(torch.float32).numpy() | ||
| elif self.save_dtype in ("bfloat16", DType.BFLOAT16): | ||
| return t.to(torch.float32).numpy().astype(ml_dtypes.bfloat16) | ||
| return t.numpy() | ||
| else: | ||
| return t.numpy() | ||
| return t |
There was a problem hiding this comment.
can we simplify this somehow?
ffb5bec to
a1edf65
Compare
| if config.scan_layers: | ||
| # If it's a standard scanned layer, we use the configured param_scan_axis. | ||
| axis_to_stack = config.param_scan_axis | ||
| axis_to_stack = config.param_scan_axis if len(target_shape) > config.param_scan_axis else 0 |
There was a problem hiding this comment.
can you include a comment here?
My guess is this is used for 1D scales?
There was a problem hiding this comment.
Yes for 1D scales. Added a comment to specify this
dbf4b02 to
fde4737
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
fde4737 to
ed14346
Compare
ed14346 to
205de7a
Compare
205de7a to
41f7756
Compare
41f7756 to
ebc3f73
Compare
ebc3f73 to
cb11b27
Compare
cb11b27 to
5e61891
Compare
5e61891 to
f3a466f
Compare
f3a466f to
e4f3ad5
Compare
e4f3ad5 to
69bc314
Compare
…stion in to_maxtext Extends the MaxText HuggingFace checkpoint conversion pipeline to directly ingest FP8 weight tensors and companion scale tensors. Key Changes: - to_maxtext: Support float8_e4m3fn and float8_e5m2 save dtypes in _convert_tensor_to_numpy and _eager_getter, preserving each tensor's source precision (companion scales keep their source dtype; unquantized modules stay bfloat16). - to_maxtext: Support scale key suffix resolution (.weight_scale, .scale, .scale_inv, .weight_scale_inv) in LazyHFLoader and _eager_getter. - tensor_handling & to_maxtext: Handle 1D per-layer scalar scale stacking along axis 0 when scan_layers=True. - utils: Support slicing 1D MaxText parameters along axis 0 during checkpoint export. - tests: Add unit tests verifying FP8 tensor casting and scale suffix resolution in hf_checkpoint_conversion_test.py.
69bc314 to
c84e000
Compare
Description
Enhances MaxText's standalone checkpoint conversion tool (
src/maxtext/checkpoint_conversion/to_maxtext.py) to directly ingest, serialize, and store native 8-bit float weights (float8_e4m3fn,float8_e5m2) and companion scale tensors into Orbax format.Motivation & Context
Previously,
to_maxtext.pyonly supportedsave_dtype="bfloat16"andsave_dtype="float32". Converting pre-quantized multi-terabyte FP8 Hugging Face checkpoints forced CPU upcasting to BF16, blowing up disk and RAM requirements by 2x.Key Changes
torch.float8_e4m3fn/torch.float8_e5m2tensors asuint8views before bridging toml_dtypes.float8_e4m3fnin NumPy memory, avoiding unsupported standard NumPy float8 casts.LazyHFLoaderfor common Hugging Face scale tensor naming conventions (.weight_scale,.scale,.weight_scale_inv,.scale_inv).--save_dtype="float8_e4m3fn"is specified, adhering to declarativeunquantized_modules.tensor_handling.py,utils.py):if tensor.ndim > param_scan_axis) when stacking parameters across scanned decoder layers to safely handle lower-rank scale tensors.--save_dtypearguments to acceptfloat8_e4m3fnandfloat8_e5m2.Part 2 of 5 in the FP8 Weight-Only Dynamic Dequantization series (depends on #5053).
If the change fixes a bug or a Github issue, please include a link, e.g.,:
FIXES: b/123456
FIXES: #123456
You can also provide a comma-separated list. If you don't want to close a bug but
simply to reference it, use BUGS, e.g.:
BUGS: b/123456
Notice 1: Once all tests pass, the "pull ready" label will automatically be assigned.
This label is used for administrative purposes. Please do not add it manually.
Notice 2: For external contributions, our settings currently require an approval from a MaxText maintainer to trigger CI tests.
Tests
Tested end-to-end checkpoint conversion of Hugging Face FP8 models (
neuralmagic/Meta-Llama-3.1-8B-Instruct-FP8):Verified that linear weights are stored in
float8_e4m3fnand scale tensors infloat32.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.