[Pytorch][Common] Hybrid quantization#2817
Conversation
Signed-off-by: Evgeny <etsykunov@nvidia.com>
for more information, see https://pre-commit.ci
Greptile SummaryThis PR introduces hybrid (per-direction) quantization for Transformer Engine, allowing rowwise and columnwise directions of a tensor to use different quantization formats (e.g. NVFP4 forward + MXFP8 backward) via
Confidence Score: 4/5Safe to merge as an experimental feature; the core quantize/GEMM/FSDP2 paths are well-guarded and previously-raised crashes have been addressed in subsequent commits within the PR. The two remaining findings are in the FSDP2 storage protocol (Float8BlockwiseQTensorStorage.fsdp_extract_buffers bidirectional inconsistency) and the aten.clone quantizer aliasing in HybridQuantizedTensor. Neither is exercised by any current code path, but both represent contract violations that could silently corrupt state in future callers or under concurrent quantizer mutation. transformer_engine/pytorch/tensor/storage/float8_blockwise_tensor_storage.py (fsdp_buffer_fields/fsdp_extract_buffers bidirectional contract) and transformer_engine/pytorch/tensor/hybrid_tensor.py (aten.clone shared quantizer reference). Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[HybridQuantizer.quantize_impl] --> B{rowwise_usage?}
B -- yes --> C[rowwise_quantizer.quantize]
B -- no --> D[rowwise_result = None]
C --> E{columnwise_source?}
D --> E
E -- original --> F[columnwise input = tensor]
E -- rowwise_dequantized --> G[dequantize rowwise_result]
G --> F2[columnwise input = dequantized]
F --> H{columnwise_usage?}
F2 --> H
H -- yes --> I[columnwise_quantizer.quantize]
H -- no --> J[columnwise_result = None]
I --> K[HybridQuantizedTensor]
J --> K
K --> L{GEMM dispatch}
L --> M[_unwrap_hybrid_A/B]
M --> N[_unwrap_identity_tensor]
N --> O[cuBLAS GEMM]
K --> P{FSDP2 all-gather}
P --> Q[fsdp_pre_all_gather]
Q --> R[all_gather_into_tensor]
R --> S[fsdp_post_all_gather + _sync_usage]
S --> T[Reconstructed full-param HybridQuantizedTensor]
Reviews (30): Last reviewed commit: "Fix delayed-scaling hybrid FP8 FSDP guar..." | Re-trigger Greptile |
timmoon10
left a comment
There was a problem hiding this comment.
Overall I think this moves us in a good direction. I see some minor bugs, as well as bugs reported by @greptile-apps.
| rowwise_result = self.rowwise_quantizer.quantize(tensor) | ||
| columnwise_result = self.columnwise_quantizer.quantize(tensor) |
There was a problem hiding this comment.
Do we handle the case where not all usages are needed? I'd expect something like:
| rowwise_result = self.rowwise_quantizer.quantize(tensor) | |
| columnwise_result = self.columnwise_quantizer.quantize(tensor) | |
| rowwise_result = self.rowwise_quantizer.quantize(tensor) if self.rowwise_usage else None | |
| columnwise_result = self.columnwise_quantizer.quantize(tensor) if self.columnwise_usage else None |
| requires_grad: bool = False, | ||
| pin_memory: bool = False, | ||
| ) -> HybridQuantizedTensor: | ||
| self.rowwise_quantizer.internal = True |
There was a problem hiding this comment.
Could we just set internal=True in the constructor? I don't think we ever need PyTorch tensor functionality in the per-usage data.
There was a problem hiding this comment.
This would not work under FSDP2.
| def factory(role): | ||
| if role == "linear_weight": | ||
| return HybridQuantizer( | ||
| rowwise_quantizer=_make_fp8_quantizer(), | ||
| columnwise_quantizer=_make_mxfp8_quantizer(), | ||
| ) | ||
| if role == "linear_input": | ||
| return HybridQuantizer( | ||
| rowwise_quantizer=_make_fp8_quantizer(), | ||
| columnwise_quantizer=_make_nvfp4_quantizer(), | ||
| ) | ||
| if role in ("linear_grad_output", "linear_grad_input"): | ||
| return HybridQuantizer( | ||
| rowwise_quantizer=_make_mxfp8_quantizer(), | ||
| columnwise_quantizer=_make_nvfp4_quantizer(), | ||
| ) | ||
| return None |
There was a problem hiding this comment.
This is horrifying. Good test.
Signed-off-by: Evgeny <etsykunov@nvidia.com>
Signed-off-by: Evgeny <etsykunov@nvidia.com>
for more information, see https://pre-commit.ci
Signed-off-by: Evgeny <etsykunov@nvidia.com>
Signed-off-by: Evgeny <etsykunov@nvidia.com>
Signed-off-by: Evgeny <etsykunov@nvidia.com>
for more information, see https://pre-commit.ci
Signed-off-by: Evgeny <etsykunov@nvidia.com>
for more information, see https://pre-commit.ci
Signed-off-by: Evgeny <etsykunov@nvidia.com>
Signed-off-by: Evgeny <etsykunov@nvidia.com>
Signed-off-by: Evgeny <etsykunov@nvidia.com>
for more information, see https://pre-commit.ci
| # DCP serializes ``CustomRecipe`` via ``pickle``; closure-based qfactories | ||
| # (lambdas, inner functions referencing captured state) are not picklable, | ||
| # so the qfactory must live at module scope. See | ||
| # ``run_fsdp2_fused_adam.py::test_hybrid_dcp_output_parity``. |
There was a problem hiding this comment.
This comment is potentially useful, but I don't think it is in the right place - shouldn't it be closer to the actual implementation?
| for param in model.parameters(): | ||
| state = optimizer.state[param] | ||
| assert state["exp_avg"].dtype == torch.float32 | ||
| assert state["exp_avg_sq"].dtype == torch.float32 | ||
| if "master_param" in state: | ||
| assert state["master_param"].dtype == torch.float32 | ||
|
|
||
| assert losses[-1] < losses[0], f"Loss did not decrease: {losses[0]:.4f} -> {losses[-1]:.4f}" |
There was a problem hiding this comment.
That's not a very strict test, is there a way for us to do some numerical correctness comparisons?
There was a problem hiding this comment.
Enabled check for the monotonic loss decrease (still mostly sanity), and also enabled hybrid vs vanilla bitwise recipe comparizon, see e.g. test_fused_adam_hybrid_vs_base_recipe_parity.
| def _canonical_view_shape(input_shape: Iterable[int], shape: Iterable[int]) -> torch.Size: | ||
| """Resolve PyTorch view shape syntax, including ``-1`` inference.""" | ||
| return torch.empty(tuple(input_shape), device="meta").view(*shape).shape |
There was a problem hiding this comment.
That's the same formula that was used in one of the other files. If useful, it should be somewhere where it could be used without rediscovering, but still I think it is pretty roundabout...
There was a problem hiding this comment.
Additionally using torch.empty, view etc would introduce quite a bit of CPU overheads.. We can define a function to do manual size resolution in python that resolves -1, 0 etc.
Here something Gemini gave me
def get_actual_view_shape_full(input_shape: tuple, view_shape: tuple) -> tuple:
# 1. First, resolve any '0' dimensions by copying from the input_shape
resolved_zeros = []
for i, dim in enumerate(view_shape):
if dim == 0:
if i >= len(input_shape):
raise ValueError("Dimension '0' exceeds input shape dimensions")
resolved_zeros.append(input_shape[i])
else:
resolved_zeros.append(dim)
# 2. Calculate element totals
total_elements = math.prod(input_shape)
known_elements = math.prod(d for d in resolved_zeros if d != -1)
# 3. Resolve the '-1' dimension if it exists
if -1 in resolved_zeros:
if resolved_zeros.count(-1) > 1:
raise ValueError("Only one dimension can be -1")
if total_elements % known_elements != 0:
raise ValueError(f"Cannot reshape {input_shape} to {view_shape}")
missing_dim = total_elements // known_elements
return tuple(missing_dim if d == -1 else d for d in resolved_zeros)
# 4. Strict validation if no -1 is used
if total_elements != known_elements:
raise ValueError(f"Cannot reshape {input_shape} to {view_shape}")
return tuple(resolved_zeros)
There was a problem hiding this comment.
Now float8 tensor and storage share the same better shaped utility, see c7e11d2
| ) | ||
|
|
||
| @classmethod | ||
| def __torch_dispatch__(cls, func, types, args, kwargs=None): |
There was a problem hiding this comment.
Do we need this function to be this big? We should be able to mostly just delegate to the held tensor, right?
There was a problem hiding this comment.
Good point, delegated
| "instance or a QuantizerRequest. For an intentional no-op " | ||
| "(high-precision / unquantized) slot, return an " | ||
| "IdentityQuantizer instead of None." |
There was a problem hiding this comment.
We could probably just allow None to mean the IdentityQuantizer here?
There was a problem hiding this comment.
I would not do that. Current behavior is explicit. Treating a factory return value of None as an Identity quantizer would silently broaden the custom recipe API and could hide a missing role. Also none quantizer has a semantic meaning in the modules, which is different from the IdentityQuantizer, right? I suggest Identity should be requested explicitly.
| def _canonical_view_shape(input_shape: Iterable[int], shape: Iterable[int]) -> torch.Size: | ||
| """Resolve PyTorch view shape syntax, including ``-1`` inference.""" | ||
| return torch.empty(tuple(input_shape), device="meta").view(*shape).shape |
There was a problem hiding this comment.
Additionally using torch.empty, view etc would introduce quite a bit of CPU overheads.. We can define a function to do manual size resolution in python that resolves -1, 0 etc.
Here something Gemini gave me
def get_actual_view_shape_full(input_shape: tuple, view_shape: tuple) -> tuple:
# 1. First, resolve any '0' dimensions by copying from the input_shape
resolved_zeros = []
for i, dim in enumerate(view_shape):
if dim == 0:
if i >= len(input_shape):
raise ValueError("Dimension '0' exceeds input shape dimensions")
resolved_zeros.append(input_shape[i])
else:
resolved_zeros.append(dim)
# 2. Calculate element totals
total_elements = math.prod(input_shape)
known_elements = math.prod(d for d in resolved_zeros if d != -1)
# 3. Resolve the '-1' dimension if it exists
if -1 in resolved_zeros:
if resolved_zeros.count(-1) > 1:
raise ValueError("Only one dimension can be -1")
if total_elements % known_elements != 0:
raise ValueError(f"Cannot reshape {input_shape} to {view_shape}")
missing_dim = total_elements // known_elements
return tuple(missing_dim if d == -1 else d for d in resolved_zeros)
# 4. Strict validation if no -1 is used
if total_elements != known_elements:
raise ValueError(f"Cannot reshape {input_shape} to {view_shape}")
return tuple(resolved_zeros)
| raise NotImplementedError( | ||
| "FSDP2 is only supported for MXFP8Tensors with compact scales" | ||
| ) | ||
| names = self.fsdp_buffer_fields() |
There was a problem hiding this comment.
I think it would be good to reuse fsdp extract buffers and fsdp_assign_gather for the current tensor class as well along with hybrid tensor(in this case mxfp8 and similarly for others)
| return dst | ||
|
|
||
| # ── FSDP2: new_zeros ───────────────────────────────────────── | ||
| if func == aten.new_zeros.default: |
There was a problem hiding this comment.
This is risky if we use it for anything apart from FSDP2. Might make sense to zero out the substorages.
| # Factories stay small; these tests target TP/SP plumbing. | ||
|
|
||
|
|
||
| def _make_fp8_current_quantizer(*, fp8_dtype=tex.DType.kFloat8E4M3): |
There was a problem hiding this comment.
We now recommend to use transformer_engine.pytorch.DType instead of tex.DType in TE. So we should change it here as well
Signed-off-by: Evgeny <etsykunov@nvidia.com>
…ation in GroupedLinear, etc Signed-off-by: Evgeny <etsykunov@nvidia.com>
for more information, see https://pre-commit.ci
Signed-off-by: Evgeny <etsykunov@nvidia.com>
for more information, see https://pre-commit.ci
Signed-off-by: Evgeny <etsykunov@nvidia.com>
Signed-off-by: Evgeny <etsykunov@nvidia.com>
|
/te-ci pytorch L1 |
for more information, see https://pre-commit.ci
Signed-off-by: Evgeny <etsykunov@nvidia.com>
for more information, see https://pre-commit.ci
|
/te-ci pytorch L1 |
Signed-off-by: Evgeny <etsykunov@nvidia.com>
Signed-off-by: Evgeny <etsykunov@nvidia.com>
Signed-off-by: Evgeny <etsykunov@nvidia.com>
vthumbe1503
left a comment
There was a problem hiding this comment.
Have a few comments on the tests. Apart from that LGTM from FSDP2 side of things
| module.unshard() | ||
| # Hybrid sub-storages (e.g. MXFP8 scale unpad/repad through FSDP2) can introduce | ||
| # small numerical differences vs the manual dequantize-then-allgather path. | ||
| tols = dict(atol=5e-4, rtol=5e-3) |
There was a problem hiding this comment.
instead of replicating the function, could we please add tols as argument to the function?
There was a problem hiding this comment.
Good point. I reuse the existing all-gather checker and add an optional tols argument.
|
|
||
|
|
||
| @pytest.mark.skipif(not fp8_available, reason=f"FP8: {reason_for_no_fp8}") | ||
| @xfail_hopper_columnwise_per_tensor_fp8 |
There was a problem hiding this comment.
Doesnt this increase the test timings by quite a bit, given that we are executing torchrun for each and every pytest?
Given that we use the same number of processes for all these torchrun tests, it might be better to use a single torchrun that shares multiple pytest, similar to how we are doing it with FSDP2 tests.
Not a blocker for this PR if running this test is say under 2 mins. But I am not sure if that is the case.
There was a problem hiding this comment.
Addressed in 461daa1. Tests using the same quantization recipe are now passed together to one torchrun. The complete two-GPU suite takes about 87 seconds on the Blackwell node, so it remains under two minutes.
| return getattr(transformer_engine.common.recipe, recipe)() | ||
|
|
||
|
|
||
| def _hybrid_fp8_current_qfactory(role): |
There was a problem hiding this comment.
I think it makes sense to abstract away and put the creation of HybridQuantizerFactory in a single test utily file that both distributed and non-distrbuted tests can use. I see the similar kind of functions defined in tp_sp distributed tests, cpu offloading tests etc.
There was a problem hiding this comment.
I am not sure if this makes sense, but we can also potentially expose a HybridQuantizerFactory from transformer_engine.common, something like this
@dataclass
class HybridQuantizerFactory:
"""Compose two role-aware base factories into HybridQuantizer for selected roles."""
rowwise_factory: Callable[[Optional[QuantizerRole]], Quantizer]
columnwise_factory: Callable[[Optional[QuantizerRole]], Quantizer]
fallback_factory: Callable[[Optional[QuantizerRole]], Quantizer] | None = None
# roles that become HybridQuantizer
hybrid_module_types: frozenset[str] = frozenset({"linear", "grouped_linear"})
hybrid_tensor_types: frozenset[str] = frozenset({"input", "weight", "output"})
columnwise_source: Literal["original", "rowwise_dequantized"] = "original"
# optional overrides: tensor_type -> factory (e.g. grads -> Identity)
role_overrides: dict[str, Callable] = field(default_factory=dict)
def __call__(self, role: Optional[QuantizerRole]):
if role is not None and role.tensor_type in self.role_overrides:
return self.role_overrides[role.tensor_type](role)
if (
role is not None
and role.module_type in self.hybrid_module_types
and role.tensor_type in self.hybrid_tensor_types
):
return HybridQuantizer(
rowwise_quantizer=self.rowwise_factory(role),
columnwise_quantizer=self.columnwise_factory(role),
columnwise_source=self.columnwise_source,
)
fallback = self.fallback_factory or self.columnwise_factory
return fallback(role)
And the tests in this file for example can boil down to
_HYBRID_QFACTORIES = {
"HybridFP8CurrentScaling": HybridQuantizerFactory(
current_scaling_quantizer_factory,
current_scaling_quantizer_factory,
),
"HybridMXFP8": HybridQuantizerFactory(
mxfp8_quantizer_factory,
mxfp8_quantizer_factory,
),
"HybridFloat8BlockScaling": HybridQuantizerFactory(
float8_block_scaling_quantizer_factory,
float8_block_scaling_quantizer_factory,
),
"HybridMixed_MXFP8_FP8": HybridQuantizerFactory(
mxfp8_quantizer_factory,
current_scaling_quantizer_factory,
fallback_factory=current_scaling_quantizer_factory,
),
"HybridFP8CurrentScalingIdentity": HybridQuantizerFactory(
current_scaling_quantizer_factory,
high_precision_factory, # or a tiny identity_factory
fallback_factory=current_scaling_quantizer_factory,
role_overrides={
"grad_output": high_precision_factory,
"grad_input": high_precision_factory,
},
),
"Identity": high_precision_factory,
}
There was a problem hiding this comment.
This might not be general enough. I can imagine user might wanting to have specific quantizer for a specific (tensor_type, module_type) combination. So I would leave it upto you to make the judgement @negvet . But my main concern is the code duplication of the hybrid quantization factory in the tests.
There was a problem hiding this comment.
I agree with the duplication concern. A fully general public factory abstraction raises broader questions around GEMM-centric configuration, role targeting, and non-Linear modules, etc, so I would prefer to think about it later. For this PR, I’ll move the common, explicit hybrid qfactory functions into a shared test utility and reuse them across FSDP2, TP/SP, CPU-offload, and unit tests. Specialized factories with different fallback semantics will remain local.
Signed-off-by: Evgeny <etsykunov@nvidia.com>
|
/te-ci L0 L1 |
Signed-off-by: Evgeny <etsykunov@nvidia.com>
for more information, see https://pre-commit.ci
Signed-off-by: Evgeny <etsykunov@nvidia.com>
Description
Hybrid (per-direction) quantization. Hybrid means rowwise/colwise can use different formats via CustomRecipe(qfactory).
This is an experimental feature.
The main problem that it tries to solve is that precision requirements are non-uniform.
Current recipes set one format for both rowwise and colwise directions.
Hybrid quantization enables, e.g. MXFP8 fwd and NVFP4 bwd (or vice versa) or any other valid combination. No need for a hardcoded recipe for every combination.
Composer-style (Composer 2 paper) grouped GEMM recipe, e.g. row-scaled NVFP4 fwd + MXFP8 bwd:
By default, the above factory uses
columnwise_source="original", so MXFP8 backward operands are quantized from the original high-precision tensor. Usecolumnwise_source="rowwise_dequantized"when the backward operand should be derived from the dequantized rowwise NVFP4 forward value.C++ optimizations (fusions, etc.) will come as standalone PRs. cc @kainzhong
TODO:
Follow-up issue tracker #3158.
Integration
Ecosystem integration (all functional, unit-tested):
Megatron-LM integration status:
--fp{4,8}-param-gather+ dist opt (persistent low-precision params viaquantized_model_init+ sharded-master FP32 → quantized cast viaquantize_master_weights.)- [Done] Per-tensor Float8 hybrid (delayed and/or current, any per-direction combination
including same-format, cross-format Float8, single-direction)
- [TODO] Per-block hybrid sub-quantizers (MXFP8, NVFP4, Float8Blockwise) — each rejected per-direction by
quantize_master_weights; unblocker is TE-side cast-helper / kernel.--fp{4,8}-param-gather(fix private attribute access)--fp{4,8}-param-gather- [Done] TE-side hybrid FSDP2 path works end-to-end for Float8 / MXFP8 / Float8Blockwise sub-storages (TODO: need some minor MLM update)
- [TODO] NVFP4 sub-storage FSDP2 hooks
_hybrid_split_quantizeunder Megatron MoE)Review
Total diff +14000
New hybrid source (
hybrid_tensor.py,hybrid_tensor_storage.py,identity_tensor.py,identity_tensor_storage.py) ~1800Adjacent modifications ~1500
Tests are the rest (~10K)
Suggested reading order
-columnwise_source controls whether columnwise quantization uses the original input or the rowwise-dequantized value.
1.1 Identity passthrough — b99277a
Type of change
Changes
Please list the changes introduced in this PR:
Checklist: