Quantize activations before EP all gather - #4812
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for Qwix quantization (pre-quantized activations) across GMM and ragged sort/gather kernels, including integration in the MoE layer and a new unit test. While these changes enable quantized execution paths, several critical issues must be addressed: a runtime TypeError in the new unit test due to an unsupported lhs_scale argument in gmm_v2, a potential ZeroDivisionError in ragged_gather_reduce_v2.py when num_cores is 2, and an AttributeError from accessing jnp.bfloat16.dtype. Additionally, shape checks are required in ragged_sort.py before gathering scales to prevent runtime failures on scalar or per-channel scales, private imports in ops.py should be replaced with standard JAX broadcasting, and dead code in ragged_gather_reduce_v2.py should be cleaned up.
| actual_prequantized = gmm_backend.gmm_v2( | ||
| lhs_q, | ||
| rhs_q, | ||
| group_sizes, | ||
| rhs_scale=rhs_scale, | ||
| lhs_scale=lhs_scale, | ||
| group_offset=group_offset_arr, | ||
| maybe_quantize_lhs=False, | ||
| ) |
There was a problem hiding this comment.
The gmm_v2 function does not accept lhs_scale as a parameter. Passing lhs_scale=lhs_scale here will raise a TypeError at runtime, causing the unit test to fail. Instead, the output of gmm_v2 should be scaled by lhs_scale manually.
| actual_prequantized = gmm_backend.gmm_v2( | |
| lhs_q, | |
| rhs_q, | |
| group_sizes, | |
| rhs_scale=rhs_scale, | |
| lhs_scale=lhs_scale, | |
| group_offset=group_offset_arr, | |
| maybe_quantize_lhs=False, | |
| ) | |
| actual_prequantized = gmm_backend.gmm_v2( | |
| lhs_q, | |
| rhs_q, | |
| group_sizes, | |
| rhs_scale=rhs_scale, | |
| group_offset=group_offset_arr, | |
| maybe_quantize_lhs=False, | |
| ) | |
| actual_prequantized *= lhs_scale.astype(actual_prequantized.dtype) |
| num_cores = sc_info.num_cores * sc_info.num_subcores | ||
|
|
||
| num_column_partitions = _calculate_num_column_partitions(hidden_size, input_size, num_cores, num_lanes, num_simd_lanes) | ||
| num_row_partitions = num_cores // num_column_partitions |
There was a problem hiding this comment.
When num_cores == 2, num_column_partitions can grow larger than num_cores (e.g., to 4 or 8), which causes num_row_partitions = num_cores // num_column_partitions to evaluate to 0. A value of 0 for num_row_partitions leads to a ZeroDivisionError in _preprocess and main_kernel (e.g., valid_rows_mask.shape[0] // num_row_partitions). Please clamp num_row_partitions to at least 1 using max(1, ...).
| num_row_partitions = num_cores // num_column_partitions | |
| num_row_partitions = max(1, num_cores // num_column_partitions) |
| if out_dtype is None or jnp.issubdtype(out_dtype, jnp.float8_e4m3fn): | ||
| out_dtype = jnp.bfloat16.dtype |
There was a problem hiding this comment.
In standard JAX/NumPy, jnp.bfloat16 is a type object and does not have a .dtype attribute. Accessing jnp.bfloat16.dtype will raise an AttributeError at runtime. Please use jnp.bfloat16 directly.
| if out_dtype is None or jnp.issubdtype(out_dtype, jnp.float8_e4m3fn): | |
| out_dtype = jnp.bfloat16.dtype | |
| if out_dtype is None or jnp.issubdtype(out_dtype, jnp.float8_e4m3fn): | |
| out_dtype = jnp.bfloat16 |
| if isinstance(hidden_states_local, qpl.QArray): | ||
| x_qval = ragged_gather( | ||
| hidden_states_local.qvalue, | ||
| token_indices_sorted, | ||
| shard_output_start[None], | ||
| shard_output_end[None], | ||
| enforce_fallback=enforce_gather_fallback, | ||
| flops_override=gather_flops_override, | ||
| bytes_accessed_override=gather_bytes_accessed_override, | ||
| ) | ||
| x_scale = ragged_gather( | ||
| hidden_states_local.scale, | ||
| token_indices_sorted, | ||
| shard_output_start[None], | ||
| shard_output_end[None], | ||
| enforce_fallback=enforce_gather_fallback, | ||
| flops_override=gather_flops_override, | ||
| bytes_accessed_override=gather_bytes_accessed_override, | ||
| ) | ||
| x = qpl.QArray(qvalue=x_qval, scale=x_scale) |
There was a problem hiding this comment.
If hidden_states_local is a QArray with a per-tensor (scalar) or per-channel scale, calling ragged_gather on hidden_states_local.scale will fail at runtime because the scale dimensions do not match the token dimension. Please add a check to only gather the scale if its first dimension matches the token dimension, similar to the logic used in moe.py.
if isinstance(hidden_states_local, qpl.QArray):
x_qval = ragged_gather(
hidden_states_local.qvalue,
token_indices_sorted,
shard_output_start[None],
shard_output_end[None],
enforce_fallback=enforce_gather_fallback,
flops_override=gather_flops_override,
bytes_accessed_override=gather_bytes_accessed_override,
)
if hidden_states_local.scale.shape[0] == hidden_states_local.qvalue.shape[0]:
x_scale = ragged_gather(
hidden_states_local.scale,
token_indices_sorted,
shard_output_start[None],
shard_output_end[None],
enforce_fallback=enforce_gather_fallback,
flops_override=gather_flops_override,
bytes_accessed_override=gather_bytes_accessed_override,
)
else:
x_scale = hidden_states_local.scale
x = qpl.QArray(qvalue=x_qval, scale=x_scale)| if isinstance(hidden_states_local, qpl.QArray): | ||
| x_qval = ragged_gather( | ||
| hidden_states_local.qvalue, | ||
| sliced_indices, | ||
| jnp.int32(0)[None], | ||
| gather_end[None], | ||
| enforce_fallback=enforce_gather_fallback, | ||
| flops_override=gather_flops_override, | ||
| bytes_accessed_override=gather_bytes_accessed_override, | ||
| ) | ||
| x_scale = ragged_gather( | ||
| hidden_states_local.scale, | ||
| sliced_indices, | ||
| jnp.int32(0)[None], | ||
| gather_end[None], | ||
| enforce_fallback=enforce_gather_fallback, | ||
| flops_override=gather_flops_override, | ||
| bytes_accessed_override=gather_bytes_accessed_override, | ||
| ) | ||
| x = qpl.QArray(qvalue=x_qval, scale=x_scale) |
There was a problem hiding this comment.
If hidden_states_local is a QArray with a per-tensor (scalar) or per-channel scale, calling ragged_gather on hidden_states_local.scale will fail at runtime because the scale dimensions do not match the token dimension. Please add a check to only gather the scale if its first dimension matches the token dimension, similar to the logic used in moe.py.
if isinstance(hidden_states_local, qpl.QArray):
x_qval = ragged_gather(
hidden_states_local.qvalue,
sliced_indices,
jnp.int32(0)[None],
gather_end[None],
enforce_fallback=enforce_gather_fallback,
flops_override=gather_flops_override,
bytes_accessed_override=gather_bytes_accessed_override,
)
if hidden_states_local.scale.shape[0] == hidden_states_local.qvalue.shape[0]:
x_scale = ragged_gather(
hidden_states_local.scale,
sliced_indices,
jnp.int32(0)[None],
gather_end[None],
enforce_fallback=enforce_gather_fallback,
flops_override=gather_flops_override,
bytes_accessed_override=gather_bytes_accessed_override,
)
else:
x_scale = hidden_states_local.scale
x = qpl.QArray(qvalue=x_qval, scale=x_scale)| if lhs_scale is not None: | ||
| out = call_with_generic_broadcast(jnp.multiply, out, lhs_scale.astype(out.dtype)) |
There was a problem hiding this comment.
Importing from a private module qwix._src.core.qarray is a maintainability risk as these internal APIs are unstable and can change without notice. Since both out and lhs_scale are standard JAX arrays, standard JAX/NumPy broadcasting (e.g., out * lhs_scale) is fully supported and should be preferred.
| if lhs_scale is not None: | |
| out = call_with_generic_broadcast(jnp.multiply, out, lhs_scale.astype(out.dtype)) | |
| if lhs_scale is not None: | |
| out = out * lhs_scale.astype(out.dtype) |
| if False and num_iterations > _CostModelConstants.MAX_ITERATIONS: | ||
| break |
There was a problem hiding this comment.
Disabling the MAX_ITERATIONS check with if False and ... introduces dead code and is a code smell. If the check is no longer desired, please remove or comment it out cleanly.
| if False and num_iterations > _CostModelConstants.MAX_ITERATIONS: | |
| break | |
| # TODO: Determine if MAX_ITERATIONS check is still needed or can be removed. |
Reconciles the ahead-of-time activation quantization (quantize before EP all-gather / ragged-sort so both move fp8 instead of bf16) with main's independent history since this branch's stale base: - ops.py / pallas_mosaic_tpu_v2_gmm_kernel.py: supersedes PR #4735's static-LHS-scaling mechanism (_fwd_prepare_lhs_scale/LhsRef), which assumes lhs always arrives unquantized and has no QArray handling -- incompatible with activations arriving pre-quantized. This branch's ahead-of-time quantization already covers #4735's target case (qwix's own qpl.quantize() natively supports "fixed" calibration), so nothing is lost. - moe.py: re-applied permute()/unpermute()/roe_ag_and_route/extract_vma QArray handling and the use_single_sparsecore removal on top of main's current moe.py (which independently gained DSV4 aux-loss-free routing and unrelated forward-pass bug fixes since this branch's base). - ragged_gather_reduce_v2.py: also removes disabled dead code (`if False and ...`) and a silent broad-except around qwix.dequantize() in the SparseCore fallback path. - Deliberately did not re-apply: stale vllm_batched_rpa/get_logical_axis_rules reversions (main already has this content; branch was just behind), or the sparse_matmul dispatch restructuring + two quantization-guard weakenings (kept main's existing self.config.quantization and ... guards intact). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fac2abc to
1fb67bf
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Follow-up to the previous rebase commit -- audited every hunk against "is this strictly required for quantize-before-EP-all-gather" and reverted what wasn't: - moe.py, ragged_sort.py: restore use_single_sparsecore (a SparseCore core-count knob for the ragged kernels, unrelated to activation dtype) that had been dropped as part of the original PR's own cleanup. main already carries it unmodified; only the QArray dual-dispatch is new. - ragged_gather_reduce_v2.py: revert to main verbatim. The SparseCore capacity/tiling fixes (num_cores==2 handling, assert->fallback, etc.) aren't quantization-specific -- they'd matter for bf16 runs under the same hardware conditions. The QArray-dequant fallback in _fallback_implementation is also dead code: checked every call site of ragged_gather_reduce and none ever pass it a QArray (only gradients and GMM outputs, never quantized activations flow through the reduce path). - tests/unit/pallas_mosaic_tpu_v2_kernel_test.py: revert to main verbatim. test_gmm_prequantized_activation exercises gmm_v2's lhs_scale parameter, which belongs to PR #4735's superseded static-scale mechanism and doesn't exist in this branch's kernel signature. - ops.py / pallas_mosaic_tpu_v2_gmm_kernel.py: confirmed already minimal, no #4735 mechanism present, only the lhs_q_dtype detection / inner_kernel bypass / out_dtype bf16 floor needed for pre-quantized lhs to work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per feedback: don't delete a colleague's in-kernel quantization logic just
because it's currently unreachable for our specific call path -- restore
it as an additional case rather than replacing it, and revisit any actual
overlap in a separate PR/session.
_fwd_run_tokamax_v2 (and make_gmm_configs / inner_kernel in the kernel)
now handle three cases:
1. lhs is a pre-quantized QArray (ours): unwrap to .qvalue, no in-kernel
scale, real scale reapplied externally after the kernel call.
2. lhs is raw with a static "fixed"-calibration scale available (#4735):
_fwd_prepare_lhs_scale supplies lhs_scale, kernel quantizes internally
using it instead of a dynamic per-block absmax.
3. lhs is raw with neither: existing dynamic in-kernel quantization,
unchanged.
Verified: AOT compile check (train_compile_671b_v6e64.sh, deepseek3-671b
fp8, v6e-64) still compiles cleanly with this layered version, same memory
footprint as before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the ops.py-level jnp.issubdtype(int) or jnp.issubdtype(e4m3fn) enumeration with qwix._src.core.numerics.should_quantize(dtype), which generalizes correctly to any quantized dtype (e5m2, int4, etc.) instead of only recognizing float8_e4m3fn specifically -- consistent with the call_with_generic_broadcast private-qwix-import precedent already in this file. Did NOT apply the same change to pallas_mosaic_tpu_v2_gmm_kernel.py: that file's make_gmm_configs/inner_kernel are shared by the backward DLHS/DRHS gmm_v2 calls, where gradients are quantized to bwd_qtype=float8_e5m2 (per the fp8_full recipe) -- a different dtype than the forward act_qtype (float8_e4m3fn). The old, narrower check only recognized e4m3fn as "already quantized", so e5m2 gradients fell through to the dynamic in-kernel quantization branch (safe). Generalizing it there too causes the kernel to treat e5m2 lhs as already-quantized and hit an unsupported Mosaic cast (float8_e4m3fn -> float8_e5m2) when pairing it with e4m3fn rhs -- confirmed by reproducing and reverting just those two call sites. Left as the original enumeration there, verified via a full AOT compile check (train_compile_671b_v6e64.sh) both ways. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tize block 1. Factor the 3 near-identical ring_ragged_sort call sites in permute() (qvalue, scale, non-QArray) into a local _permute_ring_ragged_sort() closure over the shared config args (topk_indices_2d, buffer_size, etc). 2. Remove permute()'s own "pre-quantize before ragged gather" block. It was redundant for roe_ag_and_route (ring-of-experts): that caller already quantizes x into a QArray before its EP all-gather, so by the time permute() ran this block, `not isinstance(inputs_2d, qpl.QArray)` was always False and it never actually fired. Worse, it was live but unsafe for ra2a_and_route (the non-ring-of-experts caller), which passes x in raw: this block would quantize it into a QArray that then flows into either jax.lax.ragged_all_to_all (a raw XLA primitive that doesn't understand a QArray pytree) or local_permute()/a2a_ragged_sort (which, unlike ring_ragged_sort, has no QArray dual-dispatch at all). Since the feature is specifically "quantize before EP all-gather" for the ring-of-experts path, and roe_ag_and_route already owns that correctly, removing this block closes the gap rather than leaving latent-broken code in the unrelated ra2a path. Verified via a full AOT compile check (train_compile_671b_v6e64.sh, deepseek3-671b fp8, v6e-64, ring-of-experts) -- same memory footprint as before, confirming roe_ag_and_route's own quantization is sufficient on its own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PiperOrigin-RevId: 959862142
Description
Start with a short description of what the PR does and how this is a change from
the past.
The rest of the description includes relevant details and context, examples:
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
Please describe how you tested this change, and include any instructions and/or
commands to reproduce.
Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.