Skip to content

Commit 6e7e6ed

Browse files
asolergi-nvclaude
andauthored
dist_ckpt: inference-only streaming checkpoint dequantization for --fp8-param-gather / --fp4-param-gather loads (#7405)
Signed-off-by: asolergi-nv <asolergibert@nvidia.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1ddc6e4 commit 6e7e6ed

15 files changed

Lines changed: 1238 additions & 11 deletions

File tree

‎megatron/core/dist_checkpointing/exchange_utils.py‎

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from .core import CheckpointingException
1919
from .dict_utils import nested_values
2020
from .mapping import ShardedStateDict, ShardedTensor, is_main_replica
21-
from .utils import _sharded_tensor_shard_id, _ShardId, debug_time
21+
from .utils import _quantized_storage_tensors, _sharded_tensor_shard_id, _ShardId, debug_time
2222

2323
# TODO: remove TE references once the TE bug is fixed
2424
# Check if Transformer Engine has Float8Tensor class
@@ -764,12 +764,147 @@ def exchange_loaded_tensors_broadcast(
764764
return all_loaded_tensors
765765

766766

767+
_QUANTIZED_STORAGE_PART_ALIGNMENT = 16
768+
769+
770+
def _aligned_nbytes(nbytes: int) -> int:
771+
return -(-nbytes // _QUANTIZED_STORAGE_PART_ALIGNMENT) * _QUANTIZED_STORAGE_PART_ALIGNMENT
772+
773+
774+
def _quantized_storage_nbytes(tensor: torch.Tensor) -> int:
775+
"""Size of the packed quantized storage of a TE quantized tensor (see `_pack_...`)."""
776+
return sum(
777+
_aligned_nbytes(part.numel() * part.element_size())
778+
for part in _quantized_storage_tensors(tensor)
779+
)
780+
781+
782+
def _pack_quantized_storage(tensor: torch.Tensor) -> torch.Tensor:
783+
"""Packs the raw storage of a TE quantized tensor (codes and scales) into one byte buffer.
784+
785+
Exchanging the raw storage instead of the dequantized values keeps the loading rank's
786+
encoding bit for bit on the receivers (re-quantizing dequantized values can pick different
787+
block scales) and halves the exchanged bytes. Parts are 16-byte aligned so that every part
788+
can be viewed in place with its own dtype.
789+
"""
790+
buf = torch.empty(_quantized_storage_nbytes(tensor), dtype=torch.uint8, device=tensor.device)
791+
offset = 0
792+
for part in _quantized_storage_tensors(tensor):
793+
nbytes = part.numel() * part.element_size()
794+
buf[offset : offset + nbytes].view(part.dtype).view(part.shape).copy_(part)
795+
offset += _aligned_nbytes(nbytes)
796+
return buf
797+
798+
799+
def _unpack_quantized_storage(buf: torch.Tensor, tensor: torch.Tensor) -> None:
800+
"""Inverse of `_pack_quantized_storage`, writing into the storage of `tensor`."""
801+
offset = 0
802+
for part in _quantized_storage_tensors(tensor):
803+
nbytes = part.numel() * part.element_size()
804+
part.copy_(buf[offset : offset + nbytes].view(part.dtype).view(part.shape))
805+
offset += _aligned_nbytes(nbytes)
806+
if offset != buf.numel():
807+
raise CheckpointingException(
808+
f'Packed quantized storage ({buf.numel()} bytes) does not match the destination'
809+
f' ({offset} bytes)'
810+
)
811+
812+
813+
def exchange_loaded_tensors_broadcast_quantized(
814+
loaded_tensors: Dict[_ShardId, torch.Tensor],
815+
unloaded_shards: Dict[_ShardId, ShardedTensor],
816+
shard_distribution: ShardDistribution,
817+
parallelization_group: Optional[torch.distributed.ProcessGroup] = None,
818+
) -> Dict[_ShardId, torch.Tensor]:
819+
"""`exchange_loaded_tensors_broadcast` for state dicts with quantized destinations.
820+
821+
Used by the streaming dequantize load, which loads quantized destinations in place on the
822+
loading rank. Such tensors are broadcast as their packed raw storage (see
823+
`_pack_quantized_storage`) and unpacked straight into the receivers' destinations, so that
824+
every rank ends up with the loading rank's encoding and no high-precision copy is created.
825+
Other tensors are exchanged as by `exchange_loaded_tensors_broadcast`.
826+
827+
Args: see `exchange_loaded_tensors_broadcast`.
828+
"""
829+
from ..fp8_utils import is_float8tensor # Avoid circular import
830+
831+
main_rank_for_shard, _, shard_to_metadata, all_ranks_for_shard = shard_distribution
832+
local_rank = torch.distributed.get_rank(group=parallelization_group)
833+
834+
# Ranks that do not need a shard still take part in its broadcast and must know the size of
835+
# its packed storage, which only the loading rank knows: gather the sizes once up front.
836+
local_nbytes = {
837+
shard_id: _quantized_storage_nbytes(tensor)
838+
for shard_id, tensor in loaded_tensors.items()
839+
if is_float8tensor(tensor)
840+
}
841+
all_nbytes = [None] * torch.distributed.get_world_size(group=parallelization_group)
842+
torch.distributed.all_gather_object(all_nbytes, local_nbytes, group=parallelization_group)
843+
quantized_nbytes = {k: v for rank_nbytes in all_nbytes for k, v in rank_nbytes.items()}
844+
845+
if not quantized_nbytes:
846+
return exchange_loaded_tensors_broadcast(
847+
loaded_tensors, unloaded_shards, shard_distribution, parallelization_group
848+
)
849+
850+
plain_loaded_tensors = {k: v for k, v in loaded_tensors.items() if k not in quantized_nbytes}
851+
plain_distribution = ShardDistribution(
852+
{k: v for k, v in main_rank_for_shard.items() if k not in quantized_nbytes},
853+
shard_distribution.shards_in_this_group,
854+
shard_to_metadata,
855+
all_ranks_for_shard,
856+
)
857+
all_loaded_tensors = exchange_loaded_tensors_broadcast(
858+
plain_loaded_tensors, unloaded_shards, plain_distribution, parallelization_group
859+
)
860+
861+
for shard_id, rank in main_rank_for_shard.items():
862+
if shard_id not in quantized_nbytes:
863+
continue
864+
nbytes = quantized_nbytes[shard_id]
865+
if rank == local_rank:
866+
all_loaded_tensors[shard_id] = loaded_tensors[shard_id]
867+
if len(all_ranks_for_shard[shard_id]) == 1:
868+
# Only the loading rank needs this tensor, no exchange needed.
869+
continue
870+
if rank == local_rank:
871+
quantized = loaded_tensors[shard_id]
872+
buf = _pack_quantized_storage(quantized)
873+
else:
874+
quantized = unloaded_shards[shard_id].data if shard_id in unloaded_shards else None
875+
if quantized is not None and (
876+
not is_float8tensor(quantized) or _quantized_storage_nbytes(quantized) != nbytes
877+
):
878+
raise CheckpointingException(
879+
f'Shard {shard_id} was loaded as a quantized tensor on rank {rank} but its'
880+
f' destination on rank {local_rank} does not have the same quantized storage'
881+
)
882+
buf = torch.empty(nbytes, dtype=torch.uint8, device='cuda')
883+
global_src_rank = (
884+
rank
885+
if parallelization_group is None
886+
else torch.distributed.get_global_rank(parallelization_group, rank)
887+
)
888+
torch.distributed.broadcast(buf, src=global_src_rank, group=parallelization_group)
889+
if rank != local_rank and quantized is not None:
890+
with torch.no_grad():
891+
_unpack_quantized_storage(buf, quantized)
892+
# Hand back a view rather than the destination object itself: `load_state_dict`
893+
# copies the loaded tensor into the param, and TE's generic quantized `copy_`
894+
# cannot copy a tensor onto itself (it detaches the source storage first).
895+
all_loaded_tensors[shard_id] = quantized.view(quantized.shape)
896+
del buf
897+
898+
return all_loaded_tensors
899+
900+
767901
def exchange_by_distribution(
768902
loaded_tensors: Dict[_ShardId, torch.Tensor],
769903
unloaded_shards: Dict[_ShardId, ShardedTensor],
770904
shard_distribution: ShardDistribution,
771905
parallelization_group: Optional[torch.distributed.ProcessGroup] = None,
772906
exchange_algo="broadcast",
907+
exchange_quantized_storage: bool = False,
773908
) -> Dict[_ShardId, torch.Tensor]:
774909
"""Exchange tensors loaded by different ranks using the specified exchange_algo.
775910
@@ -783,6 +918,10 @@ def exchange_by_distribution(
783918
distribution. Tensors will be exchanged within this group
784919
exchange_algo (str): The algorithm used for performing exchanges.
785920
Defaults to 'broadcast'.
921+
exchange_quantized_storage (bool): with the 'broadcast' algorithm, exchange quantized
922+
tensors as their raw storage and unpack them into the receivers' quantized
923+
destinations (streaming dequantize load), see
924+
`exchange_loaded_tensors_broadcast_quantized`.
786925
787926
Returns:
788927
Dict[_ShardId, torch.Tensor]: dictionary mapping shard ids to tensors
@@ -796,7 +935,11 @@ def exchange_by_distribution(
796935
elif exchange_algo == "gather_rounds":
797936
exchange_fn = exchange_loaded_tensors_gather_rounds
798937
elif exchange_algo == "broadcast":
799-
exchange_fn = exchange_loaded_tensors_broadcast
938+
exchange_fn = (
939+
exchange_loaded_tensors_broadcast_quantized
940+
if exchange_quantized_storage
941+
else exchange_loaded_tensors_broadcast
942+
)
800943
else:
801944
raise NotImplementedError(f"Unrecognized gather algorithm: {exchange_algo}")
802945
return exchange_fn(loaded_tensors, unloaded_shards, shard_distribution, parallelization_group)

‎megatron/core/dist_checkpointing/serialization.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@
3535
TorchDistSaveShardedStrategy,
3636
_get_filesystem_reader,
3737
)
38-
from .utils import extract_sharded_base, force_all_tensors_to_non_fp8
38+
from .utils import (
39+
extract_sharded_base,
40+
force_all_tensors_to_non_fp8,
41+
prepare_quantized_tensors_for_streaming_load,
42+
)
3943
from .validation import (
4044
StrictHandling,
4145
determine_global_metadata,
@@ -137,7 +141,19 @@ def load(
137141
# params with a high-precision state dict;
138142
# 2. When using delayed scaling, this loading process writes an extra value into the global
139143
# amax_history buffer of Transformer Engine, which is undesirable.
140-
force_all_tensors_to_non_fp8(sharded_state_dict)
144+
#
145+
# Dequantizing everything up front allocates a high-precision copy of every quantized param
146+
# at once. When the strategy streams the dequantization (`stream_ckpt_dequant`, a load path
147+
# for inference), quantized tensors stay in the state dict and the load planner quantizes
148+
# each destination in place from a per-tensor high-precision scratch (see
149+
# `MCoreLoadPlanner.resolve_tensor`). Case 1 above is then unsupported by construction,
150+
# delayed scaling (case 2: its scale is only known once `load_state_dict` restores the fp8
151+
# metadata) is rejected, and the few tensors that cannot be quantized in place (see
152+
# `prepare_quantized_tensors_for_streaming_load`) are still dequantized up front.
153+
if getattr(sharded_strategy, "stream_ckpt_dequant", False):
154+
prepare_quantized_tensors_for_streaming_load(sharded_state_dict)
155+
else:
156+
force_all_tensors_to_non_fp8(sharded_state_dict)
141157

142158
sharded_state_dict, nonpersistent_state_dict, sh_ten_factories = load_preprocess(
143159
sharded_state_dict

‎megatron/core/dist_checkpointing/strategies/fully_parallel.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ def __init__(
218218
self.cached_distribution: Optional[ShardDistribution] = None
219219
self.cached_global_metadata: Optional[Metadata] = None
220220

221+
@property
222+
def stream_ckpt_dequant(self) -> bool:
223+
"""Forwards the streaming dequantize flag of the wrapped strategy (see
224+
`TorchDistLoadShardedStrategy`), so that `serialization.load` keeps the quantized
225+
destinations in the state dict. Only the `broadcast` exchange supports it: it replicates
226+
the raw quantized storage of the loading rank into the receivers' destinations (see
227+
`exchange_loaded_tensors_broadcast_quantized`)."""
228+
return getattr(self.base_strategy, "stream_ckpt_dequant", False)
229+
221230
@debug_time("FullyParallelLoadStrategyWrapper.load", logger)
222231
def load(self, sharded_state_dict: ShardedStateDict, checkpoint_dir: Path) -> StateDict:
223232
"""Distributes the load and calls underlying strategy only for parts of the state dict.
@@ -255,6 +264,12 @@ def load(self, sharded_state_dict: ShardedStateDict, checkpoint_dir: Path) -> St
255264
if get_pg_size(self.parallelization_group) <= 1:
256265
return self.base_strategy.load(sharded_state_dict, checkpoint_dir)
257266

267+
if self.stream_ckpt_dequant and self.exchange_algo != 'broadcast':
268+
raise CheckpointingException(
269+
'The streaming dequantize load (stream_ckpt_dequant) supports only the'
270+
f' `broadcast` exchange algorithm, got `{self.exchange_algo}`.'
271+
)
272+
258273
# Step 1 and 2: exchange load metadata and distribute the load
259274
with debug_time("self.apply_loading_parallelization", logger):
260275
precomputed_distribution: ShardDistribution | None = self.apply_loading_parallelization(
@@ -326,6 +341,7 @@ def load(self, sharded_state_dict: ShardedStateDict, checkpoint_dir: Path) -> St
326341
precomputed_distribution,
327342
self.parallelization_group,
328343
self.exchange_algo,
344+
exchange_quantized_storage=self.stream_ckpt_dequant,
329345
)
330346
if not set(unloaded_shards.keys()).issubset(all_loaded_tensors.keys()):
331347
missing_shards = set(unloaded_shards.keys()) - all_loaded_tensors.keys()

0 commit comments

Comments
 (0)