1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MacのローカルLLMを遅くしていたのはモデルではなくテンソルの次元だった

1
Posted at

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=5120N=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のmatmulmmaddmmは同じ崖を示さない
  • bf16fp16で発生し、検証上の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を分けて比較する。

  1. model、weight dtype、量子化方式
  2. prompt長と生成token数
  3. batch size
  4. cold startかwarm stateか
  5. macOS、PyTorch、推論frameworkのversion
  6. 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)

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?