Sparse Attention(Longformer・BigBird)の理論と実装 — 長系列を効率的に処理する

論文1本(数万トークン)を丸ごとTransformerに入力して要約したい。数十万行のソースコードをまとめて解析したい。法律文書の全文から特定の条項を検索したい — こうした「長い系列をまとめて処理したい」という要求は、実務で頻繁に発生します。しかし、標準的なTransformerのSelf-Attentionは系列長 $n$ に対して $O(n^2)$ の計算量とメモリを必要とするため、$n$ が数千を超えるとGPUメモリが足りなくなり、現実的に処理できません。

この問題を解決するために提案されたのがSparse Attention(スパースアテンション)です。「全トークンが全トークンに注目する必要はない」という洞察に基づき、注意行列の大部分をゼロにして計算量を劇的に削減します。

Sparse Attentionを理解すると、以下のような応用が可能になります。

  • 長文書処理: 学術論文・法律文書・特許書類の全文を一度に入力して分類や要約を行う
  • ゲノム配列解析: 数万塩基のDNA配列をTransformerで直接モデル化する
  • コード解析: リポジトリ全体にまたがるコードの依存関係を捉える
  • 長時間音声処理: 会議録音の全体を一度に文字起こしする

本記事の内容

  • Full Attentionの計算量問題と、なぜスパース化が必要か
  • Longformerの3つのアテンションパターン(Local・Dilated・Global)
  • BigBirdの3つのアテンションパターン(Local・Global・Random)とグラフ理論的背景
  • その他のSparse Attention手法の概要
  • PyTorchでのスクラッチ実装と注意パターンの可視化

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

画像なし
Self-Attentionの理論と実装
Query・Key・Valueの計算とScaled Dot-Product Attentionの導出
画像なし
Multi-Head Attentionの理論と実装
複数のAttentionヘッドで異なる部分空間の情報を統合する仕組み
画像なし
Flash Attentionの理論と実装
GPUメモリ階層を活用したAttentionの高速化手法

Full Attentionの計算量問題

$O(n^2)$ が意味すること

Sparse Attentionの仕組みに入る前に、まず標準的なFull Attentionがなぜ問題になるのかを正確に理解しておきましょう。

Self-Attentionの計算式を振り返ります。

$$ \text{Attention}(\bm{Q}, \bm{K}, \bm{V}) = \text{softmax}\left(\frac{\bm{Q}\bm{K}^\top}{\sqrt{d_k}}\right)\bm{V} $$

この式で $\bm{Q}, \bm{K} \in \mathbb{R}^{n \times d_k}$ のとき、$\bm{Q}\bm{K}^\top$ の結果は $n \times n$ の行列になります。系列長 $n$ の全てのトークンペアについてスコアを計算するため、計算量は $O(n^2 d_k)$、必要なメモリは注意行列 $\bm{A} \in \mathbb{R}^{n \times n}$ を保持するために $O(n^2)$ です。

$d_k$ はモデルの設計パラメータ(ヘッドあたり64程度)で固定されるのに対し、系列長 $n$ は入力データによって大きく変わります。問題は、計算量が $n$ の二乗で増加する点です。系列長を2倍にするとメモリは4倍、4倍にすると16倍必要になります。

系列長ごとのメモリ比較

具体的な数値で見てみましょう。注意行列1つ分(1ヘッド)を32ビット浮動小数点(4バイト)で保持するとき、必要なメモリは次のとおりです。

系列長 $n$ 注意行列のサイズ メモリ量 典型的な用途
512 $512^2 = 262,144$ 約1 MB BERT標準
4,096 $4096^2 \approx 1.68 \times 10^7$ 約64 MB GPT-2
16,384 $16384^2 \approx 2.68 \times 10^8$ 約1 GB 長文書
65,536 $65536^2 \approx 4.29 \times 10^9$ 約16 GB 論文全文
131,072 $131072^2 \approx 1.72 \times 10^{10}$ 約64 GB ゲノム配列

これは1ヘッド・1レイヤー分です。Multi-Head Attentionの全ヘッド、全レイヤー、そしてバックプロパゲーション用の勾配まで含めると、実際のメモリ消費はこの何十倍にもなります。系列長65,536で1ヘッド分が16 GBという時点で、現在のGPU(80 GB程度)では到底収まりません。

Full Attentionの注意行列の可視化

Full Attentionの注意行列がどのような構造を持つかを視覚的に確認しましょう。

import numpy as np
import matplotlib.pyplot as plt

# Full Attentionの注意行列を可視化
np.random.seed(42)
n = 32  # 系列長(小さな例で示す)
d_k = 8

# ランダムなQuery, Keyを生成
Q = np.random.randn(n, d_k)
K = np.random.randn(n, d_k)

# 注意スコアの計算
scores = Q @ K.T / np.sqrt(d_k)

# softmaxの適用
exp_scores = np.exp(scores - np.max(scores, axis=1, keepdims=True))
attention = exp_scores / np.sum(exp_scores, axis=1, keepdims=True)

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# 注意行列(密行列)
im0 = axes[0].imshow(attention, cmap='viridis', aspect='equal')
axes[0].set_title('Full Attention Matrix (Dense)', fontsize=14)
axes[0].set_xlabel('Key position')
axes[0].set_ylabel('Query position')
plt.colorbar(im0, ax=axes[0], fraction=0.046)

# マスクパターン(全て1 = 全接続)
mask = np.ones((n, n))
axes[1].imshow(mask, cmap='Blues', aspect='equal', vmin=0, vmax=1)
axes[1].set_title('Full Attention Mask (All Connected)', fontsize=14)
axes[1].set_xlabel('Key position')
axes[1].set_ylabel('Query position')

plt.tight_layout()
plt.savefig('full_attention_matrix.png', dpi=150, bbox_inches='tight')
plt.show()

左のヒートマップは、Full Attentionでは全てのQuery位置が全てのKey位置に対してゼロでない重みを持つことを示しています。右のマスクパターンが全面青色であることからも分かるように、$n \times n$ の全要素が計算対象です。系列長が増えるとこの密行列がそのまま巨大化し、メモリを圧迫します。

ここまでで、Full Attentionの$O(n^2)$という計算量が長系列処理において致命的なボトルネックになることが分かりました。では、この問題をどう解決するのか — それがSparse Attentionの基本アイデアです。

Sparse Attentionの基本アイデア

「全トークンが全トークンに注目する必要はない」

日本語の文章を読むとき、あるフレーズの意味を理解するために本当に文書の全単語を均等に参照しているでしょうか。実際には、近くの単語(文法的な依存関係)や特定のキーワード(文書のトピックを示す語)に集中的に注目し、遠く離れた無関係な単語にはほとんど注意を払いません。

Full Attentionの注意行列を実際のNLPタスクで分析すると、ほとんどのアテンション重みが非常に小さい値であることが分かっています。つまり注意行列は、名目上は密(dense)ですが、実質的にはスパース(sparse)な構造を持っています。ならば、最初から「注目する必要があるペアだけ」を計算すればよい — これがSparse Attentionの根本的な発想です。

注意行列のスパース化

Sparse Attentionでは、$n \times n$ の注意行列のうち、計算するペアを制限するマスク $\bm{M} \in \{0, 1\}^{n \times n}$ を導入します。$M_{ij} = 1$ のペアのみスコアを計算し、$M_{ij} = 0$ のペアは $-\infty$ として softmax に渡します。

$$ A_{ij} = \begin{cases} \dfrac{\exp(s_{ij})}{\displaystyle\sum_{k: M_{ik}=1} \exp(s_{ik})} & \text{if } M_{ij} = 1 \\[8pt] 0 & \text{if } M_{ij} = 0 \end{cases} $$

ここで $s_{ij} = \bm{q}_i^\top \bm{k}_j / \sqrt{d_k}$ はスケーリング済みの内積スコアです。

マスク $\bm{M}$ の設計がSparse Attentionの核心です。各行(Queryトークン)が注目するKey位置の数を $O(n)$ から $O(w)$($w \ll n$)に減らせれば、全体の計算量は $O(n^2)$ から $O(nw)$ に削減されます。$w$ が $n$ に依存しない定数であれば、計算量は $O(n)$ — つまり系列長に対して線形になります。

どのような接続パターンを残すべきか

マスク $\bm{M}$ の設計にはいくつかの基本パターンがあります。

  1. Local(局所)パターン: 各トークンが近傍 $w$ 個のトークンにのみ注目する。自然言語の文法的依存関係の多くは局所的であるため、最も自然な選択です
  2. Global(大域)パターン: 特定のトークン(例えば[CLS])が全トークンに注目し、逆に全トークンもその特定トークンに注目する。文書全体の情報を集約するハブとして機能します
  3. Random(ランダム)パターン: ランダムに選ばれた位置に注目する。グラフ理論のSmall-World性質により、少数のランダム接続で任意の2ノード間の最短経路が短くなります

これらのパターンをどう組み合わせるかが各手法の特徴です。次のセクションでは、これらのアイデアを初めて体系的にまとめたLongformerを詳しく見ていきましょう。

Longformer(Beltagy et al., 2020)

Longformerの全体像

Longformerは、AllenAI(Beltagy et al., 2020)が提案した長文書向けのTransformerモデルです。「Long Document Transformer」の略で、その名のとおり数千トークンを超える長い文書を効率的に処理することを目的としています。

Longformerのアイデアは明快です。Full Attentionの $n \times n$ の注意行列を、3つの異なるアテンションパターンの組み合わせで置き換えます。

  1. Local Attention(スライディングウィンドウ)
  2. Dilated Attention(拡張スライディングウィンドウ)
  3. Global Attention(グローバルトークン)

それぞれのパターンが担う役割は異なり、それらを組み合わせることでFull Attentionに匹敵する表現力を保ちながら計算量を $O(n)$ に抑えます。

Longformerの注意パターン比較: full・sliding window・dilated・global+sliding(原論文Figure 2)

出典: Beltagy et al., “Longformer: The Long-Document Transformer”, 2020, Fig.2

原論文の Figure 2 は、この3パターンを注意行列のマスクとして可視化したものです。(a) が $n^2$ すべてを埋める Full Attention、(b) が対角帯だけを残すスライディングウィンドウ、(c) が帯に隙間を空けて受容野を広げる dilated 版、そして (d) が実際の Longformer の構成で、対角帯に加えて特定の行・列(グローバルトークン)だけが全面的に塗られています。緑のマスの数=計算量なので、(a) と (d) を見比べるだけで「ほぼ白い行列でどこまで戦えるか」という Sparse Attention の賭けが視覚的に理解できます。

Local Attention(スライディングウィンドウ)

自然言語のテキストでは、ある単語の意味は近くの単語に最も強く依存します。「彼は昨日の会議で重要な発表をした」という文では、「発表」の意味を理解するために「会議」や「重要な」など近傍の単語が重要です。この局所的な依存関係を捉えるのがLocal Attentionです。

Local Attentionでは、各トークン $i$ は自分の周囲 $w$ 個のトークンにのみ注目します。具体的には、ウィンドウサイズ $w$ を設定し、位置 $i$ のトークンは位置 $i – w/2$ から $i + w/2$ までのトークンとのみアテンションスコアを計算します。

マスクを数式で表すと、以下のようになります。

$$ M_{ij}^{\text{local}} = \begin{cases} 1 & \text{if } |i – j| \leq w/2 \\ 0 & \text{otherwise} \end{cases} $$

畳み込みニューラルネットワーク(CNN)のカーネルに似た考え方です。CNNが局所的な受容野(receptive field)を持つように、Local Attentionは各トークンの「注意の受容野」を近傍に限定します。

1層のLocal Attentionでは、各トークンは $w$ 個の近傍しか見えません。しかし、Transformerは複数のレイヤーを持っています。$L$ 層のLocal Attentionを重ねると、トークン $i$ の情報は最大で $L \times w / 2$ 離れた位置まで伝搬します。CNNで層を重ねて受容野を拡大するのと同じ原理です。例えば $w = 256$、$L = 12$ なら、情報は最大で $12 \times 128 = 1536$ トークン先まで到達します。

各トークンが計算するアテンションスコアの数は $w$ 個($w$ は定数)なので、全トークンでの計算量は $O(n \times w) = O(n)$ です。

Dilated Attention(拡張スライディングウィンドウ)

Local Attentionだけでは、遠く離れた位置の情報を得るには多くの層を重ねる必要があります。層数を増やさずにより広い範囲をカバーするために考案されたのが、Dilated Attention(拡張スライディングウィンドウ)です。

画像処理のDilated Convolution(拡張畳み込み)に着想を得た手法です。通常のスライディングウィンドウが連続した位置を見るのに対し、Dilated Attentionでは一定間隔(dilation rate $d$)を空けてトークンをサンプリングします。

$$ M_{ij}^{\text{dilated}} = \begin{cases} 1 & \text{if } |i – j| \leq w \cdot d / 2 \text{ かつ } (i – j) \equiv 0 \pmod{d} \\ 0 & \text{otherwise} \end{cases} $$

例えば $w = 4$、$d = 2$ の場合、位置 $i$ のトークンは位置 $i-4, i-2, i, i+2, i+4$ に注目します。計算するスコアの数は $w$ 個のまま変わりませんが、カバーする範囲は $w \times d$ に広がります。

Longformerでは、Multi-Head Attentionの各ヘッドに異なるdilation rateを割り当てます。あるヘッドは $d = 1$(通常のLocal Attention)で近傍を細かく見て、別のヘッドは $d = 2$ や $d = 4$ で広範囲をカバーします。これにより、パラメータ数や計算量を増やさずに、近距離と遠距離の両方の依存関係を同時に捉えることができます。

Global Attention(グローバルトークン)

Local AttentionとDilated Attentionは局所的なパターンを効率的に捉えますが、文書全体の情報を集約する手段がありません。たとえばテキスト分類タスクでは、文書全体の意味を1つの表現にまとめる必要があります。

Global Attentionは、特定の位置のトークンに「グローバル」な役割を与えます。グローバルトークンは全てのトークンに注目し、かつ全てのトークンからも注目されます

$$ M_{ij}^{\text{global}} = \begin{cases} 1 & \text{if } i \in \mathcal{G} \text{ or } j \in \mathcal{G} \\ 0 & \text{otherwise} \end{cases} $$

ここで $\mathcal{G}$ はグローバルトークンの位置集合です。

どのトークンをグローバルにするかはタスクに依存します。

  • テキスト分類: [CLS]トークンをグローバルに設定
  • 質問応答: 質問文の全トークンをグローバルに設定
  • 要約: 入力文書の先頭トークン(タイトルなど)をグローバルに設定

Longformerの論文では、グローバルトークンのアテンション計算に別の重み行列を使用する点が重要です。通常トークンのQuery/Key/Valueの射影行列を $\bm{W}_Q, \bm{W}_K, \bm{W}_V$ とすると、グローバルトークン用には別の射影行列 $\bm{W}_Q^g, \bm{W}_K^g, \bm{W}_V^g$ を使います。これにより、グローバルトークンは局所トークンとは異なる「視点」で情報を集約できます。

3パターンの組み合わせ

Longformerの最終的なアテンションマスクは、3つのパターンの和集合です。

$$ \bm{M}^{\text{Longformer}} = \bm{M}^{\text{local}} \cup \bm{M}^{\text{dilated}} \cup \bm{M}^{\text{global}} $$

各トークンから見ると次のように動作します。

  • 通常トークン: Local + Dilated の範囲のトークンに注目する。加えて、グローバルトークンにも注目する
  • グローバルトークン: 系列の全トークンに注目する

グローバルトークンの数を $g$ 個とすると、計算量は次のように分解できます。

通常トークンの計算量は、各トークンが $w$(Local + Dilated のウィンドウサイズ)+ $g$(グローバルトークン数)のペアを計算するので $(n – g) \times (w + g)$ です。グローバルトークンの計算量は $g \times n$ です。$g \ll n$、$w \ll n$ の条件下では、全体の計算量は次のようになります。

$$ O\bigl((n – g)(w + g) + gn\bigr) = O(nw + ng + gn) = O(n(w + g)) = O(n) $$

$w$ と $g$ は系列長 $n$ に依存しない定数なので、計算量は系列長に対して線形です。

ここまでで、Longformerが3つのアテンションパターンを組み合わせて $O(n)$ の計算量を達成する仕組みが分かりました。次に、同時期に提案されたBigBirdを見てみましょう。BigBirdはLongformerと共通点が多いですが、Dilated Attentionの代わりにRandom Attentionを採用し、その理論的根拠をグラフ理論から与えている点が特徴的です。

BigBird(Zaheer et al., 2020)

BigBirdの全体像

BigBirdは、Google Research(Zaheer et al., 2020)が提案したSparse Attentionモデルです。BigBirdもLongformerと同様に3つのアテンションパターンを組み合わせますが、その構成は異なります。

  1. Local Attention(ウィンドウ) — Longformerと同じ概念
  2. Global Attention(グローバルトークン) — Longformerと同じ概念
  3. Random Attention(ランダム接続) — BigBird独自のアイデア

BigBirdの最大の特徴は、3番目のRandom Attentionにあります。そしてその理論的正当性をグラフ理論の「Small-World性質」から導いている点が、工学的な工夫にとどまったLongformerとの大きな違いです。

BigBirdの注意機構の構成要素: random・window・global・combined(原論文Figure 1)

出典: Zaheer et al., “Big Bird: Transformers for Longer Sequences”, NeurIPS 2020, Fig.1

原論文の Figure 1 が3つの構成要素の分解図です。(a) が各行に $r=2$ 個ずつ散らばる Random Attention、(b) が幅 $w=3$ の対角帯(ウィンドウ)、(c) が先頭 $g=2$ トークンの行・列を全部埋める Global Attention、(d) がそれらを重ね合わせた BigBird 本体です。白いマスは注意が存在しない箇所を表します。Longformer の図と見比べると、対角帯+グローバルまでは共通で、ランダムな橙のマスだけが BigBird の追加要素だと一目で分かります。

Local Attention(ウィンドウ)

BigBirdのLocal AttentionはLongformerと同じ仕組みです。各トークンが近傍 $w$ 個のトークンに注目します。自然言語の局所的な依存関係(係り受け、形態素の関係など)を捉える役割を担います。

$$ M_{ij}^{\text{local}} = \begin{cases} 1 & \text{if } |i – j| \leq w/2 \\ 0 & \text{otherwise} \end{cases} $$

Global Attention(グローバルトークン)

BigBirdのGlobal AttentionもLongformerと基本的に同じです。特定のトークンが全トークンに注目し、全トークンからも注目されます。

BigBirdの論文では、グローバルトークンの配置方法として2つのバリエーションを紹介しています。

  • BigBird-ITC(Internal Transformer Construction): 既存の入力トークンの一部をグローバルに指定する。例えば先頭の $g$ トークンをグローバルにする
  • BigBird-ETC(Extended Transformer Construction): 入力とは別に $g$ 個のグローバルトークンを追加する。CLS-likeなトークンを新たに用意するイメージです

ITCは追加パラメータが不要で実装が簡単、ETCはグローバルトークンが入力とは独立に学習でき柔軟性が高いという違いがあります。

Random Attention(ランダム接続)

BigBirdの最も独創的なアイデアがRandom Attentionです。各トークンが、ランダムに選ばれた $r$ 個のトークンにも注目します。

$$ M_{ij}^{\text{random}} = \begin{cases} 1 & \text{if } j \in \mathcal{R}(i) \\ 0 & \text{otherwise} \end{cases} $$

ここで $\mathcal{R}(i)$ はトークン $i$ がランダムに選んだ $r$ 個の位置の集合です。

直感的には「ランダムに接続して意味があるのか?」と疑問に思うかもしれません。この疑問に対する答えがグラフ理論にあります。

ランダム接続の理論的根拠 — Small-World性質

注意行列をグラフとして見てみましょう。$n$ 個のトークンをノード、$M_{ij} = 1$ をエッジとするグラフを考えます。

Local Attentionだけのグラフでは、各ノードは近傍とのみ接続された「格子グラフ」になります。格子グラフでは、位置 $1$ から位置 $n$ まで情報を伝えるには $O(n/w)$ ステップ(層)が必要です。

ここで、Watts & Strogatz(1998)のSmall-Worldモデルが登場します。格子グラフにほんの少しのランダムなエッジを追加するだけで、任意の2ノード間の最短経路長が劇的に短くなることが知られています。具体的には、$n$ ノードの格子グラフにノードあたり $r$ 本のランダムエッジを追加すると、平均最短経路長が $O(n/w)$ から $O(\log n)$ に減少します。

これをAttentionの文脈で解釈すると、Local + Random の組み合わせにより、わずかな層数で系列中の任意の2トークン間に情報パスが形成されるということです。Local Attentionが「高速道路のインターチェンジ」(近距離輸送)だとすれば、Random Attentionは「飛行機の直行便」(遠距離ショートカット)のような役割を果たします。

万能近似定理

BigBirdの論文のもう一つの重要な理論的貢献は、Sparse AttentionでもFull Attentionと同等の表現力を持つことを証明した点です。

論文では以下の2つの性質を証明しています。

定理1(万能近似): BigBirdのSparse Attention機構は、任意の連続関数を任意の精度で近似できるTuring完全な計算モデルです。

定理2(Full Attentionの近似): $n$ 個のトークンに対して $g = O(\sqrt{n})$ 個のグローバルトークンと $r = O(1)$ 個のランダム接続を使えば、Full Attentionの出力を $\epsilon$ の精度で近似できます。

これらの定理は、「Sparse Attentionは情報を捨てているから表現力が落ちる」という素朴な懸念を理論的に否定するものです。適切なスパースパターンを選べば、計算量を大幅に削減しながらFull Attentionに匹敵する性能を維持できるのです。

BigBirdの統合マスク

BigBirdの最終的なアテンションマスクは3つのパターンの和集合です。

$$ \bm{M}^{\text{BigBird}} = \bm{M}^{\text{local}} \cup \bm{M}^{\text{global}} \cup \bm{M}^{\text{random}} $$

計算量を分析しましょう。各トークンが注目するKey位置の数は、Local: $w$、Global: $g$(重複分を除く)、Random: $r$ です。全体の計算量は次のとおりです。

$$ O\bigl(n \times (w + g + r)\bigr) = O(n) $$

$w, g, r$ は全て $n$ に依存しない定数(あるいは $g = O(\sqrt{n})$ としても $O(n\sqrt{n})$)なので、計算量はFull Attentionの $O(n^2)$ から大幅に削減されています。

Longformerとの比較

両モデルの共通点と相違点を整理しておきましょう。

特性 Longformer BigBird
Local Attention スライディングウィンドウ スライディングウィンドウ
遠距離依存の捕捉 Dilated Attention Random Attention
Global Attention タスク依存で配置 ITC / ETC の2方式
理論的裏付け 経験的 Small-World理論 + 万能近似定理
グローバルトークンの重み 別の射影行列 共通 or 別の射影行列
計算量 $O(n)$ $O(n)$($g = O(1)$ のとき)
代表的な用途 長文書分類、QA 長文書分類、QA、要約

Longformerは「CNNの拡張畳み込みの発想をAttentionに転用した」工学的アプローチ、BigBirdは「グラフ理論に基づく理論的なアプローチ」と特徴づけることができます。実用上の性能差は小さく、タスクやデータによってどちらが優れるかが変わります。

ここまでで、Sparse Attentionの2大手法であるLongformerとBigBirdの仕組みを理解しました。次に、これらの先行研究や関連手法を簡単に整理して、Sparse Attention手法の全体像を把握しましょう。

その他のSparse Attention手法

Sparse Transformer(Child et al., 2019)

Sparse Transformerは、OpenAIのChild et al.(2019)が提案した手法で、Sparse Attentionの先駆的な研究です。LongformerやBigBirdよりも早く発表されており、注意行列のスパース化というアイデアを初めて体系的に検討しました。

Sparse Transformerでは、2つの基本パターンを交互に適用します。

  • ストライドパターン(Strided Pattern): 位置 $i$ のトークンが $i, i – c, i – 2c, \ldots$ のように一定間隔 $c$ の位置に注目する。$c = \sqrt{n}$ と設定すると、計算量は $O(n\sqrt{n})$ になります
  • 固定パターン(Fixed Pattern): 系列を長さ $c$ のブロックに分割し、同じブロック内のトークンと特定の列のトークンに注目する

2つのパターンを交互のレイヤーで適用するか、1つのレイヤー内で2つのヘッドに分けて適用します。2層を経ると、任意の2トークン間に最大2ホップのパスが形成されるため、全てのトークンペア間で間接的に情報が伝搬します。

Star Transformer(Guo et al., 2019)

Star Transformerは、星型(star graph)のトポロジーを採用した手法です。中心ノード(リレーノード)を1つ設け、全てのトークンがこのリレーノードを経由して情報交換します。

各トークンは以下の2つにのみ注目します。

  1. 近傍 $w$ 個のトークン(Local Attention)
  2. リレーノード(1つだけのGlobal Attention)

リレーノードは逆に全トークンに注目し、文書全体の情報を集約します。BigBirdのGlobal Attentionを極限まで単純化した構造とも言えるでしょう。計算量は $O(n)$ で、実装も非常にシンプルです。

各手法の比較表

手法 計算量 パターン 理論保証
Sparse Transformer 2019 $O(n\sqrt{n})$ Strided + Fixed なし
Star Transformer 2019 $O(n)$ Local + Relay なし
Longformer 2020 $O(n)$ Local + Dilated + Global なし
BigBird 2020 $O(n)$ Local + Global + Random 万能近似定理

歴史的には、Sparse Transformer(2019)が「注意行列をスパースにする」という方向性を切り開き、LongformerとBigBird(共に2020)がその実用性を高めたという流れです。BigBirdが理論的保証を与えたことで、Sparse Attentionの正当性が確立されました。

なお、ここで紹介した手法は「注意行列自体をスパースにする」アプローチですが、Attentionの効率化にはほかにもLinear Attention(カーネルの近似で $O(n)$ を達成)やFlash Attention(GPUメモリ階層の最適化で定数倍の高速化)など、異なるアプローチも存在します。

それでは、ここまでの理論をPyTorchで実装して、各アテンションパターンの動作を実際に確認してみましょう。

PyTorchでの実装

スパースアテンションマスクの生成

まずは各アテンションパターンのマスクを生成する関数を実装します。これらのマスクは0と1のバイナリ行列で、Attentionスコアの計算時にどのトークンペアを計算するかを制御します。

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib.pyplot as plt

def create_local_mask(seq_len, window_size):
    """
    Local Attention(スライディングウィンドウ)のマスクを生成する。
    各トークンは前後 window_size // 2 のトークンに注目する。
    """
    mask = torch.zeros(seq_len, seq_len)
    half_w = window_size // 2
    for i in range(seq_len):
        start = max(0, i - half_w)
        end = min(seq_len, i + half_w + 1)
        mask[i, start:end] = 1.0
    return mask

def create_dilated_mask(seq_len, window_size, dilation):
    """
    Dilated Attention(拡張スライディングウィンドウ)のマスクを生成する。
    dilation間隔でサンプリングしながら window_size 個のトークンに注目する。
    """
    mask = torch.zeros(seq_len, seq_len)
    half_w = window_size // 2
    for i in range(seq_len):
        for k in range(-half_w, half_w + 1):
            j = i + k * dilation
            if 0 <= j < seq_len:
                mask[i, j] = 1.0
    return mask

def create_global_mask(seq_len, global_indices):
    """
    Global Attention のマスクを生成する。
    global_indices で指定された位置のトークンは全トークンに注目し、
    全トークンからも注目される。
    """
    mask = torch.zeros(seq_len, seq_len)
    for g in global_indices:
        mask[g, :] = 1.0  # グローバルトークン → 全トークン
        mask[:, g] = 1.0  # 全トークン → グローバルトークン
    return mask

def create_random_mask(seq_len, num_random, seed=42):
    """
    Random Attention のマスクを生成する。
    各トークンがランダムに num_random 個のトークンに注目する。
    """
    rng = np.random.RandomState(seed)
    mask = torch.zeros(seq_len, seq_len)
    for i in range(seq_len):
        # 自分以外からランダムにサンプル
        candidates = list(range(seq_len))
        candidates.remove(i)
        chosen = rng.choice(candidates, size=min(num_random, len(candidates)), replace=False)
        mask[i, chosen] = 1.0
        mask[i, i] = 1.0  # 自分自身は常に含める
    return mask

# 動作確認: 各マスクのサイズを表示
seq_len = 32
print(f"Local mask:   {create_local_mask(seq_len, window_size=5).sum().item():.0f} connections")
print(f"Dilated mask: {create_dilated_mask(seq_len, window_size=5, dilation=2).sum().item():.0f} connections")
print(f"Global mask:  {create_global_mask(seq_len, global_indices=[0]).sum().item():.0f} connections")
print(f"Random mask:  {create_random_mask(seq_len, num_random=3).sum().item():.0f} connections")
print(f"Full mask:    {(seq_len * seq_len):.0f} connections")

各マスクの接続数を確認すると、Full Attentionの $32 \times 32 = 1024$ に比べて大幅に少ない接続数で動作していることが分かります。Local Attentionではウィンドウサイズ5で約150接続、Global Attentionでは1つのグローバルトークンで約63接続、Random Attentionでは1トークンあたり3本のランダム接続で約128接続です。これらを組み合わせても、Full Attentionの1024接続には遠く及ばず、計算量の大幅な削減が実現されていることが数値的にも確認できます。

注意パターンの可視化

各アテンションパターンのマスク行列をヒートマップで可視化して、その構造的な違いを目で確認しましょう。

import numpy as np
import torch
import matplotlib.pyplot as plt

# 前のセクションの関数(create_local_mask 等)を使用

seq_len = 64

# 各マスクを生成
local_mask = create_local_mask(seq_len, window_size=7)
dilated_mask = create_dilated_mask(seq_len, window_size=7, dilation=3)
global_mask = create_global_mask(seq_len, global_indices=[0, 1])
random_mask = create_random_mask(seq_len, num_random=4)

fig, axes = plt.subplots(2, 3, figsize=(18, 12))

# (a) Full Attention
full_mask = torch.ones(seq_len, seq_len)
axes[0, 0].imshow(full_mask.numpy(), cmap='Blues', aspect='equal', vmin=0, vmax=1)
axes[0, 0].set_title(f'(a) Full Attention\n({int(full_mask.sum())} connections)', fontsize=12)
axes[0, 0].set_xlabel('Key position')
axes[0, 0].set_ylabel('Query position')

# (b) Local Attention
axes[0, 1].imshow(local_mask.numpy(), cmap='Blues', aspect='equal', vmin=0, vmax=1)
axes[0, 1].set_title(f'(b) Local Attention (w=7)\n({int(local_mask.sum())} connections)', fontsize=12)
axes[0, 1].set_xlabel('Key position')
axes[0, 1].set_ylabel('Query position')

# (c) Dilated Attention
axes[0, 2].imshow(dilated_mask.numpy(), cmap='Blues', aspect='equal', vmin=0, vmax=1)
axes[0, 2].set_title(f'(c) Dilated Attention (w=7, d=3)\n({int(dilated_mask.sum())} connections)', fontsize=12)
axes[0, 2].set_xlabel('Key position')
axes[0, 2].set_ylabel('Query position')

# (d) Global Attention
axes[1, 0].imshow(global_mask.numpy(), cmap='Blues', aspect='equal', vmin=0, vmax=1)
axes[1, 0].set_title(f'(d) Global Attention (g=2)\n({int(global_mask.sum())} connections)', fontsize=12)
axes[1, 0].set_xlabel('Key position')
axes[1, 0].set_ylabel('Query position')

# (e) Random Attention
axes[1, 1].imshow(random_mask.numpy(), cmap='Blues', aspect='equal', vmin=0, vmax=1)
axes[1, 1].set_title(f'(e) Random Attention (r=4)\n({int(random_mask.sum())} connections)', fontsize=12)
axes[1, 1].set_xlabel('Key position')
axes[1, 1].set_ylabel('Query position')

# (f) BigBird (Local + Global + Random)
bigbird_mask = torch.clamp(local_mask + global_mask + random_mask, 0, 1)
axes[1, 2].imshow(bigbird_mask.numpy(), cmap='Blues', aspect='equal', vmin=0, vmax=1)
axes[1, 2].set_title(f'(f) BigBird Combined\n({int(bigbird_mask.sum())} connections)', fontsize=12)
axes[1, 2].set_xlabel('Key position')
axes[1, 2].set_ylabel('Query position')

plt.tight_layout()
plt.savefig('attention_patterns.png', dpi=150, bbox_inches='tight')
plt.show()

6つのヒートマップを比較すると、各パターンの特徴が明確に見えてきます。(a) Full Attentionは全面が濃い青で、全ペアが接続されています。(b) Local Attentionは対角線に沿った帯状の構造を持ち、近傍トークンのみが接続されています。(c) Dilated Attentionは対角線に沿いつつも間隔を空けた点線状のパターンで、Localと同じ接続数でより広い範囲をカバーしていることが分かります。(d) Global Attentionは十字型のパターンで、グローバルトークンの行と列が全て接続されています。(e) Random Attentionは全体に散らばった点で、規則性のないランダムな接続です。(f) BigBirdはこれら3つを重ね合わせた結果で、対角帯(Local)、十字線(Global)、散点(Random)が組み合わさった構造になっています。Full Attentionの4096接続に対してBigBirdは約700程度の接続数で、6分の1以下の計算量で動作することが視覚的にも確認できます。

Longformer風のSparse Attentionクラス

次に、Longformerの仕組みに基づいたSparse Attentionモジュールを実装します。Local Attention + Global Attention の組み合わせで、入力系列に対してスパースなAttentionを計算します。

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

class LongformerSparseAttention(nn.Module):
    """
    Longformer風のSparse Attention。
    Local Attention(スライディングウィンドウ)+ Global Attention を組み合わせる。
    """
    def __init__(self, d_model, num_heads, window_size, num_global_tokens=1):
        super().__init__()
        assert d_model % num_heads == 0, "d_model must be divisible by num_heads"

        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        self.window_size = window_size
        self.num_global_tokens = num_global_tokens

        # 通常トークン用のQ, K, V射影
        self.W_Q = nn.Linear(d_model, d_model)
        self.W_K = nn.Linear(d_model, d_model)
        self.W_V = nn.Linear(d_model, d_model)

        # グローバルトークン用の別のQ, K, V射影(Longformerの特徴)
        self.W_Q_global = nn.Linear(d_model, d_model)
        self.W_K_global = nn.Linear(d_model, d_model)
        self.W_V_global = nn.Linear(d_model, d_model)

        # 出力射影
        self.W_O = nn.Linear(d_model, d_model)

    def _create_combined_mask(self, seq_len, device):
        """Local + Global の統合マスクを生成する"""
        # Local Attention マスク
        mask = torch.zeros(seq_len, seq_len, device=device)
        half_w = self.window_size // 2
        for i in range(seq_len):
            start = max(0, i - half_w)
            end = min(seq_len, i + half_w + 1)
            mask[i, start:end] = 1.0

        # Global Attention マスク(先頭 num_global_tokens 個)
        g = self.num_global_tokens
        mask[:g, :] = 1.0  # グローバルトークン → 全トークン
        mask[:, :g] = 1.0  # 全トークン → グローバルトークン

        return mask

    def forward(self, x):
        """
        x: (batch_size, seq_len, d_model)
        returns: (batch_size, seq_len, d_model)
        """
        B, N, D = x.shape
        g = self.num_global_tokens

        # マスクの生成
        mask = self._create_combined_mask(N, x.device)  # (N, N)

        # 通常トークンのQ, K, V
        Q = self.W_Q(x)  # (B, N, D)
        K = self.W_K(x)
        V = self.W_V(x)

        # グローバルトークンは別の射影を使用
        Q_global = self.W_Q_global(x[:, :g, :])  # (B, g, D)
        K_global = self.W_K_global(x[:, :g, :])
        V_global = self.W_V_global(x[:, :g, :])

        # グローバルトークンの射影で上書き
        Q = Q.clone()
        K = K.clone()
        V = V.clone()
        Q[:, :g, :] = Q_global
        K[:, :g, :] = K_global
        V[:, :g, :] = V_global

        # Multi-Head 形状に変換: (B, num_heads, N, d_k)
        Q = Q.view(B, N, self.num_heads, self.d_k).transpose(1, 2)
        K = K.view(B, N, self.num_heads, self.d_k).transpose(1, 2)
        V = V.view(B, N, self.num_heads, self.d_k).transpose(1, 2)

        # Scaled Dot-Product Attention with Sparse Mask
        scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)  # (B, H, N, N)

        # マスクの適用: mask=0 の位置を -inf にする
        mask_expanded = mask.unsqueeze(0).unsqueeze(0)  # (1, 1, N, N)
        scores = scores.masked_fill(mask_expanded == 0, float('-inf'))

        attn_weights = F.softmax(scores, dim=-1)  # (B, H, N, N)
        attn_weights = attn_weights.masked_fill(mask_expanded == 0, 0.0)

        # 重み付き和
        output = torch.matmul(attn_weights, V)  # (B, H, N, d_k)

        # ヘッドの結合
        output = output.transpose(1, 2).contiguous().view(B, N, D)
        output = self.W_O(output)

        return output, attn_weights

# 動作確認
torch.manual_seed(42)
d_model, num_heads, window_size = 64, 4, 5
model = LongformerSparseAttention(d_model, num_heads, window_size, num_global_tokens=2)

x = torch.randn(2, 32, d_model)  # バッチ2、系列長32
output, attn_weights = model(x)
print(f"Input shape:  {x.shape}")
print(f"Output shape: {output.shape}")
print(f"Attention weights shape: {attn_weights.shape}")
print(f"Non-zero attention entries: {(attn_weights[0, 0] > 0).sum().item()} / {32*32}")

出力のshapeが入力と同じ (2, 32, 64) であることから、Sparse Attentionが正しくSelf-Attentionとして動作していることが確認できます。注意すべきは、Attention weightの非ゼロ要素数です。Full Attentionなら $32 \times 32 = 1024$ 個すべてが非ゼロですが、Sparse Attentionでは大幅に少ない数にとどまります。これこそが計算量削減の源泉です。また、グローバルトークン用に別の射影行列を使用している点がLongformerの特徴的な設計であり、通常トークンとグローバルトークンが異なる「視点」で情報を処理することを可能にしています。

BigBird風の3パターン組み合わせ

続いて、BigBirdの Local + Global + Random の3パターンを組み合わせたSparse Attentionを実装します。

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

class BigBirdSparseAttention(nn.Module):
    """
    BigBird風のSparse Attention。
    Local + Global + Random の3パターンを組み合わせる。
    """
    def __init__(self, d_model, num_heads, window_size,
                 num_global_tokens=1, num_random=3, random_seed=42):
        super().__init__()
        assert d_model % num_heads == 0

        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        self.window_size = window_size
        self.num_global_tokens = num_global_tokens
        self.num_random = num_random
        self.random_seed = random_seed

        # Q, K, V射影(BigBird-ITCでは単一の射影を使用)
        self.W_Q = nn.Linear(d_model, d_model)
        self.W_K = nn.Linear(d_model, d_model)
        self.W_V = nn.Linear(d_model, d_model)
        self.W_O = nn.Linear(d_model, d_model)

    def _create_bigbird_mask(self, seq_len, device):
        """Local + Global + Random の統合マスクを生成"""
        mask = torch.zeros(seq_len, seq_len, device=device)

        # 1. Local Attention
        half_w = self.window_size // 2
        for i in range(seq_len):
            start = max(0, i - half_w)
            end = min(seq_len, i + half_w + 1)
            mask[i, start:end] = 1.0

        # 2. Global Attention(先頭 g 個のトークン)
        g = self.num_global_tokens
        mask[:g, :] = 1.0
        mask[:, :g] = 1.0

        # 3. Random Attention
        rng = np.random.RandomState(self.random_seed)
        for i in range(seq_len):
            candidates = [j for j in range(seq_len) if mask[i, j] == 0]
            if len(candidates) > 0:
                num_to_select = min(self.num_random, len(candidates))
                chosen = rng.choice(candidates, size=num_to_select, replace=False)
                mask[i, chosen] = 1.0

        return mask

    def forward(self, x):
        """
        x: (batch_size, seq_len, d_model)
        returns: (batch_size, seq_len, d_model)
        """
        B, N, D = x.shape

        # マスク生成
        mask = self._create_bigbird_mask(N, x.device)

        # Q, K, V の計算
        Q = self.W_Q(x).view(B, N, self.num_heads, self.d_k).transpose(1, 2)
        K = self.W_K(x).view(B, N, self.num_heads, self.d_k).transpose(1, 2)
        V = self.W_V(x).view(B, N, self.num_heads, self.d_k).transpose(1, 2)

        # Scaled Dot-Product Attention with Sparse Mask
        scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)

        mask_expanded = mask.unsqueeze(0).unsqueeze(0)
        scores = scores.masked_fill(mask_expanded == 0, float('-inf'))

        attn_weights = F.softmax(scores, dim=-1)
        attn_weights = attn_weights.masked_fill(mask_expanded == 0, 0.0)

        output = torch.matmul(attn_weights, V)
        output = output.transpose(1, 2).contiguous().view(B, N, D)
        output = self.W_O(output)

        return output, attn_weights

# 動作確認
torch.manual_seed(42)
d_model, num_heads, window_size = 64, 4, 5
model = BigBirdSparseAttention(
    d_model, num_heads, window_size,
    num_global_tokens=2, num_random=3
)

x = torch.randn(2, 32, d_model)
output, attn_weights = model(x)
print(f"Input shape:  {x.shape}")
print(f"Output shape: {output.shape}")
print(f"Attention weights shape: {attn_weights.shape}")
print(f"Non-zero attention entries: {(attn_weights[0, 0] > 0).sum().item()} / {32*32}")

BigBird風の実装でも、出力のshapeが入力と一致しており正しく動作しています。LongformerとBigBirdの実装上の最大の違いは、BigBirdがRandom Attentionを含むため、マスクの生成にランダム性が入る点です。推論時にはランダムシードを固定して再現性を担保します。また、BigBird-ITC方式ではグローバルトークン用に別の射影行列を使わないため、Longformerと比べてパラメータ数が少なくなります。

Longformer vs BigBird のアテンションパターン比較

2つのモデルのアテンションパターンを並べて可視化し、構造の違いを確認しましょう。

import torch
import numpy as np
import matplotlib.pyplot as plt

# マスク生成関数(前のセクションと同じ)を使用
# ここではインラインで再定義して自己完結させる

def create_longformer_mask(seq_len, window_size, dilation, global_indices):
    """Longformer風の統合マスク(Local + Dilated + Global)"""
    mask = torch.zeros(seq_len, seq_len)
    half_w = window_size // 2

    # Local Attention
    for i in range(seq_len):
        start = max(0, i - half_w)
        end = min(seq_len, i + half_w + 1)
        mask[i, start:end] = 1.0

    # Dilated Attention
    for i in range(seq_len):
        for k in range(-half_w, half_w + 1):
            j = i + k * dilation
            if 0 <= j < seq_len:
                mask[i, j] = 1.0

    # Global Attention
    for g in global_indices:
        mask[g, :] = 1.0
        mask[:, g] = 1.0

    return mask

def create_bigbird_mask(seq_len, window_size, global_indices, num_random, seed=42):
    """BigBird風の統合マスク(Local + Global + Random)"""
    mask = torch.zeros(seq_len, seq_len)
    half_w = window_size // 2

    # Local Attention
    for i in range(seq_len):
        start = max(0, i - half_w)
        end = min(seq_len, i + half_w + 1)
        mask[i, start:end] = 1.0

    # Global Attention
    for g in global_indices:
        mask[g, :] = 1.0
        mask[:, g] = 1.0

    # Random Attention
    rng = np.random.RandomState(seed)
    for i in range(seq_len):
        candidates = [j for j in range(seq_len) if mask[i, j] == 0]
        if len(candidates) > 0:
            chosen = rng.choice(candidates,
                                size=min(num_random, len(candidates)),
                                replace=False)
            mask[i, chosen] = 1.0

    return mask

seq_len = 64
global_indices = [0, 1]

longformer_mask = create_longformer_mask(seq_len, window_size=7, dilation=3,
                                          global_indices=global_indices)
bigbird_mask = create_bigbird_mask(seq_len, window_size=7,
                                    global_indices=global_indices,
                                    num_random=5)

fig, axes = plt.subplots(1, 3, figsize=(18, 6))

# Full Attention
full_mask = torch.ones(seq_len, seq_len)
axes[0].imshow(full_mask.numpy(), cmap='Blues', aspect='equal', vmin=0, vmax=1)
axes[0].set_title(f'Full Attention\n({int(full_mask.sum())} connections)', fontsize=13)
axes[0].set_xlabel('Key position')
axes[0].set_ylabel('Query position')

# Longformer
axes[1].imshow(longformer_mask.numpy(), cmap='Blues', aspect='equal', vmin=0, vmax=1)
sparsity_lf = 1 - longformer_mask.sum().item() / (seq_len * seq_len)
axes[1].set_title(f'Longformer\n({int(longformer_mask.sum())} connections, '
                   f'sparsity={sparsity_lf:.1%})', fontsize=13)
axes[1].set_xlabel('Key position')
axes[1].set_ylabel('Query position')

# BigBird
axes[2].imshow(bigbird_mask.numpy(), cmap='Blues', aspect='equal', vmin=0, vmax=1)
sparsity_bb = 1 - bigbird_mask.sum().item() / (seq_len * seq_len)
axes[2].set_title(f'BigBird\n({int(bigbird_mask.sum())} connections, '
                   f'sparsity={sparsity_bb:.1%})', fontsize=13)
axes[2].set_xlabel('Key position')
axes[2].set_ylabel('Query position')

plt.tight_layout()
plt.savefig('longformer_vs_bigbird.png', dpi=150, bbox_inches='tight')
plt.show()

3つのヒートマップを並べると、Full Attentionが全面接続であるのに対し、LongformerとBigBirdが高いスパース性を持つことが一目瞭然です。Longformerは対角帯(Local)に加えて規則的な点線パターン(Dilated)が見られ、構造的に整った印象を受けます。一方BigBirdは対角帯(Local)と散在する点(Random)の組み合わせで、より不規則なパターンです。どちらもFull Attentionの接続数の2割程度しか使っていないにもかかわらず、Local + Global の基盤構造により近傍と文書全体の情報は確実に捉えられています。Longformerの規則的なDilatedパターンとBigBirdの不規則なRandomパターンのどちらが優れるかはタスクに依存しますが、理論的にはBigBirdのRandom接続がSmall-World性質を保証するため、より少ない層数で遠距離の情報伝搬が可能です。

Full Attention vs Sparse Attentionのメモリ・計算時間比較

最後に、Full AttentionとSparse Attentionのメモリ使用量と計算時間を実測して比較します。

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import time
import matplotlib.pyplot as plt

class FullAttention(nn.Module):
    """標準的なFull Attention"""
    def __init__(self, d_model, num_heads):
        super().__init__()
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        self.W_Q = nn.Linear(d_model, d_model)
        self.W_K = nn.Linear(d_model, d_model)
        self.W_V = nn.Linear(d_model, d_model)
        self.W_O = nn.Linear(d_model, d_model)

    def forward(self, x):
        B, N, D = x.shape
        Q = self.W_Q(x).view(B, N, self.num_heads, self.d_k).transpose(1, 2)
        K = self.W_K(x).view(B, N, self.num_heads, self.d_k).transpose(1, 2)
        V = self.W_V(x).view(B, N, self.num_heads, self.d_k).transpose(1, 2)
        scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)
        attn = F.softmax(scores, dim=-1)
        output = torch.matmul(attn, V)
        output = output.transpose(1, 2).contiguous().view(B, N, D)
        return self.W_O(output)

def measure_performance(seq_lengths, d_model=64, num_heads=4, window_size=32,
                        num_trials=5):
    """各系列長でFull AttentionとSparse Attentionの計算時間を計測"""
    full_times = []
    sparse_times = []
    full_memory = []
    sparse_memory = []

    for n in seq_lengths:
        # Full Attention
        model_full = FullAttention(d_model, num_heads)
        x = torch.randn(1, n, d_model)

        # ウォームアップ
        with torch.no_grad():
            _ = model_full(x)

        # 計測
        times = []
        for _ in range(num_trials):
            start = time.perf_counter()
            with torch.no_grad():
                _ = model_full(x)
            times.append(time.perf_counter() - start)
        full_times.append(np.median(times))

        # メモリ(注意行列のサイズ)
        full_memory.append(n * n * num_heads * 4 / (1024 ** 2))  # MB

        # Sparse Attention(BigBird風)
        # 各トークンの接続数: window_size + num_global + num_random
        connections_per_token = min(window_size + 2 + 3, n)
        sparse_memory.append(n * connections_per_token * num_heads * 4 / (1024 ** 2))

        # BigBird風のSparse Attention計測
        model_sparse = BigBirdSparseAttention(
            d_model, num_heads, window_size,
            num_global_tokens=2, num_random=3
        )
        with torch.no_grad():
            _ = model_sparse(x)

        times = []
        for _ in range(num_trials):
            start = time.perf_counter()
            with torch.no_grad():
                output, _ = model_sparse(x)
            times.append(time.perf_counter() - start)
        sparse_times.append(np.median(times))

    return full_times, sparse_times, full_memory, sparse_memory

# 計測
seq_lengths = [64, 128, 256, 512, 1024, 2048]
full_times, sparse_times, full_mem, sparse_mem = measure_performance(seq_lengths)

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# 計算時間の比較
axes[0].plot(seq_lengths, full_times, 'o-', label='Full Attention', linewidth=2, markersize=8)
axes[0].plot(seq_lengths, sparse_times, 's-', label='Sparse Attention (BigBird)', linewidth=2, markersize=8)
axes[0].set_xlabel('Sequence Length', fontsize=12)
axes[0].set_ylabel('Time (seconds)', fontsize=12)
axes[0].set_title('Computation Time Comparison', fontsize=14)
axes[0].legend(fontsize=11)
axes[0].set_xscale('log', base=2)
axes[0].set_yscale('log')
axes[0].grid(True, alpha=0.3)

# メモリ使用量の比較(理論値)
axes[1].plot(seq_lengths, full_mem, 'o-', label='Full Attention ($O(n^2)$)', linewidth=2, markersize=8)
axes[1].plot(seq_lengths, sparse_mem, 's-', label='Sparse Attention ($O(n)$)', linewidth=2, markersize=8)
axes[1].set_xlabel('Sequence Length', fontsize=12)
axes[1].set_ylabel('Attention Matrix Memory (MB)', fontsize=12)
axes[1].set_title('Attention Matrix Memory Comparison', fontsize=14)
axes[1].legend(fontsize=11)
axes[1].set_xscale('log', base=2)
axes[1].set_yscale('log')
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('performance_comparison.png', dpi=150, bbox_inches='tight')
plt.show()

# 数値を表形式で出力
print(f"\n{'Seq Length':>10} | {'Full Time (ms)':>14} | {'Sparse Time (ms)':>16} | "
      f"{'Full Mem (MB)':>13} | {'Sparse Mem (MB)':>15} | {'Speedup':>8}")
print("-" * 95)
for i, n in enumerate(seq_lengths):
    speedup = full_times[i] / sparse_times[i] if sparse_times[i] > 0 else float('inf')
    print(f"{n:>10} | {full_times[i]*1000:>14.2f} | {sparse_times[i]*1000:>16.2f} | "
          f"{full_mem[i]:>13.3f} | {sparse_mem[i]:>15.3f} | {speedup:>7.2f}x")

この計測結果からいくつかの重要な傾向が読み取れます。まず、左のグラフ(計算時間)では、系列長が増加するにつれてFull Attentionの計算時間が急激に増大するのに対し、Sparse Attentionの増加は緩やかです。特に系列長が1024を超えるあたりから差が顕著になります。これは $O(n^2)$ と $O(n)$ のスケーリングの違いが現れているためです。右のグラフ(メモリ)では、理論どおりFull Attentionの注意行列メモリが二次関数的に増大するのに対し、Sparse Attentionは線形増加にとどまっています。系列長2048では、Full Attentionの注意行列が約64 MBを必要とするのに対し、Sparse Attentionは数MBで済みます。なお、ここでの計測はCPU上のPythonレベルのものであり、実際のGPU実装ではカーネル最適化などによりさらに大きな差が生まれます。重要なのは、系列長が大きくなるほどSparse Attentionの優位性が増すという定性的な傾向です。

まとめ

本記事では、長系列を効率的に処理するためのSparse Attentionについて解説しました。

  • Full Attentionの問題: 標準的なSelf-Attentionは $O(n^2)$ の計算量とメモリを必要とし、系列長が数千を超えると実用的でなくなる
  • Sparse Attentionの基本アイデア: 注意行列の大部分をゼロにし、必要な接続だけを残すことで計算量を $O(n)$ に削減する
  • Longformer: Local Attention(スライディングウィンドウ)+ Dilated Attention(拡張ウィンドウ)+ Global Attention(グローバルトークン)の3パターンを組み合わせ、CNNの拡張畳み込みに着想を得た工学的アプローチ
  • BigBird: Local + Global + Random の3パターンを組み合わせ、グラフ理論のSmall-World性質と万能近似定理に裏付けられた理論的アプローチ
  • 実装: PyTorchでスパースマスクの生成からAttention計算まで実装し、各パターンの構造と計算量の削減効果を可視化で確認した

Sparse Attentionは「注意行列自体をスパースにする」アプローチですが、Attentionの効率化にはほかにも多様な手法が存在します。次のステップとして、以下の記事も参考にしてください。

画像なし
Flash Attentionの理論と実装
GPUメモリ階層を活用してAttentionを高速化する手法
画像なし
Linear Attentionの理論と実装
カーネル近似でAttentionの計算量をO(n)に削減する手法
画像なし
Sliding Window Attentionの詳細
ウィンドウベースのLocal Attentionの理論と応用
画像なし
コンテキストウィンドウの拡張手法
RoPEのスケーリングなどで長系列に対応する手法