Apple Silicon上のローカルLLMには、同じ積を計算しているのにテンソルが3次元というだけで遅くなる経路があった。
PyTorch 2.14は、自己回帰デコードで頻出する[B, 1, K]を2次元へ見せ直し、非効率な反復GEMVを一括GEMMへ戻した。
M4 ProでのQwen3-1.7B検証では、batch 8のデコードが68 ms/tokenから26.1 ms/tokenへ短縮されている。
PyTorch Foundationは2026年9月2日、PyTorch 2.14を公開した。2,995件のcommit、487人のcontributorから成る大きなreleaseだが、MacでローカルLLMを動かす人にとって重要なのは、派手な新APIよりF.linearの小さな分岐修正かもしれない。
本稿では、release blogと採用されたPull Requestを基に、なぜ同じ線形演算が最大8.5倍遅くなったのか、1行に近い条件変更がなぜ効いたのか、そして何を測らなければ改善を誤読するのかを分解する。
この記事の数値は筆者の実機benchmarkではない。PyTorch公式release blogと、PyTorch repositoryで公開されたM4 Pro / M4 Maxの測定結果を読み解いたものである。
問題はGPU性能ではなくdispatchだった
Transformerの全結合層は、概念的には次の線形変換を何度も実行する。
y = x W^T + b
自己回帰生成では、prefillでprompt全体を処理した後、decodeで新しいtokenを1個ずつ作る。batch sizeをB、入力特徴数をK、出力特徴数をNとすると、decode時の入力はしばしば[B, 1, K]になる。中央の1が、今回の重要な手掛かりだ。
数学的には、次の3つは同じ積を表せる。
| PyTorch上の形 | 入力shape | 出力shape | 意味 |
|---|---|---|---|
3D F.linear
|
[B, 1, K] |
[B, 1, N] |
batchごとに1 tokenを変換 |
2D F.linear
|
[B, K] |
[B, N] |
singleton次元を畳んだ同じ計算 |
3D matmul
|
[B, 1, K] |
[B, 1, N] |
転置weightとの行列積 |
ところが修正前のApple Silicon向けMPS backendでは、半精度の3D F.linearだけが別の実行経路へ入った。2D版とmatmulは高速なのに、3D版はbatch要素ごとのmatrix-vector multiplication、つまりGEMVとして反復実行された。
期待する経路
[B, 1, K] -> [B, K] -> 1回のGEMM -> [B, N] -> [B, 1, N]
修正前の経路
[B, 1, K] -> B個の[1, K] -> B回のGEMV -> [B, 1, N]
大きな行列演算としてまとめられる仕事を、小さな演算へ分割すれば、kernel起動やdispatchの固定費を繰り返し払う。GPUの理論演算性能を上げても、仕事の渡し方が細切れなら埋められない差が残る。
測定値が示した不連続な崖
Pull Request #189855の再現条件は、M4 Pro、macOS 26.4、source buildのPyTorch 2.14.0a0、bfloat16だった。Qwen3-14Bのgate projectionを模したK=5120、N=17408で、warmup 10回の後に30回測定し、その中央値を比較している。
| B | 3D F.linear
|
2D F.linear
|
3D matmul
|
3D / 2D |
|---|---|---|---|---|
| 1 | 0.89 ms | 0.91 ms | 0.93 ms | 1.0倍 |
| 4 | 3.00 ms | 0.97 ms | 0.97 ms | 3.1倍 |
| 8 | 5.76 ms | 0.94 ms | 0.93 ms | 6.1倍 |
| 14 | 9.91 ms | 1.17 ms | 1.18 ms | 8.5倍 |
| 16 | 1.15 ms | 1.14 ms | 1.15 ms | 1.0倍 |
| 32 | 1.28 ms | 1.29 ms | 1.30 ms | 1.0倍 |
遅延はbatch 14までほぼ線形に増え、16で突然正常へ戻る。通常のcapacity不足なら、batchを増やした先で滑らかに飽和するはずだ。この不連続性はhardwareの限界ではなく、一定のshapeを境に選択されるkernelが変わったことを示す。
現象には明確な境界もあった。
- MPS backend固有で、CPUやCUDA一般の問題として報告されたものではない
-
F.linear固有で、同じshapeのmatmul、mm、addmmは同じ崖を示さない -
bf16とfp16で発生し、検証上のfp32は影響を受けない - 3Dかつ小さい非batch次元で目立ち、
[B, 1, K]が最も持続的に悪い - 高速経路へ切り替わるbatch閾値は出力幅
Nにも依存する
つまり「MacのGPUがLLMに遅い」では粗すぎる。正確には、特定のbackend、演算API、dtype、shape、batch範囲の組み合わせが遅かった。
どこで経路を間違えたのか
PyTorchのaten::linearはMPS上で_mps_linearへ割り当てられる。macOS 15以降、連続配置、非complex、半精度という条件では、MPSGraphを介さない_mps_linear_nographのfast pathへ進む。
この経路は3Dの[B, 1, K]をそのままAppleのMPSNDArrayMatrixMultiplicationへ渡していた。broadcast可能な3D入力としては正しいが、実際の実行はbatchごとのGEMVになった。一方、旧いgraph側には3D以上の入力を2Dへflattenする性能回避策が既にあった。
ここが興味深い。新しいfast pathは一般には速いが、旧経路にあったshape固有の回避策を引き継いでいなかった。「新しいbackendへ移した」というarchitecture変更が、演算の正しさを保ったままperformance regressionを作った。
修正の核心は次の変換である。
# 概念を簡略化したもの
input_2d = input.flatten(0, -2) # [B, 1, K] -> [B, K]
output_2d = output.flatten(0, -2) # [B, 1, N] -> [B, N]
_mps_linear_nograph(input_2d, weight, bias, output_2d)
連続配置のtensorに対するこのflattenはdata copyではなくzero-copy viewになる。値を移動せず、同じbufferを2次元としてkernelへ提示する。その結果、batchごとの反復GEMVではなく、まとめたGEMMとして処理できる。
実装上は、M5以降の非決定性回避に限定されていた2D化条件を、bias条件を満たす全ての3D超入力へ広げた。大規模な新kernelではなく、既存kernelが理解しやすいshapeへ正規化する修正だ。
修正後は何が変わったか
同じM4 Proのmicrobenchmarkで、修正後の3D / 2D比は全batchでほぼ1.0倍になった。
| B | 修正前の3D | 修正後の3D | 修正後の2D | 出力一致 |
|---|---|---|---|---|
| 1 | 0.89 ms | 0.94 ms | 0.94 ms | bit-identical |
| 8 | 5.76 ms | 0.93 ms | 0.93 ms | bit-identical |
| 14 | 9.91 ms | 1.16 ms | 1.16 ms | bit-identical |
| 16 | 1.15 ms | 1.17 ms | 1.17 ms | bit-identical |
| 64 | 1.97 ms | 1.97 ms | 1.96 ms | bit-identical |
重要なのは、速くなっただけでなく、もともと正常だった大きいbatchでregressionがなく、出力もbit単位で一致したことだ。近似精度を落として速度と交換したのではない。
別のM4 Maxによるend-to-end確認では、Qwen3-1.7B、bf16、batch 8のdecodeが68 ms/tokenから26.1 ms/tokenへ短縮された。時間は約61.6%減、速度換算では約2.61倍である。最大8.5倍という値は1つの線形層のmicrobenchmarkであり、モデル全体が8.5倍速くなったわけではない。
線形層の局所改善: 最大8.5倍
モデル全体のdecode: 68 -> 26.1 ms/token、約2.61倍
この差は自然だ。1 tokenの生成には複数のlinear以外にattention、normalization、sampling、memory accessなども含まれる。局所kernelの高速化は、処理全体に占めるそのkernelの比率を超えてend-to-end性能を押し上げられない。
自分の環境で確認する方法
PyTorch 2.14へ更新したら、まずversion、device、dtype、shapeを記録する。torch.mps.synchronize()を入れずにwall-clockを測ると、非同期実行の投入時間だけを拾う可能性がある。
import time
import torch
import torch.nn.functional as F
device = "mps"
dtype = torch.bfloat16
K, N = 5120, 17408
W = torch.randn(N, K, device=device, dtype=dtype)
def median_ms(fn, warmup=10, iterations=30):
for _ in range(warmup):
fn()
torch.mps.synchronize()
samples = []
for _ in range(iterations):
torch.mps.synchronize()
start = time.perf_counter()
fn()
torch.mps.synchronize()
samples.append((time.perf_counter() - start) * 1000)
return sorted(samples)[len(samples) // 2]
for batch in (1, 4, 8, 14, 16, 32):
x3 = torch.randn(batch, 1, K, device=device, dtype=dtype)
x2 = x3.squeeze(1)
t3 = median_ms(lambda: F.linear(x3, W))
t2 = median_ms(lambda: F.linear(x2, W))
print(batch, round(t3, 2), round(t2, 2), round(t3 / t2, 2))
評価はmicrobenchmarkだけで終えない方がよい。実際のモデルで次を固定し、prefillとdecodeを分けて比較する。
- model、weight dtype、量子化方式
- prompt長と生成token数
- batch size
- cold startかwarm stateか
- macOS、PyTorch、推論frameworkのversion
- p50だけでなくp95、peak memory、出力一致
batch 1では元の測定でも差がほぼなかった。単一sessionしか使わない利用者が、Qwen3-1.7Bのbatch 8と同じ改善を期待するのは誤りになる。
実務への含意
この修正は、ローカル推論の性能調査で「モデル名とGPU名だけを比較してはいけない」理由をよく示している。
まず、shapeはAPIの付帯情報ではなく、backend選択への入力である。要素数も数式も同じでも、rankやsingleton次元によって別kernelへdispatchされる。profileでは演算名だけでなく、各層へ到達した実shapeを見る必要がある。
次に、microbenchmarkとend-to-end benchmarkは役割が違う。前者は原因を局所化し、後者は利用者が得る改善を測る。最大値だけを製品性能として語ると、8.5倍と2.61倍を混同する。
そして、framework更新は新機能の導入だけではない。生成kernel、graph compiler、allocator、copy pathの修正が、同じmodel weightの価値を変える。hardware更新を検討する前に、runtimeのversion差を同一条件で測る方が安い場合がある。
限界と未確認事項
- 公開された測定はM4 ProとM4 Maxが中心で、全Apple Silicon世代、全macOS version、全modelへの一般化はできない。
- 8.5倍は特定shapeの
F.linearに対する局所値である。実モデル全体の改善率ではない。 - end-to-endの68から26.1 ms/tokenはQwen3-1.7B、
bf16、batch 8という1構成の報告で、独立した第三者benchmarkではない。 -
fp32、batch 1、別API経路など、もともと問題を踏まなかった構成では改善が小さいか存在しない。 - PyTorch 2.14のMPS関連機能にはAPI Unstableと明記された項目が多い。将来のreleaseでdispatch条件や性能特性は変わり得る。
-
flattenできる連続配置の経路を説明した。非連続tensor、特殊なbias、推論framework側の独自kernelは別の経路を通る可能性がある。 - 本稿は2026年9月5日時点の公式情報に基づく。筆者自身による複数machineでの追試は行っていない。
まとめ
PyTorch 2.14が直したのは、Apple Siliconの計算能力そのものではない。[B, 1, K]というdecode shapeを3Dのまま反復GEMVへ渡していたdispatchを、zero-copyで[B, K]へ見せ、一括GEMMへ戻した。
この事例から得られる実務的な教訓は明快だ。ローカルLLMが遅いときは、model sizeやGPU使用率だけでなく、どのshapeが、どのdtypeで、どのbackendとkernelへ送られたかを測る。性能の崖は、siliconではなくtensorの次元に隠れていることがある。
参考リンク
PyTorch 2.14 Release Blog(PyTorch Foundation、2026-09-02)
PyTorch 2.14.0 Release Notes(PyTorch GitHub、2026-09-02)
[MPS] F.linear fix for [B,1,K] seq=1 decode regression(PyTorch Pull Request #189855、2026-07-14、2026-07-20 merge)
MPS backend(PyTorch Documentation、最終更新2026-03-13)
torch.nn.functional.linear(PyTorch Documentation)
MPSGraph matrixMultiplication(Apple Developer Documentation)