refactor: split quantize into multi-type primitive

- split quantize into quantize.cuh, templated on input type (bf16/fp16/fp32)
- rename pybind entry quantize_bf16 to quantize; validate the fmt enum
- fix fp8x2 packing: one 32-bit word packs two pairs (halves were dropped)
- drop the dead OutFp8 template param; GEMM output is always bf16
- fp8_state.reset() restores recipe/format defaults too (test state leak)
- rewrite tests for the two-primitive API with fp32-domain amax references
This commit is contained in:
2026-08-25 20:07:40 +08:00
parent 3e57cc8069
commit 057c0d33df
9 changed files with 530 additions and 1083 deletions
+11 -35
View File
@@ -69,32 +69,17 @@ struct Fp8GemmTraits {
static constexpr int kCtaThreads = (BlockM / 64) * (BlockN / 32) * 32;
};
// Quantize-kernel parameter POD: BF16 -> FP8 with fused amax and optional
// delayed-scaling ring finalization. Separate from FP8Params so each
// operator owns exactly the fields it touches (the GEMM never reads amax /
// ring state). Same NSDMI rationale: amax / ring_state gate optional paths
// via null checks. Still an aggregate, still trivially copyable.
// Quantize-kernel parameter POD: float input (bf16 / fp16 / fp32) -> FP8
// with fused amax.
struct FP8QuantizeParams {
// BF16 input and FP8 output buffers; scale_a is the quantization step
// (device scalar). amax_a (may be null) is zero-initialized by the
// binding and receives the raw-domain absolute maximum.
const void* __restrict__ a_ptr = nullptr;
void* __restrict__ out_ptr = nullptr;
const float* __restrict__ scale_a = nullptr;
float* __restrict__ amax_a = nullptr;
// Float input and FP8 output buffers; scale is the quantization
// multiplier (device scalar). amax (may be null) is zero-initialized by
// the binding and receives the raw-domain absolute maximum.
const void* __restrict__ input_ptr = nullptr;
void* __restrict__ output_ptr = nullptr;
// Optional delayed-scaling ring finalization. ring_state packs
// [hist[ring_len] | scale | counter] with ring_len = numel - 2. When
// non-null and amax_a is set, the last-finishing block records the
// measured amax into hist[ring_idx], reduces the window and publishes
// the next step's scale (max(hist) / fp8_max / 2^ring_margin) — the
// fused replacement for the eager hist-write / max / scale-write chain,
// at zero extra launches. The counter slot is a persistent zero-armed
// int32 (float bits) electing the last block each launch.
float* ring_state = nullptr;
int ring_len = 0;
int ring_idx = 0;
int ring_margin = 0;
const float* __restrict__ scale = nullptr;
float* __restrict__ amax = nullptr;
// Element count (only the elementwise quantize kernel uses it).
int total = 0;
@@ -103,24 +88,15 @@ struct FP8QuantizeParams {
// Unified GEMM parameter POD, mirroring AttentionParams: one struct flows
// through the pre-quantized GEMM kernels. Each kernel touches only the
// fields it needs; buffers are raw pointers packed by the torch binding.
// Pointer members default to null (same NSDMI rationale as AttentionParams:
// bias / out_scale gate optional paths via null checks, so a partially
// packed struct must never hold garbage non-null pointers). Still an
// aggregate, still trivially copyable.
// Pointer members default to null so optional paths cannot hold garbage.
struct FP8Params {
// Inputs: a/b are FP8 for the pre-quantized path. Scales are
// quantization steps (device scalars).
const void* __restrict__ a_ptr = nullptr;
const void* __restrict__ b_ptr = nullptr;
const void* __restrict__ bias = nullptr;
const float* __restrict__ scale_a = nullptr;
const float* __restrict__ scale_b = nullptr;
const float* __restrict__ bias_scale = nullptr;
// Output: BF16 or FP8 (E4M3). out_scale is the output quantization step
// (FP8 output only).
void* __restrict__ out_ptr = nullptr;
const float* __restrict__ out_scale = nullptr;
const float* __restrict__ scale = nullptr;
// Shapes. `int` covers every realistic LLM shape; the kernels promote
// to int64 for all pointer arithmetic.
int m, n, k;
+21 -174
View File
@@ -2,7 +2,8 @@
// FP8 GEMM device code — pure CUDA, no torch. Mirrors the attention kernel
// layout (attn_*_mma.cuh): kernels take the FP8Params POD, tile shape and
// FP8 format ride on compile-time template parameters, and launchers are
// plain functions usable from both the torch binding and pure C tests.
// plain functions usable from both the torch binding and pure C tests. The
// quantize kernel lives in quantize.cuh.
#include <cuda_bf16.h>
#include <cuda_fp8.h>
@@ -46,128 +47,11 @@ struct fp8_input<FP8Format::E5M2> {
// FP8 MMA lives in the shared astrai::mma_sync template (common/mma.cuh);
// instantiate it with fp8_input<Fmt>::type. Accumulates in-place: callers
// pass the same accumulator array as both `d` and `c`.
// warp_reduce_max / atomic_max_float (quantize amax) live in
// common/reduce.cuh; the cp.async pipeline primitives (predicated 16-byte
// copy, commit_group, wait_group + runtime dispatch) in common/cp_async.cuh.
// warp_reduce_sum / group_reduce_sum (GEMM) live in common/reduce.cuh; the
// cp.async pipeline primitives (predicated 16-byte copy, commit_group,
// wait_group + runtime dispatch) in common/cp_async.cuh.
// ---------------------------------------------------------------------------
// Quantize kernel: BF16 -> FP8 (E4M3 or E5M2), fused amax over raw values.
// ---------------------------------------------------------------------------
// Convert one packed bf16 pair to one packed fp8 pair. amax sees the *raw*
// (unscaled) values; the stored bytes see value * inv. Bit-identical to the
// scalar __nv_fp8_*(q) constructor path (round-nearest-even + satfinite).
template <FP8Format Fmt>
__device__ __forceinline__ unsigned quantize2(unsigned pair, float inv,
float& amax) {
const float lo = __bfloat162float(__ushort_as_bfloat16(pair & 0xffffu));
const float hi = __bfloat162float(__ushort_as_bfloat16(pair >> 16));
amax = fmaxf(amax, fmaxf(fabsf(lo), fabsf(hi)));
constexpr __nv_fp8_interpretation_t kFmt =
Fmt == FP8Format::E5M2 ? __NV_E5M2 : __NV_E4M3;
return static_cast<unsigned>(__nv_cvt_float2_to_fp8x2(
make_float2(lo * inv, hi * inv), __NV_SATFINITE, kFmt));
}
template <FP8Format Fmt>
__global__ void fp8_quantize_kernel(FP8QuantizeParams p) {
const float inv = 1.0f / *p.scale_a;
const auto* x = reinterpret_cast<const __nv_bfloat16*>(p.a_ptr);
void* x8 = p.out_ptr;
float* amax = p.amax_a;
float local_amax = 0.0f;
const int64_t stride = (int64_t)blockDim.x * gridDim.x;
// Vectorized body: 8 bf16 (16B load) -> 8 fp8 (8B store) per step. Torch
// allocations are >=16B aligned and the binding passes freshly allocated
// contiguous buffers, so element 0 keeps the uint4/uint2 accesses
// natural; a misaligned base (contiguous view with an odd storage
// offset) falls back to the scalar loop below via total_vec = 0.
const bool aligned =
((reinterpret_cast<uintptr_t>(x) | reinterpret_cast<uintptr_t>(x8)) & 15) ==
0;
const int64_t total_vec = aligned ? p.total / 8 : 0;
const uint4* xv = reinterpret_cast<const uint4*>(x);
uint2* o8 = reinterpret_cast<uint2*>(x8);
for (int64_t i = blockIdx.x * blockDim.x + threadIdx.x; i < total_vec;
i += stride) {
const uint4 v = xv[i];
const unsigned pair[4] = {v.x, v.y, v.z, v.w};
unsigned packed[2] = {0u, 0u};
#pragma unroll
for (int j = 0; j < 4; ++j)
packed[j >> 1] |= quantize2<Fmt>(pair[j], inv, local_amax)
<< (16 * (j & 1));
o8[i] = make_uint2(packed[0], packed[1]);
}
// Scalar tail (and full fallback for misaligned bases).
for (int64_t i = total_vec * 8 + blockIdx.x * blockDim.x + threadIdx.x;
i < p.total; i += stride) {
const float f = __bfloat162float(x[i]);
local_amax = fmaxf(local_amax, fabsf(f));
if constexpr (Fmt == FP8Format::E5M2) {
reinterpret_cast<__nv_fp8_e5m2*>(x8)[i] = __nv_fp8_e5m2(f * inv);
} else {
reinterpret_cast<__nv_fp8_e4m3*>(x8)[i] = __nv_fp8_e4m3(f * inv);
}
}
if (amax) {
local_amax = warp_reduce_max(local_amax);
__shared__ float slots[32];
if ((threadIdx.x & 31) == 0) slots[threadIdx.x >> 5] = local_amax;
__syncthreads();
if (threadIdx.x == 0) {
float v = 0.0f;
for (int w = 0; w < (blockDim.x >> 5); ++w) v = fmaxf(v, slots[w]);
atomic_max_float(amax, v);
}
}
if (p.ring_state && amax) {
// Delayed-scaling ring finalization as a last-block epilogue (the
// CUDA threadFenceReduction pattern): the fence + counter elect the
// final block once every block's atomic_max above is visible; warp 0
// folds the fresh amax into the window, reduces it and publishes the
// next step's scale, then re-arms the counter for the next launch.
// __fdiv_rn / ldexpf keep the scale bit-identical to the eager
// (peak / fp8_max) / 2^margin fp32 chain despite --use_fast_math.
__threadfence();
__shared__ bool ring_last;
if (threadIdx.x == 0)
ring_last = atomicAdd(reinterpret_cast<int*>(p.ring_state +
p.ring_len + 1),
1) == gridDim.x - 1;
__syncthreads();
if (ring_last && threadIdx.x < 32) {
float* hist = p.ring_state;
const int lane = threadIdx.x;
float v = 0.0f;
if (lane < p.ring_len) v = hist[lane];
if (lane == p.ring_idx) {
v = *amax; // the global amax is final now
hist[lane] = v;
}
// Windows longer than one warp (atypical) fold the tail.
for (int i = lane + 32; i < p.ring_len; i += 32) {
float h = hist[i];
if (i == p.ring_idx) {
h = *amax;
hist[i] = h;
}
v = fmaxf(v, h);
}
const float peak = warp_reduce_max(v);
if (lane == 0) {
constexpr float kFmtMax =
Fmt == FP8Format::E5M2 ? 57344.0f : 448.0f;
p.ring_state[p.ring_len] = fmaxf(
ldexpf(__fdiv_rn(peak, kFmtMax), -p.ring_margin), 1e-12f);
__threadfence();
// Re-arm the counter (0.0f bits == int32 0).
p.ring_state[p.ring_len + 1] = 0.0f;
}
}
}
}
// Swizzled address inside a flat [rows * K] staging tile: the 16-byte chunk
// index is XORed with a row-dependent slice so a warp's fragment load (8
@@ -446,8 +330,7 @@ struct Fp8GemmSmem {
// (mt x nt = 4x4 MMA each). The 64x128 variant runs 4 warps / 128 threads and
// exists for small-M calls: m <= 64 wastes half of every 128-row CTA, so the
// launcher dispatches to it there (see launch_fp8_gemm).
template <typename Traits, bool OutFp8 = false,
typename LayoutA = RowMajor, typename LayoutB = RowMajor, bool kGroupRaster = false,
template <typename Traits, typename LayoutA = RowMajor, typename LayoutB = RowMajor, bool kGroupRaster = false,
bool kBStaged = true>
__global__ void __launch_bounds__(Traits::kCtaThreads,
Fp8GemmSmem<Traits, LayoutA, LayoutB,
@@ -495,7 +378,6 @@ __global__ void __launch_bounds__(Traits::kCtaThreads,
const auto* a = reinterpret_cast<const T8*>(p.a_ptr);
const auto* b = reinterpret_cast<const T8*>(p.b_ptr);
auto* out_bf16 = reinterpret_cast<__nv_bfloat16*>(p.out_ptr);
auto* out_fp8 = reinterpret_cast<__nv_fp8_e4m3*>(p.out_ptr);
const int64_t m = p.m, n = p.n, k = p.k;
const int64_t a_ld = p.a_ld, b_ld = p.b_ld;
@@ -537,8 +419,7 @@ __global__ void __launch_bounds__(Traits::kCtaThreads,
(int64_t)block_n * kBlockN + warp_n * 32 + thread_in_group * 2;
const int a_row0 = warp_m * 64; // + mt * 16 in the loop
const int b_row0 = warp_n * 32; // + nt * 8
const float sa = *p.scale_a;
const float sb = *p.scale_b;
const float scale = *p.scale;
float acc[4][4][4] = {}; // [nt][mt][acc]
// Both operands end up in the canonical [M][kK] / [N][kK] shared tiles
@@ -742,49 +623,25 @@ __global__ void __launch_bounds__(Traits::kCtaThreads,
}
}
const float output_scale = sa * sb;
// Fused bias: BF16 raw values, or FP8 storage dequantized by its own
// scale (bias_scale != null selects the FP8 path; the format follows the
// kernel's Traits). Added in real units after the operand dequantization
// and before any output quantization.
const auto* bias16 = static_cast<const __nv_bfloat16*>(p.bias);
const auto* bias8 = static_cast<const T8*>(p.bias);
auto bias_val = [&](int64_t col) -> float {
if (p.bias == nullptr || col >= n) return 0.0f;
if (p.bias_scale == nullptr) return __bfloat162float(bias16[col]);
return __half2float(__half(bias8[col])) * *p.bias_scale;
};
const float output_scale = scale;
#pragma unroll
for (int nt = 0; nt < 4; ++nt) {
const int64_t col = output_col + nt * 8;
const float b0 = bias_val(col);
const float b1 = bias_val(col + 1);
// Per-row store: FP8 packs two adjacent columns into one 16-bit
// write, BF16 into one 32-bit __nv_bfloat162 (single cvt+pack
// instruction); boundary or unaligned columns fall back to scalar
// converts so a pack never crosses the row edge or misaligns.
auto store_out = [&](int64_t row, float v0, float v1) {
if (row >= m) return;
const float r0 = v0 * output_scale + b0;
const float r1 = v1 * output_scale + b1;
if constexpr (OutFp8) {
if (col + 1 < n) {
*reinterpret_cast<unsigned short*>(out_fp8 + row * n + col) =
static_cast<unsigned short>(__nv_cvt_float2_to_fp8x2(
make_float2(r0 * *p.out_scale, r1 * *p.out_scale),
__NV_SATFINITE, __NV_E4M3));
} else {
out_fp8[row * n + col] = __nv_fp8_e4m3(r0 * *p.out_scale);
}
const float r0 = v0 * output_scale;
const float r1 = v1 * output_scale;
auto* dst = out_bf16 + row * n + col;
if (col + 1 < n && (reinterpret_cast<uintptr_t>(dst) & 3) == 0) {
*reinterpret_cast<__nv_bfloat162*>(dst) =
__floats2bfloat162_rn(r0, r1);
} else {
auto* dst = out_bf16 + row * n + col;
if (col + 1 < n && (reinterpret_cast<uintptr_t>(dst) & 3) == 0) {
*reinterpret_cast<__nv_bfloat162*>(dst) =
__floats2bfloat162_rn(r0, r1);
} else {
dst[0] = __float2bfloat16(r0);
if (col + 1 < n) dst[1] = __float2bfloat16(r1);
}
dst[0] = __float2bfloat16(r0);
if (col + 1 < n) dst[1] = __float2bfloat16(r1);
}
};
#pragma unroll
@@ -803,16 +660,6 @@ __global__ void __launch_bounds__(Traits::kCtaThreads,
// Launchers — pure CUDA (no torch), usable from the binding and pure C tests.
// ---------------------------------------------------------------------------
template <FP8Format Fmt>
void launch_fp8_quantize(const FP8QuantizeParams& p, cudaStream_t stream) {
constexpr int kThreads = 256;
// One block per 256 vectors (8 elements each); at least one block so the
// scalar tail of a tiny / misaligned tensor is still covered.
int64_t blocks = (p.total / 8 + kThreads - 1) / kThreads;
if (blocks < 1) blocks = 1;
fp8_quantize_kernel<Fmt><<<blocks, kThreads, 0, stream>>>(p);
}
// Launch one kernel instantiation with its shared-memory budget: stages live
// in dynamic smem, so budgets beyond the 48KB static limit opt in once per
// instantiation via cudaFuncSetAttribute (see AGENTS.md "dynamic shared
@@ -851,7 +698,7 @@ void launch_with_smem(int smem_bytes, dim3 grid, dim3 block,
// dX ~39 TF staged vs ~38 direct).
constexpr int64_t kCrossStageMinK = 8192;
template <FP8Format Fmt, bool OutFp8 = false, typename LayoutA = RowMajor,
template <FP8Format Fmt, typename LayoutA = RowMajor,
typename LayoutB = RowMajor, int kK = 64, int Stages = 2,
bool GroupRaster = std::is_same_v<LayoutA, ColMajor> || std::is_same_v<LayoutB, ColMajor>>
void launch_fp8_gemm(const FP8Params& p, cudaStream_t stream) {
@@ -860,24 +707,24 @@ void launch_fp8_gemm(const FP8Params& p, cudaStream_t stream) {
if (p.m <= 64) {
using Traits = Fp8GemmTraits<Fmt, 64, 128, kK, Stages>;
if (b_staged)
launch_with_smem<fp8_gemm_kernel<Traits, OutFp8, LayoutA, LayoutB,
launch_with_smem<fp8_gemm_kernel<Traits, LayoutA, LayoutB,
GroupRaster, true>>(
Fp8GemmSmem<Traits, LayoutA, LayoutB, true>::kBytes, grid,
dim3(Traits::kCtaThreads), stream, p);
else
launch_with_smem<fp8_gemm_kernel<Traits, OutFp8, LayoutA, LayoutB,
launch_with_smem<fp8_gemm_kernel<Traits, LayoutA, LayoutB,
GroupRaster, false>>(
Fp8GemmSmem<Traits, LayoutA, LayoutB, false>::kBytes, grid,
dim3(Traits::kCtaThreads), stream, p);
} else {
using Traits = Fp8GemmTraits<Fmt, 128, 128, kK, Stages>;
if (b_staged)
launch_with_smem<fp8_gemm_kernel<Traits, OutFp8, LayoutA, LayoutB,
launch_with_smem<fp8_gemm_kernel<Traits, LayoutA, LayoutB,
GroupRaster, true>>(
Fp8GemmSmem<Traits, LayoutA, LayoutB, true>::kBytes, grid,
dim3(Traits::kCtaThreads), stream, p);
else
launch_with_smem<fp8_gemm_kernel<Traits, OutFp8, LayoutA, LayoutB,
launch_with_smem<fp8_gemm_kernel<Traits, LayoutA, LayoutB,
GroupRaster, false>>(
Fp8GemmSmem<Traits, LayoutA, LayoutB, false>::kBytes, grid,
dim3(Traits::kCtaThreads), stream, p);
+94 -433
View File
@@ -1,76 +1,68 @@
// FP8 GEMM torch binding: tensor validation, FP8Params packing, template
// dispatch and pybind. Device code lives in gemm.cuh (pure CUDA) —
// mirroring the attn_*.cu / attn_*_mma.cuh split of the attention kernels.
// CUDA bindings for the two stateless FP8 primitives.
#include <torch/extension.h>
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAGuard.h>
#include <cuda_bf16.h>
#include <torch/extension.h>
#include <cstdint>
#include <mutex>
#include <tuple>
#include <unordered_map>
#include "gemm.cuh"
#include "../common/device.cuh"
#include "gemm.cuh"
#include "quantize.cuh"
using namespace astrai::fp8;
namespace {
// FP8Format / FP8Params and the launchers live in astrai::fp8 (common.h /
// gemm.cuh); this TU opens the using-directive above so the binding reads
// them unqualified.
void check_fp8_device(const torch::Tensor& tensor) {
static std::mutex mutex;
static std::unordered_map<int, bool> supported;
const int device = tensor.device().index();
{
std::lock_guard<std::mutex> lock(mutex);
auto cached = supported.find(device);
if (cached != supported.end()) {
TORCH_CHECK(cached->second,
"fused FP8 MMA requires compute capability 8.9 or newer");
auto it = supported.find(device);
if (it != supported.end()) {
TORCH_CHECK(it->second, "FP8 MMA requires compute capability 8.9+");
return;
}
}
const auto* properties = at::cuda::getDeviceProperties(device);
const bool is_supported =
astrai::sm_at_least(properties->major, properties->minor,
astrai::kMinSmForFp8Major,
astrai::kMinSmForFp8Minor);
const bool ok = astrai::sm_at_least(
properties->major, properties->minor, astrai::kMinSmForFp8Major,
astrai::kMinSmForFp8Minor);
{
std::lock_guard<std::mutex> lock(mutex);
supported.emplace(device, is_supported);
supported.emplace(device, ok);
}
TORCH_CHECK(is_supported,
"fused FP8 MMA requires compute capability 8.9 or newer");
TORCH_CHECK(ok, "FP8 MMA requires compute capability 8.9+");
}
void check_scale(const torch::Tensor& scale, const torch::Tensor& input,
const char* name) {
void check_scale(const torch::Tensor& scale, const torch::Tensor& input) {
TORCH_CHECK(scale.is_cuda() && scale.device() == input.device() &&
scale.scalar_type() == torch::kFloat32 && scale.numel() == 1,
name, " must be a CUDA float32 scalar on the input device");
"scale must be a CUDA float32 scalar on the input device");
}
// ---- FP8Params packing (mirrors attention/entry_utils.cuh pack_* helpers) ----
void pack_quantize(FP8QuantizeParams& p, const void* input, void* output,
const torch::Tensor& scale, torch::Tensor& amax,
int64_t total) {
p.input_ptr = input;
p.output_ptr = output;
p.scale = scale.data_ptr<float>();
p.amax = amax.data_ptr<float>();
p.total = static_cast<int>(total);
}
void pack_gemm_params(FP8Params& p, const void* a, const void* b, void* out,
const torch::Tensor& sa, const torch::Tensor& sb,
const torch::Tensor* out_scale, const void* bias,
const torch::Tensor* bias_scale, int64_t m, int64_t n,
int64_t k, int64_t a_ld, int64_t b_ld) {
void pack_gemm(FP8Params& p, const void* a, const void* b, void* output,
const torch::Tensor& scale, int64_t m, int64_t n, int64_t k,
int64_t a_ld, int64_t b_ld) {
p.a_ptr = a;
p.b_ptr = b;
p.out_ptr = out;
p.scale_a = sa.data_ptr<float>();
p.scale_b = sb.data_ptr<float>();
p.out_scale = out_scale ? out_scale->data_ptr<float>() : nullptr;
p.bias = bias;
p.bias_scale = bias_scale ? bias_scale->data_ptr<float>() : nullptr;
p.out_ptr = output;
p.scale = scale.data_ptr<float>();
p.m = static_cast<int>(m);
p.n = static_cast<int>(n);
p.k = static_cast<int>(k);
@@ -78,443 +70,112 @@ void pack_gemm_params(FP8Params& p, const void* a, const void* b, void* out,
p.b_ld = static_cast<int>(b_ld);
}
// Pack the quantize params, optionally wiring the delayed-scaling ring.
// ring (may be null) packs [hist[len] | scale | counter]; len/margin come
// from the active recipe and idx is the caller's slot for this step.
void pack_quantize_params(FP8QuantizeParams& p, const void* x, void* x8,
const torch::Tensor& scale, torch::Tensor* amax,
const torch::Tensor* ring, int64_t ring_idx,
int64_t ring_margin, int64_t total) {
p.a_ptr = x;
p.out_ptr = x8;
p.scale_a = scale.data_ptr<float>();
p.amax_a = amax ? amax->data_ptr<float>() : nullptr;
if (ring && ring->defined()) {
TORCH_CHECK(ring->is_cuda() && ring->scalar_type() == torch::kFloat32 &&
ring->numel() >= 3 && ring->is_contiguous(),
"ring must be a contiguous CUDA float32 tensor packing "
"[hist | scale | counter]");
p.ring_state = ring->data_ptr<float>();
p.ring_len = static_cast<int>(ring->numel() - 2);
p.ring_idx = static_cast<int>(ring_idx);
p.ring_margin = static_cast<int>(ring_margin);
}
p.total = static_cast<int>(total);
}
// ---- GEMM launch dispatch (runtime flags -> compile-time kernel variants) ----
template <FP8Format Fmt, int Variant>
void launch_gemm_variant(const FP8Params& p, cudaStream_t stream) {
static_assert(Variant >= 0 && Variant < 8,
"invalid FP8 GEMM dispatch variant");
constexpr bool out_fp8 = (Variant & 4) != 0;
// Variant bits 1/0 = trans_a/trans_b -> CUTLASS-style layout tags
// (trans_a ? A ColMajor : RowMajor, same for B; see common.h).
void launch_variant(const FP8Params& p, cudaStream_t stream) {
using LayoutA = std::conditional_t<(Variant & 2) != 0, ColMajor, RowMajor>;
using LayoutB = std::conditional_t<(Variant & 1) != 0, ColMajor, RowMajor>;
launch_fp8_gemm<Fmt, out_fp8, LayoutA, LayoutB>(p, stream);
launch_fp8_gemm<Fmt, LayoutA, LayoutB>(p, stream);
}
template <FP8Format Fmt>
void dispatch_gemm(const FP8Params& p, cudaStream_t stream, bool out_fp8,
bool trans_a, bool trans_b) {
// Encode the runtime flags as [output FP8, transpose A, transpose B].
const int variant = (static_cast<int>(out_fp8) << 2) |
(static_cast<int>(trans_a) << 1) |
void dispatch_gemm(const FP8Params& p, cudaStream_t stream, bool trans_a,
bool trans_b) {
const int variant = (static_cast<int>(trans_a) << 1) |
static_cast<int>(trans_b);
switch (variant) {
case 0: launch_gemm_variant<Fmt, 0>(p, stream); break;
case 1: launch_gemm_variant<Fmt, 1>(p, stream); break;
case 2: launch_gemm_variant<Fmt, 2>(p, stream); break;
case 3: launch_gemm_variant<Fmt, 3>(p, stream); break;
case 4: launch_gemm_variant<Fmt, 4>(p, stream); break;
case 5: launch_gemm_variant<Fmt, 5>(p, stream); break;
case 6: launch_gemm_variant<Fmt, 6>(p, stream); break;
case 7: launch_gemm_variant<Fmt, 7>(p, stream); break;
case 0: launch_variant<Fmt, 0>(p, stream); break;
case 1: launch_variant<Fmt, 1>(p, stream); break;
case 2: launch_variant<Fmt, 2>(p, stream); break;
case 3: launch_variant<Fmt, 3>(p, stream); break;
}
}
} // namespace
// ---------------------------------------------------------------------------
// Entry points
// ---------------------------------------------------------------------------
std::tuple<torch::Tensor, torch::Tensor> quantize_bf16(torch::Tensor x,
torch::Tensor scale,
int64_t fmt) {
// BF16 -> FP8 quantize with fused amax. fmt: 0 = E4M3, 1 = E5M2.
// Returns (x8, amax); the caller never clears amax (zero-initialized here).
TORCH_CHECK(x.is_cuda() && scale.is_cuda(), "CUDA tensors required");
TORCH_CHECK(x.scalar_type() == torch::kBFloat16, "x must be bf16");
check_scale(scale, x, "scale");
std::tuple<torch::Tensor, torch::Tensor> quantize(torch::Tensor x,
torch::Tensor scale,
int64_t fmt) {
TORCH_CHECK(x.is_cuda(), "CUDA tensors required");
TORCH_CHECK(x.scalar_type() == torch::kBFloat16 ||
x.scalar_type() == torch::kHalf ||
x.scalar_type() == torch::kFloat32,
"x must be bf16, fp16 or fp32");
TORCH_CHECK(fmt == static_cast<int64_t>(FP8Format::E4M3) ||
fmt == static_cast<int64_t>(FP8Format::E5M2),
"unsupported quantization type: expected E4M3 (0) or E5M2 (1)");
check_scale(scale, x);
check_fp8_device(x);
const at::cuda::OptionalCUDAGuard guard(x.device());
auto stream = at::cuda::getCurrentCUDAStream();
auto x_c = x.contiguous();
auto x8 = torch::empty_like(
x_c, x_c.options().dtype(fmt ? torch::kFloat8_e5m2
: torch::kFloat8_e4m3fn));
// amax feeds the cross-block atomic_max; zero it on the stream (empty +
// memset, not torch::zeros — the latter routes through a fill_ dispatcher).
auto amax = torch::empty({1}, x_c.options().dtype(torch::kFloat32));
cudaMemsetAsync(amax.data_ptr(), 0, sizeof(float), stream.stream());
auto input = x.contiguous();
auto output = torch::empty_like(
input, input.options().dtype(fmt ? torch::kFloat8_e5m2
: torch::kFloat8_e4m3fn));
auto amax = torch::zeros({1}, input.options().dtype(torch::kFloat32));
FP8QuantizeParams p;
pack_quantize_params(p, x_c.data_ptr(), x8.data_ptr(), scale, &amax,
nullptr, 0, 0, x_c.numel());
if (fmt) {
launch_fp8_quantize<FP8Format::E5M2>(p, stream.stream());
pack_quantize(p, input.data_ptr(), output.data_ptr(), scale, amax,
input.numel());
const bool e5m2 = fmt == static_cast<int64_t>(FP8Format::E5M2);
if (x.scalar_type() == torch::kHalf) {
if (e5m2)
launch_fp8_quantize<FP8Format::E5M2, __half>(p, stream.stream());
else
launch_fp8_quantize<FP8Format::E4M3, __half>(p, stream.stream());
} else if (x.scalar_type() == torch::kFloat32) {
if (e5m2)
launch_fp8_quantize<FP8Format::E5M2, float>(p, stream.stream());
else
launch_fp8_quantize<FP8Format::E4M3, float>(p, stream.stream());
} else {
launch_fp8_quantize<FP8Format::E4M3>(p, stream.stream());
if (e5m2)
launch_fp8_quantize<FP8Format::E5M2, __nv_bfloat16>(
p, stream.stream());
else
launch_fp8_quantize<FP8Format::E4M3, __nv_bfloat16>(
p, stream.stream());
}
C10_CUDA_CHECK(cudaGetLastError());
return {x8, amax};
return {output, amax};
}
torch::Tensor mm_fp8(torch::Tensor a, torch::Tensor b, torch::Tensor sa,
torch::Tensor sb, int64_t out_dtype,
c10::optional<torch::Tensor> out_scale, int64_t trans_a,
int64_t trans_b) {
// Pre-quantized FP8 GEMM: out = op(a) @ op(b)^T * (sa * sb), FP32 accum.
// trans_a / trans_b select the operand layout (0 = stored [M,K]/[K,N],
// 1 = transposed [K,M]/[N,K]); the default (0/0) is the plain a @ b.
// out_dtype: 0 = BF16 (default), 1 = FP8 E4M3 (requires out_scale, the
// output quantization step). Both operands share one format.
torch::Tensor mm_fp8(torch::Tensor a, torch::Tensor b, torch::Tensor scale,
int64_t trans_a, int64_t trans_b) {
TORCH_CHECK(a.is_cuda() && b.is_cuda(), "CUDA tensors required");
TORCH_CHECK(a.scalar_type() == torch::kFloat8_e4m3fn ||
a.scalar_type() == torch::kFloat8_e5m2,
"a and b must be fp8 (e4m3fn or e5m2)");
TORCH_CHECK(a.scalar_type() == b.scalar_type(),
"a and b must share the same fp8 format");
"a and b must be fp8");
TORCH_CHECK(a.scalar_type() == b.scalar_type(), "a and b must share format");
TORCH_CHECK(a.dim() == 2 && b.dim() == 2, "a and b must be 2D");
TORCH_CHECK(a.device() == b.device(), "a and b must be on the same device");
check_scale(sa, a, "sa");
check_scale(sb, a, "sb");
TORCH_CHECK(a.device() == b.device(), "a and b must share device");
check_scale(scale, a);
check_fp8_device(a);
const at::cuda::OptionalCUDAGuard guard(a.device());
auto stream = at::cuda::getCurrentCUDAStream();
auto a_c = a.contiguous();
auto b_c = b.contiguous();
const bool ta = (trans_a == 1), tb = (trans_b == 1);
// Physical leading dimension = column count of each contiguous buffer.
const bool ta = trans_a != 0;
const bool tb = trans_b != 0;
const int64_t a_ld = a_c.size(1);
const int64_t b_ld = b_c.size(1);
// Logical GEMM shape derived from the layout flags.
const int64_t m = ta ? a_c.size(1) : a_c.size(0);
const int64_t k = ta ? a_c.size(0) : a_c.size(1);
const int64_t n = tb ? b_c.size(0) : b_c.size(1);
const int64_t k2 = tb ? b_c.size(1) : b_c.size(0);
TORCH_CHECK(k == k2, "inner dim mismatch");
const bool out_fp8 = (out_dtype == 1);
TORCH_CHECK(out_dtype == 0 || out_fp8,
"out_dtype must be 0 (bf16) or 1 (fp8 e4m3)");
torch::Tensor os;
if (out_fp8) {
TORCH_CHECK(out_scale.has_value(), "fp8 output requires out_scale");
os = out_scale.value();
check_scale(os, a, "out_scale");
}
auto out = torch::empty(
{m, n},
out_fp8 ? a_c.options().dtype(torch::kFloat8_e4m3fn)
: a_c.options().dtype(torch::kBFloat16));
TORCH_CHECK(k == (tb ? b_c.size(1) : b_c.size(0)), "inner dim mismatch");
auto output = torch::empty({m, n}, a_c.options().dtype(torch::kBFloat16));
FP8Params p;
pack_gemm_params(p, a_c.data_ptr(), b_c.data_ptr(), out.data_ptr(), sa, sb,
out_fp8 ? &os : nullptr, nullptr, nullptr, m, n, k, a_ld,
b_ld);
pack_gemm(p, a_c.data_ptr(), b_c.data_ptr(), output.data_ptr(), scale, m, n,
k, a_ld, b_ld);
if (a.scalar_type() == torch::kFloat8_e4m3fn)
dispatch_gemm<FP8Format::E4M3>(p, stream.stream(), out_fp8, ta, tb);
dispatch_gemm<FP8Format::E4M3>(p, stream.stream(), ta, tb);
else
dispatch_gemm<FP8Format::E5M2>(p, stream.stream(), out_fp8, ta, tb);
dispatch_gemm<FP8Format::E5M2>(p, stream.stream(), ta, tb);
C10_CUDA_CHECK(cudaGetLastError());
return out;
}
std::tuple<torch::Tensor, torch::Tensor, torch::Tensor, torch::Tensor,
torch::Tensor>
linear_forward_fp8(torch::Tensor x, torch::Tensor w, torch::Tensor bias,
torch::Tensor sx, torch::Tensor sw, int64_t fmt,
c10::optional<torch::Tensor> bias_scale,
c10::optional<torch::Tensor> x_ring, int64_t x_ring_idx,
int64_t x_ring_margin, c10::optional<torch::Tensor> w_ring,
int64_t w_ring_idx, int64_t w_ring_margin) {
// Pure FP8 forward: quantize x/w (fmt: 0 = E4M3, 1 = E5M2), then the
// pre-quantized GEMM; the dequantized BF16 output gets the bias added.
// Returns (out, x8, w8, amax_x, amax_w): the quantized operands are
// handed back so the policy layer can cache the weight quantization
// (torch autocast's cached_cast analog — w8 is reused while the weight
// tensor is unchanged, and the backward can share x8/w8 when the fwd/bwd
// formats match). amax_x / amax_w come from the quantize kernels
// (zero-initialized here; a pre-quantized w reports amax_w = 0 — nothing
// to feed a delayed ring). w may itself be pre-quantized fp8 storage
// matching fmt (static inference weights): the weight quantize is
// skipped, amax_w stays 0, and w8 returns the passed-in w.
// When x_ring / w_ring are given (delayed scaling), the quantize kernels
// finalize them in-kernel: the returned amax is already folded into the
// ring window and the next step's scale is published on device.
TORCH_CHECK(x.is_cuda() && w.is_cuda(), "CUDA tensors required");
const auto f8opt = fmt ? torch::kFloat8_e5m2 : torch::kFloat8_e4m3fn;
const bool w_prequant = w.scalar_type() == f8opt;
TORCH_CHECK(
x.scalar_type() == torch::kBFloat16 &&
(w.scalar_type() == torch::kBFloat16 || w_prequant),
"x must be bf16; w must be bf16 or pre-quantized fp8 matching fmt");
TORCH_CHECK(x.device() == w.device(), "x and w must be on the same device");
check_scale(sx, x, "sx");
check_scale(sw, x, "sw");
check_fp8_device(x);
const at::cuda::OptionalCUDAGuard guard(x.device());
auto stream = at::cuda::getCurrentCUDAStream();
auto x_c = x.reshape({-1, w.size(1)}).contiguous(); // [M, K]
auto w_c = w.contiguous(); // [N, K]
int64_t m = x_c.size(0), k = x_c.size(1), n = w_c.size(0);
TORCH_CHECK(w_c.dim() == 2 && w_c.size(1) == k, "inner dim mismatch");
const bool has_bias = bias.defined() && bias.numel() > 0;
const bool b_prequant = has_bias && bias.scalar_type() == f8opt;
if (has_bias) {
TORCH_CHECK(bias.is_cuda() && bias.device() == x.device() &&
bias.numel() == n &&
(bias.scalar_type() == torch::kBFloat16 || b_prequant),
"bias must be CUDA bf16 or pre-quantized fp8 matching fmt, "
"with shape [N]");
TORCH_CHECK(b_prequant == bias_scale.has_value(),
"fp8 bias requires bias_scale (and bf16 bias takes none)");
if (b_prequant) check_scale(*bias_scale, x, "bias_scale");
}
auto x8 = torch::empty({m, k}, x_c.options().dtype(f8opt));
// Each amax slot feeds a cross-block atomic_max, so it must start at 0.
// torch::zeros would route through a fill_ dispatcher (~50us CPU per call
// in the profile); a caching-allocator empty + cudaMemsetAsync is ~2us.
// Zero both up front: the pre-quantized-w path never quantizes w, so its
// amax_w is never atomically written and must not carry stale bytes. The
// returned values are the freshly measured (or 0) amax either way.
auto amax_x = torch::empty({1}, x.options().dtype(torch::kFloat32));
auto amax_w = torch::empty({1}, x.options().dtype(torch::kFloat32));
cudaMemsetAsync(amax_x.data_ptr(), 0, sizeof(float), stream.stream());
cudaMemsetAsync(amax_w.data_ptr(), 0, sizeof(float), stream.stream());
auto out = torch::empty({m, n}, x_c.options());
auto quantize = [&](const torch::Tensor& src, torch::Tensor& dst,
const torch::Tensor& scale, torch::Tensor* amax,
const c10::optional<torch::Tensor>& ring,
int64_t ring_idx, int64_t ring_margin) {
FP8QuantizeParams qp;
pack_quantize_params(qp, src.data_ptr(), dst.data_ptr(), scale, amax,
ring ? &*ring : nullptr, ring_idx, ring_margin,
src.numel());
if (fmt) {
launch_fp8_quantize<FP8Format::E5M2>(qp, stream.stream());
} else {
launch_fp8_quantize<FP8Format::E4M3>(qp, stream.stream());
}
};
quantize(x_c, x8, sx, &amax_x, x_ring, x_ring_idx, x_ring_margin);
// Static inference weights arrive pre-quantized (w8 storage + its scale);
// only freshly-loaded bf16 weights quantize here.
torch::Tensor w8 = w_prequant
? w_c
: torch::empty({n, k}, x_c.options().dtype(f8opt));
if (!w_prequant)
quantize(w_c, w8, sw, &amax_w, w_ring, w_ring_idx, w_ring_margin);
FP8Params p;
// Forward is the NT layout: A = x8 [M,K] (a_ld = k), B = w8 [N,K]
// (b_ld = k), out = x @ w^T. The bias is fused into the epilogue (bf16
// raw, or fp8 + bias_scale on the static path).
auto bias_c = has_bias ? bias.contiguous() : bias;
pack_gemm_params(p, x8.data_ptr(), w8.data_ptr(), out.data_ptr(), sx, sw,
nullptr, has_bias ? bias_c.data_ptr() : nullptr,
b_prequant ? &*bias_scale : nullptr, m, n, k, k, k);
if (fmt) {
launch_fp8_gemm<FP8Format::E5M2, false, RowMajor, ColMajor>(
p, stream.stream());
} else {
launch_fp8_gemm<FP8Format::E4M3, false, RowMajor, ColMajor>(
p, stream.stream());
}
C10_CUDA_CHECK(cudaGetLastError());
std::vector<int64_t> shape(x.sizes().begin(), x.sizes().end() - 1);
shape.push_back(n);
return {out.reshape(shape), x8, w8, amax_x, amax_w};
}
std::tuple<torch::Tensor, torch::Tensor, torch::Tensor, torch::Tensor>
linear_backward_fp8(torch::Tensor g, torch::Tensor x, torch::Tensor w,
std::vector<int64_t> masks, torch::Tensor sg,
torch::Tensor sw, torch::Tensor sx, int64_t fmt,
c10::optional<torch::Tensor> g_ring, int64_t g_ring_idx,
int64_t g_ring_margin) {
// Pre-quantized FP8 backward: grad is quantized once (E4M3 or E5M2 per
// `fmt`), then dX / dW run as FP8 tensor-core GEMMs sharing g8.
// Returns (grad_input, grad_weight, grad_bias, amax_g). With g_ring
// (delayed scaling), the g quantize kernel finalizes the ring in-kernel
// (amax folded into the window, next step's scale published on device);
// the w/x quantizes for dX / dW never touch rings — each operand's ring
// is finalized exactly once per step (by the forward or this kernel).
TORCH_CHECK(g.is_cuda() && x.is_cuda() && w.is_cuda(), "CUDA tensors required");
// Each operand may be bf16 (quantized here) or already fp8 matching fmt
// (reused from the forward — the fp8 cached_cast analog, symmetric with
// the forward's pre-quantized w path). Pre-quantized operands skip their
// quantize kernel; their scale is still passed for the GEMM dequant.
const auto f8opt = fmt ? torch::kFloat8_e5m2 : torch::kFloat8_e4m3fn;
const bool g_prequant = g.scalar_type() == f8opt;
const bool x_prequant = x.scalar_type() == f8opt;
const bool w_prequant = w.scalar_type() == f8opt;
TORCH_CHECK(
(g.scalar_type() == torch::kBFloat16 || g_prequant) &&
(x.scalar_type() == torch::kBFloat16 || x_prequant) &&
(w.scalar_type() == torch::kBFloat16 || w_prequant),
"g, x, and w must be bf16 or pre-quantized fp8 matching fmt");
TORCH_CHECK(g.device() == x.device() && g.device() == w.device(),
"g, x, and w must be on the same device");
TORCH_CHECK(masks.size() == 3, "masks must contain three values");
check_fp8_device(g);
const at::cuda::OptionalCUDAGuard guard(g.device());
auto stream = at::cuda::getCurrentCUDAStream();
auto g_c = g.reshape({-1, w.size(0)}).contiguous(); // [M, N]
auto x_c = x.reshape({-1, x.size(-1)}).contiguous(); // [M, K]
auto w_c = w.contiguous(); // [N, K]
int64_t m = g_c.size(0), n = w_c.size(0), k = w_c.size(1);
TORCH_CHECK(x_c.size(0) == m && x_c.size(1) == k && g_c.size(1) == n,
"backward shape mismatch");
auto grad_input =
torch::empty_like(x, x.options().dtype(torch::kBFloat16));
auto grad_weight =
torch::empty_like(w, w.options().dtype(torch::kBFloat16));
auto grad_bias = torch::empty({0}, g.options());
// amax_g feeds a cross-block atomic_max in the g quantize kernel; zero it
// on the stream (empty + memset, not torch::zeros — see the forward).
// Only needed when a g quantize runs (mask[0]||mask[1]); the bias-only
// fallback below overwrites it via .copy_, so a wasted memset elsewhere
// is harmless.
auto amax_g = torch::empty({1}, g.options().dtype(torch::kFloat32));
cudaMemsetAsync(amax_g.data_ptr(), 0, sizeof(float), stream.stream());
auto quantize = [&](const torch::Tensor& src, torch::Tensor& dst,
const torch::Tensor& scale, torch::Tensor* amax,
const c10::optional<torch::Tensor>& ring,
int64_t ring_idx, int64_t ring_margin) {
FP8QuantizeParams qp;
pack_quantize_params(qp, src.data_ptr(), dst.data_ptr(), scale, amax,
ring ? &*ring : nullptr, ring_idx, ring_margin,
src.numel());
if (fmt) {
launch_fp8_quantize<FP8Format::E5M2>(qp, stream.stream());
} else {
launch_fp8_quantize<FP8Format::E4M3>(qp, stream.stream());
}
};
// Four-layout backward: the gradient and activation tensors keep their
// natural row-major layout, and the kernel reads them transposed where the
// GEMM needs it (the ColMajor layout tags pick the crosswise stage-load).
// No torch-level `.transpose().contiguous()`
// copies are required — dX uses g8 [M,N] as A with w8 [N,K] read transposed
// as B; dW uses g8 transposed as A with x8 transposed as B.
// g is quantized once (amax_g measured here); both GEMMs share g8.
auto run_bwd_gemm = [&](const FP8Params& gp, bool trans_a, bool trans_b) {
if (fmt)
dispatch_gemm<FP8Format::E5M2>(gp, stream.stream(), false, trans_a,
trans_b);
else
dispatch_gemm<FP8Format::E4M3>(gp, stream.stream(), false, trans_a,
trans_b);
};
torch::Tensor g8;
if (masks[0] || masks[1]) {
if (g_prequant) {
g8 = g_c;
} else {
g8 = torch::empty({m, n}, g.options().dtype(f8opt));
quantize(g_c, g8, sg, &amax_g, g_ring, g_ring_idx, g_ring_margin);
}
}
// dX = g @ w: A = g8 [M,N] (contract over N), B = w8 [N,K] read transposed
// (b[p*b_ld + n] = w[p,n]); out = [M,K], a_ld = N, b_ld = K, contract = N.
if (masks[0]) {
torch::Tensor w8;
if (w_prequant) {
w8 = w_c;
} else {
w8 = torch::empty({n, k}, g.options().dtype(f8opt));
quantize(w_c, w8, sw, nullptr, c10::nullopt, 0, 0);
}
auto grad_input_2d = grad_input.reshape({m, k});
FP8Params gp;
pack_gemm_params(gp, g8.data_ptr(), w8.data_ptr(),
grad_input_2d.data_ptr(), sg, sw, nullptr, nullptr,
nullptr, m, k, n, n, k);
run_bwd_gemm(gp, false, false);
}
// dW = g^T @ x: A = g8 [M,N] read transposed (a[p*a_ld + m] = g[p,m]), B =
// x8 [M,K] read transposed (b[p*b_ld + n] = x[p,n]); out = [N,K], a_ld = N,
// b_ld = K, contract = M.
if (masks[1]) {
torch::Tensor x8;
if (x_prequant) {
x8 = x_c;
} else {
x8 = torch::empty({m, k}, g.options().dtype(f8opt));
quantize(x_c, x8, sx, nullptr, c10::nullopt, 0, 0);
}
FP8Params gp;
pack_gemm_params(gp, g8.data_ptr(), x8.data_ptr(),
grad_weight.data_ptr(), sg, sx, nullptr, nullptr,
nullptr, n, k, m, n, k);
run_bwd_gemm(gp, true, false);
}
if (!masks[0] && !masks[1] && !g_prequant) {
amax_g.copy_(g_c.abs().amax().to(torch::kFloat32));
}
C10_CUDA_CHECK(cudaGetLastError());
if (masks[2]) {
// A pre-quantized g has no bf16 source to reduce; dequantize with its
// scale before the batch-sum so grad_bias stays in the true gradient
// domain (sg * sum(g8)).
grad_bias = g_prequant
? (g_c.to(torch::kFloat32) * sg).sum(0).to(torch::kBFloat16)
: g_c.sum(0).to(g.scalar_type());
}
return {grad_input, grad_weight, grad_bias, amax_g};
return output;
}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("quantize_bf16", &quantize_bf16, py::arg("x"), py::arg("scale"),
py::arg("fmt"),
"BF16 to FP8 (E4M3/E5M2) quantize with fused amax; returns (x8, amax)");
m.def("mm_fp8", &mm_fp8, py::arg("a"), py::arg("b"), py::arg("sa"),
py::arg("sb"), py::arg("out_dtype") = 0,
py::arg("out_scale") = py::none(), py::arg("trans_a") = 0,
py::arg("trans_b") = 0,
"Pre-quantized FP8 GEMM: op(a) @ op(b)^T * (sa * sb); out_dtype "
"0=bf16, 1=fp8 e4m3 (requires out_scale); trans_a/trans_b select "
"the operand layout (default 0/0 = a@b)");
m.def("linear_forward_fp8", &linear_forward_fp8, py::arg("x"),
py::arg("w"), py::arg("bias"), py::arg("sx"), py::arg("sw"),
py::arg("fmt") = 0, py::arg("bias_scale") = py::none(),
py::arg("x_ring") = py::none(), py::arg("x_ring_idx") = 0,
py::arg("x_ring_margin") = 0, py::arg("w_ring") = py::none(),
py::arg("w_ring_idx") = 0, py::arg("w_ring_margin") = 0,
"Pure FP8 linear forward: quantize x/w, pre-quantized GEMM with the "
"bias fused into the epilogue; w and bias may be pre-quantized fp8 "
"matching fmt (static inference path; fp8 bias requires bias_scale);"
" x_ring/w_ring optionally finalize a delayed-scaling ring "
"([hist | scale | counter] float32 buffer) in-kernel; returns "
"(out, x8, w8, amax_x, amax_w) — x8 is [M,K], w8 is [N,K] (the "
"passed-in w on the pre-quantized path)");
m.def("linear_backward_fp8", &linear_backward_fp8, py::arg("g"),
py::arg("x"), py::arg("w"), py::arg("masks"), py::arg("sg"),
py::arg("sw"), py::arg("sx"), py::arg("fmt"),
py::arg("g_ring") = py::none(), py::arg("g_ring_idx") = 0,
py::arg("g_ring_margin") = 0,
"FP8 linear backward; g_ring optionally finalizes the gradient's "
"delayed-scaling ring in-kernel; returns (grad_input, grad_weight, "
"grad_bias, amax_g)");
m.def("quantize", &quantize, py::arg("x"), py::arg("scale"),
py::arg("fmt"));
m.def("mm_fp8", &mm_fp8, py::arg("a"), py::arg("b"), py::arg("scale"),
py::arg("trans_a") = 0, py::arg("trans_b") = 0);
}
+173
View File
@@ -0,0 +1,173 @@
#pragma once
// FP8 quantize device code — pure CUDA, no torch. Any float input element
// type (bf16 / fp16 / fp32) converts to E4M3 or E5M2 with a fused amax over
// the raw (unscaled) values. Mirrors the GEMM file's split: kernels take the
// FP8QuantizeParams POD, formats and input types ride on template parameters,
// and the launcher is a plain function usable from both the torch binding and
// pure C tests.
#include <cuda_bf16.h>
#include <cuda_fp16.h>
#include <cuda_fp8.h>
#include <cuda_runtime.h>
#include <cstdint>
#include "common.h"
#include "../common/reduce.cuh"
namespace astrai {
namespace fp8 {
// Input element type traits: one element -> float, and the vectorized
// unpack of one 16-byte load into kVecElems floats.
template <typename InT>
struct quant_in_traits;
template <>
struct quant_in_traits<__nv_bfloat16> {
static constexpr int kVecElems = 8;
static __device__ __forceinline__ float to_float(__nv_bfloat16 v) {
return __bfloat162float(v);
}
static __device__ __forceinline__ void load_vec(const uint4& raw,
float* f) {
const unsigned w[4] = {raw.x, raw.y, raw.z, raw.w};
#pragma unroll
for (int j = 0; j < 4; ++j) {
f[2 * j] =
__bfloat162float(__ushort_as_bfloat16(w[j] & 0xffffu));
f[2 * j + 1] = __bfloat162float(__ushort_as_bfloat16(w[j] >> 16));
}
}
};
template <>
struct quant_in_traits<__half> {
static constexpr int kVecElems = 8;
static __device__ __forceinline__ float to_float(__half v) {
return __half2float(v);
}
static __device__ __forceinline__ void load_vec(const uint4& raw,
float* f) {
const __half2* h2 = reinterpret_cast<const __half2*>(&raw);
#pragma unroll
for (int j = 0; j < 4; ++j) {
const float2 p = __half22float2(h2[j]);
f[2 * j] = p.x;
f[2 * j + 1] = p.y;
}
}
};
template <>
struct quant_in_traits<float> {
static constexpr int kVecElems = 4;
static __device__ __forceinline__ float to_float(float v) { return v; }
static __device__ __forceinline__ void load_vec(const uint4& raw,
float* f) {
f[0] = __uint_as_float(raw.x);
f[1] = __uint_as_float(raw.y);
f[2] = __uint_as_float(raw.z);
f[3] = __uint_as_float(raw.w);
}
};
// Convert one float pair to one packed fp8 pair. The stored bytes see
// value * mult (round-nearest-even + satfinite).
template <FP8Format Fmt>
__device__ __forceinline__ unsigned cvt_fp8x2(float a, float b) {
constexpr __nv_fp8_interpretation_t kFmt =
Fmt == FP8Format::E5M2 ? __NV_E5M2 : __NV_E4M3;
return static_cast<unsigned>(__nv_cvt_float2_to_fp8x2(
make_float2(a, b), __NV_SATFINITE, kFmt));
}
// Quantize kernel: float input -> FP8 (E4M3 or E5M2), fused amax over raw
// values.
template <FP8Format Fmt, typename InT>
__global__ void fp8_quantize_kernel(FP8QuantizeParams p) {
const float mult = *p.scale;
const auto* x = static_cast<const InT*>(p.input_ptr);
void* x8 = p.output_ptr;
float* amax = p.amax;
float local_amax = 0.0f;
const int64_t stride = (int64_t)blockDim.x * gridDim.x;
// Vectorized body: one 16B load -> kVecElems fp8 bytes per step (8
// elements for 16-bit inputs, 4 for fp32). Torch allocations are >=16B
// aligned and the binding passes freshly allocated contiguous buffers,
// so element 0 keeps the uint4 access natural; a misaligned base
// (contiguous view with an odd storage offset) falls back to the scalar
// loop below via total_vec = 0.
constexpr int kVecElems = quant_in_traits<InT>::kVecElems;
const bool aligned =
((reinterpret_cast<uintptr_t>(x) |
reinterpret_cast<uintptr_t>(x8)) &
15) == 0;
const int64_t total_vec = aligned ? p.total / kVecElems : 0;
const uint4* xv = reinterpret_cast<const uint4*>(x);
for (int64_t i = blockIdx.x * blockDim.x + threadIdx.x; i < total_vec;
i += stride) {
float f[kVecElems];
quant_in_traits<InT>::load_vec(xv[i], f);
// One 32-bit word packs two fp8x2 pairs (4 elements).
unsigned packed[kVecElems / 4];
#pragma unroll
for (int j = 0; j < kVecElems / 4; ++j) {
local_amax = fmaxf(
local_amax,
fmaxf(fmaxf(fabsf(f[4 * j]), fabsf(f[4 * j + 1])),
fmaxf(fabsf(f[4 * j + 2]), fabsf(f[4 * j + 3]))));
const unsigned lo =
cvt_fp8x2<Fmt>(f[4 * j] * mult, f[4 * j + 1] * mult);
const unsigned hi =
cvt_fp8x2<Fmt>(f[4 * j + 2] * mult, f[4 * j + 3] * mult);
packed[j] = (lo & 0xffffu) | (hi << 16);
}
if constexpr (kVecElems == 8)
reinterpret_cast<uint2*>(x8)[i] =
make_uint2(packed[0], packed[1]);
else
reinterpret_cast<unsigned*>(x8)[i] = packed[0];
}
// Scalar tail (and full fallback for misaligned bases).
for (int64_t i = total_vec * kVecElems + blockIdx.x * blockDim.x +
threadIdx.x;
i < p.total; i += stride) {
const float v = quant_in_traits<InT>::to_float(x[i]);
local_amax = fmaxf(local_amax, fabsf(v));
if constexpr (Fmt == FP8Format::E5M2) {
reinterpret_cast<__nv_fp8_e5m2*>(x8)[i] =
__nv_fp8_e5m2(v * mult);
} else {
reinterpret_cast<__nv_fp8_e4m3*>(x8)[i] =
__nv_fp8_e4m3(v * mult);
}
}
if (amax) {
local_amax = warp_reduce_max(local_amax);
__shared__ float slots[32];
if ((threadIdx.x & 31) == 0) slots[threadIdx.x >> 5] = local_amax;
__syncthreads();
if (threadIdx.x == 0) {
float v = 0.0f;
for (int w = 0; w < (blockDim.x >> 5); ++w)
v = fmaxf(v, slots[w]);
atomic_max_float(amax, v);
}
}
}
template <FP8Format Fmt, typename InT>
void launch_fp8_quantize(const FP8QuantizeParams& p, cudaStream_t stream) {
constexpr int kThreads = 256;
// One block per 256 vectors; at least one block so the scalar tail of a
// tiny / misaligned tensor is still covered.
constexpr int kVecElems = quant_in_traits<InT>::kVecElems;
int64_t blocks = (p.total / kVecElems + kThreads - 1) / kThreads;
if (blocks < 1) blocks = 1;
fp8_quantize_kernel<Fmt, InT><<<blocks, kThreads, 0, stream>>>(p);
}
} // namespace fp8
} // namespace astrai