腾讯エンジニア:大規模モデルの出力を確定させるための AI インフラ手法
本文の状態
日本語全文を表示中
詳細モードで約35分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
Tencent Engineering
腾讯エンジニアは、大模型の出力が同じ提示詞でも異なる理由を浮動小数点演算順序の不確定性に特定し、ハードウェアレベルの並列最適化が科学的研究における再現性を阻害する根本原因であると分析した。
AI深層分析を開く2026年8月5日 21:31
AI深層分析
キーポイント
バッチ処理による出力不整合の発生メカニズム
同一環境・提示詞でも他リクエストが存在すると出力が変わる現象は、情報汚染ではなく、システムが訪存遅延を隠すために非決定性のハードウェア並列最適化を実行した結果である。
浮動小数点演算の結合律不成立と精度劣化
浮動小数点加算は結合律を満たさないため、演算順序が変化すると最終的な丸め誤差が異なり、結果として数値やモデル出力に差異が生じる。
IEEE 754標準における丸め規則の影響
FP16形式では特定の条件下で中間計算結果が正中間値となり「偶数への丸め」ルールが適用されるため、意図しない数値の跳ね返りが発生する。
科学的研究における再現性の重要性
強化学習などの分野では実験の安定性を保つために確定的なロールアウトが必要であり、この非決定性は研究の基盤である再現性を損なう要因となる。
メモリウォールとGEMMの最適化
現代GPUでは計算ユニットの性能が顕存帯域より著しく上回るため、データ転送待ちによるアイドル状態が発生する。このボトルネックを解消するために、大規模行列を小ブロックに分割し高速キャッシュで処理するIO-Awareな分塊アルゴリズムが採用される。
重要な引用
Reproducibility is a bedrock of scientific progress.
浮点加法不满足结合律:(a + b) + c ≠ a + (b + c)
系统触发了非确定性的硬件级并行优化(Non-deterministic Hardware-level Parallel Optimizations)
現代 GPU の計算ユニット(Tensor Core)算力增长太猛,远远甩开了显存带宽的增速。
編集コメントを表示
編集コメント
大模型の挙動を「確定的」に制御したい開発者にとって、ハードウェアレベルの非決定性に起因するバグは見過ごされがちだが、この分析はその根本原因を浮動小数点演算の物理特性まで遡って解明している。科学的研究や産業応用において再現性を担保するためには、単なるパラメータ調整ではなく、推論基盤の設計思想自体を見直す必要があることを示唆する重要な知見である。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。
AI インフラの深化:大規模モデルに確定的な出力を実現する方法
腾讯程序员 2026-08-05 17:25 广东
AI インフラを一段階レベルアップさせる
著者:binnnliu
背景
大規模モデルとの対話で、同じプロンプトを入力しても毎回結果が異なることに気づいたことはありませんか?これは「大規模モデルは本質的に単語を予測する装置である」という我々の一般的な認識に合致しています。
しかし、再現性は科学発展の基盤です。そのため、大規模モデルから完全に確定的な出力を得ることは極めて重要な研究課題であり、特に強化学習においては、実験の再現性とトレーニングプロセスの安定性を保証するために、決定論的なロールアウト(Rollout)が必要です。
Reproducibility is a bedrock of scientific progress.
多くの人が最初に思い浮かべるのは、「同じハードウェアで、同じプロンプトなら」という点でしょう。Temperature を 0 に設定してランダムサンプリングを無効化すればよいのではないか?あるいは、業務要件で Temperature > 0 が必要な場合でも、乱数シード(Seed)を固定すれば毎回同じ出力が得られるのではないか?
実際に検証しました。環境は vllm serve Qwen/Qwen3-8B です。
結論は「部分的に正しい」です。
「正しい」と言える点:ランタイム実行間での確実性(Run-to-run Determinism)として、同一時間に他のリクエストがない状態で同じプロンプトを複数回送信すれば、回答は一致します。
「正しくない」と言える点:バッチ不変性(Batch Invariance / 批次不变性)の観点では、同一時間に他のリクエストが存在する場合、同じプロンプトを複数回送信しても回答が異なります。
異なるリクエスト同士が互いに影響し合うというのでしょうか?あり得ない話です。各リクエスト間のアテンション機構は物理的に厳密に隔離されており、コンテキスト汚染の問題など起こりようがありません。
実はその根源は、推論エンジン層の動的なバッチ組立てと演算子スケジューリング戦略が浮動小数点加算の順序を変化させていることにあります。そして、浮動小数点加算には結合律が成り立たないのです:(a + b) + c ≠ a + (b + c)。
システム設計の観点から見ると、結果にばらつきが生じる根本原因は情報の干渉ではなく、システムがメモリアクセス遅延を隠蔽するために、非決定論的なハードウェアレベルの並列最適化(Non-deterministic Hardware-level Parallel Optimizations)を底層で発動させている点にあります。
浮点数と定点数を比較すると、浮点数の本質は科学記数法にあります。これは限られたビット数(例えば 16 ビットや 32 ビット)の中で、表現できる数値の範囲と絶対精度の間のトレードオフを実現するための仕組みです。
指数が大きくなるほど表現可能な数値の範囲は広がりますが、尾数のビット数が固定されているため、絶対精度(つまりステップ幅)は低下します。この絶対精度(ステップ幅)は以下の式で計算されます:ULP(x) = 2E - M。ここで E はその浮動小数点数の実指数、M は尾数のビット数を表します。
IEEE 754 標準によると、FP16 は 1 ビットの符号位、5 ビットの指数位、そして 10 ビットの尾数から構成されています。例えば、数値が 2048(すなわち 2^11)の場合、その規格化された表現は 1.0000000000_2 × 2^11 となります。このとき、隣り合う表現可能な数の間のステップ幅(ULP)は 2 に増加します。つまり、FP16 では 2048 の次の数は 2050 となり、2049 を正確に表現することはできません。
もしハードウェアが 2048 + 1 を計算する場合、演算器内部ではより広い GRS 拡張ビット(保護位、舍入位、粘滞位)を用いて 2049 という正確な結果を導き出します。しかし、この結果を 10 ビットの尾数に書き戻す際、2049 はちょうど 2048 と 2050 の真ん中に位置するため、「最も近い値へ丸める(偶数に丸める)」ルール(Round to Nearest, ties to Even)が適用されます。その結果、最終的に 2048 に強制的に丸められてしまいます。
ここで注意すべき点は以下の通りです:
この例はあくまで「浮動小数点の加法は結合律を満たさない」ことを示すためのものです。実際の推論プロセスでは、GEMM(行列乗算)の入力は FP16 または BF16 ですが、累加演算は FP32 で行われます。バッチごとのばらつき(batch variance)が存在する根本原因は、FP16 の記憶精度が低いことだけでなく、リダクションのトポロジー(結合順序や構造)が変わったことにあります。
- 数値:規格化された実指数 / 実際の指数 (物理尾数位:10 ビット) / 累加の分解
- 0.5:1.0000000000_2 × 2^-1 / -1 / 0000000000
- 14 (01110):1.0000000000_2 × 2^0 / 0 / 0000000000
- 2:1.0000000000_2 × 2^1 / 1 / 0000000000
- 2046:1.1111111110_2 × 2^10 / 10 / 25 (11001) → 1111111110_2 = 2^10 + 2^9 + ⋯ + 2^1
- 2047:1.1111111111_2 × 2^10 / 10 / 25 (11001) → 1111111111_2 = 2^10 + 2^9 + ⋯ + 2^0
20481.00000000002 × 21111
26 (11010)
0000000000211
20501.00000000012 × 21111
26 (11010)
0000000001211 + 21
では、具体的にどのような操作が浮動小数点加算の順序を変えてしまうのでしょうか?その答えを見つけるには、まず行列演算から見ていく必要があります。
GEMM の Batch Invariance(バッチ不変性)
なぜ GEMM 演算は浮動小数点加算の順序を変えてしまうのでしょうか?その理由は GEMM の最適化戦略にあります。
GEMM の最適化戦略
この問題を理解するには、まず「メモリウォール」の下で GPU の GEMM オペレーターがどのように進化してきたかを把握しておく必要があります。多くの人が疑問に思うでしょう。「単なる基本的な行列乗算なのに、なぜこれほど複雑になるのか?」
答えは「極めて複雑」です。実際、AI インフラの分野全体がこの細部を巡って熾烈な競争を繰り広げています。その核心にある理由は、現代 GPU の計算ユニット(Tensor Core)の演算能力が劇的に向上した一方で、それを支えるメモリ帯域幅の伸びが追いついていないからです。
この格差により、GPU は計算ユニットがデータ転送のために待たされる時間が長くなり、結果として計算ユニットは入力データを待つ間に大量にアイドル状態(Compute Bound から Memory Bound への変化)になってしまいます。これが「メモリウォール」と呼ばれる現象です。
for i from 0 to M: // 外側ループ:A の行(全 M 行)を走査
for j from 0 to N: // 中間ループ:B の列(全 N 列)を走査
for k from 0 to K: // 内側ループ:A の i 行目と B の j 列目の内積(長さ K)を計算
C[i,j] += A[i,k] * B[k,j]
Tiling - 分塊行列乗算
単純に上記の式に従って要素を一つずつ計算すると、乗算・加算演算のたびに極めて低速なグローバルメモリ(HBM)からデータを読み出す必要があります。オンチップの高速キャッシュ(SRAM/共有メモリ)やレジスタが全く利用されないため、膨大な数の HBM への重複読み出しが発生し、計算リソースを十分に活用できなくなります。
メモリ帯域のボトルネックを打破するため、業界では IO-Aware(I/O 感知)なブロック分割アルゴリズムが採用されています。その核心は「化整为零」、つまり大規模な行列をキャッシュに収まる小さなブロックに切り分けることにあります。スケジューリング時には、各 CTA(Cooperative Thread Array/Thread Block)を結果行列 C の特定の小部分 Cij に割り当て、この CTA がその小ブロックの計算を全権で担当します。
ある CTA が Cij の計算を担当する際の、完全なデータフローは以下の通りです。
累算器の初期化
レジスタを割り当て、目標とする分塊 Cij の中間累積結果を格納します。
K 次元に沿った縮約(Reduction)
共通次元である K に沿ってステップごとにスキャンを進めます。各ステップ p では以下のパイプラインを実行します。
- Load (HBM → SRAM): 計算に必要なサブブロック Aip と Bpj を、低速なグローバルメモリから高速な共有メモリ(SRAM)へ、一度にバッチ処理で転送します。
- Compute (Warp GEMM): 各 Warp が SRAM からデータをレジスタに取り出し、Tensor Core に渡して高速な行列乗算を実行します。
- Accumulate: 今回計算された積の結果を、レジスタ内の累算器に直接加算します。結果はメモリへ書き戻しません。
エピローグと書き戻し
K 次元のループが完了すると、レジスタ内の累算器には Cij の最終的な正確な値が格納されます。その後、一括してグローバルメモリ(HBM)へ書き戻します。
Split-K
これまでの整理したロジックを踏まえると、各 CTA は出力行列 C の 1 つの Tile を担当し、内部で K 次元に沿って順次ループを実行します。
しかし現実には、K 次元が極めて巨大で、出力行列 C(つまり M と N 次元)が非常に小さく、結果として切り出せる Tile がたった 4 つしか存在しないという極端な状況に直面することがあります。この場合、GPU のハードウェアスケジューラーは作業を担うために 4 つの CTA しか起動しません。NVIDIA H100 は 132 個の SM を備えているため、残りの 128 個の SM が完全にアイドル状態となり、たった 4 つの CTA が K 次元という極めて長いループを必死に実行するのを待機していることになります。これにより GPU の計算リソースは著しく浪費されてしまいます。
空間次元(M, N)でこれ以上のタスクを切り出せない場合、例えば LLM 推論の Decoding 段階では Batch Size が極小となり M=1 です。もし強制的に空間分割のみを行うと、GPU のコアの多くが作業待ちとなってしまいます。そこで K 次元をさらに細かく分割し、異なる CTA に計算を割り当てることはできないでしょうか?これが「Split-K」最適化戦略です。
その核心となる考え方は、単一の CTA が K 次元全体を独自に計算する従来のロジックを打破することにあります。K 次元を複数のセグメント(Split_K)に分割し、異なる複数の CTA に同時に同じ Cij ブロックの異なる K セグメントにおける局所的な乗算・累積処理を担当させます。
(注意:通常の Tiling 計算でも K 次元に沿って分段して計算することはあります。しかし両者には本質的な違いがあります。BLOCK_K は時間軸上の順次処理であり、同じ CTA がグローバルメモリから BLOCK_K サイズのデータを搬送し、レジスタ内で固定された順序で累積していきます。一方、SPLIT_K は空間軸上の並列処理であり、物理的に独立した複数の CTA に強制的にタスクを分散させて同時に計算を行います。)
図のように Split-K = 2 と設定すると、異なる色の Tile が 2 つの独立した CTA によって並列処理され、計算リソースの利用効率が即座に倍増します。最終的には atomic_add を用いて結果を行列 C の同一位置に累積加算します。ここで留意すべき点は、SPLIT_K の値が大きすぎると atomic_add の競合が増大し、その効果は次第に減衰していくことです。
原子加(atomic_add)以外にも、Workspace Reduction という実装手法があります。その具体的なワークフローは以下の通りです。
Workspace の割り当て(メモリ確保)
HBM に [split_k, M, N] サイズの一時バッファ(Workspace)を別途確保します。
Partial GEMM Kernel の実行
主となる行列乗算カーネルを開始します。各スレッドブロックは K 次元の断片(スライス)を担当し、計算が完了すると原子加を使わずに、その結果を Workspace 内の自分専用の領域(workspace[k_idx, m, n])へ直接書き込みます。
Reduction Kernel の実行
主カーネルの実行と同期が完了した後、独立した軽量な規約カーネルを起動します。このカーネルは split_k 次元に沿って Workspace 内の局所結果を加算し、最終的な合計値を出力行列 C に書き込みます。
GROUP_M - Swizzle L2 Cache
CTA(Cooperative Thread Array)のスケジューリング順序を制約することで、広範囲に散らばったメモリアクセスによる L2 キャッシュの競合(キャッシュスラッシング)やデータ頻繁な追放を防ぎます。これは論理的に複数の独立した CTA を、GROUP_SIZE_M × N のサイズを持つマクロスケジューリング行列へと再構成するものです。
この限定された連続実行区間では、スケジューリングされた CTA が空間的に隣接する出力ブロックを集中的に処理するため、L2 キャッシュにロード済みの行列 A と B のデータを共有できます。これによりデータの時間的局所性と L2 キャッシュの再利用率が最大化されます。
これは別の次元におけるタイリング(Tiling)と言えるでしょう。目的は L2 の再利用です。前述した従来のタイリングが SRAM やレジスタの再利用を目的としたのに対し、さらに高度なテンソル並列化(Column/Row Parallel)もまた、より高次元のタイリングと捉えることができます。
演算子チューニング
前文探讨了 Tiling、Split-K、Swizzle 等旨在缓解访存瓶颈的核心优化策略。但在实际的 GPU 硬件执行层面,针对不同规模的输入张量(Tensor Shape),系统必须确定具体的并行化切分配置:即矩阵分块的具体维度(BLOCK_M / BLOCK_N / BLOCK_K)以及 K 维度的切分段数(SPLIT_K)。这些切分参数的选择,不仅直接决定了算子的访存效率与硬件利用率(Occupancy),更关键的是——分块参数的动态变化会重塑底层浮点数累加的归约拓扑(Reduction Tree),这是导致大模型推理在不同批次下失去 Batch Invariance 的根本原因。
为了解析推理引擎底层为何会根据输入特征动态变更这些调度参数,我们需要先回顾 GPU 算子的编程范式演进,并深入探讨现代编译器引入的自动调优(AutoTune)机制。
CUDA VS Triton
在之前的《AI Infra 入门:GPU 是如何工作的》中,我们探讨了 CUDA 编程模型与 GPU 硬件执行模型:Grid 定义了内部 Thread Block 的组织形式 (gridDim);Thread Block 定义了内部 thread 的组织形式 (blockDim)。
然而,原生 CUDA 编程的门槛依然极高,开发者需要手动实现各种底层且繁琐的优化逻辑。OpenAI Triton 的出现,彻底改变了这一现状。它大幅降低了编写高性能 GPU 算子(Kernel)的门槛,让开发者能以接近 Python 的生产力,写出接近 CUDA C++ 专家级性能的代码。借助内核融合(Kernel Fusion)、IO-Aware 等底层优化技术,Triton 不仅保证了极致性能,还兼顾了极佳的硬件无关性。
两者核心思想的差异在于编程范式:
CUDA(以线程为中心):开发者需要显式定义底层网格,例如 <<>>,由底层生成 128 个线程,硬件再将其划分为 4 个 Warp 调度执行。
Triton(以 Tiling/Block 为中心与自动调优):开发者只需关注数据分块,并可通过 @triton.autotune 装饰器提供一个配置搜索空间(如不同的分块大小、测试 4 个或 8 个 Warp)。Triton 编译器不仅会在底层自动将需求(如 4 个 Warp)精准映射为 4×32=128 个线程以屏蔽硬件细节(即 <<>>),更会在运行时自动进行基准测试,智能选出当前硬件下的最优参数组合。
在 PyTorch 2.0 及更高版本中,引入了 OpenAI Triton 作为编译器:
前端分析:当用户调用 torch.compile(model) 时,PyTorch 的前端(如 TorchDynamo)会捕获计算图。
后端优化与代码生成:默认后端 TorchInductor 会分析图中可以被 Kernel Fusion 的操作,生成 OpenAI Triton 代码。
JIT コンパイルと実行:最後に、Triton の JIT(Just-In-Time)コンパイラーがこのコードを引き継ぎ、特定の GPU ハードウェア向けに高度に最適化された単一の融合カーネル(Fused Kernel)としてコンパイルし、最終的に効率的に実行します。
このエコシステムにおいて、PyTorch(Inductor と Triton を活用)は、従来 CUDA の専門家が多額の時間を費やして行っていた性能チューニングを自動的に担当するようになりました。自動調優機能を持つ Inductor+Triton は、ほとんどのシナリオで劇的な性能向上をもたらしますが、本質的には汎用的な自動化ソリューションです。極めてクリティカルで、性能を限界まで引き出す必要がある特殊な演算子においては、依然として手動による低レベルの最適化が不可欠です。
いずれにせよ、コンパイラーによる自動調優であれ、人手による手動調優であれ、抽象化の奥にある GPU 底辺の物理的な制約とチューニングの論理を理解することが必須となります。
ハードウェア実行層では、複数の Thread Block が同じストリームマルチプロセッサ(SM)を共有できます。SM の物理リソース(レジスタや共有メモリなど)がまだ満杯でない限り、ハードウェアスケジューラーはより多くの Block を同一の SM 内に配置して並列実行します。ただし、各 SM には並行処理の硬性上限(例:最大スレッド数 2048、最大 Block 数 32)が存在します。
同じ Thread Block 内の Thread は、共有メモリ(Shared Memory)を介してデータ交換を行い、同期(__syncthreads())を行うことができます。
レジスタ制限
A100/H100 の各 SM には、物理的なレジスタファイルサイズが固定されており、65,536 個の 32 ビットレジスタ(256 KB)です。ハードウェアスレッドごとに割り当てられる最大レジスタ数は 255 です。一方、単一のスレッドが必要とするレジスタ数は以下の 2 つの部分で構成されます。
- ベースオーバーヘッド(Base Overhead):各スレッド固有のもので、メモリポインタ、ループカウンタ、TMA 状態などを格納するために使用されます。
- アキュムレータの配分(Accumulator Share):出力ブロックサイズを仮定し、アキュムレータが BLOCK_M × BLOCK_N の場合、このレジスタは全スレッドで均等に割り当てられます。各プロセスのレジスタ使用量は (BLOCK_M × BLOCK_N) / NUM_WARPS / 32 で計算されます。Tiling サイズが大きくなれば、num_warps を同期して増やさなければならず、そうしないとレジスタスパリング(Register Spilling)が発生します。
関連する式:(BLOCK_M * BLOCK_N) / (num_warps * 32) <= 255
通常は 128 程度に抑える必要があります。もし 255 を超えた場合は、num_warps を大きくするか、BLOCK サイズを小さくする必要があります。
実行パターンの制約と共有メモリの制限
従来の CUDA 最適化において、メモリアクセスの待ち時間を隠すために Occupancy(占用率)を高めることは基本原則でした。これはスレッドレベルの並列処理(TLP)による遅延隠蔽です。SM(Streaming Multiprocessor)上に常駐するアクティブな Warp の数、つまり Occupancy が高いほど、ハードウェアスケジューラは現在の Warp がメモリアクセスで停止した際に、すぐに切り替え可能な他の待機状態の Warp を見つけやすくなります。このゼロオーバーヘッドのコンテキストスイッチにより、計算命令とメモリアクセスが時間軸上で重なり合い、グローバルメモリの物理的な遅延を効果的に隠すことができます。
しかし、Ampere や Hopper アーキテクチャでは、Tensor Core が極めて高い浮動小数点演算スループットを提供する一方で、HBM(High Bandwidth Memory)の物理的なメモリアクセス遅延はそれに見合うほど短縮されていません。計算能力がメモリ帯域を圧倒的に上回るこの状況下では、高 Occupancy 戦略の効果が急激に低下します。SM 上に多くの Warp が常駐していても、それらはすぐに現在の計算タスクを完了し、一斉にメモリアクセス要求を発行するため、すべての Warp が同時に停止してしまうのです。この場合、スレッドレベルのコンテキストスイッチだけでは、メモリレベルの物理的な遅延を隠すことができません。
Tensor Core の高いスループットを維持するために、現代の GPU ではハードウェアレベルの非同期メモリアクセス転送機構(Ampere アーキテクチャでは cp.async、Hopper アーキテクチャでは TMA エンジン)が導入されました。num_stages の核心となる考え方は、マルチバッファリング(Multi-Buffering)を活用して計算命令とデータ転送命令を非同期で並行実行することです。つまり、GEMM の最適化方針は「高並列処理によるメモリアクセス遅延の隠蔽」から、「非同期パイプライン(Asynchronous Pipelining)による遅延の隠蔽」へとシフトしています。
num_stages の設定が小さすぎると(デフォルト値の 2 など)、計算プロセスがデータ準備完了を待ってパイプラインに隙間(Pipeline Bubble)が生じ、全体のスループットが制限されてしまいます。逆に設定が大きすぎると、SRAM が溢れてコンパイルエラー(Out of Shared Memory)が発生するだけでなく、マルチステージパイプラインの状態を管理するためのポインタが追加のレジスタリソースを消費し、性能が低下することもあります。SRAM 容量がより大きい H100 などの異なるハードウェアアーキテクチャで高度なチューニングを行う際の本質的な課題は、リソース溢れを引き起こさずにオーバーラップ率を最大化する最適な境界条件を見つけることです。
(BLOCK_M * BLOCK_K + BLOCK_N * BLOCK_K) * 1 バイト * num_stages <= SMEM の物理上限(H100 では 228KB)
チューニングのトレードオフ
Llama-3-8B の Q/K/V Linear Proj - Fused QKV を例に挙げます。hidden_size は 4096、qkv_proj_size は 6144 です。
- 項目:詳細
- 目標行列:Fused QKV
- 計算式:QKVout = X · Wqkv
- 入力 X の次元:[num_sched_tokens, hidden_size]
- 重み行列 Wqkv の次元:[hidden_size, qkv_proj_size]
- 出力結果の次元:[num_sched_tokens, qkv_proj_size]
- 備考:重みを列方向に結合し、1 回の広幅 GEMM で実行。4096 (Q) + 1024 (K) + 1024 (V) = 6144
つまり、M=num_sched_tokens、N=6144、K=4096 となります。ここで BLOCK_K は通常、16、32、または 64 に設定されます。
BLOCK_M / BLOCK_N の設定:
M が大きい場合(M > 4096)は、原則として計算強度を高め、BLOCK_M × BLOCK_N を可能な限り大きくして SMEM の利用効率を最大化する必要があります。この際、num_warps の適切な値を見つけることが重要です。
num_warps が小さすぎると、実際に作業を行うスレッド数が不足し、1 つのスレッドに割り当てられる累加器用レジスタが多くなりすぎてしまいます。その結果、スレッドあたりの最大 255 個という物理的なレジスタ数の上限を超え、「Register Spilling(レジスタの溢れ)」が発生します。
一方、num_warps が大きすぎると、基礎的なオーバーヘッドの割合が大きくなり、1 つの CTA が多くの SM リソースを占有してしまいます。その結果、GPU の Occupancy(稼働率)が低下します。したがって、BLOCK_M × BLOCK_N を十分に大きく保つために、適切な num_warps の値を選ぶ必要があります。
M が小さい場合(M < 64)は、BLOCK_M を M に合わせて縮小し、SM の計算能力を最大限に活用するために、以下の 2 つの対策が有効です。1. BLOCK_N を適度に小さくしてより多くの Tile を生成し、異なる SM に分散する。2. Split-K を有効にして、K 次元方向により多くの CTA を並列処理として起動する。
指標と最適化目標
- 項目:関連ハードウェアリソース / 役割と定義 / Trade-Off(トレードオフ)
- BLOCK_M / BLOCK_N:共有メモリ (SMEM)、L1 キャッシュ / 行列の行方向と列方向におけるサブブロックのサイズ(例:128, 256)。これにより、共有メモリに読み込まれるデータ量が決定されます。 / サイズが大きすぎると、各スレッドが消費するレジスタ数が増えすぎて「Register Spilling」を引き起こします。これはレジスタが極端に遅いローカルメモリへ溢れ出す現象であり、性能の急激な低下(雪崩)を招きます。
- BLOCK_K:共有メモリ (SMEM) / K 次元方向での累加処理の長さです。 / 上記と同様の理由で、通常は 32 または 64 に設定されます。M/N/num_stages と組み合わせて計算し、総 SMEM 使用量を調整する必要があります。
- num_warps:レジスタ、Occupancy(並行度) / 各 Thread Block に割り当てられる Warp の数です(1 Warp = 32 スレッド)。本質的には計算負荷を分散させるための分母となります。 / 小さすぎると累加器がスレッドあたりの最大 255 個のレジスタ上限を超え、致命的な Register Spilling が発生します。大きすぎると、1 つの Block が多くの Warp を占有してしまい、SM 内に収められる Block 数が減り、並行度(Occupancy)が急激に低下します。
- num_stages:グローバルメモリ (HBM)、レイテンシのカバー / ソフトウェアによるパイプライン処理の段数です。複数のバッファを確保し、計算ユニットが現在のブロックを処理している間に、バックグラウンドで次のブロックの読み込みを非同期で行います。 / 設定値が大きすぎると SMEM が溢れる原因となります。
- GROUP_M (Swizzle):L2 キャッシュ / 連続する複数の行をグループ化してパックし、デフォルトの逐行スキャンを打破します。L2 キャッシュに既にロードされている行列 A と B のデータを共有することで、データの時間的局所性と L2 キャッシュの再利用率を最大化します。
- SPLIT_K:ストリーミングマルチプロセッサ (SM)、計算利用率 / 非常に長い K 次元を複数の Block に分割して同時に処理し、最後に原子加算(Atomic Add)で結果を結合します。M と N が極小で K が極大の場合(Flash-Decoding など)にのみ有効化され、SM の利用効率を向上させます。
Batch Invariance の由来
前節では多くの最適化戦略について議論しましたが、具体的にどのパラメータの変更が浮動小数点加算の順序を変えているのでしょうか?
数学的な本質を振り返ると、行列乗算 C = A × B(A は [M, K] 行列、B は [K, N] 行列、C は [M, N] 行列)において、任意の要素 Ci,j の計算ロジックは一定です。つまり、A の i 行目と B の j 列目の内積を計算するだけです。
Ci,j = Σk=0K-1 Ai,k × Bk,j
Triton や CUDA の底层优化において、各種スケジューリングパラメータの動的調整がもたらす「決定性(Determinism)」への影響には、本質的な違いがあります。
BLOCK_M と BLOCK_N(影響なし)
分塊のロジックは、Tile_A [BLOCK_M, BLOCK_K] と Tile_B [BLOCK_K, BLOCK_N] を読み込み、出力として Tile_C [BLOCK_M, BLOCK_N] を計算することです。これは空間次元におけるタスクマッピングに過ぎず、どの要素をまとめて独立して計算するか、そして特定の CTA (Thread Block) が行列のどの領域を担当するかを決定するだけです。
出力行列 C の特定要素 Ci,j について考えてみましょう。それがどの CTA に割り当てられ、どのような隣接要素とともに計算されようとも、その根底にある K 次元の点積ロジックは変わりません。したがって、この 2 つのパラメータを変更しても、浮動小数点加算の順序には影響しません。
GROUP_M / Swizzle(影響なし)
Swizzle の本質は、複数の CTA がグリッド (Grid) レベルで実行される順序を変えることで、L2 Cache のヒット率を向上させることにあります。これは「どの空間ブロックを先に計算し、その後に行うか」を決めるだけで、特定の分塊内部における乗算と加算の過程には一切干渉しません。したがって、Swizzle も Batch Invariance を破壊することはありません。
BLOCK_K(確定的な誤差を導入)
これは縮約次元 (Reduction Dimension) におけるステップサイズを定義するパラメータです。BLOCK_K の値を変更すると、1 回のループでレジスタに読み込まれるデータ量が直接変化し、結果として Tensor Core 内部の MMA 命令が構成する累加ツリーのトポロジーが変わります。この変更は浮動小数点加算の順序を変化させます(つまり、異なる BLOCK_K では異なる結果が得られますが、同じ BLOCK_K の値であれば結果は一定になります)。
SPLIT_K(非確定的な誤差を導入)
これは K 次元を複数の CTA に強制的に分割し、並列実行させる手法です。Atomic Add を用いて結果を統合する場合、GPU ハードウェアがどのスレッドブロックを先に実行するかは完全にランダムであるため、加算の順序も制御不能なランダムなものになります。Workspace Reduction を採用した場合でも、SPLIT_K の段数が動的に変化すれば、累加ツリーのトポロジー自体が変わってしまいます。
num_warps と num_stages の本質は、ハードウェアリソースの割り当てとパイプラインスケジューリング(どの CTA を使って計算するか、非同期転送のためにいくつのキャッシュプールを確保するか)にあります。これらはマクロな行列点積の計算ロジックや Reduction Tree のトポロジーを変えるものではありません。
以上より、推論エンジンには性能と確実性の間に本質的なアーキテクチャ上の衝突が存在します。極限のメモリアクセス再利用(BLOCK_K の動的調整)を追求し、かつ SM (Stream Multiprocessor) の並行利用率を高めるために SPLIT_K を動的に有効化すると、その結果として基盤となるヒューリスティックなスケジューリング戦略が浮動小数点演算の Reduction Tree トポロジーを不可避的に変更してしまいます。これが、大規模モデルが動的 Batch 下で Batch Invariance を失う根本的な理由です。
では、実際の推論シナリオにおいて、このメカニズムはどのように修復されるのでしょうか?次は vLLM の実装に深く入り込み、それがどうやって底層のハードウェア特性と上層のスケジューリングロジックの間で Trade-off を行っているかを見ていきましょう。
vLLM における GEMM の Batch Invariance サポート
解決策はどうあるべきでしょうか?直感的には、推論エンジン内で Split-K をグローバルに無効化し、BLOCK_K を固定すればよいように思えます。しかし、現実のエンジニアリング実装はこれほど単純ではありません。
現代の推論エンジン(vLLM など)における GEMM 操作の実行パスは、複数の抽象層と多様なバックエンドをまたぐ複雑な経路をたどります。厳密な Batch Invariance を実現するには、単一の設定変更で済む話ではなく、特定のハードウェア・ソフトウェア実行環境に応じて、多次元の執行パスのルーティングとパラメータ制約が必要です。
具体的な GEMM 実行パスは、以下の 3 つの次元の組み合わせによって決定されます:
ハードウェアアーキテクチャの違い:SM80(Ampere)と SM90/SM100(Hopper/Blackwell)では、基盤となる GEMM 実行のパラダイムが根本的に異なります。前者はワープレベルの並行処理でメモリアクセス遅延を隠蔽しますが、後者は TMA と WGMMA を活用した非同期パイプラインに依存しています。この実行パラダイムの違いにより、Batch Invariance(バッチ不変性)を実現するロジックも変わります。
データ精度の違い:bf16 や fp16 といった精度は、生態系が最も成熟している cuBLASLt が担当します。一方、fp8 や fp4 などの低精度では CUTLASS(さらには DeepGEMM などの専用カーネル)へと切り替わります。これに伴い、異なるバックエンドの制御ロジックも適用されます。
オペレーター入口(API Backend)の違い:nn.Linear は vLLM 独自のディスパッチを経由し、カスタム高性能カーネルへ直接ルーティングできます。一方、裸の torch.mm や bmm は PyTorch のディスパッチャー → cuBLAS/cuBLASLt を経由するため、调度権限はフレームワークとクローズドソースライブラリ側にあります。
SM8x と SM90/100 の比較
SM80:ワープレベルのテンソルコア(mma.sync)スケジューリング。最適化のポイントは利用効率を高めることで、ワープがデータ転送と計算の両方を行います。
4096 * 4096 → CTA Tile(例:128*128)→ Warp Tile(32 * 64)→ mma 命令レベル (16 * 8 * 16)
SM90/100:TMA ハードウェアユニットの導入と WGMMA 命令(ワープグループ 128 スレッド)により、パラダイムが転換しました。非同期パイプラインを活用して遅延を隠蔽します。
4096 × 4096 の大行列では、CTA(Cooperative Thread Array)に割り当てられるメモリ空間が十分確保されるため、各 Warp に広めの専用領域(例:32×64 など)を割り当てることができます。この大きな Tile をさらに小さな単位に分割して Warp に配分し、複数の Warp が交互にメモリアクセスと計算を行うことで、メモリの待ち時間を隠蔽します。K 次元方向では各 Warp が独立して実行され、互いに干渉することなくシリアル処理を完遂するため、実行間(run-to-run)で結果の安定性は保たれます。
しかし、バッチサイズが変わる場合、BLOCK_K の値も変動し、浮動小数点加算の順序が揺らぐ可能性があります。ただし、必ずしもそうなるわけではありません。これは Warp レベルでの K 処理が一定かどうかにかかっています。例えば、ヒューリスティックな自動チューニングにより BLOCK_K が 32 から 64 に変わっても、実際の基盤実行では K=16 をステップとして Tile を順次処理するため、バッチによる変動は底層で解消されます。つまり、BLOCK_K の影響を無視して Split-K に注目すればよいのでしょうか?大行列においては、この考え方は概ね妥当です。
一方、32×32 といった小行列の場合、1 つの CTA に割り当てられ(K=4096)、各 Warp が得られる Tile が極めて小さくなります。その結果、十分な数の Tile を確保できず、M/N 方向の並列度が不足します。利用可能な Warp の数が減り、SM の利用率が低下するため、K 次元でのシリアル処理だけではメモリの待ち時間を十分に隠すことができません。そこでシステムは、すべての Warp が同一の 32×32 行列上で重なるようにし、時間(K)方向で強制的に分割する「Warp-level K-Slicing」を採用します。複数の Warp がそれぞれ K の一部を計算し、最後に共有メモリ内の Reduction Tree で結果を統合します。バッチパラメータの変化によって K の分割数が変わると、Reduction Tree の形状も変化し、浮動小数点加算の順序が揺らぐ原因となります。
小行列の場合、GEMM ライブラリはより小さな Tile や GEMV/SIMT 方式、Persistent Kernel、Warp-level K-Slicing、CTA-level Split-K、Stream-K など、状況に応じた複数のアルゴリズムから最適なものを選択します。Warp-level K-Slicing は、同一の出力 Tile を複数の Warp がそれぞれ K の断片ごとに計算し、CTA 内で部分結果を統合する手法です。これにより単一 CTA 内の有効な Warp 数は増えますが、Grid 全体の CTA 数が増えるわけではありません。SM の利用率をさらに高めるには、通常は CTA-level Split-K が併用されます。
しかし、SM90 以降のアーキテクチャでは、従来の高 Occupancy を利用してメモリアクセス待ち時間を隠す依存度が低下しています。標準的な WGMMA mainloop では、1 つの Warpgroup が分散された Accumulator を共同で管理し、K の Tile を順次処理します。これにより、複数の独立した Warp による部分結果を生成してから CTA 内で統合する手順は不要になります。
図:大行列処理における CTA、Warp Group Tile、および指令レベルのデータフロー
コードコメント「Hopper (SM90) と Blackwell (SM100): バッチのばらつきが生じる唯一の原因は Split-K である」からわかるように、現在サポートされテストされている FP16/BF16 の PyTorch/cuBLASLt パスでは、SM90 Hopper および SM100 Blackwell アーキテクチャ上で Split-K を禁止することでバッチ不変性(batch invariance)が保証されます。
ここで注意が必要なのは、この結論は cuBLAS や cuBLASLt がクローズドソースであるためテスト結果に基づいている点です。SM90/SM100 では Split-K を固定するだけでバッチ不変性が得られますが、SM8x アーキテクチャではバッチ不変性を確保するために、BLOCK-K と Split-K の両方を同時に固定する必要があります。
また、「Split-k で複数のワークスペースを使用すると、収縮(reduction)ツリーは順序通りに処理されるため結果が一定になる」と誤解する人もいますが、これは実行間での安定性(run-to-run 次元)に過ぎません。K の値が変われば結果も変化します。
ここまで説明した通り、最終的な浮動小数点加算の順序は、メインループにおける BLOCK_K のステージング(CTA Tiling)だけでなく、より低次元の Warp Tiling や Warp Group Tiling、さらにハードウェア固有のアトミック命令の粒度にも影響されます。ただし、この問題を解決するアプローチとしては CTA Tiling の観点から検討する必要があります。
SM80 における GEMM のバッチ不変性サポートについて
SM80 アーキテクチャでは SPLIT_K と BLOCK_K の両方に注意を払う必要があります。Linear レイヤーの GEMM 演算においては、SM のバージョンや eager モード・compile モードのいずれであっても、必ず linear_batch_invariant から matmul_persistent が呼び出されるフローになります。
class UnquantizedLinearMethod(LinearMethodBase):
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: torch.Tensor | None = None,
) -> torch.Tensor:
if envs.VLLM_BATCH_INVARIANT and current_platform.is_cuda_alike():
return linear_batch_invariant(x, layer.weight, bias)
return dispatch_unquantized_gemm()(layer, x, layer.weight, bias) 而对于非线性层的计算,比如代码中直接调用 torch.mm,则还需要额外考虑。由于 SM80 在 cuBLASLt 上不支持禁用 BLOCK_K,只能更换 Triton 实现,最终也会走到 matmul_persistent。其中 matmul_persistent 是一个 persistent kernel 即 griddim=num_sm,固定 K 顺序、固定 tile、不切 SPLIT_K。
if current_platform.is_device_capability_family(80):
# SM80 (Ampere) cannot rely on cuBLASLt-only determinism; install the
# triton persistent matmul overrides for mm/addmm/matmul/linear.
_batch_invariant_LIB.impl("aten::mm", mm_batch_invariant, "CUDA")
_batch_invariant_LIB.impl("aten::addmm", addmm_batch_invariant, "CUDA")
_batch_invariant_LIB.impl("aten::matmul", matmul_batch_invariant, "CUDA")
_batch_invariant_LIB.impl("aten::
原文を表示
原创 腾讯程序员 2026-08-05 17:25 广东
image
一文进阶AI Infra
image
作者:binnnliu
背景
你有没有发现跟大模型对话,同样的提示词每次结果都不一样。 这符合我们对于“大模型的本质就是个猜词器”的一贯认知。
然而,可重复性是科学进步的基石。因此让大模型输出完全确定的结果是一个非常值得研究的问题,特别是强化学习需要确定性的 Rollout,来保证实验的可复现性和训练过程的稳定性。
Reproducibility is a bedrock of scientific progress.
其实很多同学第一时间会想到: 同样的硬件,同样的提示词:
是不是把 Temperature 设置为 0,关掉随机采样就好了?
如果业务需要 Temperature > 0,那把随机数种子(Seed)锁死,是不是也能保证每次输出一样?
我已经测试过了,环境:vllm serve Qwen/Qwen3-8B 答案: 是也不是。
是(Run-to-run 确定性): 同一时间,没有其他请求时,多次同样的提示词请求,回答是一样的;
不是(Batch Invariance / 批次不变性): 同一时间,有其他请求时,多次同样的提示词请求,回答是不一样的;
什么!难道不同的请求间会相互影响?不可能,不同请求间的注意力机制是严格物理隔离,不存在上下文污染的问题。
其实这一切的根源是推理引擎底层的动态组批与算子调度策略引发了浮点加法的顺序变化,而浮点加法不满足结合律:(a + b) + c ≠ a + (b + c)。
从系统设计的角度来看,导致结果波动的根本原因,不在于信息干扰,而在于系统为了掩盖访存延迟,在底层触发了非确定性的硬件级并行优化(Non-deterministic Hardware-level Parallel Optimizations)。
相对于定点数,浮点数的本质是科学计数法,主要是为了在有限的位数内(如仅仅 16 bit 或 32 bit),实现动态的数值范围与绝对精度间的trade-off。指数越大能表示的数值范围也就越大,同时因为尾数位数长度固定,指数越大能表示的绝对精度也就越低。绝对精度(即步长)的计算公式为:ULP(x) = 2E - M,其中 E 是该浮点数的真实指数,M 是尾数位数。
根据 IEEE 754 标准,FP16 包含 1bit符号位、5bit指数位和 10bit尾数位。当数值为 2048(即 211)时,其规格化表示为 1.00000000002 × 211。此时,相邻可表示数之间的步长(ULP)增至 2,这意味着 FP16 的下一个可表示数为 2050,无法精确表示 2049。当底层硬件执行 2048 + 1 时,运算器内部会借助更宽的 GRS 扩展位(保护位、舍入位、粘滞位)得出精确结果 2049。但当结果需要写回 10 位尾数时,由于 2049 恰好位于 2048 与 2050 的正中间,系统触发了向偶数舍入(Round to Nearest, ties to Even)的规则,最终结果被强行舍入回 2048。
这里需要说明是:
这个例子只是为了说明“浮点加法不满足结合律”。在实际 推理过程中, GEMM 是 FP16/BF16 输入、FP32 累加;batch variance 依然存在,根因是 reduction topology 改变,而不只是 FP16 存储精度低。
数值规格化表示真实指数实际存储指数物理尾数位 (10 bit)累加拆解
0.51.00000000002 × 2-1-1
14 (01110)
00000000002-1
11.00000000002 × 200
15 (01111)
000000000020
21.00000000002 × 211
16 (10000)
000000000021
20461.11111111102 × 21010
25 (11001)
1111111110210 + 29 + ⋯ + 21
20471.11111111112 × 21010
25 (11001)
1111111111210 + 29 + ⋯ + 20
20481.00000000002 × 21111
26 (11010)
0000000000211
20501.00000000012 × 21111
26 (11010)
0000000001211 + 21
那么问题又来了,都有哪些操作会改变浮点加法顺序呢?这个我们要从矩阵运算说起。
GEMM的Batch Invariance
GEMM操作为啥会改变浮点加法顺序呢?这要从GEMM的优化策略说起。
GEMM的优化策略
要理解这个问题,必须要先理解内存墙下 GPU GEMM算子的演进路线。很多人可能会问:不就是一个基础的矩阵乘法吗,怎么搞得这么复杂?
答案是:极其复杂,甚至整个 AI Infra 领域都在围绕它疯狂卷细节。 核心原因在于:现代 GPU 的计算单元(Tensor Core)算力增长太猛,远远甩开了显存带宽的增速。这就导致 GPU 大部分时间都在等数据搬运,计算单元由于未能及时获取输入数据,大量时间处于空闲等待状态(即 Compute Bound 退化为 Memory Bound)。这就是所谓的内存墙(Memory Wall)。
for i from 0 to M: // 外层:遍历 A 的行 (总共 M 行)
for j from 0 to N: // 中层:遍历 B 的列 (总共 N 列)
for k from 0 to K: // 内层:计算 A第i行 和 B第j列 的点积 (长度为 K)
C[i,j] += A[i,k] * B[k,j]Tiling - 分块矩阵乘法
如果单纯按照上述公式逐个元素去计算,每次乘加运算都需要从极其缓慢的全局显存(HBM)中读取数据。由于完全没有利用到片上高速缓存(SRAM / 共享内存)和寄存器(Registers),这会导致海量的 HBM 重复读操作,使得算力无法的充分利用。
为了打破内存带宽的瓶颈,业界演化出了 IO-Aware(I/O 感知) 的分块算法。其核心思想是:化整为零,将大矩阵切分成适合放入缓存的小块。 调度时,将每个CTA(Cooperative Thread Array/ Thread Block)与结果矩阵 C 的一个特定小块 Cij 绑定,让该 CTA 全权负责这个小块的计算。
一个 CTA 负责计算 Cij 的完整数据流转逻辑如下:
初始化累加器
分配寄存器,专门用于存放目标分块 Cij 的中间累加结果。
沿着K 维度规约
沿着公共维度 K 步进扫描。在每一个步长(Step p)内执行以下流水线:
Load (HBM → SRAM):将当前计算所需的子块 Aip 和 Bpj 从缓慢的全局显存,一次性批量搬运到速度更快的共享内存(SRAM)中。
Compute (Warp GEMM):各个 Warp 从 SRAM 中将数据提取至寄存器,交由 Tensor Core 执行极速的矩阵乘法。
Accumulate:将本轮算出的乘积结果,就地与寄存器中的累加器相加。不写回显存。
收尾与写回(Epilogue)
当 K 维度的循环全部跑完,寄存器中的累加器便得到了 Cij 的最终精确值。此时,再将其统一写回全局显存(HBM)。
Split-K
基于我们刚刚梳理的逻辑:每个 CTA 负责输出矩阵 C 的一个 Tile,并在内部沿着 K 维度串行跑循环。
但现实中经常会遇到一种极端情况:假设 K 的维度极其庞大,而输出矩阵 C (即 M 和 N 维度)非常小,小到只能切出 4 个 Tile。
此时,GPU 硬件调度器只会拉起 4 个 CTA 去干活。要知道,一块 NVIDIA H100 拥有 132 个 SM,这就意味着有 128 个 SM 处于完全空闲的状态,都在等待那 4 个 CTA 在极其漫长的 K 维度上苦哈哈地跑 Loop,GPU 的算力也被极大的浪费了。
既然在空间维度(M, N)上切不出更多的任务,比如在 LLM 推理的 Decoding 阶段,Batch Size 极小,M=1,如果强行只按空间切分,会导致 GPU 上大量核心无活可干,那能不能在K维度拆分,分配给不同的CTA计算呢?——这就是所谓的 Split-K 优化策略。它的核心思想是:打破单个 CTA 独自计算整个 K 维度的逻辑,把 K 维度切分成多段(Split_K),让多个不同的 CTA 同时处理同一个 Cij 块在不同 K 片段上的局部乘积累加。
(特别注意:其实在普通的 Tiling 计算时,也是沿着 K 维度分段计算的。但两者有本质区别:BLOCK_K 是时间上的串行,同一个 CTA 每次从全局显存搬运 BLOCK_K 大小的数据,在寄存器中按固定顺序累加;而 SPLIT_K 是空间上的并行,强行把任务分发给物理上独立的多个 CTA 同时计算。)
如图所示,假设设置 Split-K = 2,不同颜色的 Tile 分别由两个独立的 CTA 并行处理,算力利用率瞬间翻倍。最终通过atomic_add将结果累加到C的同一位置。 这里需要注意的是:SPLIT_K 太大, atomic_add 竞争增加,收益会递减。
当然除了atomic_add的方式,还有一种实现方式: Workspace Reduction,具体工作流程:
分配 Workspace(显存分配):在HBM中额外分配一块大小为 [split_k, M, N] 的临时内存缓冲区(Workspace)。
执行 Partial GEMM Kernel: 启动主矩阵乘法 Kernel。每个 Thread Block 负责计算 K 维度上的一个切片。计算完成后,不使用原子加,而是将局部结果直接写入到 Workspace 中属于自己的那个切片位置(即 workspace[k_idx, m, n])。
执行 Reduction Kernel:主 Kernel 执行完毕并同步后,启动第二个独立的轻量级规约 Kernel。这个 Kernel 负责沿着 split_k维度,将 Workspace 中的局部结果相加,并将最终的求和结果写入到目标输出矩阵 C 中。
GROUP_M - Swizzle L2 Cache
通过约束 CTA在矩阵C中的调度顺序,避免了由于跨度过大的离散内存访问而导致的 L2 Cache 抖动(Cache Thrashing)和频繁的数据驱逐。它在逻辑上将多个独立的 CTA 重新组合成一个维度为 GROUP_SIZE_M × N 的宏观调度矩阵。在这个被限定的连续执行区间内,由于被调度的 CTA 集中处理空间上相邻的输出块,它们能够共享已加载至 L2 Cache 中的矩阵 A 和 B 的数据,从而最大化数据的时间局部性与 L2 Cache 的复用率。这何尝不是另外一种维度的Tiling呢 ? 只是为了复用L2。而之前我们提到的Tiling,是为了复用SRAM和寄存器。更进一步Tensor Parallel(Column/Row Parallel)其实也是更高维度的Tiling呢~
算子调优
前文探讨了 Tiling、Split-K、Swizzle 等旨在缓解访存瓶颈的核心优化策略。但在实际的 GPU 硬件执行层面,针对不同规模的输入张量(Tensor Shape),系统必须确定具体的并行化切分配置:即矩阵分块的具体维度(BLOCK_M / BLOCK_N / BLOCK_K)以及 K 维度的切分段数(SPLIT_K)。这些切分参数的选择,不仅直接决定了算子的访存效率与硬件利用率(Occupancy),更关键的是——分块参数的动态变化会重塑底层浮点数累加的归约拓扑(Reduction Tree),这是导致大模型推理在不同批次下失去Batch Invariance的根本原因。
为了解析推理引擎底层为何会根据输入特征动态变更这些调度参数,我们需要先回顾 GPU 算子的编程范式演进,并深入探讨现代编译器引入的自动调优(AutoTune)机制。
CUDA VS Triton
在之前的《AI Infra入门:GPU是如何工作的》中,我们探讨了 CUDA 编程模型与 GPU 硬件执行模型: Grid定义了内部Thread Block的组织形式(gridDim);Thread Block定义了内部thread的组织形式(blockDim)。
然而,原生 CUDA 编程的门槛依然极高,开发者需要手动实现各种底层且繁琐的优化逻辑。OpenAI Triton 的出现,彻底改变了这一现状。大幅降低了编写高性能 GPU 算子(Kernel)的门槛,让开发者能以接近 Python 的生产力,写出接近 CUDA C++ 专家级性能的代码。借助内核融合(Kernel Fusion)、IO-Aware 等底层优化技术,Triton 不仅保证了极致性能,还兼顾了极佳的硬件无关性。
两者核心思想的差异在于编程范式:
CUDA(以线程为中心):开发者需要显式定义底层网格,例如 <<>>,由底层生成 128 个线程,硬件再将其划分为 4 个 Warp 调度执行。
Triton(以Tiling/Block 为中心与自动调优):开发者只需关注数据分块,并可通过 @triton.autotune 装饰器提供一个配置搜索空间(如不同的分块大小、测试 4 个或 8 个 Warp)。Triton 编译器不仅会在底层自动将需求(如 4 个 Warp)精准映射为 4×32=128 个线程以屏蔽硬件细节(即<<>> ),更会在运行时自动进行基准测试,智能选出当前硬件下的最优参数组合。
在 PyTorch 2.0 及更高版本中,引入了OpenAI Triton作为编译器:
前端分析:当用户调用 torch.compile(model) 时,PyTorch 的前端(如 TorchDynamo)会捕获计算图。
后端优化与代码生成:默认后端 TorchInductor 会分析图中可以被 Kernel Fusion 的操作,生成 OpenAI Triton 代码。
JIT编译与执行:最后,由 Triton 的 JIT (Just-In-Time) 编译器接管这些代码,将其编译成一个为特定 GPU 硬件高度优化的、单一的融合内核(Fused Kernel),最终高效执行。
在这一生态体系下,PyTorch(借助 Inductor + Triton)自动接管了过去需要 CUDA 专家耗费大量精力才能完成的性能调优工作。尽管自动调优的 Inductor+Triton 在绝大多数场景下都能带来巨大的性能飞跃,但它本质上仍是一个通用型的自动化方案。当面对极其关键、对性能压榨到极致的特殊算子时,纯手工的底层优化依然不可或缺。
当然,无论是让编译器自动调优,还是我们手动调优,都必须穿透抽象,理解 GPU 底层最核心的物理边界与调优逻辑。
在硬件执行层,多个 Thread Block 可以共享同一个流多处理器(SM)。只要 SM 的物理资源(寄存器、共享内存等)还没被占满,硬件调度器就会把更多的 Block 塞进同一个 SM 里并发执行。然而,每个 SM 都有硬性的并发规格上限(例如最大线程数 2048,最大 Block 数 32)。
同一个Thread Block 内的 Thread 可以通过共享内存(Shared Memory)进行数据交换,并且可以进行同步(__syncthreads())。
寄存器限制
A100/H100 每个 SM 的物理寄存器文件大小固定为 65536 个 32-bit 寄存器(256 KB)。每个硬件线程最多只能分配 255 个寄存器。 而单个线程需要的寄存器数由两部分组成:
基础开销(Base Overhead):每个线程私有的,用来存内存指针、循环计数器、TMA 状态等;
累加器分摊(Accumulator Share):假设输出块大小,即Accumulator 为 BLOCK_M × BLOCK_N,这寄存器是被所有线程平摊的。每个进程的寄存器用量为:BLOCK_M × BLOCK_N / NUM_WARPS / 32。如果Tiling变大,必须同步增加num_warps,否则会导致Register Spilling;
关联公式:(BLOCK_M * BLOCK_N) / (num_warps * 32) <= 255 通常需控制在 128 左右, 如果超过255,num_warps 必须调大,或者 BLOCK 必须调小。
执行范式与共享内存限制
在传统的 CUDA 优化实践中,提升 Occupancy(占用率)以隐藏访存延迟是一项核心原则。即线程级并行(TLP)隐藏延迟:SM 上驻留的活跃 Warp 数量越多(即 Occupancy 越高),硬件调度器(Warp Scheduler)在当前 Warp 因访存而阻塞(Stall)时,就越容易找到其他处于就绪状态的 Warp 进行切换。通过这种零开销的上下文切换,系统使得计算指令与内存访问在时间上相互重叠,从而有效隐藏了全局内存的物理延迟。
然而,在 Ampere / Hopper 架构中,Tensor Core 提供了极高的浮点吞吐量,但 HBM 的物理访存延迟并未同比例缩减。在这种计算能力远超访存带宽的背景下,高 Occupancy 策略的边际效益急剧衰减:即使 SM 上驻留了大量 Warp,它们也会迅速耗尽当前计算任务,并集体触发访存请求,导致所有 Warp 同时陷入阻塞。此时,单纯依靠线程级的上下文切换已无法掩盖内存级别的物理延迟。
为维持 Tensor Core 的高吞吐率,现代 GPU 引入了硬件级异步内存拷贝机制(如 Ampere 架构的 cp.async 和 Hopper 架构的 TMA 引擎)。num_stages 的核心思想是利用多级缓冲(Multi-Buffering)实现计算指令与数据搬运指令的异步并发。即GEMM的优化方向从高并发掩盖访存延迟转向通过 Asynchronous Pipelining (异步流水线) 来掩盖延迟。
若 num_stages 配置过小(如默认值 2),计算过程易因等待数据就绪而产生气泡(Pipeline Bubble),导致总体吞吐量受限。若配置过大,除导致 SRAM 溢出触发编译失败(Out of Shared Memory)外,管理多级流水线状态的指针还会消耗额外的寄存器资源,造成性能倒退。在不同硬件架构(如 SRAM 容量更大的 H100)上进行高阶调优时,核心痛点在于寻找能够最大化重叠率、且不触发资源溢出的最佳边界配置参数。
(BLOCK_M * BLOCK_K + BLOCK_N * BLOCK_K) * 字节数 * num_stages <= SMEM物理上限 (如 H100 是 228KB)
调优Trade-Off
以 Llama-3-8B Q/K/V Linear Proj - Fused QKV为例,hidden_size为4096, qkv_proj_size为6144,如下:
目标矩阵
计算公式
输入 X 维度
权重矩阵 Wqkv 维度
输出结果维度
备注
Fused QKV
QKVout = X · Wqkv[num_sched_tokens, hidden_size][hidden_size,qkv_proj_size][num_sched_tokens, qkv_proj_size]权重按列拼接,执行单次宽矩阵 GEMM
4096 (Q) + 1024 (K) + 1024 (V) = 6144
即:M=num_sched_tokens,N=6144,K=4096,其中BLOCK_K一般设置为 16、32 或 64。
BLOCK_M / BLOCK_N的设置:
M很大时(M>4096),原则上我们希望提升计算强度,BLOCK_M × BLOCK_N 尽可能的大,极大地复用SMEM,此时需要寻找 num_warps 的平衡点,num_warps太小会导致干活的线程少,单个线程分摊到的累加器寄存器过多,进而击穿单线程最多 255 个寄存器的物理上限,触发Register Spilling;而num_warps太大会导致基础开销占比大,一个CTA占用大量SM资源,从而导致GPU Occupancy 降低。需要一个合适的num_warps来确保 BLOCK_M × BLOCK_N 足够大。
M很小时 (M<64),缩小 BLOCK_M 贴合 M,为了充分利用 SM 的算力,可以 1. 适度缩小 BLOCK_N从而产生更多的Tile分发给不同的 SM; 2. 开启 Split-K,在 K维度拉起更多 CTA 并行计算。
指标
优化的硬件目标
核心作用与定义
Trade-Off
BLOCK_M / BLOCK_N
共享内存 (SMEM)
一级缓存 (L1)
矩阵行与列方向的子块大小(如 128, 256),决定了每次读入共享内存的数据面积。
如果太大,会导致每个线程消耗过多寄存器,引发 Register Spilling(寄存器溢出到极慢的 Local Memory),导致性能雪崩。
BLOCK_K
共享内存 (SMEM)
在维度 K 上每次累加的长度。
同上,通常设置为 32 或 64。需与 M/N/num_stages 配合计算总 SMEM 占用。
num_warps
寄存器 (Registers)
并发度 (Occupancy)
每个 Thread Block 分配的 Warp 数量(1 Warp = 32 线程)。本质是分母,用来稀释每个线程的计算量。
太小:累加器撑爆单线程 255 个寄存器上限,发生致命的 Register Spilling。
太大:单个 Block 占用过多 Warp,导致一个 SM 里装不下几个 Block,并发度(Occupancy)暴跌。
num_stages
全局显存 (HBM)
延迟掩盖
软件流水线级数。开辟多份缓存,让计算单元在算当前块时,后台异步去取后面的块。
太大会导致 SMEM 溢出
GROUP_M即Swizzle
二级缓存 (L2 Cache)
将几个连续的行打包成一组,打破默认的逐行扫描。
共享已加载至 L2 Cache 中的矩阵 A 和 B 的数据,从而最大化数据的时间局部性与 L2 Cache 的复用率
SPLIT_K
流多处理器 (SM)
算力利用率
将极长的 K 维度切分给不同的 Block 同时算,最后再做原子加法(Atomic Add)合并。
仅在 M 和 N 极小、K 极大的情况(比如Flash-Decoding)开启,能提高SM利用率。
Batch Invariance的来源
前文探讨了诸多优化策略,那么具体是哪些参数的变动导致了浮点加法顺序的改变?
从数学本质来看,矩阵乘法 C = A × B(其中 A 为 [M, K],B 为 [K, N],C为 [M, N])中任意元素 Ci,j 的计算逻辑是恒定的——即取 A 的第 i 行与 B 的第 j 列进行点积:
Ci,j = Σk=0K-1Ai,k × Bk,j
在 Triton / CUDA 的底层优化中,针对各项调度参数的动态调整,其对确定性的影响有着本质区别:
BLOCK_M 与 BLOCK_N(无影响):分块逻辑为加载 Tile_A [BLOCK_M, BLOCK_K] 与 Tile_B [BLOCK_K, BLOCK_N],并计算输出 Tile_C [BLOCK_M, BLOCK_N]。这仅仅是在做空间维度的任务映射,决定了哪些元素被打包在一起独立计算,以及由哪个具体的 CTA (Thread Block) 负责计算哪一块区域。对于输出矩阵 C 中的特定元素 Ci,j 而言,无论它被分配给哪个 CTA、与哪些相邻元素一起被计算,其底层的 K 维度点积逻辑并未改变。因此,改变这两个参数不会影响浮点加法的顺序。
GROUP_M / Swizzle(无影响):Swizzle 本质上是改变了多个 CTA 在网格(Grid)级别的调度顺序,以此来提高 L2 Cache 的命中率。它决定的是先算哪一个空间块,后算哪一个,完全没有干涉某一个特定分块内部的乘积累加过程。因此,Swizzle 同样不会破坏Batch Invariance。
BLOCK_K(引入确定性误差):定义了在规约维度(Reduction Dimension)上的步长。改变 BLOCK_K 会直接改变单次循环中加载到寄存器中的数据量,进而改变 Tensor Core 内部 MMA指令的累加树拓扑。这种改变会导致浮点加法顺序的变化(即不同 BLOCK_K 产生不同结果,但同一 BLOCK_K 结果恒定)。
SPLIT_K(引入非确定性误差):将 K 维度强行切分给多个 CTA 并发执行。如果采用Atomic Add进行结果合并,由于 GPU 硬件调度线程块的先后顺序是完全随机的,加法顺序是完全随机不可控的。即便采用Workspace Reduction,SPLIT_K 段数的动态变化同样会改变累加树拓扑。
num_warps 和 num_stages 的本质是硬件资源分配与流水线调度(决定分配多少个cta去算、开辟几份缓存池做异步搬运)。并不改变宏观的矩阵点积计算逻辑和Reduction Tree拓扑。
综上所述,推理引擎在性能与确定性之间存在着固有的架构冲突,为了追求极致的访存复用(动态调整 BLOCK_K)与并提升流多处理器(SM)的并发利用率(动态开启 SPLIT_K),底层的启发式调度策略不可避免地改变了浮点运算的Reduction Tree拓扑,这正是大模型在动态 Batch 下丢失Batch Invariance的根本原因。那么在真实的推理场景中,该如何修补这个机制?接下来我们深入 vLLM 的实现,看看它是如何在底层硬件特性和上层调度逻辑之间做 Trade-off 的。
vLLM中GEMM的Batch Invariance支持
怎么解决呢?直觉上,只需要在推理引擎中全局禁用 Split-K 并锁死 BLOCK_K 即可。但现实的工程实现远比这复杂。现代推理引擎(如 vLLM)底层的 GEMM 操作的下发路径涉及多层抽象与多种后端。实现严格的 Batch Invariance 并非单一配置的修改,而是需要针对特定的软硬件运行环境,进行多维度的执行路径路由与参数约束。具体的GEMM执行路径决于以下三个维度的组合:
硬件架构(Architecture):SM80 (Ampere) 与 SM90/SM100 (Hopper/Blackwell) 的底层 GEMM 执行范式截然不同——前者靠 warp 级并发掩盖访存延迟,后者靠 TMA + WGMMA 的异步流水线。执行范式不同,实现Batch Invariance的逻辑也会不同。
数据精度(Data Type):bf16/fp16 由生态最成熟的 cuBLASLt 承接;而 fp8/fp4 等低精度会转向 CUTLASS(乃至 DeepGEMM 等)专用 kernel。不同后端控制逻辑也不一致。
算子入口(API Backend):nn.Linear 走的是 vLLM 自己的 dispatch,可直接路由到定制高性能内核;而裸 torch.mm / bmm 走 PyTorch dispatcher → cuBLAS/cuBLASLt,调度权在框架和闭源库手里。
SM8x vs SM90/SM100
SM80: warp维度的tensor core(mma.sync)调度 : 优化逻辑是提高占用率,warp同时负责搬运和计算。
4096 * 4096 ->CTA Tile(如128*128) -> Warp Tile(32 * 64) -> mma 指令级(16 * 8 * 16)SM90/100 TMA 硬件单元的引入和 WGMMA 指令(Warp Group 128 线程)促成了范式转移通过Asynchronous Pipelining (异步流水线)来掩盖延迟。
4096 * 4096 -> CTA Tile (如 256 *128) -> Warp Group Tile (64 *128) -> wgmma 指令级 (64 *N * 16)image
对于大矩阵,由于 CTA 分配到的总体空间足够大,每个 Warp 都能分到一块足够大的专属空间(例如 32x64)。然后直接把大 Tile 切成小 Tile 分给 Warp,这样多个 Warp 之间交替访存和计算从而掩盖内存延迟。大家都各自在 K维度独立、串行执行到底,互不干涉,run-to-run是没问题的。
但是batch-to-batch情况下BLOCK_K的发生变化,因此会引发浮点加法顺序的波动?
实际上呢,也不一定,这取决于warp level的K是不是恒定的。比如启发式的auto tune可能导致BLOCK_K从32变成64,但是实际的底层执行以K=16为步长,依次处理K维度的tile,因此就从底层消除了Batch Variance。那是不是就是说可以忽略BLOCK_K的影响,只关注Split-K就行了。讲道理,这个在大矩阵上大概率是成立的。
对于小矩阵,比如32*32分配到一个CTA(K=4096),那每个warp可能只能分配很小的Tile,从而没有足够多的Tile,即M/N并行度不足->活的warp少,占用率低-> 这种情况下在K维度串行执行就无法充分利用隐藏延迟的特性。因此,系统被迫让所有 Warp 重叠在这同一块 32 * 32 的矩阵上,强行在时间(K)维度上进行切分(Warp-level K-Slicing)。 多个 Warp 各算一段 K,最后在共享内存里通过Reduction Tree合并。一旦 Batch 参数变动导致 K 切分段数改变,规约树形状就会改变,从而引发了浮点加法顺序的波动。
当然对于小矩阵,GEMM 库此时可能选择更小的 tile、GEMV/SIMT、persistent kernel、warp-level K-slicing、CTA-level Split-K 或 Stream-K 等不同算法。
Warp-level K-slicing 会让多个 warp 分别计算同一输出 tile 的不同 K 分片,再在 CTA 内合并 partial results;它可以增加单 CTA 内的有效 warp 数,但不能增加 grid 中的 CTA 数量。为了提升SM的利用率,通常需要 CTA-level Split-K。
不过到了SM90+,降低了对传统高 occupancy 隐藏访存延迟的依赖,标准 WGMMA mainloop 中,一个warpgroup 共同维护分布式 accumulator,并依次处理 K tile,不需要生成多个独立 warp partial result 后再做 CTA 内 reduction。
所以我们可以看到代码注释:"Hopper (SM90) and Blackwell (SM100): the only source of batch variance is split-k", 即在SM90 Hopper / SM100 Blackwell上,当前受支持和测试的 FP16/BF16 PyTorch/cuBLASLt 路径,在禁止 Split-K batch invariant。
这里需要特别说明的是,SM90/SM100锁定Split-K可以获取batch invariance,SM8x需要同时锁定BLOCK-K和Split-K才能获取batch invariance都是基于测试的,因为 cuBLAS / cuBLASLt 是闭源的。
当然也有很多人误认为Split-k多workspace时,规约树是按顺序规约的,可以保证结果不变,但是这个只是run-to-run维度的,一旦K变化,结果还是会变化。
说了这么多,其实呢,最终浮点数加法的顺序,不仅受mainloop 的 BLOCK_K staging 决定的(CTA Tiling ),也受更低维度的Warp Tiling/Warp Group Tiling(以及硬件原子指令粒度)决定。但是呢,要解决这个问题却要从CTA Tiling角度来。
SM80上GEMM的Batch Invariance支持
对于SM80要同时关注SPLIT_K和BLOCK_K。其实对于Linear层的GEMM无论SM是哪个版本/无论是eager还是compile mode,都会走到linear_batch_invariant -> matmul_persistent。
class UnquantizedLinearMethod(LinearMethodBase):
def apply(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: torch.Tensor | None = None,
) -> torch.Tensor:
if envs.VLLM_BATCH_INVARIANT and current_platform.is_cuda_alike():
return linear_batch_invariant(x, layer.weight, bias)
return dispatch_unquantized_gemm()(layer, x, layer.weight, bias)而对于非线性层的计算,比如代码中直接调用torch.mm,则还需要额外考虑。由于SM80在cuBLASLt上不支持禁用BLOCK_K,只能更换Triton实现,最终也会走到matmul_persistent。其中matmul_persistent是一个persistent kernel 即griddim=num_sm,固定 K 顺序、固定 tile、不切 SPLIT_K。
if current_platform.is_device_capability_family(80):
# SM80 (Ampere) cannot rely on cuBLASLt-only determinism; install the
# triton persistent matmul overrides for mm/addmm/matmul/linear.
_batch_invariant_LIB.impl("aten::mm", mm_batch_invariant, "CUDA")
_batch_invariant_LIB.impl("aten::addmm", addmm_batch_invariant, "CUDA")
_batch_invariant_LIB.impl("aten::matmul", matmul_batch_invariant, "CUDA")
_batch_invariant_LIB.impl("aten::
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み