2023 年に Open Compute Project (OCP) が規格を策定した Microscaling Formats (MX) Specification Version 1.0 には MXFP4, 6, 8 および MXINT8 の仕様が定義されている。 AI の学習と推論向けに用意されたデータフォーマットで、前回扱った NVFP4 は MXFP4 の課題を改善する目的で作られた。
今回は、その中でも MXFP4 と MXFP8 について TorchAO 1 を使って実際に動作を確認してみることにした。
使った環境は次のとおり。
$ sw_vers ProductName: macOS ProductVersion: 26.6.2 BuildVersion: 25G83 $ uv --version uv 0.12.13 (0ebbd9274 2026-09-10 aarch64-apple-darwin) $ uv run python -V Python 3.12.14 $ uv pip list | egrep "(torch|numpy)" numpy 2.5.2 torch 2.14.0 torchao 0.18.0
もくじ
MXFP について
前述したとおり MXFP の仕様は Microscaling Formats (MX) Specification Version 1.0 に記載されている。
https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdfwww.opencompute.org
あるいは MXFP8 については Transformer Engine のドキュメントが分かりやすい。
MXFP8 は、あるテンソルを FP8 の要素と、FP8 のスケールで表現したフォーマットになっている。 上記のドキュメントでは、下記の式がそれを表現している。
x = x_fp8 * s_block
- x_fp8
- FP8 (E4M3 または E5M2) の要素
- s_block
- 32 要素 (ブロック) ごとに用意される FP8 (E8M0) のスケール
NVFP4 にはテンソル単位のスケールがあった一方で MXFP にはそれがない。 また、ブロック単位のスケールのデータ型も NVFP4 では E4M3 だった一方で MXFP では E8M0 になっている。 E8M0 には指数部のビットしかないため 2 のべき乗しか表現できない。
MXFP4 についても同様で、次のようになっている。 データの要素が FP4 (E2M1) になっている点しか違いがない。
x = x_fp4 * s_block
- x_fp4
- FP4 (E2M1) の要素
- s_block
- 32 要素 (ブロック) ごとに用意される FP8 (E8M0) のスケール
NVFP4 と比較すると、次のような点からハードウェアがシンプルに実装できる所がメリットのようだ。
- スケールが一段階と少ない
- ブロック単位のスケールが 2 のべき乗しか扱えない E8M0 になっている
なお、仕様では MXFP6 も定義されている。 ただし、後述するとおり PyTorch では現状でサポートが限られている。
下準備
ここからは TorchAO を使う準備をしていく。
まずは uv で Python の仮想環境を初期化する。
$ uv venv --clear
必要なパッケージを仮想環境にインストールする。
$ uv pip install torch --torch-backend=auto $ uv pip install torchao numpy
インストールが終わったら Python のインタプリタを起動する。
$ uv run python
MXFP に変換する
PyTorch をインポートして、MXFP4 / MXFP8 に変換するベクトルを適当に用意する。 このとき、要素数は 32 で割り切れる必要がある。 使用するデータ型は BF16 にした。
>>> import torch >>> x = torch.randn(64, dtype=torch.bfloat16) >>> x tensor([-5.4297e-01, 1.0938e+00, -1.7578e+00, -8.6719e-01, -1.9043e-01, -8.0469e-01, 7.4609e-01, -1.4496e-03, -1.0391e+00, -1.4258e-01, 2.7188e+00, -1.1719e-01, 5.1172e-01, 9.2188e-01, -4.5312e-01, 1.9141e+00, 1.2500e+00, -1.1172e+00, 6.6797e-01, -5.6250e-01, 3.8672e-01, -1.2578e+00, 2.3281e+00, 4.7607e-03, 4.0820e-01, 4.7070e-01, 1.4551e-01, 1.5938e+00, 4.8242e-01, 2.1289e-01, -1.3438e+00, -5.5908e-02, -9.5215e-02, -5.3125e-01, 5.8203e-01, 8.0078e-01, 1.7578e+00, -5.2490e-02, 1.8750e+00, 1.1172e+00, -7.0703e-01, -1.4219e+00, 8.6328e-01, 4.8828e-01, -4.8242e-01, -3.8477e-01, 6.4062e-01, -1.0703e+00, -2.2031e+00, 1.5547e+00, 1.6602e-01, -6.0547e-01, -1.2109e+00, -5.1172e-01, 3.4375e-01, 5.6641e-01, 9.5312e-01, 6.4844e-01, -1.0000e+00, -2.8125e-01, -1.9434e-01, -1.5078e+00, 1.5781e+00, 2.1406e+00], dtype=torch.bfloat16)
MXFP に変換するときは torchao.prototype.mx_formats.mx_tensor.MXTensor を使うためインポートする。
>>> from torchao.prototype.mx_formats.mx_tensor import MXTensor
元のデータを渡しつつ elem_dtype で要素のデータ型を指定する。
このときデータ型に FP4 (E2M1) を指定することで MXFP4 になる。
>> x_mxfp4 = MXTensor.to_mx( ... x, ... elem_dtype=torch.float4_e2m1fn_x2, ... )
同様に、FP8 の E4M3 あるいは E5M2 を指定することで MXFP8 になる。
>> x_mxfp8_e4m3 = MXTensor.to_mx( ... x, ... elem_dtype=torch.float8_e4m3fn, ... ) >>> x_mxfp8_e5m2 = MXTensor.to_mx( ... x, ... elem_dtype=torch.float8_e5m2, ... )
これで MXTensor のオブジェクトが得られる。
>>> type(x_mxfp4) <class 'torchao.prototype.mx_formats.mx_tensor.MXTensor'> >>> x_mxfp4.shape torch.Size([64])
なお、MXFP6 に対応する FP6 は PyTorch にデータ型の定義がない。
そのため MXFP6 は素直に扱うことができない。
元の Tensor と比べる
MXTensor#dequantize() メソッドを使うと元の Tensor オブジェクトに復元できる。
>>> x_mxfp4.dequantize() tensor([-0.5000, 1.0000, -2.0000, -0.7500, -0.2500, -0.7500, 0.7500, -0.0000, -1.0000, -0.2500, 3.0000, -0.0000, 0.5000, 1.0000, -0.5000, 2.0000, 1.0000, -1.0000, 0.7500, -0.5000, 0.5000, -1.5000, 2.0000, 0.0000, 0.5000, 0.5000, 0.2500, 1.5000, 0.5000, 0.2500, -1.5000, -0.0000, -0.0000, -0.5000, 0.5000, 0.7500, 2.0000, -0.0000, 2.0000, 1.0000, -0.7500, -1.5000, 0.7500, 0.5000, -0.5000, -0.5000, 0.7500, -1.0000, -2.0000, 1.5000, 0.2500, -0.5000, -1.0000, -0.5000, 0.2500, 0.5000, 1.0000, 0.7500, -1.0000, -0.2500, -0.2500, -1.5000, 1.5000, 2.0000], dtype=torch.bfloat16)
今回の場合、MXFP4 の要素の値は 0.25 刻みになっていることが分かる。 元の要素と比べたときの差が低精度化によって生じた誤差ということになる。
>>> x_restored = x_mxfp4.dequantize() >>> x_error = x - x_restored >>> x_error tensor([-0.0430, 0.0938, 0.2422, -0.1172, 0.0596, -0.0547, -0.0039, -0.0014, -0.0391, 0.1074, -0.2812, -0.1172, 0.0117, -0.0781, 0.0469, -0.0859, 0.2500, -0.1172, -0.0820, -0.0625, -0.1133, 0.2422, 0.3281, 0.0048, -0.0918, -0.0293, -0.1045, 0.0938, -0.0176, -0.0371, 0.1562, -0.0559, -0.0952, -0.0312, 0.0820, 0.0508, -0.2422, -0.0525, -0.1250, 0.1172, 0.0430, 0.0781, 0.1133, -0.0117, 0.0176, 0.1152, -0.1094, -0.0703, -0.2031, 0.0547, -0.0840, -0.1055, -0.2109, -0.0117, 0.0938, 0.0664, -0.0469, -0.1016, 0.0000, -0.0312, 0.0557, -0.0078, 0.0781, 0.1406], dtype=torch.bfloat16)
今回の場合は MXFP4 で最も誤差の大きな要素は絶対値で 0.3281 の差があった。
平均絶対誤差 (MAE) は 0.0908 だった。
>>> x_error.abs().max() tensor(0.3281, dtype=torch.bfloat16) >>> x_error.abs().mean() tensor(0.0908, dtype=torch.bfloat16)
MXFP8 (E4M3) では、最も誤差の大きな要素は絶対値で 0.1094 で平均絶対誤差は 0.0173 だった。
MXFP4 と比較して、前者は約 1/3、後者は約 1/5 になっている。
>>> x_restored = x_mxfp8_e4m3.dequantize() >>> x_error = x - x_restored >>> x_error.abs().max() tensor(0.1094, dtype=torch.bfloat16) >>> x_error.abs().mean() tensor(0.0173, dtype=torch.bfloat16)
これを見ると、要素のビット数が倍 (4 -> 8) になることで得られる精度上の恩恵は小さくないと感じられる。
中身を見ていく
続いては MXTensor の中身を見ていく。
ブロック単位のスケールは scale に入っている。
今回は 64 要素を変換したので、スケールの要素数は 2 になる。
前述したとおり、データ型は FP8 (E8M0) になっている。
>>> x_mxfp4.scale tensor([0.5000, 0.5000], dtype=torch.float8_e8m0fnu)
データの要素は qdata に入っている。
>>> x_mxfp4.qdata tensor([ 74, 190, 185, 131, 156, 135, 66, 106, 196, 163, 210, 6, 34, 81, 18, 141, 168, 50, 134, 70, 219, 35, 170, 195, 94, 161, 172, 33, 52, 156, 217, 101], dtype=torch.uint8) >>> x_mxfp4.qdata.shape torch.Size([32])
MXFP4 の要素のデータ型は NVFP4 と同じように Unsigned INT8 になっている。 上位ビットと下位ビットで E2M1 が 2 要素ずつ入っている点も変わらない。
一方で MXFP8 については FP8 がそのまま入っている。
>>> x_mxfp8_e4m3.qdata tensor([-7.2000e+01, 1.4400e+02, -2.2400e+02, -1.1200e+02, -2.4000e+01, -1.0400e+02, 9.6000e+01, -1.8750e-01, -1.2800e+02, -1.8000e+01, 3.5200e+02, -1.5000e+01, 6.4000e+01, 1.2000e+02, -5.6000e+01, 2.4000e+02, 1.6000e+02, -1.4400e+02, 8.8000e+01, -7.2000e+01, 4.8000e+01, -1.6000e+02, 2.8800e+02, 6.2500e-01, 5.2000e+01, 6.0000e+01, 1.8000e+01, 2.0800e+02, 6.0000e+01, 2.8000e+01, -1.7600e+02, -7.0000e+00, -1.2000e+01, -6.4000e+01, 7.2000e+01, 1.0400e+02, 2.2400e+02, -6.5000e+00, 2.4000e+02, 1.4400e+02, -8.8000e+01, -1.7600e+02, 1.1200e+02, 6.4000e+01, -6.0000e+01, -4.8000e+01, 8.0000e+01, -1.4400e+02, -2.8800e+02, 1.9200e+02, 2.2000e+01, -8.0000e+01, -1.6000e+02, -6.4000e+01, 4.4000e+01, 7.2000e+01, 1.2000e+02, 8.0000e+01, -1.2800e+02, -3.6000e+01, -2.4000e+01, -1.9200e+02, 2.0800e+02, 2.8800e+02], dtype=torch.float8_e4m3fn) >> x_mxfp8_e4m3.qdata.shape torch.Size([64])
手動で復元してみる
最後に MXTensor#dequantize() メソッドを使わずに Tensor オブジェクトを復元してみよう。
MXFP4 に関しては NVFP4 とほとんど変わらない。
操作における差はテンソル単位のグローバルなスケールがないだけ。
まずは unpack_uint4() で上位ビットと下位ビットを取り出してから f4_unpacked_to_f32() で FP4 として解釈する。
>>> from torchao.prototype.mx_formats.kernels import unpack_uint4 >>> from torchao.prototype.mx_formats.kernels import f4_unpacked_to_f32 >>> unpacked_f32_qdata = f4_unpacked_to_f32(unpack_uint4(x_mxfp4.qdata)) >>> unpacked_f32_qdata tensor([-1.0000, 2.0000, -4.0000, -1.5000, -0.5000, -1.5000, 1.5000, -0.0000, -2.0000, -0.5000, 6.0000, -0.0000, 1.0000, 2.0000, -1.0000, 4.0000, 2.0000, -2.0000, 1.5000, -1.0000, 1.0000, -3.0000, 4.0000, 0.0000, 1.0000, 1.0000, 0.5000, 3.0000, 1.0000, 0.5000, -3.0000, -0.0000, -0.0000, -1.0000, 1.0000, 1.5000, 4.0000, -0.0000, 4.0000, 2.0000, -1.5000, -3.0000, 1.5000, 1.0000, -1.0000, -1.0000, 1.5000, -2.0000, -4.0000, 3.0000, 0.5000, -1.0000, -2.0000, -1.0000, 0.5000, 1.0000, 2.0000, 1.5000, -2.0000, -0.5000, -0.5000, -3.0000, 3.0000, 4.0000])
スケールは元の要素数と揃うように引き延ばす。
>>> interleaved_f32_scale = x_mxfp4.scale.float().repeat_interleave(32, dim=-1) >>> interleaved_f32_scale tensor([0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000])
あとはかけるだけ。
>>> dequantized = interleaved_f32_scale * unpacked_f32_qdata >>> dequantized tensor([-0.5000, 1.0000, -2.0000, -0.7500, -0.2500, -0.7500, 0.7500, -0.0000, -1.0000, -0.2500, 3.0000, -0.0000, 0.5000, 1.0000, -0.5000, 2.0000, 1.0000, -1.0000, 0.7500, -0.5000, 0.5000, -1.5000, 2.0000, 0.0000, 0.5000, 0.5000, 0.2500, 1.5000, 0.5000, 0.2500, -1.5000, -0.0000, -0.0000, -0.5000, 0.5000, 0.7500, 2.0000, -0.0000, 2.0000, 1.0000, -0.7500, -1.5000, 0.7500, 0.5000, -0.5000, -0.5000, 0.7500, -1.0000, -2.0000, 1.5000, 0.2500, -0.5000, -1.0000, -0.5000, 0.2500, 0.5000, 1.0000, 0.7500, -1.0000, -0.2500, -0.2500, -1.5000, 1.5000, 2.0000])
MXTensor#dequantize() した結果と見比べても結果が一致している。
>>> x_mxfp4.dequantize() tensor([-0.5000, 1.0000, -2.0000, -0.7500, -0.2500, -0.7500, 0.7500, -0.0000, -1.0000, -0.2500, 3.0000, -0.0000, 0.5000, 1.0000, -0.5000, 2.0000, 1.0000, -1.0000, 0.7500, -0.5000, 0.5000, -1.5000, 2.0000, 0.0000, 0.5000, 0.5000, 0.2500, 1.5000, 0.5000, 0.2500, -1.5000, -0.0000, -0.0000, -0.5000, 0.5000, 0.7500, 2.0000, -0.0000, 2.0000, 1.0000, -0.7500, -1.5000, 0.7500, 0.5000, -0.5000, -0.5000, 0.7500, -1.0000, -2.0000, 1.5000, 0.2500, -0.5000, -1.0000, -0.5000, 0.2500, 0.5000, 1.0000, 0.7500, -1.0000, -0.2500, -0.2500, -1.5000, 1.5000, 2.0000], dtype=torch.bfloat16)
MXFP8 の場合は、要素がそのまま FP8 なので MXFP4 よりもやることが少ない。 ただし、FP8 はそのままだと演算できないので要素を FP32 にキャストしておく。
>>> interleaved_f32_scale = x_mxfp8_e4m3.scale.float().repeat_interleave(32, dim=-1) >>> dequantized = interleaved_f32_scale * x_mxfp8_e4m3.qdata.to(torch.float32) >>> dequantized tensor([-5.6250e-01, 1.1250e+00, -1.7500e+00, -8.7500e-01, -1.8750e-01, -8.1250e-01, 7.5000e-01, -1.4648e-03, -1.0000e+00, -1.4062e-01, 2.7500e+00, -1.1719e-01, 5.0000e-01, 9.3750e-01, -4.3750e-01, 1.8750e+00, 1.2500e+00, -1.1250e+00, 6.8750e-01, -5.6250e-01, 3.7500e-01, -1.2500e+00, 2.2500e+00, 4.8828e-03, 4.0625e-01, 4.6875e-01, 1.4062e-01, 1.6250e+00, 4.6875e-01, 2.1875e-01, -1.3750e+00, -5.4688e-02, -9.3750e-02, -5.0000e-01, 5.6250e-01, 8.1250e-01, 1.7500e+00, -5.0781e-02, 1.8750e+00, 1.1250e+00, -6.8750e-01, -1.3750e+00, 8.7500e-01, 5.0000e-01, -4.6875e-01, -3.7500e-01, 6.2500e-01, -1.1250e+00, -2.2500e+00, 1.5000e+00, 1.7188e-01, -6.2500e-01, -1.2500e+00, -5.0000e-01, 3.4375e-01, 5.6250e-01, 9.3750e-01, 6.2500e-01, -1.0000e+00, -2.8125e-01, -1.8750e-01, -1.5000e+00, 1.6250e+00, 2.2500e+00])
これで MXTensor#dequantize() の結果と揃う。
>>> x_mxfp8_e4m3.dequantize() tensor([-5.6250e-01, 1.1250e+00, -1.7500e+00, -8.7500e-01, -1.8750e-01, -8.1250e-01, 7.5000e-01, -1.4648e-03, -1.0000e+00, -1.4062e-01, 2.7500e+00, -1.1719e-01, 5.0000e-01, 9.3750e-01, -4.3750e-01, 1.8750e+00, 1.2500e+00, -1.1250e+00, 6.8750e-01, -5.6250e-01, 3.7500e-01, -1.2500e+00, 2.2500e+00, 4.8828e-03, 4.0625e-01, 4.6875e-01, 1.4062e-01, 1.6250e+00, 4.6875e-01, 2.1875e-01, -1.3750e+00, -5.4688e-02, -9.3750e-02, -5.0000e-01, 5.6250e-01, 8.1250e-01, 1.7500e+00, -5.0781e-02, 1.8750e+00, 1.1250e+00, -6.8750e-01, -1.3750e+00, 8.7500e-01, 5.0000e-01, -4.6875e-01, -3.7500e-01, 6.2500e-01, -1.1250e+00, -2.2500e+00, 1.5000e+00, 1.7188e-01, -6.2500e-01, -1.2500e+00, -5.0000e-01, 3.4375e-01, 5.6250e-01, 9.3750e-01, 6.2500e-01, -1.0000e+00, -2.8125e-01, -1.8750e-01, -1.5000e+00, 1.6250e+00, 2.2500e+00], dtype=torch.bfloat16)
めでたしめでたし。
まとめ
今回は TorchAO を使って MXFP4 と MXFP8 の振る舞いを確認した。
