はじめに
これまでの記事では、Elixir と Livebook を使って LLM の仕組みを学び、Elixir 製の LLM 推論エンジン Llamex の内部を見てきました
前回の記事には1つ宿題が残っていました
stories260K.gguf より大きな stories15M-q4_0.gguf を読み込もうとすると、5分待っても終わらなかったのです
原因は、GGUF 内で量子化されている重みを、読み込み時に Elixir の数値リストへすべて展開していたことでした
Llamex の実験的な派生版である Sol版 は、この問題へ正面から取り組んでいます
量子化されたバイナリを展開せずに保持し、必要なブロックだけを復元しながら計算する
この記事では、次の順番で Sol 版を見ていきます
- 量子化で誤差が生まれる様子をグラフで見る
- Q8_0 / Q4_0 のブロックをバイト単位で理解する
-
CompactTensorを手作りして検算する - 圧縮状態のまま行列とベクトルを掛ける仕組みを見る
- GGUF の互換性とテンソル構成を診断する
- 前回読み込めなかった15Mモデルを生成まで動かす
対象読者は次のような方です
- Elixir はある程度書けるが、LLM の量子化は詳しく知らない
- GGUF の
Q4_0やQ8_0が、どのようなデータ構造なのか知りたい - モデルのファイルサイズと実行時メモリの違いを理解したい
- 数式だけでなく、表やグラフを動かしながら学びたい
ノートブック
この記事で使うノートブックは GitHub で公開しています
以下のバッジから Livebook で直接開けます
事前準備
Livebook のインストール方法は、公式サイトを参照してください
ノートブックを開くと、先頭の Mix.install/1 が必要なライブラリをインストールします
Mix.install([
{:llamex,
github: "piacerex/llamex-gpt-5.6-sol-medium",
ref: "3c9342a7e4b1494d2ef495b42688357100c4ceb3"},
{:nx, "~> 0.12.1"},
{:kino, "~> 0.15"},
{:kino_vega_lite, "~> 0.1"},
{:req, "~> 0.5"}
])
Sol 版の Elixir 要件は1.15以上です
再現性のため、ノートブックでは Llamex のコミット、モデルのリビジョン、SHA-256 を固定しています
後半では約19MBのモデルをダウンロードします。また、15Mモデルの読み込みには筆者環境で約42秒かかりました
Sol版の全体像
通常版と Sol 版では、量子化された重みの扱いが異なります
GGUF ファイルが小さくても、読み込み後に巨大な F32 リストを作れば、実行時メモリは大きくなります
Sol 版の中心は、ファイルだけでなく メモリ上の表現も圧縮状態に保つこと です
量子化すると、なぜ小さくなるのか
LLM の重みは実数です
F32 では1つの値を32ビットで表します
70億パラメータのモデルなら、重みだけで単純計算約28GBです
| 形式 | 1値あたりの目安 | 7Bパラメータの単純計算 |
|---|---|---|
| F32 | 32ビット | 28.0GB |
| F16 | 16ビット | 14.0GB |
| Q8_0 | 8.5ビット | 約7.4GB |
| Q4_0 | 4.5ビット | 約3.9GB |
Q4_0 の 0.5 ビット分は、32個の値で共有するスケールの容量です
量子化の基本は、次の1行で表せます
復元値 = スケール × 整数の量子値
ノートブックでは、なだらかな波を作り、F32、対称 int8、簡易4bitを比較します
original = Enum.map(0..31, fn i -> 4.0 * :math.sin(i / 4.0) end)
quantize = fn values, levels_max ->
max_abs = values |> Enum.map(&abs/1) |> Enum.max()
scale = max_abs / levels_max
quantized = Enum.map(values, &round(&1 / scale))
restored = Enum.map(quantized, &(&1 * scale))
%{scale: scale, quantized: quantized, restored: restored}
end
波形をタブで見比べる
元の値と復元値を1枚のグラフへ重ねると、値がほぼ一致した系列が下に隠れてしまいます
そこでノートブックでは、次のタブで1枚ずつ切り替えます
| タブ | 見るもの |
|---|---|
| 元の値(F32) | 滑らかな基準波形 |
| 対称int8の復元値 | F32 とほぼ同じ形 |
| 対称4bitの復元値 | 段差が目立つ波形 |
| 復元誤差 | 各位置で元の値からどれだけずれたか |
全タブの縦軸を同じ範囲へ固定しているため、線の見た目をそのまま比較できます
int8 は元の波形とほぼ同じですが、4bit は表現できる段階が少ないため、階段状の変化が見えます
なお、説明用の簡易4bitと、実際の Q4_0 は区別が必要です
| 方式 | 整数値 | 段階数 | 用途 |
|---|---|---|---|
| 説明用の対称4bit | -7..7 |
15 | 誤差を直感的に見る |
| 実際の Q4_0 | -8..7 |
16 | GGUF の量子化テンソル |
Q8_0 / Q4_0 の1ブロックをバイト単位で見る
Q8_0 と Q4_0 は、32個の値を1ブロックとして扱います
同じ32個の値を、F32なら128バイト、Q8_0なら34バイト、Q4_0なら18バイトで持てます
| 形式 | 32値の容量 | F32との比較 |
|---|---|---|
| F32 | 128バイト | 1.0倍 |
| Q8_0 | 34バイト | 約1/3.8 |
| Q4_0 | 18バイト | 約1/7.1 |
Livebook では、1バイトを1つのタイルとして色分けします
先頭2バイトがスケール、残りが量子値であること、Q4_0 の行が Q8_0 より短いことを目で確認できます
CompactTensor を手作りする
Sol 版では、量子化されたバイト列を Llamex.CompactTensor で保持します
まず Q8_0 のブロックを手作りします
# f16 の 1.0 をリトルエンディアンで表した2バイト
scale_1 = <<0x00, 0x3C>>
# スケール1.0 × 量子値1..32
row = scale_1 <> :erlang.list_to_binary(Enum.to_list(1..32))
{:ok, tensor} =
Llamex.CompactTensor.new(%{
shape: [32, 1],
type: :q8_0,
payload: row
})
Q4_0 では、1バイトを上位4ビットと下位4ビットに分け、2つの量子値を詰めます
格納コードは 0..15、復元に使う整数値は コード - 8 です
| 格納コード | 復元に使う整数値 |
|---|---|
| 0 | -8 |
| 1 | -7 |
| ... | ... |
| 8 | 0 |
| 9 | 1 |
| ... | ... |
| 15 | 7 |
例えば 0x98 は、上位ニブルのコード 9 と下位ニブルのコード 8 を1バイトに詰めたものです
Q4_0 はブロック内で下位ニブル16個、上位ニブル16個の順に並べます。そのため 0x98 を16バイト並べ、スケールを1.0にすると、0.0 が16個、続いて 1.0 が16個に復元されます
バイト列を手で作って検算すると、Q4_0 が曖昧な「圧縮形式」ではなく、具体的なレイアウトとして見えてきます
展開せずに行列とベクトルを掛ける
通常版は、モデルを読み込むときに量子化テンソル全体を F32 のリストへ展開していました
Sol 版は、圧縮バイナリの上を歩きながら必要なブロックだけを一時的に復元します
実装も、この図とほぼ同じです
defp compact_dot(payload, type, vector, offset, sum) do
block_bytes = compact_block_bytes(type)
<<block::binary-size(block_bytes), rest::binary>> = payload
values = compact_values(block, type, [])
block_sum = dot(values, slice_n(vector, offset, 32))
compact_dot(rest, type, vector, offset + 32, sum + block_sum)
end
ポイントは、モデル全体に相当する巨大な F32 行列を作らないことです
一度に復元するのは1ブロック、つまり32値だけです
直接実行に対応しているのは Q4_0 と Q8_0 です
ほかの対応量子化形式は compact な形で保持できますが、直接カーネルがない形式はバックエンド準備時に展開されます
実行経路をバックエンド契約として表す
Sol 版は「どのバックエンドが、どの形式を、どの経路で計算できるか」を Llamex.Backend.Contract で表します
| 実行経路 | 内容 | バックエンド |
|---|---|---|
:direct |
純 Elixir で圧縮バイナリを直接走査する | List / AtomVM |
:blockwise_compact |
ブロック単位で Nx 演算へ変換する | Nx / NxEXLA |
経路が違っても同じ答えになるかは、ゴールデン比較で確認できます
comparison =
Llamex.Backend.Quantized.compare(
[Llamex.Backend.Nx],
q8_tensor,
ones
)
ノートブックの手作りテンソルでは、純 Elixir の direct と Nx の blockwise_compact が、どちらも [528.0, 32.0] を返します
「高速そうだから正しいだろう」ではなく、単純な基準実装と結果を比較できる設計になっています
GGUF はモデルを組み立てる前に診断する
拡張子が .gguf でも、アーキテクチャ、量子化形式、メタデータ、テンソル名が Llamex の想定と一致するとは限りません
Sol 版には、互換性を調べる Mix タスクがあります
mix llamex.gguf.inspect --supported
mix llamex.gguf.inspect model.gguf --summary
mix llamex.gguf.inspect model.gguf --config
mix llamex.gguf.inspect model.gguf --schema
mix llamex.natural.smoke model.gguf 8
mix llamex.benchmark model.gguf --tokens 8,16 --backends list,nx
診断は、F32 リストへの復元やモデル構築より前に実行できます
現在の Reader は診断時にもファイル全体を読むため、「ファイルを一切読まずに判定する」という意味ではない点には注意が必要です
ノートブックでは、260Kモデルと15Mモデルについて次を表とグラフにします
- アーキテクチャと主要メタデータ
- テンソル型ごとの件数
- 圧縮時と F32 展開時の容量
- Attention / FFN の代表的なテンソル名と保持形式
前回読み込めなかった15Mモデルを動かす
いよいよ前回の宿題です
tensor_format: :compact を指定し、stories15M-q4_0.gguf を読み込みます
{:ok, model} =
Llamex.GGUF.load(path_15m,
tensor_format: :compact
)
読み込んだテンソルの保持形式を数えると、次の構成になります
| 保持形式 | テンソル数 | 主な用途 |
|---|---|---|
CompactTensor :q4_0 |
43 | Attention / FFN の重み |
CompactTensor :q8_0 |
1 | トークン埋め込み |
| F32テンソル | 13 | 正規化などの小さなテンソル |
Q4_0 の43テンソルは、圧縮状態で約8.1MB、F32へ展開すると約58MBです
Q8_0 の1テンソルは、圧縮状態で約9.3MB、F32へ展開すると約35MBです
モデル本体に当たる Attention と FFN の重みを圧縮状態のまま持てることが、大きな差になります
筆者環境での結果は次の通りでした
| 項目 | 通常版 | Sol版のcompact保持 |
|---|---|---|
| 15Mモデルの読み込み | 5分でも完了せず | 約42秒 |
| 保持メモリ | 展開できず測定不能 | 圧縮ペイロード約17.5MB |
| 生成 | 未到達 | {:ok, ", there was a"} |
1トークンの生成には筆者環境で約10秒かかりました
高速とは言えませんが、ここで重要なのは、前回「読み込みが終わらず試せなかった」モデルが生成まで到達したことです
Sol 版は、量子化モデルを読み込めるかどうかを決めるのがファイル容量だけではなく、メモリ上でどの表現を選ぶかであることを示しています
タプルベースに刷新されたAPI
Sol 版では、公開 API も整理されています
通常版の「map オプション + 結果 map」から、「keyword オプション + {:ok, _} タプル」へ変わりました
| 操作 | 通常版 | Sol版 |
|---|---|---|
| 読み込み | ModelLoader.load(path) |
Llamex.GGUF.load(path, tensor_format: :compact) |
| 準備 | prepare_model(model, Backend.List) |
prepare_model(model, backend: Backend.List) |
| 生成 | generate(model, prompt, %{max_new_tokens: 8}) |
generate(engine, prompt, max_tokens: 8) |
| 戻り値 | 結果map、失敗時は例外 |
{:ok, value} / {:error, reason}
|
15Mモデルのロードから生成までを、新しい公開 API で書くと次の形です
with {:ok, model} <- Llamex.GGUF.load(path_15m, tensor_format: :compact),
{:ok, engine} <- Llamex.prepare_model(model, backend: Llamex.Backend.List),
{:ok, text} <- Llamex.generate(engine, "Once upon a time", max_tokens: 4) do
text
end
時間のかかるモデル読み込みでは、失敗理由を値として扱えることが重要です
量子化形式やメタデータの非互換も、呼び出し側で明示的に分岐できるようになります
Luna版との関係
Sol 版が扱うのは、主に重みの表現とメモリの問題です
もう1つの派生版である Luna 版は、Nx なしの推論経路を AtomVM 向けに切り出す「実行環境」の問題を扱います
| 派生版 | 主な問い |
|---|---|
| Sol版 | 重みをどの表現でメモリへ置くか |
| Luna版 | どのコードと依存を小さな実行環境へ持ち込むか |
現在は別々の実験的フォークであり、このノートブックで Sol の compact モデルを AtomVM 上へ載せたわけではありません
Luna 版については、別の記事で詳しく扱います
まとめ
この記事では、Llamex Sol 版の量子化テンソルを Livebook で追いました
- 量子化は「スケール × 整数」で実数を近似し、モデルの容量を小さくする
- Q8_0 は32値を34バイト、Q4_0は32値を18バイトで保持する
- 説明用の対称4bitは15段階、実際のQ4_0は16段階なので区別する
-
CompactTensorは量子化ペイロードをバイナリのまま保持する - Sol 版は必要な1ブロックだけを復元し、モデル全体の F32 行列を作らずに計算する
- バックエンド契約とゴールデン比較で、異なる実行経路の対応状況と結果を確認できる
- 前回5分待っても読み込めなかった15Mモデルが、筆者環境では約42秒で読み込め、生成まで到達した
- 公開APIはkeywordオプションと
{:ok, _}/{:error, _}タプルを使う形へ整理された
前回は Llamex の部品と推論パイプラインを見ました
今回は一段下のデータ表現へ入り、モデルが動くかどうかは、重みを何ビットで保存するかだけでなく、実行時にどの形で保持するかで決まることを確認できました
ノートブックでは、量子化前後の波形、復元誤差、ブロックのバイト配置、テンソル形式ごとの容量を実際に操作しながら確認できます
ぜひ、セルを順番に実行して、量子化された重みが計算へ使われるまでを追ってみてください


