[AMD] fix: use the hardware fp8 e4m3 convert on gfx950 (#37140)

Signed-off-by: amd-danli103 <danli103@amd.com>
This commit is contained in:
amd-danli103
2026-09-08 03:01:53 -07:00
committed by GitHub
parent 775f17b07c
commit 141febf329
6 changed files with 260 additions and 7 deletions
@@ -25,6 +25,7 @@ limitations under the License.
#else #else
#include <hip/hip_bf16.h> #include <hip/hip_bf16.h>
#include <hip/hip_fp16.h> #include <hip/hip_fp16.h>
#include <hip/hip_fp8.h>
#include <hip/hip_runtime.h> #include <hip/hip_runtime.h>
#endif #endif
@@ -128,6 +129,17 @@ __device__ __forceinline__ fp8x2_e4m3_t pack_fp8(float x, float y) {
y = fmaxf(fminf(y, kFP8Max), -kFP8Max); y = fmaxf(fminf(y, kFP8Max), -kFP8Max);
return __nv_fp8x2_e4m3(float2{x, y}); return __nv_fp8x2_e4m3(float2{x, y});
} }
#elif HIP_FP8_TYPE_OCP && !HIP_FP8_TYPE_FNUZ
// gfx950/gfx12xx write OCP e4m3 natively, so take v_cvt_pk_fp8_f32 -- RNE, both lanes in
// one instruction. Not gfx942: hardware only converts to fnuz there, and this kernel writes
// E4M3FN on every arch (the indexer caller allocates float8_e4m3fn), so gfx942 keeps the
// software cast below. Testing FNUZ too because HIP sets both macros on the host pass and
// on targets outside its list. Clip rather than ask for __HIP_SATFINITE: the x2 fast path
// converts the value it was handed, not the clamped one (ROCm 7.2).
__device__ __forceinline__ fp8x2_e4m3_t pack_fp8(float x, float y) {
const float2 v{fmaxf(fminf(x, kFP8Max), -kFP8Max), fmaxf(fminf(y, kFP8Max), -kFP8Max)};
return __hip_cvt_float2_to_fp8x2(v, __HIP_NOSAT, __HIP_E4M3);
}
#else #else
// Software float -> FP8 E4M3 conversion for ROCm // Software float -> FP8 E4M3 conversion for ROCm
__device__ __forceinline__ uint8_t cvt_float_to_fp8_e4m3(float val) { __device__ __forceinline__ uint8_t cvt_float_to_fp8_e4m3(float val) {
@@ -0,0 +1,47 @@
#include <sgl_kernel/tensor.h>
#include <sgl_kernel/utils.h>
#include <sgl_kernel/utils.cuh>
#include <sgl_kernel/deepseek_v4/fp8_utils.cuh>
#include <tvm/ffi/container/tensor.h>
#include <cstddef>
#include <cstdint>
// Elementwise float -> fp8 e4m3 through the same `pack_fp8` every fp8 store in the
// DSv4 tree goes through. It is here so that cast can be pinned against torch on its
// own: a wrong rounding or saturation boundary in there does not show up as a failure
// in the fused kernels -- it just looks like fp8 quantizing worse than it should.
namespace sglang {
constexpr size_t kCvtBlockSize = 256;
__global__ void cvt_fp8_e4m3_kernel(uint8_t* dst, const float* src, size_t num_pairs) {
const size_t idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx >= num_pairs) return;
reinterpret_cast<fp8x2_e4m3_t*>(dst)[idx] = deepseek_v4::fp8::pack_fp8(src[2 * idx], src[2 * idx + 1]);
}
void cvt_fp8_e4m3(tvm::ffi::TensorView dst, tvm::ffi::TensorView src) {
using namespace host;
auto N = SymbolicSize{"num_elements"};
auto device_ = SymbolicDevice{};
device_.set_options<kDLGPU>();
TensorMatcher({N}).with_strides({1}).with_dtype<float>().with_device(device_).verify(src);
TensorMatcher({N}).with_strides({1}).with_dtype<uint8_t>().with_device(device_).verify(dst);
const size_t num_elements = N.unwrap();
// pack_fp8 converts two values at a time
RuntimeCheck(num_elements > 0 && num_elements % 2 == 0, "num_elements must be even and non-zero, got ", num_elements);
const size_t num_pairs = num_elements / 2;
LaunchKernel(div_ceil(num_pairs, kCvtBlockSize), kCvtBlockSize, device_.unwrap())(
cvt_fp8_e4m3_kernel, static_cast<uint8_t*>(dst.data_ptr()), static_cast<const float*>(src.data_ptr()), num_pairs);
}
} // namespace sglang
@@ -7,6 +7,13 @@
#include <cstdint> #include <cstdint>
#ifndef USE_ROCM #ifndef USE_ROCM
#include <cuda_fp8.h> #include <cuda_fp8.h>
#elif defined(__gfx950__) || defined(__gfx1200__) || defined(__gfx1201__)
// Only on the arches that take the hardware branch below. hip_fp8.h is what defines
// HIP_FP8_TYPE_FNUZ, and nothing else in this include tree pulls it in, so gating on
// those macros instead would also flip the software cast's arch constants on gfx942 --
// it picks fn today because the macro is not visible there.
#include <hip/hip_fp8.h>
#define SGL_ROCM_FP8_HW_CVT 1
#endif #endif
// Small helpers shared by the DeepSeek-V4 FP8/UE8M0 quantization kernels // Small helpers shared by the DeepSeek-V4 FP8/UE8M0 quantization kernels
@@ -45,8 +52,21 @@ SGL_DEVICE fp8x2_e4m3_t pack_fp8(float x, float y) {
return fp8x2_e4m3_t{fp32x2_t{fp8_e4m3_clip(x), fp8_e4m3_clip(y)}}; return fp8x2_e4m3_t{fp32x2_t{fp8_e4m3_clip(x), fp8_e4m3_clip(y)}};
} }
#else #else
// Software float -> FP8 E4M3 conversion for ROCm/HIP. #ifdef SGL_ROCM_FP8_HW_CVT
// Supports both E4M3FN (MI350X, gfx950) and E4M3FNUZ (MI300X, gfx942). // gfx950/gfx12xx do both lanes in one v_cvt_pk_fp8_f32 (RNE), and the flavour it produces
// is the OCP one kFP8E4M3Max already assumes there. Clip first rather than passing
// __HIP_SATFINITE -- the x2 fast path converts the value it was handed, not the clamped
// one (ROCm 7.2).
//
// gfx942 keeps the software cast below, top-segment bug and all -- this instruction does
// not produce the fnuz flavour that arch needs, so it takes a separate fix.
SGL_DEVICE fp8x2_e4m3_t pack_fp8(float x, float y) {
const fp32x2_t v{fp8_e4m3_clip(x), fp8_e4m3_clip(y)};
return __hip_cvt_float2_to_fp8x2(v, __HIP_NOSAT, __HIP_E4M3);
}
#else
// Software float -> FP8 E4M3 conversion for the archs the branch above skips: gfx942,
// plus any target with no native fp8 convert.
SGL_DEVICE uint8_t cvt_float_to_fp8_e4m3(float val) { SGL_DEVICE uint8_t cvt_float_to_fp8_e4m3(float val) {
val = fp8_e4m3_clip(val); val = fp8_e4m3_clip(val);
if (val == 0.0f) return 0; if (val == 0.0f) return 0;
@@ -117,6 +137,7 @@ SGL_DEVICE fp8x2_e4m3_t pack_fp8(float x, float y) {
uint8_t y8 = cvt_float_to_fp8_e4m3(y); uint8_t y8 = cvt_float_to_fp8_e4m3(y);
return static_cast<uint16_t>(x8) | (static_cast<uint16_t>(y8) << 8); return static_cast<uint16_t>(x8) | (static_cast<uint16_t>(y8) << 8);
} }
#endif // HIP_FP8_TYPE_OCP && !HIP_FP8_TYPE_FNUZ
#endif #endif
} // namespace deepseek_v4::fp8 } // namespace deepseek_v4::fp8
@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from sglang.kernels.jit.utils import cache_once, load_jit
from .utils import make_name
if TYPE_CHECKING:
from tvm_ffi.module import Module
@cache_once
def _jit_fp8_cvt_module() -> Module:
return load_jit(
make_name("fp8_cvt"),
cuda_files=["deepseek_v4/fp8_cvt.cuh"],
cuda_wrappers=[("cvt_fp8_e4m3", "cvt_fp8_e4m3")],
)
def cvt_fp8_e4m3(src: torch.Tensor) -> torch.Tensor:
"""Cast fp32 to fp8 e4m3 through the same ``pack_fp8`` the fp8 stores use.
Nothing in the serving path calls this -- it is here so the conversion can be
compared against torch on its own. Inside the fused kernels every value goes
through a quantization scale first, which turns a wrong conversion byte into
"fp8 is a bit lossy" rather than a failure.
Args:
src: contiguous 1D fp32 CUDA/HIP tensor of even length -- the conversion runs
two values at a time.
Returns:
uint8 tensor of the same length holding the raw e4m3 bytes.
Raises:
RuntimeError: if the length is zero or odd, or a tensor does not match.
"""
dst = torch.empty_like(src, dtype=torch.uint8)
_jit_fp8_cvt_module().cvt_fp8_e4m3(dst, src)
return dst
@@ -0,0 +1,116 @@
"""The DSv4 fp8 e4m3 conversion on its own, byte for byte against torch.
Every fp8 store in the DSv4 tree quantizes through ``pack_fp8``, and none of the
callers can see when it is wrong: the value has already been divided by a
quantization scale, so a bad rounding or saturation boundary comes back as fp8 being
lossier than it should be, not as a failure. ``pack_fp8`` on ROCm used to be a
hand-written bit twiddle, and it had two: the whole top exponent segment saturated
to the max normal, and the binade under the min subnormal flushed to zero instead of
rounding up to it. Both are pinned below.
gfx950 only. gfx942 still runs the software cast, bugs and all, because the
instruction cannot produce the fnuz bytes that arch writes; that one needs its own fix.
The reference is torch's own cast.
"""
import math
import unittest
import torch
from sglang.kernels.ops.attention.dsv4.fp8_cvt import cvt_fp8_e4m3
from sglang.kernels.ops.quantization.fp8_kernel import fp8_dtype, fp8_max
from sglang.srt.utils import is_gfx95_supported, is_hip
from sglang.test.ci.ci_register import register_amd_ci
from sglang.test.test_utils import CustomTestCase
# the default amd runner is mi300, where pack_fp8 still takes the software path this
# does not cover
register_amd_ci(est_time=60, suite="stage-b-test-1-gpu-small-amd-mi35x")
DEVICE = torch.device("cuda")
# start of the top exponent segment, i.e. the largest power of two the format holds
TOP_BINADE = 2.0 ** math.floor(math.log2(fp8_max))
def _representable():
vals = torch.arange(256, dtype=torch.uint8).view(fp8_dtype).float()
return vals[vals.isfinite()]
def _domain():
"""every representable value, every midpoint between two of them, every bf16"""
vals = _representable()
mids = ((vals[:, None] + vals[None, :]) / 2).flatten()
cases = torch.cat(
[
vals,
mids,
torch.linspace(-fp8_max, fp8_max, 100003),
torch.arange(1 << 16, dtype=torch.int32).view(torch.bfloat16).float(),
]
)
# stay inside the range: past the max the two casts are allowed to disagree on
# whether to clamp or produce NaN, which is not what this is testing
cases = cases[cases.isfinite() & (cases.abs() <= fp8_max)].unique()
if cases.numel() % 2:
cases = cases[:-1]
return cases.contiguous()
def _as_bytes(x):
# the conversion runs two values at a time, so the length has to stay even --
# e4m3fnuz has an odd number of representable values (only 0x80 is NaN)
if x.numel() % 2:
x = x[:-1]
x = x.contiguous().to(DEVICE)
return cvt_fp8_e4m3(x), x.to(fp8_dtype).view(torch.uint8)
@unittest.skipUnless(
torch.cuda.is_available() and is_hip() and is_gfx95_supported(),
"the gfx95 path of pack_fp8 is what this pins",
)
class TestDsv4Fp8Cast(CustomTestCase):
def test_matches_torch_over_the_whole_range(self):
cases = _domain()
got, want = _as_bytes(cases)
bad = got != want
if bool(bad.any()):
v = cases.to(DEVICE)[bad]
worst = v.abs().argmax()
self.fail(
f"{int(bad.sum())} of {cases.numel()} bytes differ, "
f"|v| in [{v.abs().min():.4e}, {v.abs().max():.4e}]; e.g. "
f"{v[worst]:.6g} -> {got[bad][worst].item():#04x} "
f"(torch {want[bad][worst].item():#04x})"
)
def test_top_exponent_segment_is_not_saturated(self):
# testing the exponent alone here used to write every value from TOP_BINADE up
# to the max out as the max normal
vals = _representable()
# pack_fp8 clips to fp8_max, so anything the format holds above it never comes
# out of the conversion
top = vals[(vals.abs() >= TOP_BINADE) & (vals.abs() <= fp8_max)]
self.assertGreater(top.numel(), 2)
got, want = _as_bytes(top)
self.assertTrue(torch.equal(got, want))
# and they really are distinct values, not all the same byte
self.assertGreater(int(got.unique().numel()), 2)
def test_binade_below_the_min_subnormal_rounds_up(self):
min_subnormal = _representable().abs()
min_subnormal = min_subnormal[min_subnormal > 0].min().item()
# (midpoint, min subnormal): rounds up. the midpoint itself is a tie and goes
# to even, i.e. to zero
band = torch.linspace(min_subnormal / 2, min_subnormal, 2049)[1:-1]
band = torch.cat([band, -band])
got, want = _as_bytes(band.contiguous())
self.assertTrue(torch.equal(got, want))
self.assertTrue(bool((got & 0x7F).all()))
if __name__ == "__main__":
unittest.main()
@@ -17,19 +17,22 @@ branches introduced by the scheduling optimization.
from __future__ import annotations from __future__ import annotations
import pytest import pytest
import sgl_kernel # noqa: F401 the ROCm path dispatches to torch.ops.sgl_kernel
import torch import torch
from sglang.kernels.ops.attention.dsv4 import ( from sglang.kernels.ops.attention.dsv4 import (
fused_q_indexer_rope_first_quant, fused_q_indexer_rope_first_quant,
fused_q_indexer_rope_hadamard_quant, fused_q_indexer_rope_hadamard_quant,
) )
from sglang.srt.utils import is_hip from sglang.srt.utils import is_gfx95_supported, is_hip
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
_is_hip = is_hip() _is_hip = is_hip()
register_cuda_ci(est_time=13, stage="base-b", runner_config="1-gpu-large") register_cuda_ci(est_time=13, stage="base-b", runner_config="1-gpu-large")
register_amd_ci(est_time=45, suite="jit-kernel-unit-test-amd") # the mi35x suite rather than the default AMD one: the ROCm case below is gfx95-only, and
# everything else in here skips on HIP, so the mi300 registration only ever produced skips
register_amd_ci(est_time=45, suite="stage-b-test-1-gpu-small-amd-mi35x")
HEAD_DIM = 128 HEAD_DIM = 128
ROPE_DIM = 64 ROPE_DIM = 64
@@ -45,10 +48,10 @@ N_HEADS = 64
BATCHES = [1, 8, 64, 256, 512, 2048] BATCHES = [1, 8, 64, 256, 512, 2048]
def _skip_if_unavailable(): def _skip_if_unavailable(hip_ok=False):
if not torch.cuda.is_available(): if not torch.cuda.is_available():
pytest.skip("CUDA required") pytest.skip("CUDA required")
if _is_hip: if _is_hip and not hip_ok:
pytest.skip("Indexer fused Q kernel is CUDA-specific") pytest.skip("Indexer fused Q kernel is CUDA-specific")
@@ -73,7 +76,14 @@ def _fp8_dequant_ok(q_fp8, ref, scale):
@pytest.mark.parametrize("pos_dtype", [torch.int32, torch.int64]) @pytest.mark.parametrize("pos_dtype", [torch.int32, torch.int64])
@pytest.mark.parametrize("batch", BATCHES) @pytest.mark.parametrize("batch", BATCHES)
def test_v4_rope_hadamard_quant_matches_reference(batch, pos_dtype): def test_v4_rope_hadamard_quant_matches_reference(batch, pos_dtype):
_skip_if_unavailable() # runs on gfx95 too: elementwise.py routes this one to the AOT op there, and that op
# carries its own copy of the cast, so this is the only coverage it gets
_skip_if_unavailable(hip_ok=True)
if _is_hip:
if not is_gfx95_supported():
pytest.skip("gfx942 keeps the software cast in the AOT copy")
if pos_dtype is torch.int64:
pytest.skip("the ROCm AOT op takes int32 positions only")
dev = "cuda" dev = "cuda"
g = torch.Generator(device=dev).manual_seed(0) g = torch.Generator(device=dev).manual_seed(0)
q = torch.randn( q = torch.randn(
@@ -157,6 +167,9 @@ def test_v32_rope_first_quant_matches_reference(batch):
# Strided weight (the non-contiguous wk slice) matches contiguous (V4 path). # Strided weight (the non-contiguous wk slice) matches contiguous (V4 path).
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
def test_v4_strided_weight_matches_contiguous(): def test_v4_strided_weight_matches_contiguous():
# stays CUDA-only. the ROCm op reads the weight linearly, so a non-contiguous slice
# comes out wrong there -- unrelated to the cast, and latent, since the indexer hands
# it the contiguous weights_proj output
_skip_if_unavailable() _skip_if_unavailable()
dev = "cuda" dev = "cuda"
B = 512 # grid-stride regime B = 512 # grid-stride regime