Skip to content

Commit f69161e

Browse files
committed
Merge fix/rocm-quantfmt-docs: Quark MXFP4 format + quantfmt cross-ref + AMD-lanes docs (rocm #10/#11/#15)
2 parents 4bf0314 + cb049b2 commit f69161e

8 files changed

Lines changed: 663 additions & 2 deletions

File tree

docs/backends.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ Path `engine/hip`, package `hip`. The default `linux && amd64` build is native-f
4545

4646
Registers as `"rocm"`. GGUF loading works; safetensors model-pack loading is **not yet available** in the current quarantine landing (blocked on a missing upstream package — the load fails with an explicit message rather than guessing).
4747

48+
#### Quantisation lanes on AMD
49+
50+
Not every quantisation format is equally "native" on ROCm, and this shapes
51+
what a ROCm quant contribution should target. The AMD-native lanes are:
52+
53+
- **GGUF** (`q4_0`, `q4_K`, `q8_0`) — MLX-style group-affine quantisation;
54+
`engine/hip`'s existing `rocm_mlx_q4_projection` kernel family already
55+
serves this.
56+
- **FP8** (W8A8, via llm-compressor / AMD Quark) — 8-bit weight+activation.
57+
- **MXFP4** (via AMD Quark) — OCP microscaled 4-bit (`model/quant/mxfp4`),
58+
hardware-accelerated from CDNA4/MI350 onward.
59+
60+
**GPTQ, AWQ, and Marlin are unsupported on AMD** — this is not a gap to
61+
close by porting a kernel, it is the ecosystem's own shape: Marlin, the fast
62+
kernel both GPTQ and AWQ lean on, is hand-written Nvidia tensor-core PTX with
63+
no ROCm equivalent. Scoping ROCm quant work as "port GPTQ to HIP" swims
64+
against that grain; the native-feeling targets are GGUF, FP8, and MXFP4
65+
above. See `docs/design-rocm.md` §A.2 and the
66+
[vLLM ROCm quantization compatibility matrix](https://docs.vllm.ai/en/latest/features/quantization/)
67+
for the full picture.
68+
4869
### About `llama_cpp`
4970

5071
`llama_cpp` is still a slot in the preference order, but **no package in this repository registers it** as an `inference.Backend`. The serving layer provides `serving.HTTPBackend` (name `"http"`) and `serving.LlamaBackend` (name `"llama"`) that wrap an external llama.cpp HTTP server as a `TextModel` — but these are serving-level adapters, not registered inference backends.

docs/design-rocm.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,18 @@ wrapper through this seam, not writing new compute).
336336
runtime block) — that's a defensible split, not obviously a duplication to
337337
collapse, but worth naming so nobody builds a third parser for a third
338338
format assuming neither existing one applies.
339+
340+
**Resolved (dispatch item 11): kept intentionally separate, cross-referenced.**
341+
`quantfmt.QuantInfo` reads external-tool provenance (`quantization_config`,
342+
no in-tree engine load-path caller today — see Open question 3); `model.QuantConfig`
343+
reads this engine's own runtime dequant instructions (`quantization`,
344+
consumed directly by the assembler via `For(name)`). Different config keys,
345+
different shapes, different consumers, zero shared code — unifying them
346+
would conflate an import-time detection concern with an execution-time one
347+
for no benefit. Both types now carry a doc-comment cross-reference to the
348+
other (`model/quant/quantfmt.go`'s `QuantInfo` and `model/quant_config.go`'s
349+
`QuantConfig`), so a future reader lands on the right parser instead of
350+
building a third one.
339351
- **The exporter side** (`model/quant/{gptq,awq,fp8,nf4,autoround,jang,codebook,mlxaffine}/`)
340352
is genuinely engine-neutral pure-Go pack/export code (confirmed no engine
341353
imports via the package tree — each is `export.go`/`pack.go`/`load.go`

go/model/quant/mxfp4/mxfp4.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
// Package mxfp4 writes OCP Microscaling FP4 (MXFP4) blockwise weights: E2M1
4+
// elements in 32-element blocks that share one E8M0 power-of-two scale. This
5+
// is the AMD-native low-precision lane AMD Quark emits for its MX/FP4 output
6+
// (see model/quant/quantfmt.go's MethodQuark, and model.QuantConfig's
7+
// "mxfp4" mode in model/quant_config.go, which already validates
8+
// group_size==32/bits==4 for this format).
9+
//
10+
// Bit layout is the OCP Microscaling Formats (MX) Specification v1.0
11+
// (https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf):
12+
// each element is E2M1 (1 sign + 2 exponent + 1 mantissa bit, exponent bias
13+
// 1), giving the 8 non-negative magnitudes {0, 0.5, 1, 1.5, 2, 3, 4, 6}; each
14+
// block of 32 elements shares one E8M0 scale (an unsigned 8-bit exponent,
15+
// bias 127, 0xFF reserved — https://sw23.github.io/fp-conv/formats/fp4-e2m1.html).
16+
// Both the block size and the magnitude range are cross-checked against a
17+
// real AMD Quark MXFP4 checkpoint
18+
// (huggingface.co/amd/Qwen3.5-397B-A17B-MXFP4/blob/main/config.json:
19+
// quantization_config.global_quant_config.weight = {dtype: "fp4", qscheme:
20+
// "per_group", group_size: 32, scale_format: "e8m0"}). Neither E2M1 nor E8M0
21+
// represent Inf/NaN — the format is finite-only.
22+
//
23+
// q, _ := mxfp4.Quantize(weights, []int{64})
24+
// back, _ := mxfp4.Dequantize(q)
25+
package mxfp4
26+
27+
import (
28+
"math"
29+
30+
core "dappco.re/go"
31+
)
32+
33+
// BlockSize is the number of elements that share one E8M0 scale — fixed at
34+
// 32 by the OCP MX spec, and confirmed both in model.QuantConfig's mxfp4
35+
// validation (model/quant_config.go) and in AMD Quark's emitted group_size.
36+
const BlockSize = 32
37+
38+
// MaxE2M1 is the largest finite magnitude an E2M1 element can represent.
39+
const MaxE2M1 = float32(6)
40+
41+
// magnitudes is the E2M1 non-negative value table (OCP MX spec v1.0): index
42+
// is the 3-bit unsigned code (2 exponent bits + 1 mantissa bit, bias 1).
43+
var magnitudes = [8]float32{0, 0.5, 1, 1.5, 2, 3, 4, 6}
44+
45+
// Tensor is a blockwise-quantised MXFP4 weight: one E2M1 nibble per element,
46+
// two packed per byte (like nf4.Tensor), and one decoded E8M0 scale per
47+
// BlockSize-element block.
48+
type Tensor struct {
49+
Data []byte
50+
Scale []float32
51+
Shape []int
52+
}
53+
54+
// Quantize packs values into blockwise MXFP4. Each BlockSize-run gets its
55+
// own E8M0 power-of-two scale — the smallest power of two that keeps the
56+
// block's peak magnitude within E2M1's finite range (MaxE2M1) — and each
57+
// element rounds to the nearest E2M1 code.
58+
//
59+
// q, _ := mxfp4.Quantize([]float32{0, 1, 2, 3}, []int{4})
60+
func Quantize(values []float32, shape []int) (Tensor, error) {
61+
if len(values) == 0 || product(shape) != len(values) {
62+
return Tensor{}, core.NewError("mxfp4: values must match a non-empty shape")
63+
}
64+
out := Tensor{
65+
Data: make([]byte, (len(values)+1)/2),
66+
Scale: make([]float32, (len(values)+BlockSize-1)/BlockSize),
67+
Shape: append([]int(nil), shape...),
68+
}
69+
for block := range out.Scale {
70+
start, end := block*BlockSize, min((block+1)*BlockSize, len(values))
71+
var peak float32
72+
for _, value := range values[start:end] {
73+
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
74+
return Tensor{}, core.NewError("mxfp4: values must be finite")
75+
}
76+
peak = max(peak, float32(math.Abs(float64(value))))
77+
}
78+
scale := blockScale(peak)
79+
out.Scale[block] = scale
80+
for i := start; i < end; i++ {
81+
code := encodeE2M1(values[i] / scale)
82+
if i&1 == 0 {
83+
out.Data[i/2] = code << 4
84+
} else {
85+
out.Data[i/2] |= code
86+
}
87+
}
88+
}
89+
return out, nil
90+
}
91+
92+
// Dequantize expands a blockwise MXFP4 Tensor back to float32.
93+
//
94+
// values, _ := mxfp4.Dequantize(q)
95+
func Dequantize(tensor Tensor) ([]float32, error) {
96+
count := product(tensor.Shape)
97+
if count == 0 || len(tensor.Data) != (count+1)/2 || len(tensor.Scale) != (count+BlockSize-1)/BlockSize {
98+
return nil, core.NewError("mxfp4: invalid tensor")
99+
}
100+
out := make([]float32, count)
101+
for i := range out {
102+
code := tensor.Data[i/2] & 15
103+
if i&1 == 0 {
104+
code = tensor.Data[i/2] >> 4
105+
}
106+
out[i] = decodeE2M1(code) * tensor.Scale[i/BlockSize]
107+
}
108+
return out, nil
109+
}
110+
111+
// blockScale picks the E8M0 power-of-two scale for a block given its peak
112+
// (largest absolute) value: the smallest power of two large enough that
113+
// peak/scale never exceeds MaxE2M1, so the block's largest element always
114+
// stays representable. peak==0 (an all-zero block) uses scale 1 rather than
115+
// dividing by zero.
116+
//
117+
// blockScale(6) == 1
118+
func blockScale(peak float32) float32 {
119+
if peak == 0 {
120+
return 1
121+
}
122+
exp := clampExponent(int(math.Ceil(math.Log2(float64(peak) / float64(MaxE2M1)))))
123+
return float32(math.Ldexp(1, exp))
124+
}
125+
126+
// clampExponent bounds an E8M0 exponent to its storable range. E8M0 is an
127+
// unsigned 8-bit field biased by 127 with 0xFF reserved (NaN) per the OCP MX
128+
// spec, so the representable signed exponent range is [-127, 127].
129+
//
130+
// clampExponent(500) == 127
131+
func clampExponent(exp int) int {
132+
switch {
133+
case exp < -127:
134+
return -127
135+
case exp > 127:
136+
return 127
137+
default:
138+
return exp
139+
}
140+
}
141+
142+
// encodeE2M1 maps a value already divided by its block scale to the nearest
143+
// E2M1 code: bit 3 is the sign, bits 2:0 index magnitudes. Values outside
144+
// magnitudes' range clamp to the nearest (largest) representable magnitude
145+
// rather than overflowing.
146+
//
147+
// encodeE2M1(2) == 4
148+
func encodeE2M1(value float32) byte {
149+
sign := byte(0)
150+
if value < 0 {
151+
sign, value = 0x8, -value
152+
}
153+
best, distance := byte(0), float32(math.MaxFloat32)
154+
for i, candidate := range magnitudes {
155+
if d := float32(math.Abs(float64(value - candidate))); d < distance {
156+
best, distance = byte(i), d
157+
}
158+
}
159+
return sign | best
160+
}
161+
162+
// decodeE2M1 reverses encodeE2M1: bit 3 is the sign, bits 2:0 index
163+
// magnitudes.
164+
//
165+
// decodeE2M1(0xC) == -2
166+
func decodeE2M1(code byte) float32 {
167+
value := magnitudes[code&0x7]
168+
if code&0x8 != 0 {
169+
return -value
170+
}
171+
return value
172+
}
173+
174+
// product returns the element count a shape describes, 0 for an empty shape.
175+
//
176+
// product([]int{5, 13}) == 65
177+
func product(shape []int) int {
178+
if len(shape) == 0 {
179+
return 0
180+
}
181+
out := 1
182+
for _, value := range shape {
183+
out *= value
184+
}
185+
return out
186+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
package mxfp4_test
3+
4+
import (
5+
core "dappco.re/go"
6+
"dappco.re/go/inference/model/quant/mxfp4"
7+
)
8+
9+
func ExampleQuantize() {
10+
tensor, _ := mxfp4.Quantize([]float32{-1, 0, 1, 0}, []int{2, 2})
11+
core.Println(len(tensor.Data), len(tensor.Scale))
12+
// Output:
13+
// 2 1
14+
}
15+
func ExampleDequantize() {
16+
values, _ := mxfp4.Dequantize(mxfp4.Tensor{Data: []byte{0xC4}, Scale: []float32{1}, Shape: []int{2}})
17+
core.Println(values)
18+
// Output:
19+
// [-2 2]
20+
}

go/model/quant/mxfp4/mxfp4_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
package mxfp4
3+
4+
import (
5+
"math"
6+
"testing"
7+
)
8+
9+
func TestQuantize_Good(t *testing.T) {
10+
values := make([]float32, 65)
11+
for i := range values {
12+
values[i] = float32(i-32) / 32
13+
}
14+
q, err := Quantize(values, []int{5, 13})
15+
if err != nil {
16+
t.Fatal(err)
17+
}
18+
got, err := Dequantize(q)
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
for i := range values {
23+
// Half the largest gap in the E2M1 table (4 to 6) is 1, in units of
24+
// the block's own scale — a safe, format-derived error bound.
25+
tol := q.Scale[i/BlockSize]
26+
if math.Abs(float64(got[i]-values[i])) > float64(tol) {
27+
t.Fatalf("value %d error %g (scale %g)", i, got[i]-values[i], tol)
28+
}
29+
}
30+
}
31+
func TestQuantize_Bad(t *testing.T) {
32+
if _, err := Quantize(nil, nil); err == nil {
33+
t.Fatal("Quantize(nil) error = nil")
34+
}
35+
}
36+
func TestQuantize_Ugly(t *testing.T) {
37+
if _, err := Quantize([]float32{1}, []int{2}); err == nil {
38+
t.Fatal("Quantize(shape mismatch) error = nil")
39+
}
40+
}
41+
func TestDequantize_Good(t *testing.T) {
42+
// High nibble 0xC = sign|4 -> -magnitudes[4] = -2; low nibble 0x4 -> +2.
43+
got, err := Dequantize(Tensor{Data: []byte{0xC4}, Scale: []float32{1}, Shape: []int{2}})
44+
if err != nil || got[0] != -2 || got[1] != 2 {
45+
t.Fatalf("Dequantize = %v, %v", got, err)
46+
}
47+
}
48+
func TestDequantize_Bad(t *testing.T) {
49+
if _, err := Dequantize(Tensor{}); err == nil {
50+
t.Fatal("Dequantize(empty) error = nil")
51+
}
52+
}
53+
func TestDequantize_Ugly(t *testing.T) {
54+
if _, err := Dequantize(Tensor{Data: []byte{0}, Shape: []int{3}}); err == nil {
55+
t.Fatal("Dequantize(malformed) error = nil")
56+
}
57+
}
58+
59+
// TestEncodeE2M1_Good — every table magnitude, signed and unsigned, encodes
60+
// to its own index with the sign bit in bit 3.
61+
func TestEncodeE2M1_Good(t *testing.T) {
62+
for i, m := range magnitudes {
63+
if got := encodeE2M1(m); got != byte(i) {
64+
t.Errorf("encodeE2M1(%g): got code %#x, want %#x", m, got, i)
65+
}
66+
if m == 0 {
67+
continue // no distinct negative zero code
68+
}
69+
if got := encodeE2M1(-m); got != byte(i)|0x8 {
70+
t.Errorf("encodeE2M1(%g): got code %#x, want %#x", -m, got, byte(i)|0x8)
71+
}
72+
}
73+
}
74+
75+
// TestEncodeE2M1_Ugly — a magnitude far outside the table clamps to the
76+
// largest representable code (7 -> magnitude 6) instead of overflowing.
77+
func TestEncodeE2M1_Ugly(t *testing.T) {
78+
if got := encodeE2M1(100); got != 7 {
79+
t.Errorf("encodeE2M1(100): got %#x, want 0x7 (clamped to magnitude 6)", got)
80+
}
81+
if got := encodeE2M1(-100); got != 7|0x8 {
82+
t.Errorf("encodeE2M1(-100): got %#x, want %#x (clamped, signed)", got, byte(7)|0x8)
83+
}
84+
}
85+
86+
// TestDecodeE2M1_Good — all 16 codes decode without panicking, matching the
87+
// magnitude table plus sign bit.
88+
func TestDecodeE2M1_Good(t *testing.T) {
89+
for code := 0; code < 16; code++ {
90+
want := magnitudes[code&0x7]
91+
if code&0x8 != 0 {
92+
want = -want
93+
}
94+
if got := decodeE2M1(byte(code)); got != want {
95+
t.Errorf("decodeE2M1(%#x): got %g, want %g", code, got, want)
96+
}
97+
}
98+
}
99+
100+
// TestBlockScale_Good — an exact-boundary peak (6) needs no scaling; an
101+
// all-zero block (peak 0) avoids dividing by zero.
102+
func TestBlockScale_Good(t *testing.T) {
103+
if got := blockScale(6); got != 1 {
104+
t.Errorf("blockScale(6): got %g, want 1", got)
105+
}
106+
if got := blockScale(0); got != 1 {
107+
t.Errorf("blockScale(0): got %g, want 1 (avoid divide by zero)", got)
108+
}
109+
}
110+
111+
// TestBlockScale_Ugly — a vanishingly small (denormal-range) peak needs an
112+
// exponent below E8M0's storable floor; the result clamps to 2^-127 rather
113+
// than underflowing to zero.
114+
func TestBlockScale_Ugly(t *testing.T) {
115+
got := blockScale(1e-40)
116+
want := float32(math.Ldexp(1, -127))
117+
if got != want {
118+
t.Errorf("blockScale(1e-40): got %g, want %g (clamped to E8M0 min exponent)", got, want)
119+
}
120+
if got == 0 {
121+
t.Fatalf("blockScale(1e-40): got 0, want a nonzero clamped scale")
122+
}
123+
}
124+
125+
// TestClampExponent_Good — E8M0 is an unsigned 8-bit field biased by 127
126+
// (0xFF reserved for NaN per the OCP MX spec), so both directions clamp at
127+
// +/-127.
128+
func TestClampExponent_Good(t *testing.T) {
129+
cases := map[int]int{-500: -127, -127: -127, 0: 0, 127: 127, 500: 127}
130+
for exp, want := range cases {
131+
if got := clampExponent(exp); got != want {
132+
t.Errorf("clampExponent(%d): got %d, want %d", exp, got, want)
133+
}
134+
}
135+
}

0 commit comments

Comments
 (0)