使用 cuDNN 和 TransformerEngine 加快 GPU 注意力機制

1. 簡介

Jax on GPU 學習路徑。實驗室 5:GPU 注意力機制。

在本程式碼實驗室中,您將著重於 NVIDIA GPU 的注意力運算。您會先從簡單的實作開始,接著改用 JAX 內建的融合注意力機制,然後啟用 NVIDIA 的 cuDNN 後端執行相同作業。完成後,您將瞭解每個層級的變化,以及隨著問題規模擴大,GPU 最佳化路徑的速度會提升多少。

學習內容

  • 使用基本 JAX 運算,從頭開始實作縮放點積注意力機制
  • 改用 jax.nn.dot_product_attention,這是 JAX 內建的融合核心
  • 使用 implementation="cudnn" 強制 cuDNN 後端,並新增因果遮罩
  • 掃描序列長度和批次大小,查看融合核心的成效
  • 比較多頭注意力形狀 MHA、GQA 和 MQA
  • 對 NVIDIA TransformerEngine 注意力機制進行基準測試,並檢查其 FP8 程式碼路徑

軟硬體需求

  • 已啟用計費功能的 Google Cloud 雲端專案,以及涵蓋 GPU 用量的研討會抵免額或預留項目
  • 所選區域中至少有 2 個 NVIDIA L4 GPU 的配額 (如何查看 GPU 配額)
  • 完成程式碼實驗室 1 至 4,或具備同等的 CUDA 支援 JAX GPU 環境
  • 熟悉矩陣相乘和 Softmax。不需要 CUDA 或 cuDNN 經驗。

預計完成時間:60 分鐘。

注意力機制的運作方式

縮放點積注意力機制會採用三個輸入內容:查詢、鍵和值,並計算:

Attention(Q, K, V) = softmax(Q K^T / sqrt(d_k)) V

縮放比例係數 1 / sqrt(d_k) 可防止點積隨著頭部維度增加而過大,避免 Softmax 進入梯度極小的區域。

四個步驟,每個步驟都會影響下一個步驟:

  1. 分數 — Q @ K.T:點積會測量每個查詢位置應注意每個鍵位置的程度。
  2. 縮放 + Softmax — softmax(scores / sqrt(d)):縮放可防止梯度消失,Softmax 則會將分數轉換為總和為 1 的注意力權重。
  3. Attend — weights @ V:值向量的加權總和會產生每個查詢位置的輸出內容。
  4. 輸出:形狀與 Q 相同,每個查詢位置現在都包含其關注位置的資訊。

2. 事前準備

選取專案

在 Google Cloud 控制台中,選取或建立已啟用計費功能的專案。

開啟 Cloud Shell

點選控制台右上方的終端機圖示「啟用 Cloud Shell」,啟動 Cloud Shell 工作階段,然後將其指向您的專案:

gcloud config set project <YOUR_PROJECT_ID>

佈建 GPU 環境

在 Cloud Shell 執行下列指令,程式碼實驗室 1 會詳細說明各項指令,包括 GPU 配額和可用區規定。

gcloud services enable \
  container.googleapis.com \
  compute.googleapis.com \
  iam.googleapis.com \
  cloudresourcemanager.googleapis.com \
  logging.googleapis.com \
  monitoring.googleapis.com

git clone https://github.com/Google-Cloud-AI/partner-ai-nvidia.git
cd partner-ai-nvidia/05-workshops/jax-on-gpu/terraform

cp terraform.tfvars.example terraform.tfvars

編輯 terraform.tfvars 並設定 project_id = "",然後佈建叢集並部署 JupyterLab:

terraform init
terraform apply
$(terraform output -raw get_credentials_command)

cd ..
kubectl apply -f deploy/jupyter.yaml

terraform apply約需 12 分鐘。完成後,請等待 Pod 和 LoadBalancer,然後從 Pod 記錄讀取一次性 JupyterLab 權杖:

kubectl get pod jax-jupyter -w         # wait for Running, then Ctrl+C
kubectl get svc jax-jupyter-svc -w     # wait for EXTERNAL-IP, then Ctrl+C
kubectl logs jax-jupyter | grep -o 'token=[a-z0-9]*' | head -1

開啟 http://:8884,貼上權杖,然後在 /workspace 中建立新的 Python 3 筆記本。本程式碼實驗室中的每個程式碼區塊,都會放入該筆記本的儲存格。

安裝本程式碼實驗室所需的項目

!pip install --quiet matplotlib flax

設定及驗證 GPU

匯入 JAX,並確認預設後端為 GPU。這個儲存格也會定義 block_tree、show_table 和 show_bars,這些輔助函式會在後續步驟中,用於等待裝置工作和算繪結果。

import os
os.environ["LD_LIBRARY_PATH"] = "/usr/local/nvidia/lib64:" + os.environ.get("LD_LIBRARY_PATH", "")

import html
import math
import time
from functools import partial

from IPython.display import HTML, display
import matplotlib.pyplot as plt
import numpy as np

import jax
import jax.numpy as jnp


devices = jax.devices()
gpu_devices = [d for d in devices if d.platform == "gpu"]
device = gpu_devices[0] if gpu_devices else None

print(f"JAX version:     {jax.__version__}")
print(f"Default backend: {jax.default_backend()}")
print(f"Devices:         {devices}")

assert gpu_devices, f"This lab assumes a GPU backend. Available devices: {devices}"
print(f"Using GPU:       {device}")


def block_tree(tree):
    """Wait until a PyTree of JAX arrays is ready on device."""
    return jax.block_until_ready(tree)


def show_table(headers, rows, title=None, aligns=None):
    """Render rows as an HTML table."""
    aligns = aligns or ["left"] * len(headers)
    parts = ["<div style='font-family: system-ui; max-width: 980px;'>"]
    if title:
        parts.append(f"<h4 style='margin: 0 0 8px 0;'>{html.escape(title)}</h4>")
    parts.append("<table style='border-collapse: collapse; width: 100%; font-size: 13px;'>")
    parts.append("<thead><tr>")
    for h, a in zip(headers, aligns):
        parts.append(
            f"<th style='text-align:{a}; border-bottom:1px solid #d0d7de; padding:6px;'>"
            f"{html.escape(str(h))}</th>"
        )
    parts.append("</tr></thead><tbody>")
    for row in rows:
        parts.append("<tr>")
        for cell, a in zip(row, aligns):
            parts.append(
                f"<td style='text-align:{a}; border-bottom:1px solid #eef1f4; padding:6px;'>"
                f"{html.escape(str(cell))}</td>"
            )
        parts.append("</tr>")
    parts.append("</tbody></table></div>")
    display(HTML("".join(parts)))


def show_bars(rows, title, unit="", lower_is_better=False):
    """Render (label, value) pairs as a horizontal bar chart in HTML."""
    max_value = max(float(value) for _, value in rows) or 1.0
    color = "#1a7f37" if not lower_is_better else "#0969da"
    parts = ["<div style='font-family: Arial, sans-serif; max-width: 760px;'>"]
    parts.append(f"<h4 style='margin: 0 0 8px 0;'>{html.escape(title)}</h4>")
    for label, value in rows:
        width = max(3, 100 * float(value) / max_value)
        parts.append(
            "<div style='display:grid; grid-template-columns: 190px 1fr 130px; gap: 8px; "
            "align-items:center; margin: 6px 0;'>"
            f"<div style='font-size:13px;'>{html.escape(str(label))}</div>"
            "<div style='background:#f6f8fa; border-radius:6px; overflow:hidden; height:22px;'>"
            f"<div style='height:22px; width:{width:.1f}%; background:{color};'></div></div>"
            f"<div style='font-size:13px; font-variant-numeric: tabular-nums;'>{float(value):,.1f} {html.escape(unit)}</div>"
            "</div>"
        )
    parts.append(
        f"<div style='font-size:12px; color:#57606a;'>"
        f"{'Lower' if lower_is_better else 'Higher'} is better.</div></div>"
    )
    display(HTML("".join(parts)))

您應該會看到 JAX 版本、預設後端 gpu、CUDA 裝置清單,以及本程式碼實驗室其餘部分使用的 GPU。

3. 建構 Q、K 和 V 測試陣列

融合注意力核心會考量輸入陣列在記憶體中的配置方式,因此請先在 JAX 預期的版面配置中建構 Q、K 和 V,再編寫任何注意力程式碼。jax.nn.dot_product_attention 預期採用下列版面配置:

調暗

意義

預設

B

批量

4

T

查詢序列長度

128

S

鍵/值序列長度

128 (與自注意力機制的 T 相同)

否

注意力頭數量

8

H

每個頭部的維度

64

您會先在 float32 中進行基本實作,並使用隨機值初始化。

BATCH = 4
SEQ_LEN = 128
NUM_HEADS = 8
HEAD_DIM = 64

key = jax.random.key(0)
k1, k2, k3 = jax.random.split(key, 3)

q = jax.random.normal(k1, (BATCH, SEQ_LEN, NUM_HEADS, HEAD_DIM), dtype=jnp.float32)
k = jax.random.normal(k2, (BATCH, SEQ_LEN, NUM_HEADS, HEAD_DIM), dtype=jnp.float32)
v = jax.random.normal(k3, (BATCH, SEQ_LEN, NUM_HEADS, HEAD_DIM), dtype=jnp.float32)

q, k, v = jax.device_put((q, k, v), device)

show_table(
    ["Array", "Shape", "Dtype", "Layout"],
    [
        ("Q (query)", q.shape, q.dtype, "(B, T, N, H)"),
        ("K (key)", k.shape, k.dtype, "(B, S, N, H)"),
        ("V (value)", v.shape, v.dtype, "(B, S, N, H)"),
    ],
    title="Attention inputs on GPU",
)

您應該會看到一個表格,其中每個陣列各有一列,分別回報形狀 (4, 128, 8, 64) 和 dtype float32。jax.device_put 呼叫會將這三個陣列固定到您在設定儲存格中選取的 GPU,因此後續的基準測試不會測量主機傳輸。

4. 從頭開始實作注意力機制

使用融合核心前,請直接寫出公式,以便清楚瞭解核心取代的內容。這個實作會依序執行四個步驟:計算分數、縮放、softmax、乘以值。您會轉置頭部和序列維度,讓 matmul 在序列軸上運作。

雖然能正常運作,但會針對分數 matmul、softmax 和值 matmul 發出三個獨立的 GPU 核心啟動作業,並在 GPU 記憶體中具體化完整的 (B, N, T, S) 注意力權重矩陣。

def naive_attention(q, k, v):
    """Scaled dot-product attention from scratch."""
    scale = 1.0 / math.sqrt(q.shape[-1])

    # (B, T, N, H) to (B, N, T, H) so matmul runs over the T/S axis
    q_t = jnp.transpose(q, (0, 2, 1, 3))
    k_t = jnp.transpose(k, (0, 2, 1, 3))
    v_t = jnp.transpose(v, (0, 2, 1, 3))

    # Score: (B, N, T, H) @ (B, N, H, S) to (B, N, T, S)
    scores = jnp.matmul(q_t, jnp.transpose(k_t, (0, 1, 3, 2))) * scale
    weights = jax.nn.softmax(scores, axis=-1)

    # Attend: (B, N, T, S) @ (B, N, S, H) to (B, N, T, H)
    out_t = jnp.matmul(weights, v_t)

    # Back to (B, T, N, H)
    return jnp.transpose(out_t, (0, 2, 1, 3))


naive_out = block_tree(naive_attention(q, k, v))

show_table(
    ["", "Value"],
    [
        ("Output shape", str(naive_out.shape)),
        ("Output dtype", str(naive_out.dtype)),
    ],
    title="Naive attention",
)

您應該會看到輸出形狀 (4, 128, 8, 64) 和 dtype float32,其形狀與 Q 相同,這正是公式的「輸出」步驟所承諾的。

5. 切換至 dot_product_attention

jax.nn.dot_product_attention 會將分數、縮放、softmax 和注意力步驟融合為單一運算。JAX 和 XLA 就能最佳化記憶體存取模式。特別是當序列很長時,可以避免具體化完整的注意力權重矩陣。

如果使用預設的 implementation=None,JAX 會自動挑選最合適的後端。在具有 cuDNN 和相容輸入的 GPU 上,它可能已經使用 cuDNN。在其他硬體上,則會改用 XLA。

sdpa_out = block_tree(jax.nn.dot_product_attention(q, k, v))

max_diff = float(jnp.max(jnp.abs(naive_out - sdpa_out)))

show_table(
    ["", "Value"],
    [
        ("Output shape", str(sdpa_out.shape)),
        ("Output dtype", str(sdpa_out.dtype)),
        ("Max |naive − SDPA|", f"{max_diff:.2e}"),
        ("Outputs close (atol=1e-3)", str(bool(jnp.allclose(naive_out, sdpa_out, atol=1e-3)))),
    ],
    title="JAX SDPA vs naive",
)

您應該會看到與原始版本相同的形狀和 dtype、較小的最大絕對差異,以及 atol=1e-3 接近度檢查的 True。

6. 強制使用 cuDNN 融合後端

讓 JAX 選擇後端很方便,但從外部無法得知實際執行的核心。設定 implementation="cudnn" 會強制使用 NVIDIA 的 cuDNN 融合注意力核心。這些是手動最佳化的 GPU 核心,可將整個注意力運算融合至單一核心啟動,並採用最佳化的記憶體存取模式。

請注意,cuDNN 融合注意力機制有硬體和形狀要求,例如 GPU 運算能力應 >= 8.0 (Ampere 或更新版本),或 float16 或 bfloat16 做為輸入 dtype。如果不符合需求,且您設定了 implementation="cudnn",JAX 會引發錯誤,而不會默默改用其他方法。因此,下列程式碼會轉換為 bfloat16,並將呼叫包裝在 try/except 中:設定 HAS_CUDNN_SDPA,讓後續步驟知道這部機器是否可以使用 cuDNN 路徑。

q_bf16 = q.astype(jnp.bfloat16)
k_bf16 = k.astype(jnp.bfloat16)
v_bf16 = v.astype(jnp.bfloat16)

HAS_CUDNN_SDPA = False

try:
    cudnn_out = block_tree(
        jax.nn.dot_product_attention(q_bf16, k_bf16, v_bf16, implementation="cudnn")
    )
    HAS_CUDNN_SDPA = True

    xla_bf16_out = block_tree(
        jax.nn.dot_product_attention(q_bf16, k_bf16, v_bf16, implementation="xla")
    )
    max_diff = float(jnp.max(jnp.abs(
        cudnn_out.astype(jnp.float32) - xla_bf16_out.astype(jnp.float32)
    )))

    show_table(
        ["", "Value"],
        [
            ("Output shape", str(cudnn_out.shape)),
            ("Output dtype", str(cudnn_out.dtype)),
            ("Max |cuDNN − XLA| (both bf16)", f"{max_diff:.2e}"),
            ("Outputs close (rtol=1e-2, atol=1e-2)", str(bool(jnp.allclose(cudnn_out, xla_bf16_out, rtol=1e-2, atol=1e-2)))),
        ],
        title="cuDNN fused attention",
    )

except Exception as e:
    print(f"cuDNN SDPA not available on this GPU: {e}")
    print("Continuing with XLA backend only.")

如果 GPU 無法執行 cuDNN SDPA,程式碼會輸出原因,並在 XLA 後端繼續執行程式碼實驗室。

7. 新增因果遮罩

在自動迴歸模型 (GPT 樣式解碼器) 中,每個位置只能注意先前的位置。設定 is_causal=True 會在融合核心中套用這個下三角遮罩,因此您不必自行建構遮罩矩陣。

以下程式碼會執行兩次注意力機制 (一次使用遮罩,一次不使用),並比較兩個位置,顯示遮罩改變了什麼。

causal_out = block_tree(
    jax.nn.dot_product_attention(q, k, v, is_causal=True)
)

# With causal masking, the last position attends to all positions.
# The first position attends only to itself.
nocausal_out = block_tree(
    jax.nn.dot_product_attention(q, k, v, is_causal=False)
)

# First position should differ
first_pos_diff = float(jnp.max(jnp.abs(causal_out[:, 0] - nocausal_out[:, 0])))
# Last position should be the same
last_pos_diff = float(jnp.max(jnp.abs(causal_out[:, -1] - nocausal_out[:, -1])))

show_table(
    ["Position", "Max diff (causal vs full)", "Expected"],
    [
        ("First (t=0)", f"{first_pos_diff:.4f}", "Large — causal restricts to self only"),
        ("Last (t=T-1)", f"{last_pos_diff:.2e}", "~0 — attends to all positions either way"),
    ],
    title="Causal masking effect on attention output",
)

您應該會看到第一個位置有很大的差異,而最後一個位置的差異接近零。這種不對稱性是指位置 0 的遮罩會失去除了自身以外的所有鍵,而最終位置可能已經能看到整個序列。

8. 執行一些基準測試

現在您有三種方法可以計算同一個函式。要使用哪一種,完全取決於問題的形式,您可以自行評估。

預設形狀的時間變化版本

def benchmark_attention(fn, q, k, v, warmup=3, repeats=50):
    """Time an attention function. Returns median milliseconds per call."""
    jit_fn = jax.jit(fn)

    for _ in range(warmup):
        block_tree(jit_fn(q, k, v))

    times = []
    for _ in range(repeats):
        start = time.perf_counter()
        block_tree(jit_fn(q, k, v))
        times.append((time.perf_counter() - start) * 1000)

    return np.median(times)


t_naive = benchmark_attention(naive_attention, q, k, v)
t_sdpa = benchmark_attention(
    lambda q, k, v: jax.nn.dot_product_attention(q, k, v, implementation="xla"),
    q, k, v,
)

results = [
    ("Naive (matmul + softmax + matmul)", f"{t_naive:.2f}"),
    ("SDPA (XLA, float32)", f"{t_sdpa:.2f}"),
]
bar_data = [
    ("Naive", t_naive),
    ("SDPA XLA f32", t_sdpa),
]

if HAS_CUDNN_SDPA:
    t_sdpa_bf16 = benchmark_attention(
        lambda q, k, v: jax.nn.dot_product_attention(q, k, v, implementation="xla"),
        q_bf16, k_bf16, v_bf16,
    )
    t_cudnn = benchmark_attention(
        lambda q, k, v: jax.nn.dot_product_attention(q, k, v, implementation="cudnn"),
        q_bf16, k_bf16, v_bf16,
    )
    results.append(("SDPA (XLA, bfloat16)", f"{t_sdpa_bf16:.2f}"))
    results.append(("SDPA (cuDNN, bfloat16)", f"{t_cudnn:.2f}"))
    bar_data.append(("SDPA XLA bf16", t_sdpa_bf16))
    bar_data.append(("SDPA cuDNN bf16", t_cudnn))

show_table(
    ["Implementation", "Median ms/call"],
    results,
    title=f"Attention timing — B={BATCH}, T={SEQ_LEN}, N={NUM_HEADS}, H={HEAD_DIM}",
    aligns=["left", "right"],
)
show_bars(bar_data, "Attention latency (ms per call)", "ms", lower_is_better=True)

由於問題規模較小,所有實作方式都非常快速,且主要受限於負載,因此基本版本具有競爭力。cuDNN bf16 應該會稍微快一點,但融合注意力機制的優勢通常在序列長度較長時更明顯,因為避免使用完整注意力矩陣很重要。

掃描序列長度

序列長度越長,融合注意力核心的優勢就越明顯。基本實作會在 GPU 記憶體中具體化 (B, N, T, S) 注意力矩陣,也就是 O(T²) 記憶體。cuDNN FlashAttention 等融合核心會將運算分塊,因此不會具體化完整矩陣,記憶體用量可維持在 O(T)。

下方的掃描會測量各個實作項目的時間,序列長度介於 64 到 1024 之間。

SEQ_LENS = [64, 128, 256, 512, 1024]

sweep_results = []
for sl in SEQ_LENS:
    rk = jax.random.key(sl)
    rk1, rk2, rk3 = jax.random.split(rk, 3)

    q_s = jax.random.normal(rk1, (BATCH, sl, NUM_HEADS, HEAD_DIM), dtype=jnp.float32)
    k_s = jax.random.normal(rk2, (BATCH, sl, NUM_HEADS, HEAD_DIM), dtype=jnp.float32)
    v_s = jax.random.normal(rk3, (BATCH, sl, NUM_HEADS, HEAD_DIM), dtype=jnp.float32)
    q_s, k_s, v_s = jax.device_put((q_s, k_s, v_s), device)

    q_sb = q_s.astype(jnp.bfloat16)
    k_sb = k_s.astype(jnp.bfloat16)
    v_sb = v_s.astype(jnp.bfloat16)

    row = {"seq_len": sl}

    row["naive_ms"] = benchmark_attention(
        naive_attention,
        q_s, k_s, v_s,
        warmup=2,
        repeats=20,
    )

    row["sdpa_xla_f32_ms"] = benchmark_attention(
        lambda q, k, v: jax.nn.dot_product_attention(q, k, v, implementation="xla"),
        q_s, k_s, v_s,
        warmup=2,
        repeats=20,
    )

    row["sdpa_xla_bf16_ms"] = benchmark_attention(
        lambda q, k, v: jax.nn.dot_product_attention(q, k, v, implementation="xla"),
        q_sb, k_sb, v_sb,
        warmup=2,
        repeats=20,
    )

    if HAS_CUDNN_SDPA:
        row["cudnn_bf16_ms"] = benchmark_attention(
            lambda q, k, v: jax.nn.dot_product_attention(q, k, v, implementation="cudnn"),
            q_sb, k_sb, v_sb,
            warmup=2,
            repeats=20,
        )

    sweep_results.append(row)


headers = ["Seq len", "Naive (ms)", "SDPA XLA f32 (ms)", "SDPA XLA bf16 (ms)"]
if HAS_CUDNN_SDPA:
    headers.append("cuDNN bf16 (ms)")

table_rows = []
for r in sweep_results:
    row = [
        r["seq_len"],
        f"{r['naive_ms']:.2f}",
        f"{r['sdpa_xla_f32_ms']:.2f}",
        f"{r['sdpa_xla_bf16_ms']:.2f}",
    ]

    if HAS_CUDNN_SDPA:
        row.append(f"{r['cudnn_bf16_ms']:.2f}")

    table_rows.append(row)

show_table(
    headers,
    table_rows,
    title=f"Sequence-length sweep — B={BATCH}, N={NUM_HEADS}, H={HEAD_DIM}",
    aligns=["right"] * len(headers),
)

掃描需要一些時間,因為每個序列長度都會先編譯三或四個獨立實作,然後才計時。最後,每個序列長度 (從 64 到 1024) 應有一個資料表資料列。

這張圖表比較了注意力延遲時間隨序列長度變化的情況,包括原始實作、float32 和 bfloat16 的 JAX/XLA SDPA,以及 bfloat16 的 cuDNN 融合注意力。

fig, ax = plt.subplots(figsize=(8, 5))
seq_lens = [r["seq_len"] for r in sweep_results]

ax.plot(
    seq_lens,
    [r["naive_ms"] for r in sweep_results],
    "o-",
    label="Naive",
    color="#d1242f",
)

ax.plot(
    seq_lens,
    [r["sdpa_xla_f32_ms"] for r in sweep_results],
    "s-",
    label="SDPA XLA f32",
    color="#0969da",
)

ax.plot(
    seq_lens,
    [r["sdpa_xla_bf16_ms"] for r in sweep_results],
    "d-",
    label="SDPA XLA bf16",
    color="#8250df",
)

if HAS_CUDNN_SDPA:
    ax.plot(
        seq_lens,
        [r["cudnn_bf16_ms"] for r in sweep_results],
        "^-",
        label="cuDNN bf16",
        color="#1a7f37",
    )

ax.set_xlabel("Sequence length")
ax.set_ylabel("Median ms per call")
ax.set_title("Attention latency vs sequence length")
ax.legend()
ax.grid(True, alpha=0.25)
ax.set_xticks(seq_lens)

fig.tight_layout()
plt.show()

每個導入項目應各占一行。您應該會看到差距隨著序列變長而增加。

掃描批量

較大的批次可分攤核心啟動負擔,並提高 GPU 使用率,直到 GPU 記憶體成為瓶頸為止。在下方的掃描中,序列長度固定為 256,批次大小則會變動。

BATCH_SIZES = [1, 2, 4, 8, 16]
SWEEP_SEQ = 256

batch_results = []
for bs in BATCH_SIZES:
    rk = jax.random.key(bs + 100)
    rk1, rk2, rk3 = jax.random.split(rk, 3)

    q_b = jax.random.normal(rk1, (bs, SWEEP_SEQ, NUM_HEADS, HEAD_DIM), dtype=jnp.float32)
    k_b = jax.random.normal(rk2, (bs, SWEEP_SEQ, NUM_HEADS, HEAD_DIM), dtype=jnp.float32)
    v_b = jax.random.normal(rk3, (bs, SWEEP_SEQ, NUM_HEADS, HEAD_DIM), dtype=jnp.float32)
    q_b, k_b, v_b = jax.device_put((q_b, k_b, v_b), device)

    q_bb = q_b.astype(jnp.bfloat16)
    k_bb = k_b.astype(jnp.bfloat16)
    v_bb = v_b.astype(jnp.bfloat16)

    row = {"batch": bs}

    row["sdpa_xla_f32_ms"] = benchmark_attention(
        lambda q, k, v: jax.nn.dot_product_attention(q, k, v, implementation="xla"),
        q_b, k_b, v_b,
        warmup=2,
        repeats=20,
    )

    row["sdpa_xla_bf16_ms"] = benchmark_attention(
        lambda q, k, v: jax.nn.dot_product_attention(q, k, v, implementation="xla"),
        q_bb, k_bb, v_bb,
        warmup=2,
        repeats=20,
    )

    if HAS_CUDNN_SDPA:
        row["cudnn_bf16_ms"] = benchmark_attention(
            lambda q, k, v: jax.nn.dot_product_attention(q, k, v, implementation="cudnn"),
            q_bb, k_bb, v_bb,
            warmup=2,
            repeats=20,
        )

    batch_results.append(row)


headers = ["Batch size", "SDPA XLA f32 (ms)", "SDPA XLA bf16 (ms)"]
if HAS_CUDNN_SDPA:
    headers.append("cuDNN bf16 (ms)")

table_rows = []
for r in batch_results:
    row = [
        r["batch"],
        f"{r['sdpa_xla_f32_ms']:.2f}",
        f"{r['sdpa_xla_bf16_ms']:.2f}",
    ]

    if HAS_CUDNN_SDPA:
        row.append(f"{r['cudnn_bf16_ms']:.2f}")

    table_rows.append(row)

show_table(
    headers,
    table_rows,
    title=f"Batch-size sweep — T={SWEEP_SEQ}, N={NUM_HEADS}, H={HEAD_DIM}",
    aligns=["right"] * len(headers),
)


fig, ax = plt.subplots(figsize=(8, 5))
batches = [r["batch"] for r in batch_results]

ax.plot(
    batches,
    [r["sdpa_xla_f32_ms"] for r in batch_results],
    "s-",
    label="SDPA XLA f32",
    color="#0969da",
)

ax.plot(
    batches,
    [r["sdpa_xla_bf16_ms"] for r in batch_results],
    "d-",
    label="SDPA XLA bf16",
    color="#8250df",
)

if HAS_CUDNN_SDPA:
    ax.plot(
        batches,
        [r["cudnn_bf16_ms"] for r in batch_results],
        "^-",
        label="cuDNN bf16",
        color="#1a7f37",
    )

ax.set_xlabel("Batch size")
ax.set_ylabel("Median ms per call")
ax.set_title("Attention latency vs batch size")
ax.legend()
ax.grid(True, alpha=0.25)
ax.set_xticks(batches)

fig.tight_layout()
plt.show()

您應該會看到一個表格和一個圖表,其中每個批次大小 (從 1 到 16) 都有一個項目。這項掃描作業不含原始實作,因此兩個輸出內容只會比較三個融合路徑。

9. 比較 MHA、GQA 和 MQA

到目前為止,每個注意力頭都有自己的鍵和值。但解碼器往往會打破這種對稱性,因為在推論期間,鍵/值快取會占據大部分記憶體。

多頭注意力機制 (MHA) 會為每個頭部提供專屬的 Q、K 和 V 投影。分組查詢注意力 (GQA) 和多查詢注意力 (MQA) 機制可減少 KV 頭數量,在推論期間節省記憶體和運算資源。

jax.nn.dot_product_attention 會處理這三種情況,當 K < N 時,KV 頭會自動廣播。

rk = jax.random.key(42)
rk1, rk2, rk3, rk4, rk5 = jax.random.split(rk, 5)

q_mha = jax.random.normal(rk1, (2, 64, 8, 64), dtype=jnp.float32)

# MHA: 8 KV heads
k_mha = jax.random.normal(rk2, (2, 64, 8, 64), dtype=jnp.float32)
v_mha = jax.random.normal(rk3, (2, 64, 8, 64), dtype=jnp.float32)

# GQA: 2 KV heads (each shared by 4 query heads)
k_gqa = jax.random.normal(rk2, (2, 64, 2, 64), dtype=jnp.float32)
v_gqa = jax.random.normal(rk3, (2, 64, 2, 64), dtype=jnp.float32)

# MQA: 1 KV head (shared by all 8 query heads)
k_mqa = jax.random.normal(rk4, (2, 64, 1, 64), dtype=jnp.float32)
v_mqa = jax.random.normal(rk5, (2, 64, 1, 64), dtype=jnp.float32)

out_mha = block_tree(jax.nn.dot_product_attention(q_mha, k_mha, v_mha))
out_gqa = block_tree(jax.nn.dot_product_attention(q_mha, k_gqa, v_gqa))
out_mqa = block_tree(jax.nn.dot_product_attention(q_mha, k_mqa, v_mqa))

show_table(
    ["Pattern", "Q shape", "K shape", "V shape", "Output shape"],
    [
        ("MHA", q_mha.shape, k_mha.shape, v_mha.shape, out_mha.shape),
        ("GQA", q_mha.shape, k_gqa.shape, v_gqa.shape, out_gqa.shape),
        ("MQA", q_mha.shape, k_mqa.shape, v_mqa.shape, out_mqa.shape),
    ],
    title="Multi-head attention variants — all should produce the same output shape",
)

雖然 K 和 V 的形狀從 8 個頭縮減為 2 個,再縮減為 1 個,但這三列應回報相同的輸出形狀 (2, 64, 8, 64)。重點是,您可以縮減 KV 快取,而不必變更注意力機制下游的任何內容。

10. 基準測試 NVIDIA TransformerEngine 和 FP8

NVIDIA 的 TransformerEngine 提供針對 NVIDIA GPU 最佳化的融合注意力模組。JAX 整合功能使用 Flax Linen 樣式模組 (而非 NNX),因此模組會初始化一次,然後套用其變數。

使用 TransformerEngine 掃描序列長度

這個儲存格會檢查 NVIDIA TransformerEngine 是否可用,然後在相同工作負載下,針對多個序列長度的 bf16 因果 DotProductAttention,與採用 XLA 和 cuDNN 後端的 JAX SDPA 進行基準測試。

TE_SEQ_LENS = [128, 256, 512, 1024, 2048]
TE_BATCH = BATCH

HAS_TE = False

try:
    import transformer_engine.jax as te
    import transformer_engine.jax.flax as te_flax
    HAS_TE = True
except ImportError:
    print("TransformerEngine not installed — skipping TE sections.")

if HAS_TE:
    te_results = []

    for sl in TE_SEQ_LENS:
        rk = jax.random.key(sl + 1000)
        rk1, rk2, rk3 = jax.random.split(rk, 3)

        q_te = jax.random.normal(
            rk1, (TE_BATCH, sl, NUM_HEADS, HEAD_DIM), dtype=jnp.bfloat16
        )
        k_te = jax.random.normal(
            rk2, (TE_BATCH, sl, NUM_HEADS, HEAD_DIM), dtype=jnp.bfloat16
        )
        v_te = jax.random.normal(
            rk3, (TE_BATCH, sl, NUM_HEADS, HEAD_DIM), dtype=jnp.bfloat16
        )
        q_te, k_te, v_te = jax.device_put((q_te, k_te, v_te), device)

        te_attention = te_flax.DotProductAttention(
            head_dim=HEAD_DIM,
            num_attention_heads=NUM_HEADS,
            num_gqa_groups=NUM_HEADS,
            attn_mask_type="causal",
            transpose_batch_sequence=False,
        )

        te_vars = te_attention.init(
            jax.random.key(0),
            q_te,
            k_te,
            v_te,
            deterministic=True,
        )

        def te_fn(q, k, v):
            return te_attention.apply(te_vars, q, k, v, deterministic=True)

        row = {"seq_len": sl}

        row["sdpa_xla_bf16_ms"] = benchmark_attention(
            lambda q, k, v: jax.nn.dot_product_attention(
                q, k, v, implementation="xla", is_causal=True
            ),
            q_te, k_te, v_te,
            warmup=2,
            repeats=20,
        )

        if HAS_CUDNN_SDPA:
            row["sdpa_cudnn_bf16_ms"] = benchmark_attention(
                lambda q, k, v: jax.nn.dot_product_attention(
                    q, k, v, implementation="cudnn", is_causal=True
                ),
                q_te, k_te, v_te,
                warmup=2,
                repeats=20,
            )

        row["te_bf16_ms"] = benchmark_attention(
            te_fn,
            q_te, k_te, v_te,
            warmup=2,
            repeats=20,
        )

        te_results.append(row)


    headers = ["Seq len", "SDPA XLA bf16 causal (ms)"]
    if HAS_CUDNN_SDPA:
        headers.append("SDPA cuDNN bf16 causal (ms)")
    headers.append("TE DotProductAttention bf16 causal (ms)")

    table_rows = []
    for r in te_results:
        row = [
            r["seq_len"],
            f"{r['sdpa_xla_bf16_ms']:.2f}",
        ]

        if HAS_CUDNN_SDPA:
            row.append(f"{r['sdpa_cudnn_bf16_ms']:.2f}")

        row.append(f"{r['te_bf16_ms']:.2f}")
        table_rows.append(row)

    show_table(
        headers,
        table_rows,
        title=f"TransformerEngine sequence-length sweep — B={TE_BATCH}, N={NUM_HEADS}, H={HEAD_DIM}",
        aligns=["right"] * len(headers),
    )


    fig, ax = plt.subplots(figsize=(8, 5))
    seq_lens = [r["seq_len"] for r in te_results]

    ax.plot(
        seq_lens,
        [r["sdpa_xla_bf16_ms"] for r in te_results],
        "d-",
        label="SDPA XLA bf16 causal",
        color="#8250df",
    )

    if HAS_CUDNN_SDPA:
        ax.plot(
            seq_lens,
            [r["sdpa_cudnn_bf16_ms"] for r in te_results],
            "^-",
            label="SDPA cuDNN bf16 causal",
            color="#1a7f37",
        )

    ax.plot(
        seq_lens,
        [r["te_bf16_ms"] for r in te_results],
        "o-",
        label="TE DotProductAttention bf16 causal",
        color="#d1242f",
    )

    ax.set_xlabel("Sequence length")
    ax.set_ylabel("Median ms per call")
    ax.set_title("Causal attention latency vs sequence length")
    ax.legend()
    ax.grid(True, alpha=0.25)
    ax.set_xticks(seq_lens)

    fig.tight_layout()
    plt.show()

您應該會看到一個表格列,以及一個序列長度為 128 到 2048 的繪圖點。這項 bf16 基準測試比較 TransformerEngine 與 JAX SDPA 在相同因果注意力工作負載上的表現,但 TransformerEngine 的完整效能潛力通常會在 Hopper 和 Blackwell GPU 上,且啟用 FP8 自動轉換時才會顯現。

檢查 FP8 路徑

在 Hopper GPU (運算能力 >= 9.0,例如 H100) 上,TransformerEngine 可以執行 FP8 注意力機制,提高處理量。FP8 採用 DelayedScaling 方案,追蹤每個張量的絕對最大值記錄,藉此計算動態縮放比例係數。

  • 正向傳遞採用 E4M3 格式 (4 個指數,3 個尾數位元)
  • 反向傳播採用 E5M2 格式 (5 個指數位元、2 個尾數位元)

如果 GPU 不支援 FP8,這個儲存格會顯示程式碼的樣子,但不會執行。

if HAS_TE:
    from transformer_engine.common.recipe import DelayedScaling, Format

    gpu_name = f"{device} {getattr(device, 'device_kind', '')}".lower()
    HAS_FP8 = any(
        tag in gpu_name
        for tag in ["h100", "h200", "b100", "b200", "gb200", "blackwell"]
    )

    fp8_recipe = DelayedScaling(
        margin=0,
        fp8_format=Format.HYBRID,
        amax_history_len=1024,
        amax_compute_algo="max",
    )

    if HAS_FP8:
        FP8_SEQ_LEN = 2048
        FP8_BATCH = BATCH

        rk = jax.random.key(9000)
        rk1, rk2, rk3 = jax.random.split(rk, 3)

        q_fp8 = jax.random.normal(
            rk1, (FP8_BATCH, FP8_SEQ_LEN, NUM_HEADS, HEAD_DIM), dtype=jnp.bfloat16
        )
        k_fp8 = jax.random.normal(
            rk2, (FP8_BATCH, FP8_SEQ_LEN, NUM_HEADS, HEAD_DIM), dtype=jnp.bfloat16
        )
        v_fp8 = jax.random.normal(
            rk3, (FP8_BATCH, FP8_SEQ_LEN, NUM_HEADS, HEAD_DIM), dtype=jnp.bfloat16
        )
        q_fp8, k_fp8, v_fp8 = jax.device_put((q_fp8, k_fp8, v_fp8), device)

        fp8_attention = te_flax.DotProductAttention(
            head_dim=HEAD_DIM,
            num_attention_heads=NUM_HEADS,
            num_gqa_groups=NUM_HEADS,
            attn_mask_type="causal",
            transpose_batch_sequence=False,
        )

        bf16_vars = fp8_attention.init(
            jax.random.key(0),
            q_fp8,
            k_fp8,
            v_fp8,
            deterministic=True,
        )

        bf16_out = block_tree(
            fp8_attention.apply(
                bf16_vars,
                q_fp8,
                k_fp8,
                v_fp8,
                deterministic=True,
            )
        )

        with te.autocast(enabled=True, recipe=fp8_recipe):
            fp8_vars = fp8_attention.init(
                jax.random.key(1),
                q_fp8,
                k_fp8,
                v_fp8,
                deterministic=True,
            )
            fp8_out = block_tree(
                fp8_attention.apply(
                    fp8_vars,
                    q_fp8,
                    k_fp8,
                    v_fp8,
                    deterministic=True,
                )
            )

        max_diff_fp8 = float(jnp.max(jnp.abs(
            bf16_out.astype(jnp.float32) - fp8_out.astype(jnp.float32)
        )))

        show_table(
            ["", "Value"],
            [
                ("GPU", getattr(device, "device_kind", str(device))),
                ("Input dtype", str(q_fp8.dtype)),
                ("bf16 output dtype", str(bf16_out.dtype)),
                ("FP8 autocast output dtype", str(fp8_out.dtype)),
                ("Output shape", str(fp8_out.shape)),
                ("Max |TE bf16 - TE FP8 autocast|", f"{max_diff_fp8:.2e}"),
            ],
            title="FP8 attention with TransformerEngine",
        )

    else:
        show_table(
            ["", "Value"],
            [
                ("GPU", getattr(device, "device_kind", str(device))),
                ("FP8 support", "No detected support; requires Hopper/Blackwell-class GPU"),
            ],
            title="FP8 attention — not available on this GPU",
        )

        print()
        print("The FP8 path uses TransformerEngine autocast:")
        print()
        print("  with te.autocast(enabled=True, recipe=fp8_recipe):")
        print("      out = fp8_attention.apply(vars, q, k, v, deterministic=True)")

else:
    print("TransformerEngine not available — FP8 section skipped.")

在 L4 上,您應該會看到一個表格,其中列出 GPU 名稱,並回報未偵測到 FP8 支援,接著是兩行顯示 te.autocast 呼叫的輸出內容,您會在 Hopper GPU 上使用這個呼叫。請保留該程式碼片段:這是 FP8 在呼叫端唯一需要變更的部分。

11. 清除

刪除 Jupyter 工作負載,包括 LoadBalancer 和永久磁碟區:

kubectl delete -f deploy/jupyter.yaml

刪除叢集、節點集區、虛擬私有雲和服務帳戶:

cd terraform
terraform destroy

出現提示時,請輸入 yes,然後確認沒有遺漏任何內容:

gcloud container clusters list
gcloud compute instances list

這項專案的這兩個欄位都應留空。如果您是為了這個系列課程而建立專案,可以改為從 Cloud 控制台刪除整個專案。

12. 恭喜

您從手寫的注意力運算,改用 GPU 最佳化的融合核心,並在實際硬體上測量差異。

目前所學內容

  • Naive attention 雖然可行,但會啟動多個 GPU 核心,並在記憶體中具體化完整的注意力矩陣
  • jax.nn.dot_product_attention 會將運算融合為單一作業,而 implementation=None JAX 則會自動挑選最佳後端
  • implementation="cudnn" 會強制使用 NVIDIA 的 cuDNN 融合注意力核心,這在長序列上速度最快,且需要 bfloat16 或 float16 輸入內容和運算能力 8.0 以上版本
  • is_causal=True 的因果遮罩內建於融合核心,不需手動建立遮罩矩陣
  • GQA 和 MQA 會減少 KV 頭,在推論期間節省記憶體,而 dot_product_attention 會自動處理廣播
  • TransformerEngine 在 Hopper GPU 上提供融合注意力機制,並可選用 FP8 精確度

後續步驟

  • 程式碼實驗室 6:在多個 GPU 上擴充 JAX 訓練:說明如何在 L4 上分割陣列,並平行執行訓練步驟
  • 變更 HEAD_DIM (嘗試 32、64、128),觀察 cuDNN 接受的值,以及時間如何變化
  • 提高 SEQ_LEN (嘗試 256、512、1024、2048),並觀察記憶體用量和 cuDNN 加速比率
  • 使用 K=2 和 K=1 個 KV 頭對比 8 個查詢頭,重新執行 MHA、GQA 和 MQA 比較,並推論推論作業節省的 KV 快取

參考文件