M5 Ultra에서 잰 MLX 양자화 커널 정확도, MLX 0.32.3의 토큰 생성 경로 오차는 하한의 3.4~12배
M5 Ultra에서 MLX 양자화 곱셈 커널의 오차를 160가지 경우로 쟀습니다. mlx-serve가 쓰는 MLX 0.32.3은 토큰 생성 경로의 2~4비트 오차가 bf16 하한의 3.4~12배였고 0.32.4는 하한 그대로였습니다. 소설 반복의 원인은 아니었습니다.
M5 Ultra의 mlx-serve 글에서 mlx-serve는 DeepSeek V4.1 Flash 1017을 이 기계의 엔진 중 가장 빨리 돌렸지만, 한국어 소설 시험에서는 MLX 엔진들이 반복 루프에 빠지고 낮은 점수를 받았습니다. 그래서 MLX와 macOS 쪽에 알려진 버그 후보 두 개가 이 기계에 해당하는지 커널 단위로 쟀습니다. 하나는 토큰을 하나씩 만드는 경로의 양자화 곱셈이 중간 합을 bf16으로 더한다는 MLX 이슈 #4631이고, 다른 하나는 macOS 27의 런타임 Metal 컴파일러가 safe math에서 틀린 답을 낸다는 #4603입니다.
모델은 올리지 않았습니다. DeepSeek V4.1 Flash 라우팅 전문가와 같은 모양의 행렬을 합성 가중치로 만들고, 양자화 방식·비트·입력·경로를 바꿔 가며 한 번에 160가지 경우를 MLX 0.32.3과 0.32.4로 쟀습니다. macOS 27.0.1에서 재고 27.2로 올린 뒤 같은 시험을 다시 했는데, 두 OS의 결과가 마지막 자리까지 같았습니다.
결론
- #4631은 MLX 0.32.3에서 재현되고 0.32.4에는 없습니다. 칩이 아니라 버전 문제입니다. 업스트림 재현 스크립트를 그대로 돌리면 0.32.3의 값이 이슈에 보고된 값과 같습니다.
- mlx-serve가 이 오차를 갖고 있습니다. mlx-serve는 MLX v0.32.3을 직접 빌드해 쓰고, 그 소스에 수정 전 코드가 있습니다. 토큰 생성 경로에서 affine 2·3·4비트 전문가 곱셈의 오차가 하한의 3.4~12배(상대 오차 0.6~2%)입니다.
- oMLX 0.7.3.dev2는 MLX 0.32.4를 써서 같은 경로의 오차가 하한 그대로입니다.
- 이 오차는 소설 반복의 원인이 아니었습니다. 같은 oQ2e 팩에서 오차가 있는 mlx-serve와 없는 oMLX가 같은 결과를 냈습니다.
- #4603은 재현되지 않았습니다. 보고자의 두 커널이 모든 math 모드에서 기대 출력과 비트 단위로 같았습니다.
- 프롬프트 처리 경로는 두 버전 모두 하한의 1.0~1.5배였고, 모든 경우 두 번 실행한 결과가 비트 단위로 같았습니다.
어느 엔진이 영향을 받나
| 엔진 | 쓰는 MLX | 토큰 생성 경로 오차 |
|---|---|---|
mlx-serve main 66c788b |
v0.32.3 직접 빌드 (64ea011cb) |
하한의 3.4~12배 (affine 2·3·4비트) |
| oMLX 0.7.3.dev2 | 0.32.4 wheel | 하한 그대로 |
mlx-serve 값은 같은 커널 소스인 MLX 0.32.3 PyPI wheel로 잰 것입니다. mlx-serve의 패치는 CUDA와 C 바인딩용뿐이라 Metal 커널은 같습니다.
oMLX는 DeepSeek V4의 전문가 곱셈에서 경로가 32개 미만이면(토큰 생성은 6개) 정렬 없이 MLX 기본 gather_qmm을 부릅니다. oMLX 자체 affine 커널은 정렬된 경로가 1,024개 이상일 때만 후보인데, 행렬 가속(NAX)이 있는 칩에서는 그 크기면 기본 커널을 고르므로 이 기계에서는 쓰이지 않습니다. 둘 다 코드를 읽어 확인한 것입니다.
원인
v0.32.3의 양자화 커널(quantized.h)에서 입력 벡터를 읽는 load_vector는 sum += x[i] + x[i + 1] + ...처럼 bf16 값끼리 더합니다. v0.32.4는 U(x[i]) + ...로 float로 바꿔 더합니다. 3비트와 5비트는 8개를 이어 더해서 오차가 더 큽니다. affine 양자화는 출력에 bias × sum(x) 항이 들어가서, 입력이 한쪽으로 쏠려 있으면 이 합이 커지고 오차도 커집니다.
업스트림 재현 스크립트
이슈의 재현 스크립트(4비트 affine, 그룹 64, 전문가 8개, N 512, K 2048, seed 20261005)를 그대로 돌렸습니다. 이슈에 보고된 0.32.3 값은 M=1이 0.003795616, M=2가 0.003351253입니다.
| M | 0.32.3 q_rmse | 0.32.3 g_rmse | 0.32.4 q_rmse | 0.32.4 g_rmse |
|---|---|---|---|---|
| 1 | 0.00379562 | 0.00379562 | 0.000731587 | 0.000731587 |
| 2 | 0.000746686 | 0.00335125 | 0.000746686 | 0.000746686 |
| 4 | 0.000757026 | 0.00320014 | 0.000757026 | 0.000757026 |
| 8 | 0.000751624 | 0.00319482 | 0.000751624 | 0.000751624 |
| 16 | 0.000751911 | 0.0032744 | 0.000751911 | 0.000751911 |
q_rmse와 g_rmse는 재현 스크립트가 내는 두 지표입니다. 0.32.3에서는 M=1의 두 값과 M=2 이상의 g_rmse가 약 4~5배 컸고, 0.32.4는 모두 0.00073~0.00076으로 bf16 출력 반올림 하한 수준이었습니다. 0.32.3 M=1 g_rmse는 0.00379561586으로 보고값과 같습니다.
토큰 생성 경로
토큰 하나(T=1)를 만드는 정렬 없는 경로(gather_qmv)입니다. 값은 하한 대비 배율이고, 괄호 안은 상대 RMSE입니다. x는 입력 분포로, normal은 N(0, 1), pos는 한쪽으로 쏠린 |N(0, 1)|입니다.
| 방식 | 모양 | x | 0.32.3 | 0.32.4 |
|---|---|---|---|---|
| affine 2bit | gate_up | normal | 4.31 (7.11e-03) | 1.00 (1.65e-03) |
| affine 2bit | gate_up | pos | 6.18 (1.01e-02) | 1.00 (1.63e-03) |
| affine 2bit | down | normal | 3.41 (5.68e-03) | 1.00 (1.66e-03) |
| affine 2bit | down | pos | 6.13 (1.02e-02) | 1.00 (1.66e-03) |
| affine 3bit | gate_up | normal | 5.83 (9.52e-03) | 1.00 (1.63e-03) |
| affine 3bit | gate_up | pos | 12.27 (2.06e-02), 10배 초과 6/6행 | 1.00 (1.68e-03) |
| affine 3bit | down | normal | 6.00 (9.94e-03) | 1.00 (1.66e-03) |
| affine 3bit | down | pos | 10.03 (1.67e-02), 10배 초과 4/6행 | 1.00 (1.67e-03) |
| affine 4bit | gate_up | normal | 4.29 (7.09e-03) | 1.00 (1.65e-03) |
| affine 4bit | gate_up | pos | 7.49 (1.23e-02) | 1.00 (1.64e-03) |
| affine 4bit | down | normal | 3.66 (6.14e-03) | 1.00 (1.68e-03) |
| affine 4bit | down | pos | 6.44 (1.06e-02) | 1.00 (1.65e-03) |
| affine 8bit | gate_up | normal | 1.00 (1.66e-03) | 1.00 (1.66e-03) |
| affine 8bit | gate_up | pos | 1.00 (1.65e-03) | 1.00 (1.65e-03) |
| affine 8bit | down | normal | 1.00 (1.68e-03) | 1.00 (1.68e-03) |
| affine 8bit | down | pos | 1.00 (1.66e-03) | 1.00 (1.66e-03) |
| mxfp4 4bit | gate_up | normal | 1.00 (1.66e-03) | 1.00 (1.66e-03) |
| mxfp4 4bit | gate_up | pos | 1.00 (1.64e-03) | 1.00 (1.64e-03) |
| mxfp4 4bit | down | normal | 1.00 (1.65e-03) | 1.00 (1.65e-03) |
| mxfp4 4bit | down | pos | 1.00 (1.65e-03) | 1.00 (1.65e-03) |
8비트와 MXFP4는 0.32.3에서도 하한 그대로였습니다. 토큰 8개(T=8)도 같은 양상이었고, 0.32.3 3비트 pos는 11.4~11.9배였습니다. "10배 초과"는 행 RMSE가 하한의 10배를 넘은 행 수입니다. 엉뚱한 값이 나온 행이 아니라 10~12배 오차인 행입니다.
프롬프트 처리 경로
프롬프트 450토큰에 전문가 6개씩, 2,700개 경로를 정렬해 NAX 커널(gather_qmm_rhs)로 처리하는 경로입니다. 두 버전의 값이 모든 경우에 같아서 한 번만 적었습니다.
| 방식 | 모양 | normal | pos |
|---|---|---|---|
| affine 2bit | gate_up | 1.00 (1.66e-03) | 1.00 (1.66e-03) |
| affine 2bit | down | 1.00 (1.66e-03) | 1.00 (1.66e-03) |
| affine 3bit | gate_up | 1.20 (1.98e-03) | 1.24 (2.04e-03) |
| affine 3bit | down | 1.20 (1.99e-03) | 1.23 (2.05e-03) |
| affine 4bit | gate_up | 1.34 (2.21e-03) | 1.48 (2.45e-03) |
| affine 4bit | down | 1.33 (2.22e-03) | 1.48 (2.46e-03) |
| affine 8bit | gate_up | 1.41 (2.33e-03) | 1.43 (2.37e-03) |
| affine 8bit | down | 1.41 (2.34e-03) | 1.44 (2.38e-03) |
| mxfp4 4bit | gate_up | 1.00 (1.66e-03) | 1.00 (1.65e-03) |
| mxfp4 4bit | down | 1.00 (1.66e-03) | 1.00 (1.66e-03) |
프롬프트 처리 크기의 밀집 양자화 곱셈도 두 버전 모두 하한의 1.0~1.5배였습니다. float32 입력은 상대 오차가 1.4e-7 이하였습니다. MLX의 CPU 양자화 곱셈은 bf16 입력이면 bf16으로 누적해 상대 오차가 3.9e-2였는데(float32 입력은 1.6e-7), 엔진들은 GPU만 쓰므로 CPU는 비교 기준으로 쓰지 않았습니다.
소설 반복과의 관계
같은 oQ2e 팩(라우팅 전문가 affine 2비트)을 mlx-serve와 oMLX로 돌린 소설 시험에서 루프는 3편 중 3편 대 2편, 품질 평균은 5.3 대 6.3이었습니다. 오차가 있는 엔진과 없는 엔진이 같은 결과를 냈으니, 이 오차는 oQ2e 루프의 원인이 아닙니다. 크기도 다릅니다. 무작위 가우시안 가중치를 반올림(RTN)으로 2비트 양자화하면 출력 기준 오차가 약 39~43%인데, 커널이 더하는 오차는 1% 안팎입니다. 다만 실제 팩의 양자화 오차는 이 합성 가중치와 크기가 다릅니다.
mlx-serve의 대표 팩인 iQ-MLX 3.0bpw는 아직 영향을 모릅니다. 이 팩의 라우팅 전문가는 down이 40층 모두 affine 3비트, gate·up은 30층이 2비트·10층이 3비트이고(그룹 64, 파일 헤더로 확인), 0.32.3의 초과 오차가 가장 큰 곳이 3비트입니다(토큰 생성 경로 5.7~12배). 이 팩은 mlx-serve로만 돌렸습니다. 가르려면 mlx-serve의 커널에 float 형변환을 넣어 다시 빌드하고 소설 시험을 다시 돌려야 합니다.
macOS 27 런타임 컴파일러 (#4603)
MLX 없이 Metal을 직접 부르는 작은 드라이버로 보고자의 두 커널을 돌렸습니다.
| 커널 | safe | relaxed | fast | 같은 입력 반복 |
|---|---|---|---|---|
| 6비트 K512 | 정확 (5/5행) | 정확 | 정확 | 일관 |
| 4비트 K1024 | 정확 (5/5행) | 정확 | 정확 | 일관 |
Metal 언어 3.1·4.0·4.1 모두 safe에서 정확했습니다. 보고된 기기(M3 Ultra, macOS 27.0)는 safe에서 5행 중 2~5행이 틀렸습니다. 보고자의 두 커널만 확인한 것이라 런타임 컴파일러 전체가 안전하다는 뜻은 아닙니다.
macOS 27.2로 올린 뒤
같은 시험을 27.2(26B123)에서 다시 돌렸습니다. MLX wheel과 드라이버 바이너리는 그대로입니다.
- 두 버전 × 160가지 경우의 RMSE, 하한, 10배 초과 행 수, 비정상 값이 업데이트 전과 모두 같았습니다. 업스트림 재현 표도 같습니다.
- #4603 재현은 장치 이름 줄을 빼면 업데이트 전과 같았습니다. 여섯 번 모두 정확했고, 같은 입력 반복 두 번도 일관됐습니다. 이 드라이버는 실행할 때 OS의 Metal 컴파일러로 커널을 컴파일하므로, 27.2 컴파일러에서도 이 두 커널은 문제가 없다는 뜻입니다.
- MLX 커널은 wheel 안에 미리 컴파일된 metallib라서 OS 업데이트로 결과가 바뀌지 않는 것이 예상과 맞습니다. GPU 드라이버가 바뀌었는데도 지표가 마지막 자리까지 같았습니다. 출력 바이트를 직접 비교하지는 않았습니다.
- 27.2에서 GPU wired 한도(
iogpu.wired_limit_mb)를 기본값 0으로 두면 MLX가 보고하는 권장 작업 메모리(max_recommended_working_set_size)가 240.0GiB였습니다. 업데이트 전에 245,760MB로 설정했을 때와 같은 값입니다.
시험 방법
| 항목 | 값 |
|---|---|
| 모양 | DeepSeek V4.1 Flash 라우팅 전문가와 같음 (hidden 5120, 전문가 중간 차원 2304, 상위 6개). gate_up은 N 2304·K 5120, down은 N 5120·K 2304. 전문가는 16개만 만듦(커널 선택은 전문가 수와 무관) |
| 양자화 | affine 2·3·4·8비트(그룹 64), MXFP4(그룹 32). 가중치는 N(0, 0.02)를 bf16로 만든 뒤 mx.quantize로 양자화 |
| 입력 | bf16. normal은 N(0, 1), pos는 N(0, 1)의 절댓값 |
| 경로 | 토큰 생성: T=1·8, 정렬 없음(gather_qmv). 프롬프트 처리: T=450, 정렬(NAX gather_qmm_rhs). 밀집 qmm: M=1·6·450. 대조군: float32 입력, CPU |
| 기준값 | 같은 양자화 값을 float32로 역양자화한 가중치와 bf16 입력으로 float64 행렬곱 |
| 지표 | 출력 RMSE ÷ 기준값을 bf16로 반올림했을 때의 RMSE. 1이면 커널이 bf16 출력 반올림 말고는 오차를 더하지 않는다는 뜻 |
| 버전 | MLX 0.32.3·0.32.4 PyPI wheel |
| 실행 | 한 번에 160가지 경우, 약 2분. 같은 기계에서 다른 추론 서버를 띄운 채로 돌림 |
시험하지 않은 것
- oMLX의 다른 자체 커널,
mx.compile융합 커널, attention(SDPA) 커널 - Sushi 플러그인 커널, ds4와 llama.cpp의 커널
- 오차의 부호 평균(한쪽으로 쏠리는지). RMS만 쟀습니다.
측정 환경
Mac Studio M5 Ultra(GPU 80코어), 통합 메모리 256GB에서 11월 2일에 쟀습니다. macOS 27.0.1(26A434)에서 한 번, 27.2(26B123)로 올린 뒤 한 번 돌렸습니다.