UbuntuとRustでローカルAI推論サーバーを自作する

モバイル向けノートPCは、バッテリー駆動時間を優先する設計上、GPUの推論性能がどうしても非力になりがちです。Copilot+ PCのNPUに関する記事で整理したとおり、NPUは省電力タスク向けの専用ハードウェアであり、大きなモデルの推論を高速に回す用途は想定されていません。この非力さを補う手段として、型落ちのGeForce GTX 1080Ti(Pascal世代、2016年頃発売)を積んだUbuntu機に、RustとC/C++の推論エンジンllama.cppで自作のAI推論サーバーを構築しました。この記事では、構築の過程でぶつかった詰まりどころと、実際に測った性能差を紹介します。実装したコードの全体はGitHubリポジトリで公開しています。

Foundry Localをこのサーバーで使わなかった理由

Foundry Local入門記事で書いたとおり、Foundry Localはモデルカタログ・ハードウェア自動選択・OpenAI互換API層までを一体で提供する完成品プラットフォームです。今回のUbuntu機でも同じように使えますが、あえて使わず、エンジン層(llama.cpp)だけを使って自分でサービス層を組み立てることにしました。理由は4つあります。

  1. カタログの壁が出てくる: Foundry LocalはMicrosoftが用意したONNX変換済みモデルしか使えません。Ubuntu側でも同じ制約を持ち込むことになります。
  2. `~目的とズレる: 「GPUマシン側のサービス層を自分で作る」のが今回の趣旨なので、既存製品をそのまま別マシンで動かすだけでは学習・検証の意味が薄れます。
  3. モデルの選択肢: llama.cppが使うGGUF形式は、Hugging Face上でコミュニティが量子化した膨大なオープンモデルがそのまま使えます。ONNXカタログよりずっと広い選択肢があります。
  4. 古いGPUとの相性: 1080Ti + CUDA 11.5という組み合わせは、長年チューニングされてきたllama.cppの方が、ONNX Runtime経由より安定して速く動く可能性が高いと判断しました。

まとめると、「エンジン(llama.cpp) + カタログ + サービス層」がセットになった完成品がFoundry Localで、今回作ったのはそのうちエンジン部分だけを使い、サービス層(HTTPサーバー・モデル管理)を自分でRustで組み立てるプロジェクトです。

環境

項目 内容
CPU Intel Core i7-8700K(6コア12スレッド、第8世代)
RAM 62GiB
GPU NVIDIA GeForce GTX 1080Ti(VRAM 11GB)
OS Ubuntu 22.04.5 LTS
CUDA Toolkit 11.5、ドライバ535.309.01
推論エンジン llama-cpp-2(llama.cppのRustバインディング)

古いGPU機ならではの注意点として、ドライバ・CUDA Toolkitを最新にしすぎると、逆に古いGPU世代のサポートが打ち切られていることがあります。NVIDIAはある時点でPascal世代の新規CUDAサポートを終了する方針のため、GPU世代とCUDA/ドライバのバージョンの対応関係は事前にNVIDIA公式の対応表で確認するのが安全です。今回のようにディストロ標準リポジトリのnvidia-cuda-toolkit(やや古いバージョンで固定)を使う方が、逆に相性事故を避けられるケースもありました。

ビルドで詰まったポイント

llama-cpp-2をCUDA機能有効で依存に追加し、ビルドが通るところまでに4段階のエラーを踏みました。

1. libclangが未導入

llama-cpp-2のRustバインディング生成(bindgen)にはlibclangが必要ですが、このUbuntu機には未導入でした。

sudo apt update
sudo apt install -y libclang-dev clang

2. nvccとgccのバージョン不整合

cargo buildを実行すると、CUDAカーネル(.cu)のコンパイルで次のエラーが出ました。

/usr/include/c++/11/bits/std_function.h:530:146: error: parameter packs not expanded with '...'

原因はnvcc(CUDA 11.5)とgcc 11(Ubuntu 22.04標準)のバージョン不整合です。CUDA 11.5が公式サポートするホストコンパイラはgcc 10までで、gcc 11のヘッダをnvcc経由で解釈するとこのエラーになります。llama-cpp-2自体の問題ではなく、nvccという外部ツールチェーンの制約です。

sudo apt update
sudo apt install -y gcc-10 g++-10
export CUDAHOSTCXX=/usr/bin/g++-10

3. CUDA静的ライブラリのリンクパス

gcc-10指定後、今度はリンクエラーです。

error: could not find native static library `cudart_static`, perhaps an -L flag is missing?

このマシンはNVIDIA公式インストーラではなく、Ubuntuディストロパッケージ版のCUDA Toolkit(nvidia-cuda-toolkit)を使っており、libcudart_static.aが公式インストーラの標準パス(/usr/local/cuda/lib64/)ではなく/usr/lib/x86_64-linux-gnu/にありました。最初LIBRARY_PATH環境変数で試しましたが効きません。LIBRARY_PATHはgcc用の慣習で、rustc自身のネイティブライブラリ検索には使われないためです。正しくはRUSTFLAGSでrustcに直接-Lを渡します。

export RUSTFLAGS='-L /usr/lib/x86_64-linux-gnu'

4. 環境変数がシェルセッションに紐づいて消える

上記2つの環境変数を設定した状態でcargo buildが成功しました(ビルド時間18分18秒、llama.cpp本体をCMakeでフルビルドするため)。ただしこれをシェルのexportとして設定していたため、そのSSHコマンドの中だけの一時的なものになっていました。別のシェルセッションでcargo runすると、環境変数が引き継がれず同じビルドエラーが再発しました。

最終的に、環境変数をシェルに頼らずプロジェクトの.cargo/config.tomlに直接書き込む方式に変更しました。

[env]
CUDAHOSTCXX = "/usr/bin/g++-10"

[build]
rustflags = ["-L", "/usr/lib/x86_64-linux-gnu"]

これで、誰がどのシェルからcargo build/cargo runしても自動的に同じ設定が適用されます。cargo cleanからのクリーンビルドで、シェルに一切環境変数を設定しない状態でも成功することを確認しました。

今回これだけ詰まった原因の一つは、ハードウェアやOS自体が古かったことです。CUDA 11.5・Pascal世代のGPU・ディストロ標準リポジトリ版のCUDA Toolkitという組み合わせは、現行世代の構成であればここまでの相性問題は起きにくく、よりスムーズにビルドできたと考えられます。

モデル選定:GGUF形式とMistral-7B

日本語対応は今回のアーキテクチャ検証の範囲からいったん切り離し、純粋な推論基盤の検証を優先する方針にしました。採用したのはMistral-7B-Instruct-v0.3(Mistral AI、Apache 2.0ライセンス)です。llama.cppとの相性が最初期からのネイティブサポートで最も枯れていること、7Bと軽量で1080Ti(11GB)に余裕で収まること、OSS公開前提のためライセンスを重視したことが理由です。量子化はQ4_K_M(サイズとVRAM消費のバランスが良い定番)を使いました。

GGUF(GGML Universal File)は、llama.cppプロジェクトが定義したLLMの重み保存形式です。Foundry Local(ONNX Runtime)における「ONNX形式」に相当するものが、llama.cppエコシステムにおける「GGUF形式」だと考えると位置づけがつかみやすいです。モデルの重みだけでなく、トークナイザーやチャットテンプレートなどのメタデータも1ファイルにまとまっており、メモリマップ(mmap)で効率よく読み込める設計になっています。

ファイル名末尾のQ4_K_Mは、重み1つあたり約4bitに圧縮するQ4、単純な線形量子化より精度劣化が少ない改良方式のK、精度とサイズのバランスを取ったグレードのM(SはSmall/軽量寄り、LはLarge/精度寄り)を組み合わせた表記で、llama.cppコミュニティで最もよく使われる量子化レベルです。

最小推論コードからHTTPサーバー化まで

推論コードは、公式サンプル(llama-cpp-rs/examples/simple)を参照しつつ、次の5つの関数に分けて1つずつ動作確認しながら組み立てました。

  1. init_backend() — llama.cppバックエンド初期化
  2. load_model() — GGUFロード。with_n_gpu_layers(1000)で全レイヤーをGPUにオフロード
  3. create_context() — 推論コンテキスト作成
  4. tokenize() — プロンプトをトークン列に変換
  5. generate() — 初期バッチをdecode後、1トークンずつサンプリング・decodeを繰り返す

モデルロード時のログではload_tensors: offloaded 33/33 layers to GPUと表示され、生成中のnvidia-smiではGPU使用率が0% → 34% → 98%、VRAM使用量が4MiB → 4724MiBまで上がることを確認しました。CPUフォールバックせず、確実にGPU上で推論が実行されています。

つまずいたポイント:LlamaContextがSend/Syncではない

CLI版をそのままHTTPサーバー(axum + tokio)に組み込もうとして、最初にArc<Mutex<AppState>>でモデル・コンテキストを複数リクエストから共有しようとしたところ、コンパイルエラーになりました。

required for `Mutex<LlamaContext<'static>>` to implement `Sync`
required for `Arc<AppState>` to implement `Send`

LlamaContextがSend/Syncを実装していないため、Mutexで包んでも複数スレッド間で共有できません。unsafeで無理やりSend/Syncを付与するのではなく、モデルとコンテキストを1本の専用OSスレッドに閉じ込め、HTTPハンドラとはmpscチャンネルでやり取りする設計に変更しました。

// 起動時に1本だけ立てるワーカースレッド。
// backend/model/contextの所有権はこのスレッドに閉じ込め、
// 外部とはチャンネル経由でのみやり取りする。
fn spawn_worker(model_path: &str) -> mpsc::UnboundedSender<Job> {
    let (tx, mut rx) = mpsc::unbounded_channel::<Job>();
    let model_path = model_path.to_string();

    thread::spawn(move || {
        let backend = init_backend().expect("backend init failed");
        let model = load_model(&backend, &model_path).expect("model load failed");
        let mut ctx = create_context(&model, &backend).expect("context init failed");

        while let Some(job) = rx.blocking_recv() {
            let result = tokenize(&model, &job.prompt)
                .and_then(|tokens| generate(&mut ctx, &model, tokens, N_LEN));
            let _ = job.respond_to.send(result);
        }
    });

    tx
}

HTTPハンドラ側は、プロンプトをmpsc::UnboundedSenderでワーカーに送り、結果をoneshotチャンネルで受け取るだけです。これによりGPUへのアクセスは常にワーカースレッド1本に直列化され、複数リクエストが同時に来てもチャンネルに自動的にキューイングされます。

重大バグ:2回目以降のリクエストが必ず失敗する

Pythonベンチマーククライアントで動作確認をしていたところ、1回目の/generateは成功するのに、2回目以降は必ず次のエラーで失敗する問題が見つかりました。

init: the tokens of sequence 0 in the input batch have inconsistent sequence positions:
 - the last position stored in the memory module of the context (i.e. the KV cache) for sequence 0 is X = 64
 - the tokens for sequence 0 in the input batch have a starting position of Y = 0

原因は、ワーカースレッドが持つLlamaContextが全リクエストで使い回されるのに対し、tokenize()は常に新しいプロンプトを位置0から始まるトークン列として組み立てていたことです。1回目のリクエストでKVキャッシュに位置0〜63が書き込まれた後、2回目のリクエストが再び位置0から送られると、KVキャッシュ側の「最後の位置は64」という状態と矛盾し、デコードがエラーになります。このエラーはHTTPハンドラ内の.expect(...)でpanicしますが、axum/tokioのリクエスト単位のタスク内で起きるためサーバープロセス自体は落ちません。そのためsystemctl statusactive (running)のまま、実質的に全リクエストが失敗し続けるという分かりにくい壊れ方をします。

修正は、リクエストジョブを処理する直前にKVキャッシュをクリアし、毎回のリクエストを独立した1ターンとして扱うことです。

while let Some(job) = rx.blocking_recv() {
    ctx.clear_kv_cache(); // 前回リクエストのKVキャッシュ残留を破棄してから処理する
    let result = tokenize(&model, &job.prompt)
        .and_then(|tokens| generate(&mut ctx, &model, tokens, N_LEN));
    let _ = job.respond_to.send(result);
}

修正後、3連続のリクエストがすべて成功することを確認しました。

systemdサービス化

当初nohupでその場起動していましたが、これはSSHセッション(を起動した親シェル)に生死が紐づいており、セッション終了時にSIGHUPで道連れに終了してしまいます。ログインセッションから独立して動かすため、systemdサービスとして登録しました。

[Unit]
Description=Network GPU AI inference server
After=network.target

[Service]
Type=simple
User=user
WorkingDirectory=/home/user/network-gpu-ai-server
ExecStart=/home/user/network-gpu-ai-server/target/debug/network-gpu-ai-server
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

daemon-reloadenablestart後、OS再起動をまたいでも自動起動し、ログインセッション不要で恒常稼働することを確認しました。

ベンチマーク:型落ちGPUでも9.2倍

最後に、Foundry Local(手元のノートPCのNPU/GPU)と、今回自作したサーバー(1080Ti)の応答速度をPythonクライアントで実測比較しました。サーバー側がMistral-7B-Instruct-v0.3のため、Foundry LocalのカタログにあったMistral-7B-Instruct-v0.2(ONNX)にモデルを揃えて再測定しています。5回試行(ウォームアップ1回を除く)の平均は次のとおりでした。

環境 応答時間 生成速度
自作サーバー(1080Ti) 1.11s 45.19 tokens/sec
Foundry Local(ノートPCのGPU) 10.18s 6.29 tokens/sec

型落ちの1080Tiが、応答時間で約9.2倍速い(10.18s→1.11s)という結果になりました。分散もごく小さく、単発測定(9.27倍)ともほぼ一致しています。生成速度(tokens/sec)だけで見ると7.2倍で、応答時間ベースの倍率と一致しないのは、応答に含まれるトークン数がバックエンド間で異なるためです。CUDA 11.5・Pascal世代という2016年のハードウェアでも、モバイル向けノートPCのGPUと比べれば、生の演算性能・VRAM容量の差がそのまま応答速度の差として表れることを実感しました。現代のGPUアーキテクチャであれば、さらにこの差は開くと予想されます。

まとめ

モバイル向けノートPCのGPU/NPUの非力さを補うため、型落ちの1080TiにRust+llama.cppで自作したローカルAI推論サーバーを構築しました。Foundry Localを使わなかったのは、カタログの制約を避けたかったことと、サービス層自体を自分で組み立てたかったことが理由です。ビルド時のCUDA/gccバージョン不整合、静的ライブラリのリンクパス、LlamaContextのSend/Sync制約、KVキャッシュ未クリアによる2回目以降のクラッシュなど、詰まりどころは多くありましたが、最終的に実測9.2倍という数字が出たことで、型落ちでもデスクトップGPUを使う価値は十分にあると確認できました。この構成をLAN越しに使う場合のアーキテクチャ的な論点は、別記事で整理します。