Read OSS

GGML: llama.cpp を支えるテンソルエンジン

上級

前提知識

  • 記事1〜2
  • GPUコンピューティングの基礎知識(ホストとデバイスのメモリ、コンピュートカーネル)

GGML: llama.cpp を支えるテンソルエンジン

llama.cpp で行われるすべての行列積、すべてのアテンション計算、すべての量子化済み重みのルックアップは、最終的に GGML を経由します。GGML は ggml/ ディレクトリに収められたスタンドアロンの C テンソルライブラリです。GGML は単なる「数学バックエンド」ではありません。ポータブルで量子化に対応した推論を実現する、実行モデル・メモリレイアウト・データ型・ハードウェア抽象化を定義するライブラリです。

この記事では、GGML の内部構造を徹底的に解説します。遅延評価パターン、コアデータ構造、プラグイン可能なハードウェアサポートを実現する3層のバックエンド vtable システム、コンパイル時のバックエンド登録、デバイス間で計算を分割するスケジューラ、量子化タイプ、そして GGUF ファイルフォーマットについて順に見ていきます。

遅延評価とコア抽象化

GGML は2フェーズの実行モデルを採用しています。TensorFlow 1.x や JAX のトレーシングを使ったことがあれば馴染み深いパターンです。まず計算グラフを構築し、それから実行します。ggml_mul_mat() のようなテンソル演算は実際には計算しません。何を計算するかを記述するグラフノードを作るだけです。実際に数値計算が走るのは ggml_graph_compute() が呼ばれたときだけです。

ggml.h のヘッダコメント には具体的な例が示されています。テンソル演算を呼び出して f(x) = a*x² + b を定義し、入力値をセットして ggml_graph_compute を呼ぶことで計算する流れです。

このモデルを支えるコア型は3つあります。

ggml_tensor658行目で定義)は基本的な構成単位です。以下のフィールドを持ちます。

  • type — データ型(F32、F16、Q4_K など)
  • ne[4] — 各次元の要素数(最大4次元)
  • nb[4] — 各次元のバイト単位のストライド
  • op — このテンソルを生成した演算
  • src[GGML_MAX_SRC] — ソーステンソルへのポインタ(グラフのエッジ)
  • data — 実際のデータへのポインタ(CPU またはGPUメモリ上)
  • buffer — データを所有するバックエンドバッファ

ggml_context はテンソルのアロケーションに使うメモリアリーナです。事前に確保したバッファからバンプポインタ方式でアロケーションを行うため、テンソルごとの malloc オーバーヘッドが発生しません。計算グラフを構築する際、中間テンソルのディスクリプタ(実データではなく)はすべてこの context から確保されます。

ggml_cgraph は計算 DAG です。演算を持つノード(テンソル)と、リーフテンソル(入力)のリストで構成されます。ggml_build_forward_expand(gf, tensor) はテンソルのソースリンクをたどって依存するすべての演算を見つけ出し、グラフに追加します。

flowchart LR
    subgraph "Build Phase"
        A["ggml_new_tensor(ctx)"] --> B["ggml_mul_mat(ctx, W, x)"]
        B --> C["ggml_add(ctx, result, bias)"]
        C --> D["ggml_build_forward_expand(gf, output)"]
    end
    subgraph "Execute Phase"
        D --> E["ggml_backend_sched_alloc_graph(sched, gf)"]
        E --> F["set tensor data"]
        F --> G["ggml_backend_sched_graph_compute(sched, gf)"]
    end

ヒント: ggml_tensorsrc[] 配列が、グラフを暗黙的にエンコードする仕組みの核心です。各テンソルは自分の入力を知っています。ggml_build_forward_expand はこのツリーを再帰的にたどって完全な DAG を発見するだけです。独立した「グラフビルダー」API は存在しません。グラフそのものがテンソルの接続関係なのです。

バックエンド Vtable システム

GGML は CPU、NVIDIA GPU(CUDA)、Apple GPU(Metal)、AMD GPU(Vulkan、ROCm/HIP)、Intel GPU(SYCL)、Huawei NPU(CANN)など多様なハードウェアに対応しています。これを実現しているのが、ggml/src/ggml-backend-impl.h で定義された、3層の抽象化を持つ古典的な C の vtable パターンです。

レベル1: ggml_backend_buffer_type_i — メモリアロケーション戦略。各バッファタイプは、特定のデバイスへのバッファのアロケーション方法、必要なアライメント、そのメモリがホストからアクセス可能かどうかを知っています。「この GPU に 2GB アロケートできるか?」を問い合わせる層です。

レベル2: ggml_backend_buffer_i — アロケート済みバッファ上のデータ転送操作。set_tensor()get_tensor()memset_tensor()cpy_tensor() を提供し、ホストとデバイスのメモリ間でデータを移動します。

レベル3: ggml_backend_i — コンピュートインターフェース。graph_compute()(計算グラフの実行)、非同期テンソル操作、同期機能を提供します。

classDiagram
    class ggml_backend_buffer_type_i {
        +get_name()
        +alloc_buffer(size)
        +get_alignment()
        +get_max_size()
        +is_host()
    }
    class ggml_backend_buffer_i {
        +free_buffer()
        +get_base()
        +set_tensor(tensor, data, offset, size)
        +get_tensor(tensor, data, offset, size)
        +clear(value)
    }
    class ggml_backend_i {
        +get_name()
        +free()
        +graph_compute(graph)
        +synchronize()
    }
    class ggml_backend_buffer_type {
        iface: buffer_type_i
        device: backend_dev_t
        context: void*
    }
    class ggml_backend_buffer {
        iface: buffer_i
        buft: buffer_type_t
        context: void*
        size: size_t
    }
    ggml_backend_buffer_type --> ggml_backend_buffer_type_i : "contains"
    ggml_backend_buffer --> ggml_backend_buffer_i : "contains"
    ggml_backend_buffer_type "1" --> "*" ggml_backend_buffer : "creates"

各ハードウェアバックエンドはこれらのインターフェースをそれぞれ実装します。たとえば CUDA バックエンドのバッファタイプは cudaMalloc で GPU メモリをアロケートし、バッファインターフェースは cudaMemcpy でデータ転送を行い、コンピュートバックエンドは各 GGML 演算に対して CUDA カーネルをディスパッチします。

バックエンドの登録とディスカバリー

実行時にどのバックエンドが利用可能かを GGML はどうやって知るのでしょうか。答えは ggml/src/ggml-backend-reg.cpp でのコンパイル時登録です。ggml_backend_registry のコンストラクタは #ifdef ガードを使って各バックエンドを登録します。

ggml_backend_registry() {
#ifdef GGML_USE_CUDA
    register_backend(ggml_backend_cuda_reg());
#endif
#ifdef GGML_USE_METAL
    register_backend(ggml_backend_metal_reg());
#endif
#ifdef GGML_USE_VULKAN
    register_backend(ggml_backend_vk_reg());
#endif
// ...
#ifdef GGML_USE_CPU
    register_backend(ggml_backend_cpu_reg());
#endif
}

登録の順序が重要です。GPU バックエンドが先に登録され、CPU は最後になります。バックエンドスケジューラ(後述)はデフォルトで登録順に演算をバックエンドに割り当てるため、GPU が CPU より優先されます。

flowchart TD
    CMAKE["CMake -DGGML_CUDA=ON"] --> DEFINE["#define GGML_USE_CUDA"]
    DEFINE --> INCLUDE["#include ggml-cuda.h"]
    INCLUDE --> REG["register_backend(ggml_backend_cuda_reg())"]
    REG --> DEV["Enumerate CUDA devices"]
    DEV --> READY["Backend ready for graph compute"]

register_backend() の呼び出しごとに、そのバックエンドが公開するすべてのデバイス(例:NVIDIA GPU が2枚)を検出してグローバルなデバイスリストに追加します。GGML は共有ライブラリからの動的バックエンドロードにも対応しており、コアライブラリを再コンパイルせずにツリー外のバックエンドを追加することも可能です。

ヒント: CPU は常に最後に登録されます(#ifdef GGML_USE_CPU)。これにより、スケジューラで GPU バックエンドが優先されることが保証されます。GPU バックエンドがコンパイルされていない場合は、CPU がすべてを処理します。

バックエンドスケジューラ

1つの計算グラフが複数のバックエンドにまたがることがあります。たとえば、一部のレイヤーを GPU で処理し、埋め込みや出力を CPU で処理するケースです。バックエンドスケジューラ(ggml_backend_sched)はこれを自動的に処理します。

スケジューラの役割は3つです。

  1. グラフのパーティショニング — グラフ内の各演算について、どのバックエンドで実行するかを決定します。デフォルトの戦略では、演算の主要な入力テンソルを所有するバックエンドに演算を割り当て、その演算をサポートする最初の登録済みバックエンドにフォールバックします。

  2. バッファのアロケーション — どのバックエンドがテンソルを操作するかに基づいて、各テンソルに適切なバックエンドバッファをアロケートします。

  3. データ転送の挿入 — 演算の入力が異なるバックエンドに存在する場合(例:GPU の演算が CPU 上のテンソルを必要とする場合)、自動的にコピー演算を挿入してデータを正しいデバイスに移動します。

sequenceDiagram
    participant Ctx as llama_context
    participant Sched as Backend Scheduler
    participant GPU as CUDA Backend
    participant CPU as CPU Backend

    Ctx->>Sched: alloc_graph(gf)
    Sched->>Sched: Assign ops to backends
    Sched->>Sched: Insert cross-device copies
    Sched->>GPU: Allocate GPU tensors
    Sched->>CPU: Allocate CPU tensors
    Ctx->>Sched: graph_compute(gf)
    Sched->>GPU: Execute GPU subgraph
    GPU->>CPU: Transfer intermediate results
    Sched->>CPU: Execute CPU subgraph
    Sched-->>Ctx: Done

ユーザーが設定する n_gpu_layers パラメータは、より上位のレベルでこのパーティショニングを制御します。libllama のモデルロードコードがこの設定に基づいてレイヤーテンソルを GPU または CPU のバッファタイプに割り当て、スケジューラがその割り当てを尊重する仕組みです。

量子化タイプ

GGML の量子化タイプシステムは、このライブラリの際立った特徴の一つです。重みを32ビット浮動小数点数として格納する代わりに、GGML はブロック量子化フォーマットをサポートしています。許容できる精度を維持しながら、メモリ使用量を大幅に削減できます。

主要なタイプはいくつかのファミリーに分かれます。

ファミリー タイプ ブロックサイズ ビット/重み
IEEE 標準 F32, F16, BF16 1 32, 16, 16
均一量子化 Q8_0, Q4_0, Q4_1, Q5_0, Q5_1 32 8.5, 4.5, 5.0, 5.5, 6.0
K-quants Q2_K, Q3_K, Q4_K, Q5_K, Q6_K 256 2.6〜6.6
I-quants IQ1_S, IQ2_XXS, IQ3_S, IQ4_NL 可変 1.6〜4.5
三値 TQ1_0, TQ2_0 256 1.7, 2.1

K-quants は256要素のスーパーブロックを使用し、ブロックごとのスケールと最小値を持ちます。均一量子化よりも高い精度対サイズ比を実現します。I-quants は重要度行列とルックアップテーブルを使い、さらに積極的な圧縮を行います。

各量子化タイプは ggml.h の型トレイト構造体に登録されており、ブロックサイズ、型サイズ、量子化/逆量子化関数へのポインタが指定されています。実際の量子化/逆量子化カーネルは、CPU 向けに ggml/src/ggml-quants.c に、GPU 向けにはバックエンド固有のファイルにそれぞれ実装されています。

flowchart LR
    F16["F16 Model\n14 GB"] --> Q8["Q8_0\n7.2 GB"]
    Q8 --> Q4K["Q4_K_M\n4.1 GB"]
    Q4K --> Q2K["Q2_K\n2.7 GB"]
    Q2K --> IQ2["IQ2_XXS\n2.1 GB"]
    
    style F16 fill:#e1f5fe
    style Q8 fill:#b3e5fc
    style Q4K fill:#81d4fa
    style Q2K fill:#4fc3f7
    style IQ2 fill:#29b6f6

GGUF ファイルフォーマット

GGUF(GGML Universal File format)は、モデルのメタデータとテンソルデータの両方を格納する自己記述型のバイナリフォーマットです。その構造は ggml/include/gguf.h のヘッダコメントに記載されています。

flowchart TD
    subgraph "GGUF File Layout"
        MAGIC["Magic: 'GGUF' (4 bytes)"]
        VERSION["Version: 3 (uint32)"]
        NT["Tensor count (int64)"]
        NKV["KV pair count (int64)"]
        KV["KV Metadata Pairs\n- architecture, n_layer, n_embd...\n- tokenizer data\n- chat template"]
        TD["Tensor Descriptors\n- name, dimensions, type, offset"]
        PAD["Alignment padding"]
        DATA["Tensor Data Blob\n(aligned to GGUF_DEFAULT_ALIGNMENT)"]
    end
    MAGIC --> VERSION --> NT --> NKV --> KV --> TD --> PAD --> DATA

GGUF の主要な設計上の判断は以下の通りです。

  1. 自己記述型: モデルのロードと利用に必要なすべてのメタデータがファイル内に含まれています。general.architecture キーがモデルの種類を識別します。llama.block_countllama.embedding_length などのハイパーパラメータがシェイプ情報を提供します。トークナイザーの語彙とマージルールも KV 配列として埋め込まれています。

  2. アライメント済みデータ: テンソルデータはデフォルトで32バイトにアライメントされます(general.alignment キーで変更可能)。これにより、テンソルをコピーせずにメモリマップされたファイルから直接アクセスできる、効率的な mmap ベースのロードが可能になります。

  3. 型システム: KV の値は uint8、int8、uint16、int16、uint32、int32、float32、bool、string、これらの配列、uint64、int64、float64 を取ることができます。gguf_type 列挙型 で13の型すべてが定義されています。

  4. バージョン3: 現行バージョンは64ビットのテンソル数とオフセットをサポートしており、4GB を超えるファイルも扱えます。マジックバイトは "GGUF"(リトルエンディアンで 0x46554747)です。

ヒント: リポジトリに含まれる gguf-py Python ライブラリを使うと、GGUF ファイルを手軽に検査できます。python -m gguf.scripts.gguf_dump model.gguf を実行すると、すべてのメタデータとテンソルのシェイプが出力されます。

次のステップ

テンソル演算からハードウェアバックエンド、ファイルフォーマットまで、GGML の全体像を見てきました。しかし、デコード呼び出しの全体像はまだ完結していません。次の記事では、推論パイプラインの全体を追っていきます。llama_decode() がバッチ処理とマイクロバッチ処理をどのように管理するか、ポリモーフィックなメモリシステムが Transformer の KV キャッシュと再帰モデルの状態バッファをどのように扱うか、そして状態を壊さずに失敗から復旧する仕組みについて解説します。