Skip to content

Commit 09051ed

Browse files
authored
Add global scale support to quantized layers (#426)
* Add global scale support to quantized layers Store optional globalScale on QuantizedLinear and QuantizedEmbedding so nvfp4 weights can preserve the scale needed by lower-level MLX quantize/dequantize operations. Forward the scale when creating or dequantizing weights, add a direct pre-quantized QuantizedEmbedding initializer to match QuantizedLinear, and guard global-scale execution paths on Metal because MLX does not support globalScale dequantization there. Add focused tests for the new layer state and parameter exposure without broadening the generic quantization API surface. * Keep nvfp4 global scale on the quantized path for Metal Since MLX encodes nvfp4 group scales pre-multiplied by (448 * 6) / globalScale, the global scale factors out of dequantization linearly. Exploit this by always running quantizedMM (supported on Metal without globalScale) and applying globalScale / (448 * 6) to the output, instead of dequantizing the full weight and falling back to a plain matmul. This keeps weights quantized end-to-end on all backends, removes the dequantizedWeight fallback from QuantizedLinear and QuantizedEmbedding, and applies the scale before the bias add so the bias is unaffected. Add Metal tests that validate the GPU quantized path against CPU dequantized(globalScale:) references for QuantizedLinear (matmul + bias) and QuantizedEmbedding (asLinear and index lookup).
1 parent de3342c commit 09051ed

2 files changed

Lines changed: 168 additions & 10 deletions

File tree

Source/MLXNN/Quantized.swift

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ public protocol Quantized: Module {
2727
var mode: QuantizationMode { get }
2828
}
2929

30+
private func applyNVFP4GlobalScale(_ value: MLXArray, globalScale: MLXArray?) -> MLXArray {
31+
guard let globalScale else { return value }
32+
// NVFP4 encodes its E4M3 group scales with (448 * 6) / globalScale.
33+
return (value * (globalScale / (448 * 6))).asType(value.dtype)
34+
}
35+
3036
/// Quantize any ``Quantizable`` layer that is not already quantized.
3137
public func quantizeSingle(
3238
layer: Module, groupSize: Int = 64, bits: Int = 4, mode: QuantizationMode = .affine
@@ -159,6 +165,7 @@ open class QuantizedEmbedding: Embedding, Quantized {
159165
public let mode: QuantizationMode
160166
public let scales: MLXArray
161167
public let biases: MLXArray?
168+
@ParameterInfo(key: "global_scale") public private(set) var globalScale: MLXArray?
162169

163170
open override var shape: (Int, Int) {
164171
let (embeddingCount, dimensions) = super.shape
@@ -184,14 +191,16 @@ open class QuantizedEmbedding: Embedding, Quantized {
184191

185192
public init(
186193
weight: MLXArray, groupSize: Int = 64, bits: Int = 4,
187-
mode: QuantizationMode = .affine
194+
mode: QuantizationMode = .affine,
195+
globalScale: MLXArray? = nil
188196
) {
189197
self.groupSize = groupSize
190198
self.bits = bits
191199
self.mode = mode
200+
self._globalScale.wrappedValue = globalScale
192201

193202
let (quantizedWeight, scales, biases) = MLX.quantized(
194-
weight, groupSize: groupSize, bits: bits, mode: mode)
203+
weight, groupSize: groupSize, bits: bits, mode: mode, globalScale: globalScale)
195204

196205
self.scales = scales
197206
self.biases = biases
@@ -201,19 +210,36 @@ open class QuantizedEmbedding: Embedding, Quantized {
201210
self.freeze()
202211
}
203212

213+
/// Initializer meant for subclasses to provide arrays directly.
214+
public init(
215+
weight: MLXArray, scales: MLXArray, biases: MLXArray?,
216+
groupSize: Int, bits: Int,
217+
mode: QuantizationMode = .affine,
218+
globalScale: MLXArray? = nil
219+
) {
220+
self.groupSize = groupSize
221+
self.bits = bits
222+
self.mode = mode
223+
self.scales = scales
224+
self.biases = biases
225+
self._globalScale.wrappedValue = globalScale
226+
super.init(weight: weight)
227+
}
228+
204229
open override func callAsFunction(_ x: MLXArray) -> MLXArray {
205230
let s = x.shape
206231
let x = x.flattened()
207232
let out = dequantized(
208233
weight[x], scales: scales[x], biases: biases == nil ? nil : biases![x],
209234
groupSize: groupSize, bits: bits, mode: mode)
210-
return out.reshaped(s + [-1])
235+
return applyNVFP4GlobalScale(out, globalScale: globalScale).reshaped(s + [-1])
211236
}
212237

213238
open override func asLinear(_ x: MLXArray) -> MLXArray {
214-
quantizedMM(
239+
let result = quantizedMM(
215240
x, weight, scales: scales, biases: biases, transpose: true, groupSize: groupSize,
216241
bits: bits, mode: mode)
242+
return applyNVFP4GlobalScale(result, globalScale: globalScale)
217243
}
218244
}
219245

@@ -243,6 +269,7 @@ open class QuantizedLinear: Linear, Quantized {
243269
public let mode: QuantizationMode
244270
public let scales: MLXArray
245271
public let biases: MLXArray?
272+
@ParameterInfo(key: "global_scale") public private(set) var globalScale: MLXArray?
246273

247274
open override var shape: (Int, Int) {
248275
let shape = weight.shape2
@@ -292,14 +319,16 @@ open class QuantizedLinear: Linear, Quantized {
292319
/// Initialize a ``QuantizedLinear`` with non-quantized weights and bias.
293320
public init(
294321
weight: MLXArray, bias: MLXArray?, groupSize: Int = 64, bits: Int = 4,
295-
mode: QuantizationMode = .affine
322+
mode: QuantizationMode = .affine,
323+
globalScale: MLXArray? = nil
296324
) {
297325
self.groupSize = groupSize
298326
self.bits = bits
299327
self.mode = mode
328+
self._globalScale.wrappedValue = globalScale
300329

301330
let (quantizedWeight, scales, biases) = MLX.quantized(
302-
weight, groupSize: groupSize, bits: bits, mode: mode)
331+
weight, groupSize: groupSize, bits: bits, mode: mode, globalScale: globalScale)
303332

304333
self.scales = scales
305334
self.biases = biases
@@ -316,13 +345,15 @@ open class QuantizedLinear: Linear, Quantized {
316345
public init(
317346
weight: MLXArray, bias: MLXArray? = nil, scales: MLXArray, biases: MLXArray?,
318347
groupSize: Int, bits: Int,
319-
mode: QuantizationMode = .affine
348+
mode: QuantizationMode = .affine,
349+
globalScale: MLXArray? = nil
320350
) {
321351
self.groupSize = groupSize
322352
self.bits = bits
323353
self.mode = mode
324354
self.scales = scales
325355
self.biases = biases
356+
self._globalScale.wrappedValue = globalScale
326357
super.init(weight: weight, bias: bias)
327358
}
328359

@@ -334,7 +365,7 @@ open class QuantizedLinear: Linear, Quantized {
334365
}
335366

336367
open override func callAsFunction(_ x: MLXArray) -> MLXArray {
337-
var x = quantizedMM(
368+
var result = quantizedMM(
338369
x,
339370
weight,
340371
scales: scales,
@@ -344,10 +375,11 @@ open class QuantizedLinear: Linear, Quantized {
344375
bits: bits,
345376
mode: mode
346377
)
378+
result = applyNVFP4GlobalScale(result, globalScale: globalScale)
347379
if let bias {
348-
x = x + bias
380+
result = result + bias
349381
}
350-
return x
382+
return result
351383
}
352384

353385
/// Returns a QuantizedLinear layer that applies the same linear transformation up to the quantization error.

Tests/MLXTests/QuantizationTests.swift

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,74 @@ import MLXNN
66
import XCTest
77

88
class QuantizationTests: XCTestCase {
9+
func testQuantizedLinearAppliesNVFP4GlobalScaleOnMetal() {
10+
let (input, quantizedWeight, scales, biases, globalScale, bias, expected) =
11+
Device.withDefaultDevice(.cpu) {
12+
MLXRandom.seed(7)
13+
let weight = MLXRandom.uniform(low: -2, high: 2, [32, 32])
14+
let input = MLXRandom.uniform(low: -1, high: 1, [4, 32])
15+
let globalScale = MLX.max(abs(weight)).asType(.float32)
16+
let bias = MLXRandom.uniform(low: -1, high: 1, [32])
17+
let (quantizedWeight, scales, biases) = MLX.quantized(
18+
weight, groupSize: 16, bits: 4, mode: .nvfp4, globalScale: globalScale)
19+
let expected =
20+
matmul(
21+
input,
22+
dequantized(
23+
quantizedWeight, scales: scales, biases: biases, groupSize: 16, bits: 4,
24+
mode: .nvfp4, globalScale: globalScale, dtype: .float32
25+
).T)
26+
+ bias
27+
eval(quantizedWeight, scales, expected)
28+
return (input, quantizedWeight, scales, biases, globalScale, bias, expected)
29+
}
30+
31+
let actual = Device.withDefaultDevice(.gpu) {
32+
QuantizedLinear(
33+
weight: quantizedWeight, bias: bias, scales: scales, biases: biases,
34+
groupSize: 16, bits: 4, mode: .nvfp4, globalScale: globalScale)(input)
35+
}
36+
37+
assertEqual(actual, expected, rtol: 1e-5, atol: 1e-5)
38+
}
39+
40+
func testQuantizedEmbeddingAppliesNVFP4GlobalScaleOnMetal() {
41+
let (
42+
input, indices, quantizedWeight, scales, biases, globalScale, expectedLinear,
43+
expectedLookup
44+
) = Device.withDefaultDevice(.cpu) {
45+
MLXRandom.seed(11)
46+
let weight = MLXRandom.uniform(low: -2, high: 2, [32, 32])
47+
let input = MLXRandom.uniform(low: -1, high: 1, [4, 32])
48+
let indices = MLXArray([0, 3, 7, 31])
49+
let globalScale = MLX.max(abs(weight)).asType(.float32)
50+
let (quantizedWeight, scales, biases) = MLX.quantized(
51+
weight, groupSize: 16, bits: 4, mode: .nvfp4, globalScale: globalScale)
52+
let dequantizedWeight = dequantized(
53+
quantizedWeight, scales: scales, biases: biases, groupSize: 16, bits: 4,
54+
mode: .nvfp4, globalScale: globalScale, dtype: .float32)
55+
let expectedLinear = matmul(input, dequantizedWeight.T)
56+
let expectedLookup = dequantized(
57+
quantizedWeight, scales: scales, biases: biases, groupSize: 16, bits: 4,
58+
mode: .nvfp4, globalScale: globalScale)[indices].asType(.bfloat16)
59+
eval(quantizedWeight, scales, expectedLinear, expectedLookup)
60+
return (
61+
input, indices, quantizedWeight, scales, biases, globalScale, expectedLinear,
62+
expectedLookup
63+
)
64+
}
65+
66+
let (actualLinear, actualLookup) = Device.withDefaultDevice(.gpu) {
67+
let embedding = QuantizedEmbedding(
68+
weight: quantizedWeight, scales: scales, biases: biases,
69+
groupSize: 16, bits: 4, mode: .nvfp4, globalScale: globalScale)
70+
return (embedding.asLinear(input), embedding(indices))
71+
}
72+
73+
assertEqual(actualLinear, expectedLinear, rtol: 1e-5, atol: 1e-5)
74+
assertEqual(actualLookup, expectedLookup, rtol: 1e-5, atol: 1e-5)
75+
}
76+
977
func testQuantizedLinearShapeDesc() {
1078
let linear1 = Linear(512, 1024)
1179
let quantized1 = linear1.toQuantized(groupSize: 64, bits: 4)
@@ -39,4 +107,62 @@ class QuantizationTests: XCTestCase {
39107
let quantized = QuantizedLinear(64, 64, groupSize: 32, bits: 4, mode: .mxfp4)
40108
XCTAssertNil(quantized.biases)
41109
}
110+
111+
func testQuantizedLinearStoresGlobalScale() {
112+
let globalScale = MLXArray(1.0, dtype: .float32)
113+
let quantized = QuantizedLinear(
114+
weight: MLXArray.zeros([8, 4], dtype: .uint32),
115+
bias: nil,
116+
scales: MLXArray.ones([8, 4], dtype: .uint8),
117+
biases: nil,
118+
groupSize: 16,
119+
bits: 4,
120+
mode: .nvfp4,
121+
globalScale: globalScale)
122+
123+
XCTAssertNotNil(quantized.globalScale)
124+
XCTAssertEqual(quantized.globalScale?.dtype, .float32)
125+
XCTAssertNotNil(quantized.parameters()["global_scale"])
126+
XCTAssertNil(quantized.parameters()["globalScale"])
127+
}
128+
129+
func testQuantizedEmbeddingStoresGlobalScale() {
130+
let globalScale = MLXArray(1.0, dtype: .float32)
131+
let quantized = QuantizedEmbedding(
132+
weight: MLXArray.zeros([8, 2], dtype: .uint32),
133+
scales: MLXArray.ones([8, 2], dtype: .uint8),
134+
biases: nil,
135+
groupSize: 16,
136+
bits: 4,
137+
mode: .nvfp4,
138+
globalScale: globalScale)
139+
140+
XCTAssertNotNil(quantized.globalScale)
141+
XCTAssertEqual(quantized.globalScale?.dtype, .float32)
142+
XCTAssertNotNil(quantized.parameters()["global_scale"])
143+
XCTAssertNil(quantized.parameters()["globalScale"])
144+
}
145+
146+
func testQuantizedGlobalScaleIsOptionalParameter() {
147+
let linear = QuantizedLinear(
148+
weight: MLXArray.zeros([8, 4], dtype: .uint32),
149+
bias: nil,
150+
scales: MLXArray.ones([8, 4], dtype: .uint8),
151+
biases: nil,
152+
groupSize: 16,
153+
bits: 4,
154+
mode: .nvfp4)
155+
let embedding = QuantizedEmbedding(
156+
weight: MLXArray.zeros([8, 2], dtype: .uint32),
157+
scales: MLXArray.ones([8, 2], dtype: .uint8),
158+
biases: nil,
159+
groupSize: 16,
160+
bits: 4,
161+
mode: .nvfp4)
162+
163+
XCTAssertNil(linear.globalScale)
164+
XCTAssertNil(linear.parameters()["global_scale"])
165+
XCTAssertNil(embedding.globalScale)
166+
XCTAssertNil(embedding.parameters()["global_scale"])
167+
}
42168
}

0 commit comments

Comments
 (0)