技術 約18分で読めます

HypuraのNVMeストリーミングとTurboQuantのKVキャッシュ量子化

Flash-MoEの記事でSSDエキスパートストリーミングを紹介したばかりだが、同じ問題に別の設計で取り組むプロジェクトがもう1つある。HypuraはRust製のApple Silicon専用LLM推論スケジューラで、llama.cppのGGUFモデルをNVMeストリーミングで実行する。Flash-MoEがMoE専用だったのに対し、HypuraはDenseモデルのFFN層ストリーミングにも対応する点が異なる。

もう一方のTurboQuantはGoogle ResearchによるKVキャッシュの3ビット量子化手法(ICLR 2026採択)だ。ただしKVキャッシュの圧縮がボトルネックになるのは主にサーバーサイドの高並列推論環境であり、OllamaやLM Studioで1人で使う分にはほとんど問題にならない。その前提を整理しつつ、両方のアルゴリズムの中身を掘り下げる。

Hypura: llama.cppのメモリ限界をNVMe 3層スケジューラで突破する

llama.cppのmmap設計が生む構造的制約

llama.cppはGGUFファイルを単一のmmapとしてMetalに渡す設計になっている。M1 MaxのGPU推奨最大作業セットサイズ(recommendedMaxWorkingSetSize)は26.8GBで、30.9GBのMixtral 8x7B Q5_K_Mはこれを即座に超え、kIOGPUCommandBufferCallbackErrorOutOfMemoryでクラッシュする。

n_gpu_layersをどう調整してもこの問題を回避できない。llama.cppのCPU/GPUオフロードはレイヤー単位の振り分けだが、モデル全体を1つのmmapとして確保する設計上、mmapの総サイズがGPU作業セット上限を超えた時点でMetalが拒否する。BERT+Qwen OCR校正ツールの記事で試した--override-tensorによるテンソル単位のオフロードも、mmapの総確保サイズは変わらない。

Hypuraはこのmmap一括確保を廃止し、テンソルごとに配置先を決める設計に変えた。

LP+greedyによるテンソル配置最適化

起動時にハードウェアプロファイリングを実施する。Metal APIからGPU作業セットサイズの上限、システムRAM容量、NVMeのシーケンシャル読み取り帯域を計測し、この3つの数値をテンソル配置の制約条件に使う。

配置はLP(線形計画法、制約条件付きで目的関数を最適化する数学的手法)とgreedyアルゴリズムの2段階で決定する。

  1. 各テンソルのサイズとアクセスパターン(毎トークンアクセスか、条件付きアクセスか)を入力とする
  2. LPで「全テンソルのアクセスレイテンシの総和を最小化」する理想的な配置を求める。制約はGPU/RAM/NVMeの各容量上限
  3. LP解を初期値として、greedyで実行可能な整数解に丸める(テンソルは分割できないため、LPの連続値を離散化する必要がある)

配置の優先順位はアクセスパターンとサイズに基づく。

テンソル種別サイズ・アクセス頻度配置先
Attention / norm / embedding小さい、毎トークンGPU常駐
MoEの共有部分(ルーター等)中程度、毎トークンGPU(収まれば)
FFN / MoEエキスパート大きい、条件付きNVMe
オーバーフロー分RAM

llama.cppのn_gpu_layersが「レイヤー丸ごとGPUかCPUか」の二択なのに対して、HypuraはAttentionテンソルだけGPUに置いてFFNテンソルをNVMeに配置するといった層内の細かい制御ができる。この粒度の違いがfull-residentモードでllama.cppより速い(Qwen 14Bで1.4倍)理由の一つだろう。

Flash-MoEとの設計思想の違い

Flash-MoEとHypuraは「メモリに収まらないモデルをSSD/NVMeから読む」という核心は同じだ。ただし設計の出発点が異なる。

Flash-MoEHypura
実装C + 手書きMetalシェーダーRust + llama.cppフォーク
モデル形式Safetensors直読みGGUF
対象モデルMoE専用MoE + Dense
キャッシュOSページキャッシュ任せ(ヒット率71%)自前LRUキャッシュ(ヒット率99.5%)
テンソル配置手動(非エキスパート5.5GBをRAM常駐)LP+greedy自動最適化
プリフェッチ試して逆効果(統合メモリの帯域競合)co-activation prefetchで成功
API互換なしOllama互換HTTPサーバー

キャッシュ戦略の違いが面白い。Flash-MoEはOSのページキャッシュに任せて71%のヒット率を得た。明示的なキャッシュ管理を避けてコードをシンプルに保つ判断だ。Hypuraは自前のLRUキャッシュ(Least Recently Used、最も古い使用履歴のエントリを追い出すキャッシュ方式)を実装して99.5%のヒット率を達成している。

この差はMoEアーキテクチャの違いから来ている可能性が高い。Flash-MoEが対象とするQwen3.5は512エキスパート中4つを活性化するため、キャッシュ空間に対してエキスパートの種類が多く、OSのページキャッシュでは追い出しが頻発する。一方Mixtral 8x7Bは8エキスパート中2つの活性化で、エキスパートの種類自体が少ないためLRUキャッシュの効果が出やすい。

プリフェッチの成否も対照的だ。Flash-MoEでは予測的エキスパートルーティングが精度31%で実用にならず、プリフェッチ自体もApple Siliconの統合メモリバスでGPU計算とI/O帯域が競合して73%悪化した。Hypuraのco-activationプリフェッチが成功する理由はアプローチの違いだろう。Flash-MoEは「次にどのエキスパートが選ばれるか」を事前に1つ予測しようとしたが、Hypuraはエキスパートの同時活性化パターン(あるエキスパートが選ばれたとき、次のレイヤーでどのエキスパートが選ばれやすいか)を統計的に追跡し、共起しやすい複数候補を先読みする。候補を広く取ることでプリフェッチのヒット率を上げている。

NVMe I/Oの設計選択とmmapの罠

HypuraはNVMeアクセスにF_NOCACHEフラグ付きのpreadを使う。preadはファイルの任意の位置からスレッドセーフに読み込むシステムコール、F_NOCACHEはOSのページキャッシュをバイパスするフラグ(direct I/O)だ。

direct I/Oを選択する理由は、テンソルのロードパターンがモデル構造から予測可能で、OS側の汎用ページ置換アルゴリズムより自前のLRUキャッシュのほうが的確にヒットするためだ。OSキャッシュを経由すると、推論に無関係なシステム全体のI/Oにキャッシュが汚染される。

mmapを使わない判断はFlash-MoEの実験結果とも一致する。Flash-MoEではmmapが5倍の性能悪化を引き起こした。Apple Siliconの統合メモリ環境では、mmapのページフォルトオーバーヘッドが重い。llama.cpp自体がmmapベースの設計であり、モデル全体がメモリに収まる前提では合理的だが、収まらない場合はページフォルトが毎トークンの推論で発生してレイテンシが不安定になる。llama-serverの実験記事--no-mmapが統合メモリAPUで必須だった話とも通じる。

3つの推論モードとdense-FFN-streamingの難しさ

モデルサイズとハードウェア容量から自動選択される。

モード適用条件GPU常駐テンソルNVMe転送
full-residentGPU+RAMに収まるモデル全体なし
expert-streamingMoEモデルがGPUを超える非expertテンソルMoEエキスパートを随時ロード
dense-FFN-streaming密なモデルがGPUを超えるAttention+normFFN層をストリーミング

expert-streamingはFlash-MoEと同じ原理で、MoEの疎性(Mixture of Experts、入力に応じて一部のエキスパートだけ活性化する設計)によりI/O量が大幅に減る。Mixtralなら8エキスパート中2つだけ読むので75%削減。neuronキャッシュ(LRU)で99.5%のキャッシュヒット率を達成し、前述のco-activationプリフェッチで次に発火しやすいエキスパートをGPU計算と並行してNVMeから先読みする。

graph TD
    A[GGUFモデルファイル] --> B[ハードウェアプロファイリング<br/>GPU working set / RAM / NVMe帯域]
    B --> C[LP+greedy テンソル配置]
    C --> D[GPU Metal<br/>Attention / norm / embedding]
    C --> E[RAM<br/>オーバーフロー層]
    C --> F[NVMe<br/>FFN / MoEエキスパート]
    D --> G[推論実行]
    E --> G
    F -->|pread + F_NOCACHE| G
    G --> H{MoE?}
    H -->|Yes| I[LRUキャッシュ<br/>ヒット率99.5%]
    H -->|No| J[280MBスクラッチバッファ<br/>ストリーミング]
    I --> K[co-activation prefetch<br/>次レイヤーの候補を先読み]

dense-FFN-streamingはFlash-MoEが対応していない領域で、ここがHypuraの独自の貢献だ。Llama 70BのようなDenseモデルではMoEの疎性が使えないため、FFNのgate/up/downテンソル(約31.8GB)を毎トークンすべてNVMeから読み込む必要がある。

これが本質的に難しい理由を数字で見る。Mixtral 8x7BのMoEストリーミングでは1トークンあたり2/8エキスパート分のI/Oで済む。Llama 70Bのdense-FFN-streamingでは80層分のFFNテンソルを毎トークン読む。NVMe帯域5.1 GB/sのM1 Maxでは単純計算で1トークンあたり数秒のI/O時間になる。

Hypuraは280MBのスクラッチバッファに区切ってストリーミングすることで、GPU計算とI/Oを部分的にオーバーラップさせている。さらに22GBの匿名mmapページコミットを回避する設計により、旧実装(CPU処理34層)の0.03 tok/sから全80層Metal処理の0.3 tok/sへ10倍改善した。

ただし0.3 tok/sが現在の限界だ。M1 Max(NVMe帯域5.1 GB/s)とM5 Pro(NVMe帯域33.4 GB/s)でほぼ同速という結果は、ボトルネックがNVMe帯域ではなくper-layer I/Oストール(各層でNVMeからの読み込み完了を待つ時間、約50ms × 80層/トークン)にあることを示している。

RESEARCH_INTEGRATION_PLAN.mdにはntransformer SLEPパイプライン由来のダブルバッファリングの実装計画が記載されている。バッファAでGPU計算中にバッファBへ次層をNVMeロードし、完了したら役割を交代する。これが実装されればI/OストールとGPU計算が完全にオーバーラップし、dense-FFN-streamingの大幅な改善が見込まれる。Flash-MoEの遅延GPU実行パターンと同じ発想だが、Apple Siliconの統合メモリバスでSSD DMAとGPU計算がメモリコントローラーを共有する制約をどう克服するかが課題になる(Flash-MoEではシリアルパイプラインが最適という結論になっていた)。

ベンチマーク結果

M1 MaxとM5 Proでの実測値。

モデルサイズモードM1 Max 32GBM5 Pro 24GBllama.cpp
Qwen 2.5 14B Q4_K_M8.4GBfull-resident12.3 tok/s27.2 tok/s8.9 tok/s
Qwen 2.5 32B Q5_K_M21.7GBfull-resident6.6 tok/s
Mixtral 8x7B Q5_K_M30.9GBexpert-streaming2.2 tok/s2.7 tok/sOOM
Phi-3.5-MoE Q4_K_M23.6GBexpert-streaming3.2 tok/s
Qwen3-Coder-Next Q4_K_M45.2GBexpert-streaming1.3 tok/sOOM
Llama 3.3 70B Q4_K_M39.6GBdense-FFN-streaming0.3 tok/s0.3 tok/sOOM

Flash-MoEのQwen3.5-397B(4.36 tok/s、M3 Max 48GB)と比較すると、Hypuraのexpert-streamingはMixtral 8x7B(2.2 tok/s、M1 Max 32GB)で見劣りする。ただしモデルサイズ(209GB vs 30.9GB)もハードウェア(M3 Max 48GB vs M1 Max 32GB)も違うので直接比較は難しい。Hypuraの優位点はGGUFエコシステムとの統合で、既存のGGUFモデルをそのまま使えること、そしてDenseモデルにも対応していることだ。

0.3 tok/sはリアルタイム会話には使えないが、バックグラウンドでのドキュメント要約やバッチ処理、あるいは「手元のMacで70Bモデルの出力品質を確認する」用途には十分実用的だ。

インストールはRust 1.75+とCMakeが必要:

git clone --recurse-submodules https://github.com/t8/hypura.git
cd hypura
cargo build --release

Ollama互換HTTPサーバー(hypura serve)で/api/generate/api/chat/api/tagsが使える。Open WebUI等のOllamaプロトコル対応フロントエンドとそのまま接続できる。

TurboQuant: KVキャッシュを3ビットに圧縮してサーバースループット8倍

KVキャッシュ圧縮が実際にボトルネックになるシナリオ

KVキャッシュの量子化と聞くと「ローカルで大きなモデルを動かすのに必須」と思うかもしれないが、ローカル推論ではほとんどの場合問題にならない。

KVキャッシュのサイズは以下で決まる。

2(K+V) × バッチサイズ × レイヤー数 × KVヘッド数 × ヘッド次元 × コンテキスト長 × 精度バイト数

Llama 3.1 8BはGQA(Grouped Query Attention、クエリヘッドを複数まとめてKVヘッドを共有する設計)を採用しており、32のクエリヘッドに対してKVヘッドは8つしかない。FP16で8Kコンテキスト、1ユーザーの場合:

2 × 1 × 32層 × 8ヘッド × 128次元 × 8192トークン × 2バイト ≈ 1.1GB

モデル本体(Q4_K_Mで約4.5GB)と合わせて約5.6GB。16GBのMacでも余裕で収まる。128Kコンテキストに伸ばしても約17GBで、32GB以上のMacなら対応できる。

KVキャッシュが本当に深刻になるのは次のようなケースだ。

  • サーバーサイドで64ユーザー同時処理 → 1.1GB × 64 = 70GB(KVキャッシュだけで)
  • Geminiの1Mトークンコンテキストウィンドウ → 数百GBクラス
  • 上記の両方が重なるデータセンター環境

TurboQuantの論文がGeminiへの応用を明示しているのは偶然ではない。Google Researchの研究であり、数千のリクエストを同時に捌くサーバー環境のスループットを上げるための技術だ。OllamaやLM Studioで8Bモデルを1人で使うユーザーには関係ない。

ただしアルゴリズムとしては独創的で、KVキャッシュに限らない応用可能性がある。

従来手法の量子化定数オーバーヘッド問題

KV圧縮の記事で紹介したAttention Matchingは「どのトークンのKVを残すか」を選択する圧縮だった。TurboQuantは「各トークンのKVベクトルのビット幅を下げる」量子化による圧縮だ。

従来の量子化手法(KIVI、KVQuant、GEAR等)は小さなデータブロックごとに量子化パラメータを保存する必要がある。

量子化されたブロック = [量子化値 × N] + [スケール(FP16)] + [ゼロポイント(FP16)]

例えばグループサイズ128で2ビット量子化する場合、128個の2ビット値(32バイト)に対して4バイトの量子化定数が付く。実効ビット幅は2ビットではなく約2.25ビットになる。ビット数が少ないほどこのオーバーヘッドの比率が大きくなり、圧縮効果を相殺する。

KV圧縮の記事で触れたKeyとValueの圧縮耐性の非対称性(KeyはINT2まで落とせるがValueは精度に直結する)を活用するKIVIでも、このオーバーヘッドは避けられない。

TurboQuantはPolarQuantとQJLの2つのアルゴリズムを組み合わせて、量子化定数のオーバーヘッド自体を排除する。

PolarQuant: 極座標変換で量子化定数を不要にする

PolarQuantの核心は、デカルト座標系のベクトルを極座標に変換することで、データの分布を量子化に有利な形に変えるアイデアだ。

通常の量子化では、ベクトルの各要素の値域がバラバラなためブロックごとにスケールとゼロポイントを保存する。PolarQuantはこの前提を崩す。

d次元ベクトルを極座標に変換すると、1つの半径rと(d-1)個の角度θに分解される。半径はベクトルの大きさ(ノルム)、角度群がベクトルの方向(意味的な情報)を表す。

ここで重要なのが、高次元ベクトルの角度成分の分布特性だ。高次元空間では、ランダムなベクトルの角度成分はπ/2付近に強く集中する。これは高次元球面上の測度集中現象(concentration of measure)と呼ばれる性質で、次元が上がるほど顕著になる。LLMのKVベクトルは完全にランダムではないが、128〜256次元もあれば角度の分布は十分に集中する。

分布が集中しているなら値域は事前に既知で狭い。ブロックごとのスケール/ゼロポイントを保存せずとも、全要素に同じ均一量子化グリッドを適用できる。これが「量子化定数オーバーヘッド0」の仕組みだ。

変換は再帰的に行われる。d次元ベクトルの座標ペアを極座標に変換してd/2個の半径とd/2個の角度を得る。半径群を再び集めて同じ変換を適用する。最終的に1個のスカラー半径と多数の角度値に蒸留される。

graph TD
    A[d次元ベクトル<br/>デカルト座標] --> B[座標ペアごとに<br/>極座標変換]
    B --> C[d/2個の半径<br/>d/2個の角度]
    C --> D[半径群を再帰的に変換]
    D --> E[1個の半径 + 多数の角度]
    E --> F[半径: FP16で保持<br/>2バイト/ベクトル]
    E --> G[角度: 均一量子化<br/>定数不要]
    F --> H[復元時に掛け合わせ]
    G --> H

ベクトルあたりのオーバーヘッドは半径のFP16値1個(2バイト)だけ。128次元のベクトルを3ビットで量子化する場合、従来手法ではグループサイズ8で32個の量子化定数ペア(128バイト)が必要だったのが、2バイトで済む。圧縮率の差は歴然だ。

PolarQuantはAISTATS 2026に採択されており(arxiv:2502.02617)、単体でも独立した研究になっている。

QJL: Johnson-Lindenstrauss変換で残差を1ビット補正する

PolarQuantだけでは量子化誤差(元ベクトルと量子化復元ベクトルの差分)が残る。QJL(Quantized Johnson-Lindenstrauss)はこの残差を追加のメモリオーバーヘッドほぼゼロで補正する。

Johnson-Lindenstrauss(JL)の補題は次元削減の基礎定理だ。高次元のデータ点集合をランダムな行列で低次元に射影しても、点間の距離がε以内の精度で保存される。必要な射影先の次元はO(log(n)/ε²)で、元の次元に依存しない。埋め込みベクトルの類似度検索やデータベースの近似最近傍探索で広く使われている。

TurboQuantではこのランダム射影を使って、PolarQuantの量子化残差ベクトルを低次元に写す。写した後の各要素について、値そのものではなく符号(+1か-1か)だけを保持する。

graph TD
    A[PolarQuant残差<br/>e = x - x_quantized] --> B[ランダム行列Rで射影<br/>z = R × e]
    B --> C[符号だけ保持<br/>s_i = sign of z_i]
    C --> D[+1/-1のビット列<br/>1ビット/次元]
    E[クエリベクトルq<br/>FP16のまま] --> F[同じRで射影<br/>z_q = R × q]
    D --> G[補正付きアテンション計算<br/>score = PolarQuant項 + QJL補正項]
    F --> G

アテンションスコアの計算時、クエリベクトルqはFP16のまま量子化しない。PolarQuantで量子化されたKVベクトルとの内積に、QJL由来の補正項を加える。この補正項は「高精度クエリと1ビット符号列から計算される推定値」で、その期待値が量子化誤差の内積を正確に補償するように設計されている。論文ではこの不偏推定器(unbiased estimator、期待値が真の値に一致する推定器)の導出が理論的に証明されている。

JL射影のランダム行列Rは推論開始時に1回だけ生成し、全レイヤー・全ヘッドで共有する。保存するのは各KVベクトルの符号ビット列だけなので、追加メモリは1ビット/次元。d=128なら16バイト/ベクトル。PolarQuantの半径2バイトと合わせても18バイト/ベクトルで、FP16の256バイト/ベクトルから93%削減される。

QJLはAAAIに掲載済みで、こちらも独立した研究として成立している。

TurboQuantの2段階パイプライン

2つを組み合わせた全体像。

graph TD
    A[KVキャッシュベクトル<br/>FP16] --> B[PolarQuant<br/>極座標変換 + 均一量子化]
    B --> C[量子化KV<br/>3ビット/要素 + 半径FP16]
    A --> D[残差計算<br/>e = 元 - 復元]
    D --> E[QJL<br/>ランダム射影 + 符号化]
    E --> F[符号ビット列<br/>1ビット/次元]
    C --> G[アテンション計算]
    F --> G
    H[クエリq<br/>FP16] --> G
    G --> I[補正済みアテンションスコア]

新しいトークンが来るたびにそのKVを量子化するだけで、過去の量子化済みKVを再計算する必要がない。このオンライン適用性は、Attention Matchingのようなバッチ圧縮手法にはない強みだ。ファインチューニングも不要で、既存モデルにそのまま適用できる。

既存KV圧縮手法との位置づけ

KVキャッシュ圧縮のアプローチは大きく3系統ある。

系統代表手法圧縮方式圧縮率学習オンライン適用
トークン選択/マージH2O, SnapKV, Attention Matching重要トークンだけ残す最大50倍不要一部対応
量子化KIVI, KVQuant, TurboQuantビット幅を削減約5倍不要対応
潜在空間最適化Cartridges勾配で圧縮KVを学習50倍必要(数時間)非対応

Attention Matching(前回の記事)は50倍圧縮でCartridges並の精度を閉形式解で数秒で達成する。ただしコンテキスト全体をまずprefillしてから圧縮するバッチ処理が前提で、会話の途中でトークンが増えるたびにオンライン適用する設計ではない(論文でも付録で軽く触れる程度)。

TurboQuantはFP16(16ビット)から3ビットへの削減で約5.3倍の圧縮。Attention Matchingの50倍には及ばないが、トークンが来るたびに逐次量子化できるオンライン適用の容易さが強みだ。推論パイプラインにそのまま組み込める。

KIVIとの差は明確で、量子化定数オーバーヘッドの有無がそのまま実効ビット幅の差になる。KIVIのINT2は実効約2.25ビットだが品質劣化が目立つ。TurboQuantの3ビットは本当に3ビットで、かつ精度劣化がない。

ベンチマーク結果

Gemma、Mistral、Llama-3.1-8B-Instructを対象に、LongBench・Needle In A Haystack・ZeroSCROLLS・RULER・L-Evalで評価した結果。

指標
量子化ビット数3ビット(実効ビット幅もほぼ3ビット)
精度劣化なし(ファインチューニング不要)
KVキャッシュメモリ削減最大6倍(Needle-in-Haystack)
推論スループット最大8倍(H100、4ビット vs 32ビット比較)
LongBench集計スコアKIVIベースラインより優位

Needle-in-Haystackは長大なコンテキスト中に埋め込まれた特定の情報を正確に抽出するタスクで、KVキャッシュ圧縮の影響をもっとも受けやすい。ここでメモリ6倍削減しつつ精度を維持しているのは、PolarQuantの角度集中性を活用した量子化がアテンション分布のランキングを崩していないことを意味する。KV圧縮の記事で解説した「Keyの量子化はランキングさえ保存されればsoftmaxが吸収する」という性質と整合する。

ベクトル検索評価(GloVe d=200、Top-1再現率)でもProduct Quantization(PQ)とRabbiQを一貫して上回った。KVキャッシュ以外にも、セマンティック検索エンジンやベクトルDBの量子化に応用できるアルゴリズムだ。「証明付きで効率的(provably efficient)」な設計で、理論的な下界に近い動作をする。データ非依存(data-oblivious)な方式のため、特定データセットへの事前チューニングなしに動作する。

論文はICLR 2026採択(arxiv:2504.19874)、PolarQuantはAISTATS 2026採択(arxiv:2502.02617)、QJLはAAAI掲載。

解く問題のレイヤーが違う

HypuraとTurboQuantは対象ユーザーが全く異なる。

Hypuraは「手元のMacで大きなモデルを動かしたい」ローカルLLMユーザーのためのツールだ。llama.cppのGGUFエコシステムに乗っており、Ollama互換APIで既存のフロントエンドとすぐ接続できる。Flash-MoEがMoE専用だったのに対し、Dense-FFN-streamingでLlama 70Bのような密なモデルにも対応する点が独自の価値になっている。Flash-MoEに続いて、Apple SiliconのNVMe帯域を推論リソースとして活用するアプローチの2例目だ。

TurboQuantは「データセンターでGeminiの推論コストを下げたい」Google Researchの研究だ。ローカルで8Bモデルを1人で使う分にはKVキャッシュは1GB程度で圧縮の必要がない。ただしPolarQuantの極座標変換やQJLの1ビット符号推定は量子化理論として汎用的で、ベンチマーク結果で示したようにベクトル検索でもPQやRabbiQを上回っている。