1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Llamex Sol版をLivebookで動かす 〜 量子化テンソルを展開せず15Mモデルを生成する

1
Last updated at Posted at 2026-08-25

はじめに

これまでの記事では、Elixir と Livebook を使って LLM の仕組みを学び、Elixir 製の LLM 推論エンジン Llamex の内部を見てきました

前回の記事には1つ宿題が残っていました

stories260K.gguf より大きな stories15M-q4_0.gguf を読み込もうとすると、5分待っても終わらなかったのです

原因は、GGUF 内で量子化されている重みを、読み込み時に Elixir の数値リストへすべて展開していたことでした

Llamex の実験的な派生版である Sol版 は、この問題へ正面から取り組んでいます

量子化されたバイナリを展開せずに保持し、必要なブロックだけを復元しながら計算する

この記事では、次の順番で Sol 版を見ていきます

  1. 量子化で誤差が生まれる様子をグラフで見る
  2. Q8_0 / Q4_0 のブロックをバイト単位で理解する
  3. CompactTensor を手作りして検算する
  4. 圧縮状態のまま行列とベクトルを掛ける仕組みを見る
  5. GGUF の互換性とテンソル構成を診断する
  6. 前回読み込めなかった15Mモデルを生成まで動かす

対象読者は次のような方です

  • Elixir はある程度書けるが、LLM の量子化は詳しく知らない
  • GGUF の Q4_0Q8_0 が、どのようなデータ構造なのか知りたい
  • モデルのファイルサイズと実行時メモリの違いを理解したい
  • 数式だけでなく、表やグラフを動かしながら学びたい

ノートブック

この記事で使うノートブックは GitHub で公開しています

以下のバッジから Livebook で直接開けます

Run in 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

sizes.png

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の復元値 段差が目立つ波形
復元誤差 各位置で元の値からどれだけずれたか

全タブの縦軸を同じ範囲へ固定しているため、線の見た目をそのまま比較できます

waves.gif

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つのタイルとして色分けします

blocks.png

先頭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 の部品と推論パイプラインを見ました

今回は一段下のデータ表現へ入り、モデルが動くかどうかは、重みを何ビットで保存するかだけでなく、実行時にどの形で保持するかで決まることを確認できました

ノートブックでは、量子化前後の波形、復元誤差、ブロックのバイト配置、テンソル形式ごとの容量を実際に操作しながら確認できます

ぜひ、セルを順番に実行して、量子化された重みが計算へ使われるまでを追ってみてください

参考リンク

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?