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
…stion in to_maxtext
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.
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.--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.