なぜこの記事を書いたか
前に MoE の記事を書いたとき、「VRAM は総パラメータ、速度はアクティブパラメータで見る」と結論しました。書き終えてから、ひとつ引っかかりが残りました。なぜ速度のほうだけがアクティブパラメータに比例するのか、私はその理由を説明していなかったのです。計算量が減るから、では答えになっていません。計算量が減っても速くならない計算は、世の中にいくらでもあります。
答えは Dense 側にありました。読んでくださる方に考えてほしいのは、この一点です。LLM の生成は、計算する作業ではなく運ぶ作業である。運ぶ量で時間が決まるなら、量子化も MoE も投機的デコードも、全部「運搬量を減らす工夫」という同じ棚に並びます。
検証環境は Xeon 1コア・メモリ 3GB という貧弱な箱です。GPU はありません。ただ、帯域律速という現象はハードの豪華さに関係なく起きるので、むしろ小さい箱のほうが素直に出ます。実測はすべて NumPy 2.4.4 / Python 3.12.3、OpenBLAS 単一スレッドで取りました。
この記事の対象読者
- ローカルLLMを動かしていて、生成が思ったほど速くない理由を知りたい方
- Transformerの構造はひととおり知っている方
この記事で得られること
- 手元のマシンでそのモデルが何トークン毎秒出るかを、ベンチを回す前に見積もれるようになります
- 速度が足りないときに、量子化を深くするのか機材を替えるのかを、数字で選べるようになります
- バッチサイズをどこまで上げると効果が消えるかを、演算強度から判断できるようになります
この記事で扱わないこと
- 学習時の性能(本記事は推論、特に1トークンずつ生成する場面に限定します)
- FlashAttention など Attention 側の最適化
1. Dense とは「毎回、倉庫の全部品を運ぶ工場」
必然: 速度の話をする前に、Dense モデルが1トークンごとに何を触っているのかを確定させる必要があります。触る量が分からなければ、運搬時間も出ません。
このセクションで分かること: Dense の定義と、そこから出てくる「例外なく全部使う」という性質。
この記事を通して使う比喩を決めます。Dense モデルは、製品を1個作るたびに倉庫の全部品を作業台まで運んでくる工場です。棚のどこも素通りしません。
記号を1つずつ置きます。$P$(総パラメータ数、整数)は倉庫にある部品の総数です。$B$(バッチサイズ、整数)は、1回の搬入で同時に組み立てる製品の数にあたります。
Dense という言葉は「密」という意味ですが、指しているのは行列が密という話ではありません。入力が何であっても、同じ重みが例外なく全部使われるという意味です。MoE が受付を置いて一部の棚だけを使い分けたのに対し、Dense には受付そのものがありません。
つまり Dense では「パラメータ数」「1トークンあたりの計算量」「1トークンあたりに読むバイト数」の3つが、1本の鎖でつながっています。この鎖のどこを引っ張っても、残り2つが一緒に動きます。MoE はこの鎖を切ろうとした設計でした。
2. 鎖の正体 — 計算量とバイト数を式にする
引き継ぎ: 前節で、Dense では重みが例外なく全部使われることを確認しました。
必然: 「全部使う」を数式にしないと、計算と運搬のどちらが先に詰まるかを比べられません。次節の実測はこの比を測る実験になります。
このセクションで分かること: 演算強度という1つの数で、律速の側が決まること。
行列積 $y = xW$ では、重み1個につき掛け算と足し算が1回ずつ起きます。バッチ $B$ トークンぶんなら、こうです。
\text{FLOPs} \approx 2 P B, \qquad \text{Bytes} \approx P \cdot \frac{b}{8}
この式が言っているのは、「作業台の仕事量 FLOPs はバッチ $B$ に比例して増えるが、ベルトコンベアが運ぶ量 Bytes は $B$ に関係なく一定」ということです。$b$(1重みあたりのビット数、スカラー)は部品1個の大きさで、FP16 なら 16、Q4_K_M なら約 4.8 です。重みは $B$ が何であれ1回運べば足りるので、右側に $B$ が出てきません。
この2つの比が $I$(演算強度、スカラー・FLOP毎バイト)です。
I = \frac{\text{FLOPs}}{\text{Bytes}} \approx \frac{2PB}{P b / 8} = \frac{16 B}{b}
この式が言っているのは、「演算強度はパラメータ数 $P$ に依存せず、バッチサイズと量子化ビット数だけで決まる」ということです。工場のたとえで言えば、1回の搬入で何個作るかが、コンベアと作業台のどちらが暇になるかを決める。
数字を入れます。$b = 4.8$、1トークンずつ生成する $B = 1$ のとき、$I = 3.3$ です。一方、コンシューマ向け高性能 GPU の代表例として RTX 5090 を挙げると、カタログ上の FP16 テンソル性能は約 210 TFLOPS、メモリ帯域は 1792 GB/s。この2つが釣り合う折れ点は 117 FLOP毎バイトになります。精度や疎性の条件で演算性能側は2倍前後ぶれますが、桁は変わりません。
3.3 対 117。35倍の開きがあります。作業台は圧倒的に暇で、コンベアだけが全力で回っている。これが Dense の生成が帯域律速だと言われる中身です。
記号と比喩の対応表
| 記号 | 読み | 型 | 工場のたとえ |
|---|---|---|---|
| $P$ | 総パラメータ数 | 整数 | 倉庫にある部品の総数 |
| $B$ | バッチサイズ | 整数 | 1回の搬入で同時に作る製品数 |
| $b$ | 重み1個のビット数 | スカラー | 部品1個の大きさ |
| $I$ | 演算強度 | スカラー・FLOP毎バイト | 部品1バイトあたりに作業台がする仕事 |
| $\mathrm{BW}$ | メモリ帯域 | スカラー・GB毎秒 | ベルトコンベアの速さ |
3. 実測①: 1個ずつ作るか、まとめて作るか
引き継ぎ: 前節で、演算強度がバッチサイズに正比例することを式で確認しました。
必然: 式は正しくても、実機で本当に $B$ を増やしただけで1トークンあたりの時間が下がるのかは別問題です。ここが崩れると、以降の見積もり式は使えません。
このセクションで分かること: バッチを増やしたときの1トークンあたり時間の実測カーブ。
4096×4096 の FP32 行列を重みに見立てました。64MiB あるのでキャッシュには収まりません。
def timeit(fn, n_warmup=2, n_repeat=7):
for _ in range(n_warmup):
fn()
ts = []
for _ in range(n_repeat):
t0 = time.perf_counter()
fn()
ts.append(time.perf_counter() - t0)
return float(np.median(ts))
for b in [1, 2, 8, 32, 128, 512]:
x = RNG.normal(0, 1, (b, D)).astype(np.float32)
t = timeit(lambda: x @ w)
flops = 2.0 * b * D * D
moved = w.nbytes + x.nbytes * 2 # 重み + 入出力アクティベーション
print(b, t * 1e3, t * 1e3 / b, flops / t / 1e9, moved / t / 1e9)
| バッチ $B$ | 総時間 | 1トークンあたり | 実効GFLOPS | 実効帯域 |
|---|---|---|---|---|
| 1 | 2.57ms | 2.568ms | 13.1 | 26.1GB/s |
| 2 | 10.65ms | 5.324ms | 6.3 | 6.3GB/s |
| 8 | 11.85ms | 1.481ms | 22.7 | 5.7GB/s |
| 32 | 18.36ms | 0.574ms | 58.5 | 3.7GB/s |
| 128 | 46.05ms | 0.360ms | 93.3 | 1.5GB/s |
| 512 | 125.61ms | 0.245ms | 136.8 | 0.7GB/s |
$B = 1$ と $B = 512$ で、1トークンあたり 2.568ms 対 0.245ms。同じ重みを使っているのに、1個ずつ作ると10.5倍かかります。プロンプト処理が速くて生成が遅い理由は、まさにこれです。前者はまとめ作り、後者は1個ずつの受注生産です。
実効GFLOPS が 13.1 から 136.8 まで上がりきったところで頭打ちになるのも、そのまま読めます。このマシンの作業台の上限が 137 GFLOPS 付近だということです。
ここで正直に書いておくと、$B = 2$ だけ、$B = 1$ より遅くなりました。予想していなかったので調べたところ、OpenBLAS は $B = 1$ で行列ベクトル積の専用経路を使い、$B \geq 2$ で汎用の行列積経路に切り替えます。この箱では後者のブロッキングが噛み合っておらず、$B = 2$ から $8$ までほぼ一定の 10〜12ms になっていました。マシン固有の話ではありますが、小バッチ領域の性能は理屈どおりに素直ではないという実例にはなります。
ここまでのまとめ
- Dense は「パラメータ数」「計算量」「読むバイト数」が1本の鎖でつながった設計である
- 演算強度は $I = 16B / b$ で、パラメータ数に依存しない。1トークンずつ生成する場面では $I = 3.3$ 程度にしかならない
- 実測でも、$B = 1$ の1トークンあたり時間は $B = 512$ の10.5倍だった
ここまでは「バッチを増やせば得をする」という話でした。次は逆に、増やせない場面、つまり手元で1トークンずつ生成するときに何が時間を決めているのかを測ります。
4. 実測②: バッチ1の時間は、重みのバイト数だけで決まる
引き継ぎ: 前節で、$B = 1$ が最も効率の悪い動作点だと分かりました。
必然: 個人が手元で使う場面は、ほぼ全部この $B = 1$ です。ならば $B = 1$ で何が時間を支配しているかを特定しないと、見積もり式が書けません。
このセクションで分かること: 時間が重みのバイト数に比例し、その傾きが実効メモリ帯域になること。
重みの大きさを 4MiB から 100MiB まで25倍に振って、$B = 1$ の時間を測りました。もし帯域律速なら、時間はバイト数に正比例し、両者の比つまり実効帯域は一定になるはずです。
for d in [1024, 2048, 3072, 4096, 5120]:
w = RNG.normal(0, 0.02, (d, d)).astype(np.float32)
x = RNG.normal(0, 1, (1, d)).astype(np.float32)
t = timeit(lambda: x @ w)
print(d, w.nbytes / 2 ** 20, t * 1e3, w.nbytes / t / 1e9)
del w
big = [r for r in rows if r[0] > 30 * 2 ** 20] # キャッシュ外のみで回帰
slope = np.polyfit([r[0] for r in big], [r[1] for r in big], 1)[0]
print("out-of-cache slope =", 1 / slope / 1e9, "GB/s")
| 次元 | 重みサイズ | 時間 | 実効帯域 |
|---|---|---|---|
| 1024 | 4.0MiB | 0.152ms | 27.5GB/s |
| 2048 | 16.0MiB | 0.645ms | 26.0GB/s |
| 3072 | 36.0MiB | 1.507ms | 25.1GB/s |
| 4096 | 64.0MiB | 2.601ms | 25.8GB/s |
| 5120 | 100.0MiB | 4.112ms | 25.5GB/s |
回帰した傾きは 25.7 GB/s でした。重みを25倍にしても実効帯域は 25〜27 GB/s のまま動きません。時間はバイト数にきれいに正比例しています。
つまり $B = 1$ では、このマシンは計算をしているのではなく、ひたすら重みを読んでいる。作業台の性能はどこにも顔を出していません。§2 で式から出した「演算強度 3.3」の意味が、そのまま時間に現れた形です。
5. 見積もり式と、その使い方
引き継ぎ: 前節で、$B = 1$ の時間が「重みのバイト数 ÷ 実効帯域」で決まると実測できました。
必然: ならばこの関係をそのまま割り算にすれば、ベンチを回す前にトークン毎秒が出ます。冒頭に挙げた「何トークン毎秒出るか」への答えがここです。
このセクションで分かること: 見積もり式と、速度が足りないときの打ち手の選び方。
\text{tokens/s} \lesssim \frac{\mathrm{BW}}{P \cdot b / 8}
この式が言っているのは、「ベルトコンベアの速さを、1トークンぶんの搬入量で割ったものが上限」ということです。$\mathrm{BW}$(メモリ帯域、GB毎秒)はカタログ値、分母は要するにモデルファイルのサイズです。
BPW = 4.8 # Q4_K_M の目安
def tokens_per_sec(bandwidth, params_b, bpw=BPW, utilization=1.0):
bytes_per_token = params_b * 1e9 * bpw / 8
return bandwidth * utilization / bytes_per_token
コンシューマ向け高性能構成の代表例として RTX 5090 と RTX 4090、そして GPU を使わない DDR5-5600 デュアルチャネル構成、比較用に本記事の実測環境を並べます。
| モデル | 5090・1792GB/s | 4090・1008GB/s | DDR5・89.6GB/s | 本記事の箱・25.7GB/s |
|---|---|---|---|---|
| Dense 8B | 373 | 210 | 19 | 5 |
| Dense 14B | 213 | 120 | 11 | 3 |
| Dense 70B | 43 | 24 | 2 | 1 |
| MoE 30B-A3B・アクティブ3.3B | 905 | 509 | 45 | 13 |
単位はトークン毎秒、いずれも理論上限です。実際のメモリ帯域利用率は 60〜80% 程度なので、この表の値に 0.6〜0.8 を掛けたあたりが現実的な着地点になります。
ここで冒頭の引っかかりが解けました。MoE の速度がアクティブパラメータに比例するのは、1トークンごとに実際にコンベアへ載るのが選ばれたエキスパートの重みだけだからです。倉庫は総パラメータぶん必要でも、運ぶのはアクティブぶん。VRAM と速度で見る数字が違う理由は、容量と帯域という別々のリソースを見ているからでした。
打ち手の選び方も、この式から自動的に出ます。
個人利用の $B = 1$ はほぼ必ず左に落ちます。したがってまず疑うべきは GPU の演算性能ではなく、モデルファイルのサイズです。Q8_0 から Q4_K_M に落とすと 1重みあたり約 8.5 ビットが約 4.8 ビットになるので、運搬量は約 0.56 倍、速度はおよそ 1.8 倍になります。しかも VRAM も同じ比率で空きます。同じ 1.8 倍を機材で買おうとすると、帯域が 1.8 倍のカードが必要です。
自分の環境の実効帯域は、実務では PyTorch で3行測れば分かります。カタログ値との比がそのまま帯域利用率です。
torch.cuda.synchronize(); t0 = time.perf_counter()
y = x @ w # w は .cuda().half() 済みの重み
torch.cuda.synchronize()
print(w.numel() * w.element_size() / (time.perf_counter() - t0) / 1e9, "GB/s")
この式は重みだけを見ています。コンテキストが長くなると KV キャッシュの読み出しが無視できなくなり、上限はさらに下がります。長文を扱う構成では、重み由来の上限に届かなくても不思議ではありません。
6. トラブルシューティング
| 症状 | 原因 | 対処 |
|---|---|---|
| GPU 使用率が低いのに生成が速くならない | 帯域律速なので演算器が余っている | 量子化を1段深くする。使用率ではなく帯域利用率を見る |
| プロンプト処理は速いのに生成が遅い | 前者は大バッチ、後者は $B = 1$ | 正常な挙動。投機的デコードで実効バッチを増やす |
| 同じモデルなのに GPU を替えても速度が伸びない | 演算性能は上がったが帯域が同等 | カタログのメモリ帯域を比べ直す |
| 小さいバッチで性能が理屈どおりに出ない | BLAS カーネルの切り替わり | バッチを増やすか、実装側の最適化経路を確認する |
| 見積もりの半分しか出ない | 帯域利用率が低い | 0.6〜0.8 を掛けた値と比べる。長文なら KV キャッシュを疑う |
| 量子化したのに速度が変わらない | 逆量子化がボトルネックになっている | 演算強度を再計算し、律速側が移っていないか確認する |
用語集
| 用語 | 定義 | 工場のたとえ |
|---|---|---|
| Dense | 入力によらず全パラメータが使われる構成 | 毎回、倉庫の全部品を運ぶ |
| 演算強度 | 転送1バイトあたりの演算回数 | 部品1バイトあたりに作業台がする仕事 |
| 帯域律速 | 演算器ではなくメモリ転送が上限を決めている状態 | 作業台が暇でコンベアが渋滞している |
| ルーフラインモデル | 演算強度と性能上限の関係を1枚の図で表す解析手法 | コンベアと作業台のどちらが先に詰まるかの見取り図 |
| プリフィル | プロンプト全体をまとめて処理する段階 | まとめ作り |
| デコード | 1トークンずつ生成していく段階 | 1個ずつの受注生産 |
次に読むとよいもの
- MoE — 運ぶバイト数を減らす別の設計。この記事の式の分母を小さくする話
- GGUF と Safetensors — 分母そのもの、つまりファイルサイズを決める形式
- llama.cpp と vLLM — 見積もりと実測を突き合わせる
まとめ
Dense を一言で言うなら、製品を1個作るたびに倉庫の全部品を運んでくる工場です。運搬が仕事の本体なので、作業台をいくら速くしても完成品は増えません。
実測でもそのとおりでした。重みを25倍に振っても実効帯域は 25〜27 GB/s から動かず、時間はバイト数に正比例しました。$B = 1$ と $B = 512$ の1トークンあたり時間が10.5倍も開いたのも、同じ理屈の裏返しです。
だから持ち歩く式は1本で足ります。トークン毎秒の上限は、メモリ帯域をモデルファイルのサイズで割った値。この分母を削る工夫が量子化であり MoE であり、分子を増やす投資が機材の更新です。どちらが自分の状況で効くかは、演算強度を1回計算すれば決まります。
書き終えて思ったのは、MoE の記事で残した宿題が、Dense 側を測ることでしか解けなかったという点でした。速い仕組みを理解するには、遅い仕組みのほうを先に測る必要があったわけです。
参考文献
- Williams, Waterman, Patterson, "Roofline: An Insightful Visual Performance Model for Multicore Architectures" Communications of the ACM, 2009
(邦題訳:ルーフライン ── マルチコアアーキテクチャのための洞察に富む視覚的性能モデル)— 演算強度で律速側を判定するという枠組みの原典 - Shazeer, "Fast Transformer Decoding: One Write-Head is All You Need" arXiv:1911.02150
(邦題訳:高速なTransformerデコーディング ── 書き込みヘッドは1つで足りる)— 逐次推論が遅いのは大きなキーとバリューを繰り返し読み出すメモリ帯域コストのため、と明記した論文。Multi-Query Attention の提案元 - Pope et al., "Efficiently Scaling Transformer Inference" arXiv:2211.05102
(邦題訳:Transformer推論の効率的なスケーリング)— バッチサイズと演算強度からデコード時の律速を解析している。本記事の式の下敷き - Fedus, Zoph, Shazeer, "Switch Transformers" arXiv:2101.03961
(邦題訳:Switch Transformer)— 計算量とパラメータ数の鎖を切る側の代表例 - NVIDIA GeForce RTX 50 シリーズ製品仕様 — 本記事で引用した 1792 GB/s および 32GB GDDR7 の出典
計測環境
Intel Xeon @ 2.10GHz 1コア / メモリ 3GB / Ubuntu / Python 3.12.3 / NumPy 2.4.4、OpenBLAS 0.3.31 を単一スレッドに固定。実測値はいずれも中央値です。表中の GPU 数値は実測ではなくカタログ帯域からの算出値である点にご注意ください。
普段は AI とローカル LLM まわりの実測を投稿しています。