GPUを大量に買ったりしていると、時折GPUが余ることがある。
GPU単独で回すのも大事だが、それ以上にGPUが余る。余ったGPUのためにもう一台マシンを組もうとすると、中古品を組み合わせても筐体とマザボ、CPU、メモリやらで最低でも10万円コースになる。そこにGPUを組み合わせるわけだから全部で20万円は覚悟しなければならない。
お金だけの問題ではない。場合によっては電源を買い直さなきゃならないし、そもそも電源系を含む交換作業は面倒だ。下手すりゃ一時間くらいロスしてしまう。
ドスパラの中古で気まぐれに買ったものの、特に使っていない5060tiがあって、これを放置しておくのも勿体無いが、さりとてとりわけ使い道があるわけでもない、どうするか、と思っていたところ、RaspberryPi5にGPUを直結するというワザがあるらしいと知った。
そこで俺も真似してやってみようと思った。
材料は以下
・RaspberryPi5 ( ¥34,680)
・M.2 HAT (¥1,280)
・M.2→Oculinkアダプター(¥2,799)
・DEG1 外付けGPUドッキングステーション(¥14,384)
・電源(¥2,380)
というわけで、占めて¥55,523で揃うことになる。
ラズパイ8GBでよければさらに1万円節約できる。
あ、これとは別にPC電源ユニットが必要だが、これはうちに余っていたやつを使った。買っても7000円くらいだろう。
これに中古価格8万円くらいの5060tiをつけると、システム全体で14万円以下で揃ってしまう。
「流石にラズパイは厳しいのではないか」
と思っていたが杞憂だった。
確かにフルパワーでは戦いにくいかもしれないのだが、普通にGPUが動くのである。
LLMの推論も64Kコンテキストまでならgpt-oss-20bが問題なく動く。
gpt-oss-20bは、ちょうど一年前のo3-miniくらいの推論能力があると主張されている。スピードは72-75tok/sくらいで、十分実用域と思える。
CPUオフロードを使えば、128Kコンテキストでも動かなくはない。ただし遅い。7.5token/s位まで下がってしまう。
gpt-oss:20bも問題なく使えるちなみに、同じベンチマークを128GBのRAMを積んだM4 Maxで動かすと、80tok/sくらいになる。
MacBook Pro (M4 Max/128GB)このMacは100万円くらいした(4TB SSDというのもある)ので、ざっくり比較すればわずか15%の価格で90%の性能が出せることになる。バスの速度はGen3まで落とさなければいけないが、LLMマシンとしても十分戦える実力があるのだ。
驚いたのは、Flux 2 Klein 4Bが動いたことだ。FP8とtorch.compileを併用すると2.35秒で一枚の画像を書き上げる。当たり前だが、画質には全く問題がない。
画像生成が普通に速いこうすると、これまで「AIやるならシステムは最低30万円から」と言っていたのが、「色々我慢できるなら15万円から」にすることができる。
ただでさえ基盤剥き出しのラズパイに、まさに基盤剥き出しでGPUを乗せてさらに醜悪な電源ユニットまで剥き出しになるので、これを綺麗にケーシングするのはちょっと骨が折れそうだ。秋葉のケース屋にでも行っていいケースを買いたくなる。
あと、とにかくコンパクトというのがいい。
MicroATXで組んでもどうしても大きくなりがちになってしまう。それがこのくらいのサイズの筐体に収まるのであれば話はかなり変わってくる。
今、技研はGPUマシンだらけで文字通り足の踏み場もないのだが、ラズパイ+GPUの構成を少し工夫するだけでだいぶスペースに余裕を作れそうだ。5060tiはちょっと勿体無いかもしれないが、図体ばかりでかい3090とかをこっちに移設して純粋な計算リソースとして使うという使い方もあり得る。
ただ、注意しなければならないのは、システムRAMはVRAMの合計量より多く持つべき、という基本原則で、24GBのVRAMを持つ3090をラズパイの16GB システムRAMで完全に使い切るには少し工夫が必要になると思う。
あと注意点として、ラズパイとはいえこのレベルのマシンをSDカードで動かすのは怖いので外付けのUSB-SSDドライブを使っている。しかし複数台運用するなら、ネットワーク上のドライブを共有して使うという手もある。
とにかく、AIとの向き合い方に新しい姿勢ができたような衝撃がある。ぜひお試しあれ
LLMベンチマークのソースコードは以下。ollamaをインストールして事前に以下のコマンドを実行
$ ollama pull gpt-oss:20b
"""LLM Benchmark on Raspberry Pi 5 + RTX 5060 Ti via Ollama""" import json import time import subprocess import urllib.request OLLAMA_API = "http://localhost:11434/api/generate" MODELS = ["gpt-oss:20b"] PROMPTS = { "short": "What is the capital of France? Answer in one sentence.", "medium": "Explain how a neural network works in 3 paragraphs.", "long": "Write a detailed comparison of Python and Rust programming languages, covering syntax, performance, memory safety, ecosystem, and use cases. Write at least 500 words.", } def run_benchmark(model, prompt_name, prompt, num_runs=3): results = [] for i in range(num_runs): payload = json.dumps({ "model": model, "prompt": prompt, "stream": False, "options": {"num_gpu": 99} # offload all layers to GPU }).encode() req = urllib.request.Request(OLLAMA_API, data=payload, headers={"Content-Type": "application/json"}) start = time.time() with urllib.request.urlopen(req, timeout=300) as resp: data = json.loads(resp.read().decode()) wall_time = time.time() - start total_duration_s = data.get("total_duration", 0) / 1e9 load_duration_s = data.get("load_duration", 0) / 1e9 prompt_eval_count = data.get("prompt_eval_count", 0) prompt_eval_duration_s = data.get("prompt_eval_duration", 0) / 1e9 eval_count = data.get("eval_count", 0) eval_duration_s = data.get("eval_duration", 0) / 1e9 prompt_tps = prompt_eval_count / prompt_eval_duration_s if prompt_eval_duration_s > 0 else 0 gen_tps = eval_count / eval_duration_s if eval_duration_s > 0 else 0 results.append({ "run": i + 1, "prompt_tokens": prompt_eval_count, "gen_tokens": eval_count, "prompt_tps": prompt_tps, "gen_tps": gen_tps, "total_s": total_duration_s, "load_s": load_duration_s, "wall_s": wall_time, }) response_preview = data.get("response", "")[:100] print(f" Run {i+1}: prompt={prompt_tps:.1f} t/s, gen={gen_tps:.1f} t/s, " f"{eval_count} tokens in {eval_duration_s:.2f}s") return resultsdef gpu_stats(): try: out = subprocess.check_output( ["nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw", "--format=csv,noheader,nounits"], text=True ).strip() parts = out.split(", ") return { "gpu_util%": parts[0], "vram_used_mb": parts[1], "vram_total_mb": parts[2], "temp_c": parts[3], "power_w": parts[4], } except Exception: return {}def main(): print("=" * 70) print("LLM Benchmark: Raspberry Pi 5 + RTX 5060 Ti (16GB VRAM)") print("=" * 70) # Warmup - load model into GPU for model in MODELS: print(f"\n{'=' * 70}") print(f"Model: {model}") print(f"{'=' * 70}") # Warmup print(f" Warming up {model}...") payload = json.dumps({"model": model, "prompt": "Hi", "stream": False, "options": {"num_gpu": 99}}).encode() req = urllib.request.Request(OLLAMA_API, data=payload, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=600) as resp: _ = resp.read() print(" Warmup done.") gpu = gpu_stats() if gpu: print(f" GPU: {gpu['gpu_util%']}% util, {gpu['vram_used_mb']}/{gpu['vram_total_mb']} MB VRAM, " f"{gpu['temp_c']}°C, {gpu['power_w']}W") all_results = {} for prompt_name, prompt in PROMPTS.items(): print(f"\n --- Prompt: {prompt_name} (3 runs) ---") results = run_benchmark(model, prompt_name, prompt, num_runs=3) all_results[prompt_name] = results # Summary print(f"\n {'─' * 60}") print(f" Summary for {model}:") print(f" {'Prompt':<10} {'Prompt t/s':>12} {'Gen t/s':>12} {'Avg tokens':>12}") print(f" {'─' * 60}") for prompt_name, results in all_results.items(): avg_prompt_tps = sum(r["prompt_tps"] for r in results) / len(results) avg_gen_tps = sum(r["gen_tps"] for r in results) / len(results) avg_tokens = sum(r["gen_tokens"] for r in results) / len(results) print(f" {prompt_name:<10} {avg_prompt_tps:>12.1f} {avg_gen_tps:>12.1f} {avg_tokens:>12.0f}") gpu = gpu_stats() if gpu: print(f"\n GPU after benchmark: {gpu['temp_c']}°C, {gpu['power_w']}W") # Check processor info from ollama print(f"\n{'=' * 70}") print("Ollama model status:") try: out = subprocess.check_output(["ollama", "ps"], text=True) print(out) except Exception: pass print("Benchmark complete!")if __name__ == "__main__": main()