quantization: add FP8 E4M3 W8A16 for gpt-oss MoE expert weights

Store expert gate_up_proj and down_proj weights in FP8 E4M3 (1 byte/elem)
with per-expert FP32 scale factors. At inference, a fused CUDA kernel
dequantizes to BF16 before the existing cuBLAS batched GEMM.

Results on gpt-oss-20b (50-problem GSM8K subset):
  - FP8 TP=1: 47/50 = 94.0% (single RTX 5090, ~25 GB VRAM)
  - BF16 TP=2: 47/50 = 94.0% (requires 2× RTX 5090, ~39 GB total)

No measurable accuracy degradation. Model size: 41.8 GB → 22.7 GB (−46%).

New files:
  - tools/quantize_fp8.py: offline BF16→FP8 conversion script
  - csrc/quantization/dequant_fp8.cu: per-expert-scale dequant kernel
  - crates/xserv-kernels/src/quantization.rs: Rust FFI wrapper
  - tools/eval_gsm8k_batch.sh: GSM8K accuracy evaluation harness

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 19:33:07 +08:00
parent e1eb77baa4
commit 9f1fbbb98b
10 changed files with 474 additions and 6 deletions

View File

@@ -5,6 +5,7 @@ pub enum DType {
F32,
F16,
BF16,
FP8E4M3,
}
impl DType {
@@ -13,6 +14,7 @@ impl DType {
DType::F32 => 4,
DType::F16 => 2,
DType::BF16 => 2,
DType::FP8E4M3 => 1,
}
}
@@ -21,6 +23,7 @@ impl DType {
DType::F32 => "f32",
DType::F16 => "f16",
DType::BF16 => "bf16",
DType::FP8E4M3 => "fp8e4m3",
}
}
}

View File

@@ -52,6 +52,25 @@ impl Tensor {
}
}
/// Create a tensor from raw bytes. Used for dtypes without a Rust type
/// (e.g. FP8 E4M3) where we store the bit pattern as-is.
pub fn from_raw_bytes(data: &[u8], shape: &[usize], dtype: DType) -> Self {
let numel: usize = shape.iter().product();
assert_eq!(
data.len(),
numel * dtype.size_bytes(),
"raw bytes length {} != expected {} (numel={} * elem_size={})",
data.len(), numel * dtype.size_bytes(), numel, dtype.size_bytes()
);
Self {
storage: Storage::cpu(data.to_vec()),
shape: Dims::from_slice(shape),
strides: shape::contiguous_strides(shape),
offset: 0,
dtype,
}
}
pub fn zeros(shape: &[usize], dtype: DType, device: Device) -> Self {
let numel = shape::num_elements(shape);
let len_bytes = numel * dtype.size_bytes();
@@ -87,6 +106,7 @@ impl Tensor {
DType::F32 => Self::from_slice(&vec![1.0f32; numel], shape),
DType::F16 => Self::from_slice(&vec![half::f16::from_f32(1.0); numel], shape),
DType::BF16 => Self::from_slice(&vec![half::bf16::from_f32(1.0); numel], shape),
DType::FP8E4M3 => panic!("ones() not supported for FP8E4M3"),
}
}
@@ -265,6 +285,17 @@ impl Tensor {
unsafe { std::slice::from_raw_parts(bytes[start..].as_ptr() as *const T, len) }
}
/// Raw byte access for dtypes without a Rust type (e.g. FP8).
pub fn as_raw_bytes(&self) -> &[u8] {
assert!(self.is_contiguous(), "as_raw_bytes requires contiguous");
assert_eq!(self.device(), Device::Cpu, "as_raw_bytes requires CPU");
let bytes = self.storage.as_cpu_bytes();
let elem_size = self.dtype.size_bytes();
let start = self.offset * elem_size;
let len = self.numel() * elem_size;
&bytes[start..start + len]
}
/// Raw pointer to storage start (for GPU kernel launch).
pub fn data_ptr(&self) -> *const u8 {
match self.device() {