Powered by AppSignal & Oban Pro

Llamex Sol版: 圧縮量子化テンソルと GGUF 検証を学ぶ

llamex_sol_compact_quantization.livemd

Llamex Sol版: 圧縮量子化テンソルと GGUF 検証を学ぶ

Mix.install([
  {:llamex, github: "piacerex/llamex-gpt-5.6-sol-medium"},
  {:nx, "~> 0.12.1"},
  {:kino, "~> 0.15"},
  {:kino_vega_lite, "~> 0.1"},
  # GGUF モデルのダウンロードに使う
  {:req, "~> 0.5"}
])

このノートブックについて

このノートブックは、Llamex: Elixir 製 LLM 推論エンジンの中身を覗く の続編です。

Llamex には、異なる方向へ発展させた実験的な派生バージョンが公開されています。

バージョン リポジトリ フォーカス
通常版(前作で使用) piacerex/llamex 最小構成の推論エンジン
Sol 版(このノートブック) piacerex/llamex-gpt-5.6-sol-medium 圧縮量子化テンソルの直接実行・GGUF 検証タスク
Luna 版 piacerex/llamex-gpt-5.6-luna-xhigh 組込向け AtomVM 化

前作には宿題が残っていました。1500 万パラメータの stories15M-q4_0.gguf は、量子化テンソルを Elixir の数値リストへ全展開する設計のせいで、5 分待っても読み込みが終わりませんでした。

Sol 版の中心は、まさにこの問題への回答です。量子化されたバイナリを展開せずそのまま保持し(compact)、計算のたびに必要なブロックだけを復元する仕組みが入っています。このノートブックでは次を確認します。

  1. 量子化の考え方 — 実数を「スケール × 整数」で近似する仕組みをグラフで見る
  2. Q4_0 / Q8_0 量子化ブロックの中身(バイト単位で手作りする)
  3. Llamex.CompactTensor — 展開しないテンソルの契約
  4. 展開せずに行列×ベクトルを計算する仕組み
  5. バックエンド契約 — directblockwise_compact という2つの実行経路
  6. GGUF 検証ツール群(mix llamex.gguf.inspect など)の中身
  7. 前作の宿題だった 15M モデルを実際に動かす
  8. {:ok, _} タプルベースに刷新された新 API

実行前の注意

Sol 版の Elixir 要件は >= 1.15 に緩和されています(通常版は 1.19 以上でした)。Hex には公開されていないため、GitHub から直接取得します。

第7節以降で 19MB のモデルをダウンロードし、第8節には数十秒〜1分程度かかるセルがあります。

準備

Kino.DataTable.new([
  %{項目: "Elixir", : System.version()},
  %{項目: "Llamex (Sol)", : to_string(Application.spec(:llamex, :vsn))},
  %{項目: "Nx", : to_string(Application.spec(:nx, :vsn))}
])
defmodule LlamexSol.Visuals do
  # カテゴリごとの大きさを比べる棒グラフ(系列の色分け対応)
  def bar_chart(rows, title, x_field, y_field, opts \\ []) do
    width = Keyword.get(opts, :width, 640)
    height = Keyword.get(opts, :height, 300)
    color_field = Keyword.get(opts, :color_field)
    x_sort = Keyword.get(opts, :x_sort)

    x_opts =
      [type: :nominal, title: Atom.to_string(x_field)] ++
        if(x_sort, do: [sort: x_sort], else: [])

    chart =
      VegaLite.new(width: width, height: height, title: title)
      |> VegaLite.data_from_values(rows)
      |> VegaLite.mark(:bar, tooltip: true)
      |> VegaLite.encode_field(:x, Atom.to_string(x_field), x_opts)
      |> VegaLite.encode_field(:y, Atom.to_string(y_field),
        type: :quantitative,
        title: Atom.to_string(y_field)
      )

    chart =
      if color_field do
        chart
        |> VegaLite.encode_field(:color, Atom.to_string(color_field), type: :nominal)
        |> VegaLite.encode_field(:x_offset, Atom.to_string(color_field))
      else
        chart
      end

    Kino.VegaLite.new(chart)
  end

  # 系列を色分けした折れ線グラフ
  def line_chart(rows, title, x_field, y_field, color_field, opts \\ []) do
    width = Keyword.get(opts, :width, 640)
    height = Keyword.get(opts, :height, 300)
    point = Keyword.get(opts, :point, true)

    VegaLite.new(width: width, height: height, title: title)
    |> VegaLite.data_from_values(rows)
    |> VegaLite.mark(:line, point: point, tooltip: true)
    |> VegaLite.encode_field(:x, Atom.to_string(x_field),
      type: :quantitative,
      title: Atom.to_string(x_field)
    )
    |> VegaLite.encode_field(:y, Atom.to_string(y_field),
      type: :quantitative,
      title: Atom.to_string(y_field)
    )
    |> VegaLite.encode_field(:color, Atom.to_string(color_field), type: :nominal)
    |> Kino.VegaLite.new()
  end

  # バイト列のレイアウトを1バイト=1タイルで描く
  def byte_layout(rows, title) do
    VegaLite.new(width: 680, height: 120, title: title)
    |> VegaLite.data_from_values(rows)
    |> VegaLite.mark(:rect, tooltip: true, stroke: "white", stroke_width: 1)
    |> VegaLite.encode_field(:x, "バイト位置", type: :ordinal)
    |> VegaLite.encode_field(:y, "ブロック", type: :nominal, title: nil)
    |> VegaLite.encode_field(:color, "役割", type: :nominal)
    |> Kino.VegaLite.new()
  end
end

1. 量子化とは — 実数を「スケール × 整数」で近似する

まず「なぜ量子化するのか」からです。前作で動かした 260K モデルの GGUF はわずか 1.1MB でしたが、実用的な LLM は桁違いに大きく、例えば 70 億(7B)パラメータのモデルを F32 のまま保存すると 28GB になります。重み 1 個あたりのビット数を減らせば、容量はそれに比例して縮みます。

params_7b = 7_000_000_000

sizes =
  [
    {"F32", 32},
    {"F16", 16},
    {"Q8_0", 8.5},
    {"Q4_0", 4.5}
  ]
  |> Enum.map(fn {label, bits} ->
    %{形式: "#{label}#{bits}ビット)", GB: Float.round(params_7b * bits / 8 / 1.0e9, 1)}
  end)

LlamexSol.Visuals.bar_chart(
  sizes,
  "7B パラメータのモデルに必要な容量 — ビット数に比例して縮む",
  :形式,
  :GB,
  x_sort: Enum.map(sizes, & &1[:形式])
)

28GB は一般的な PC のメモリに載りませんが、Q4_0 の約 3.9GB なら載ります。モデルが手元で動くかどうかを左右するのがこの差です。ダウンロードした GGUF のファイル名に q4_0q8_0 と付いているのは、この量子化方式の名前です。

では、その中身に入ります。量子化(quantization)は、32 ビット浮動小数点数(F32)で持っていた重みを、もっと少ないビット数の整数に置き換える圧縮手法です。仕組みは1行で書けます。

復元値 = スケール × 整数の量子値

グループ内で一番大きい絶対値が整数の上限(int8 なら 127、4bit なら 7)にちょうど収まるようにスケールを決め、各値を「スケール何個分か」に丸めます。式だけでは掴みにくいので、実際にやってみましょう。なだらかな波を描く 32 個の F32 値を量子化します。

original = Enum.map(0..31, fn i -> Float.round(4.0 * :math.sin(i / 4.0), 4) end)

# levels_max: 整数側の最大値(int8 は 127、4bit の符号付き相当は 7)
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

q8 = quantize.(original, 127)
q4 = quantize.(original, 7)

Kino.DataTable.new([
  %{方式: "Q8_0 相当(int8)", スケール: Float.round(q8.scale, 5), 量子値の範囲: "-127 〜 127"},
  %{方式: "Q4_0 相当(4bit)", スケール: Float.round(q4.scale, 5), 量子値の範囲: "-7 〜 7"}
])

元の値と、量子化してから復元した値を重ねて描きます。

wave_rows =
  original
  |> Enum.with_index()
  |> Enum.flat_map(fn {value, index} ->
    [
      %{位置: index, : value, 系列: "元の値(F32)"},
      %{位置: index, : Float.round(Enum.at(q8.restored, index), 4), 系列: "Q8_0 相当の復元値"},
      %{位置: index, : Float.round(Enum.at(q4.restored, index), 4), 系列: "Q4_0 相当の復元値"}
    ]
  end)

LlamexSol.Visuals.line_chart(
  wave_rows,
  "量子化してから復元しても、波の形はほぼ保たれる",
  :位置,
  :値,
  :系列
)

Q8_0 相当(256 段階)は元の線とほぼ重なり、Q4_0 相当(15 段階)はところどころ段差が見えます。誤差だけを取り出して比べると、ビット数と精度のトレードオフがはっきりします。

error_rows =
  original
  |> Enum.with_index()
  |> Enum.flat_map(fn {value, index} ->
    [
      %{
        位置: index,
        誤差: Float.round(abs(Enum.at(q8.restored, index) - value), 4),
        系列: "Q8_0 相当"
      },
      %{
        位置: index,
        誤差: Float.round(abs(Enum.at(q4.restored, index) - value), 4),
        系列: "Q4_0 相当"
      }
    ]
  end)

LlamexSol.Visuals.line_chart(
  error_rows,
  "復元誤差の比較 — ビット数を削るほど誤差が増える",
  :位置,
  :誤差,
  :系列
)

誤差の上限は「スケールの半分」です。Q4_0 相当はスケールが大きい(目盛りが粗い)ので、誤差もそのぶん大きくなります。

なぜ誤差が出るのかは、「入力の実数 → 復元値」の対応を描くと一目で分かります。量子化は、連続な実数をとびとびの段に丸める操作です。

stair_rows =
  Enum.flat_map(-40..40, fn i ->
    x = i / 10
    [
      %{入力: x, 復元値: x, 系列: "F32(そのまま)"},
      %{
        入力: x,
        復元値: Float.round(round(x / q4.scale) * q4.scale, 4),
        系列: "Q4_0 相当(15段の階段)"
      }
    ]
  end)

LlamexSol.Visuals.line_chart(
  stair_rows,
  "量子化は実数を階段状に丸める",
  :入力,
  :復元値,
  :系列,
  point: false
)

斜めの直線(F32)に対して、Q4_0 相当は 15 段の階段です。階段の上に乗っている値はそのまま復元されますが、段と段の間の値は近い段へ丸められます。これが量子化誤差の正体です。

実際の GGUF では、この「グループごとにスケールを決めて丸める」操作を 32 個ずつのブロック単位で行います。テンソル全体で1つのスケールを使うと外れ値に引きずられて目盛りが粗くなりますが、32 個ごとにスケールを持てば、目盛りが局所的な値の大きさに寄り添うので誤差を抑えられます。

2. 量子化ブロックの解剖

前作で触れた通り、GGUF の量子化は 32 個(K 系は 256 個)の値を1ブロックにまとめ、ブロックごとに f16 のスケール値と縮めた量子値を持ちます。

Kino.Mermaid.new("""
flowchart TD
  A["F32 の重み 32 個 = 128 バイト"] --> B["ブロック量子化"]
  B --> C["Q8_0 ブロック = 34 バイト<br/>f16 スケール 2B + int8 × 32"]
  B --> D["Q4_0 ブロック = 18 バイト<br/>f16 スケール 2B + 4bit × 32 = 16B"]
  C --> E["復元: 量子値 × スケール"]
  D --> E
""")

Sol 版はこのブロック構造を Llamex.CompactTensor という依存ゼロの構造体として定義しています。対応する全型のレイアウトを一覧にしてみます。

Llamex.CompactTensor.supported_types()
|> Enum.map(fn type ->
  {:ok, layout} = Llamex.CompactTensor.layout(type)

  %{
    : to_string(type),
    ブロックあたりの値数: layout.block_size,
    ブロックのバイト数: layout.block_bytes,
    スケールの型: inspect(layout.scale_type),
    "1値あたりビット数": Float.round(layout.block_bytes * 8 / layout.block_size, 1)
  }
end)
|> Kino.DataTable.new(
  keys: [:型, :ブロックあたりの値数, :ブロックのバイト数, :スケールの型, :"1値あたりビット数"]
)

F32 は 1 値 32 ビットですが、Q8_0 は 8.5 ビット、Q4_0 は 4.5 ビットです。端数の 0.5 ビットはブロック共有のスケール値の分です。

Q8_0 と Q4_0 の 1 ブロックを 1 バイト=1 タイルで描くと、構造の単純さが見えます。どちらも先頭 2 バイトはブロック共有のスケール(f16)で、残りが量子値です。

layout_rows =
  Enum.map(0..33, fn i ->
    %{
      ブロック: "Q8_0(34バイト = 32値)",
      バイト位置: i,
      役割: if(i < 2, do: "スケール(f16)", else: "量子値(1バイトに1値)")
    }
  end) ++
    Enum.map(0..17, fn i ->
      %{
        ブロック: "Q4_0(18バイト = 32値)",
        バイト位置: i,
        役割: if(i < 2, do: "スケール(f16)", else: "量子値(1バイトに2値)")
      }
    end)

LlamexSol.Visuals.byte_layout(layout_rows, "1ブロックのバイトレイアウト — 同じ32個の値がこの長さの差になる")

同じ 32 個の値を、Q8_0 は 34 バイト、Q4_0 は 18 バイトで持ちます。Q4_0 の行が短いのは、1 バイトに 4 ビット値を 2 個詰めているからです。「1 つの値を何ビットで表すか」に換算して並べると、圧縮の度合いが直感的になります。

LlamexSol.Visuals.bar_chart(
  [
    %{形式: "F32", "1値あたりビット数": 32.0},
    %{形式: "Q8_0", "1値あたりビット数": 8.5},
    %{形式: "Q4_0", "1値あたりビット数": 4.5}
  ],
  "1値あたりのビット数 — Q4_0 は F32 の約 1/7",
  :形式,
  :"1値あたりビット数"
)

3. CompactTensor を手作りする(Q8_0)

仕組みを本当に理解したか確かめる一番の方法は、バイナリを自分で組んで、期待通りの計算結果が出るか見ることです。

Q8_0 の 1 ブロックは f16 スケール(2バイト) + int8 の量子値 × 32 です。f16 は 16 ビット(2バイト)の浮動小数点数で、F32 の半分の精度しかありませんが、スケールはブロック内の 32 値で共有する1個だけなので、この精度で十分という割り切りです。

f16 のビットパターンは規格(IEEE 754 half)で決まっていて、1.00x3C000.50x3800 です。GGUF はリトルエンディアン(下位バイトが先に来る並び順)なので、バイト列は次のように書けます。

# f16 をリトルエンディアンのバイト列で直書きする
scale_1_0 = <<0x00, 0x3C>>
scale_0_5 = <<0x00, 0x38>>

# 1行目: スケール1.0 × 量子値 1..32 -> 復元値は 1.0..32.0
row_1 = scale_1_0 <> :binary.list_to_bin(Enum.to_list(1..32))

# 2行目: スケール0.5 × 量子値 2 が32個 -> 復元値はすべて 1.0
row_2 = scale_0_5 <> :binary.list_to_bin(List.duplicate(2, 32))

# shape は [入力次元, 出力行数]
{:ok, q8_tensor} =
  Llamex.CompactTensor.new(%{shape: [32, 2], type: :q8_0, payload: row_1 <> row_2})

q8_tensor.type

確認には行列×ベクトル(matvec)を使います。matvec を選ぶのは、これが LLM 推論の計算のほぼすべてだからです。前作で見たアテンションの Q/K/V も FFN も、中身は「重み行列 × ベクトル」でした。つまり量子化テンソルで matvec が正しく動けば、推論全体が動きます。

このテンソルに、要素がすべて 1.0 の32次元ベクトルを掛けます。期待値は手計算できます。

  • 1行目: 1.0 + 2.0 + ... + 32.0 = 528.0
  • 2行目: 1.0 × 32 = 32.0
ones = List.duplicate(1.0, 32)

Llamex.Backend.List.matvec_tensor(q8_tensor, ones)

[528.0, 32.0] — 手で組んだ 68 バイトのバイナリが、正しく行列として振る舞いました。

内積の合計だけでなく、1 要素ずつの復元値も取り出して確かめられます。i 番目だけ 1.0 の単位ベクトルを掛ければ、各行の i 番目の復元値がそのまま返ってくるからです。実際のカーネルを通して復元値を描いてみます。

restored_rows =
  Enum.flat_map(0..31, fn index ->
    basis = List.duplicate(0.0, 32) |> List.replace_at(index, 1.0)
    [row1, row2] = Llamex.Backend.List.matvec_tensor(q8_tensor, basis)

    [
      %{位置: index, 復元値: Float.round(row1, 4), : "1行目(スケール1.0 × 量子値1..32)"},
      %{位置: index, 復元値: Float.round(row2, 4), : "2行目(スケール0.5 × 量子値2)"}
    ]
  end)

LlamexSol.Visuals.line_chart(
  restored_rows,
  "手作り Q8_0 ブロックの復元値 — 狙い通りの斜め線と水平線",
  :位置,
  :復元値,
  :行
)

1 行目は 1.0〜32.0 の斜め線、2 行目はすべて 1.0 の水平線。第1節の式「復元値 = スケール × 量子値」が、バイナリレベルでその通りに動いていることの確認です。

CompactTensor.contract/1 を使うと、このテンソルの「契約情報」——F32 に展開した場合との比較——が取れます。

contract = Llamex.CompactTensor.contract(q8_tensor)

Kino.DataTable.new([
  %{項目: "型", : inspect(contract.type)},
  %{項目: "形状", : inspect(contract.shape)},
  %{項目: "圧縮ペイロード", : "#{contract.payload_bytes} バイト"},
  %{項目: "F32 に展開した場合", : "#{contract.dense_f32_bytes} バイト"},
  %{項目: "展開倍率", : "#{Float.round(contract.f32_expansion_ratio, 2)} 倍"}
])

4. Q4_0 も手作りする

Q4_0 は 1 バイトに 4 ビット値を2個詰めます。1 バイト(8 ビット)の前半 4 ビットを上位ニブル、後半 4 ビットを下位ニブルと呼びます(ニブル = 4 ビットのこと)。

4 ビットで表せるのは 0〜15 の 16 通りだけです。負の値も扱えるように真ん中の 8 を「ゼロ」とみなし、復元は (量子値 - 8) × スケール で行います。ブロック内の並び順は、下位ニブル 16 個 → 上位ニブル 16 個です。

全バイトを 0x98 にした場合を分解してみます。

byte = 0x98
low_nibble = rem(byte, 16)
high_nibble = div(byte, 16)

Kino.DataTable.new([
  %{項目: "バイト値", : "0x98 = 2進数で 1001 1000"},
  %{
    項目: "上位ニブル(前半4ビット 1001)",
    : "量子値 #{high_nibble} → 復元値 (#{high_nibble} - 8) × 1.0 = 1.0"
  },
  %{
    項目: "下位ニブル(後半4ビット 1000)",
    : "量子値 #{low_nibble} → 復元値 (#{low_nibble} - 8) × 1.0 = 0.0"
  }
])

つまり 0x98 を 16 バイト並べると、復元値は「0.0 が 16 個(下位ニブル分)、1.0 が 16 個(上位ニブル分)」になります。

# スケール1.0 + 0x98 × 16バイト = 18バイトで1ブロック
q4_row = scale_1_0 <> :binary.copy(<<0x98>>, 16)

{:ok, q4_tensor} = Llamex.CompactTensor.new(%{shape: [32, 1], type: :q4_0, payload: q4_row})

# 復元値は [0.0 × 16, 1.0 × 16] なので、1ベクトルとの内積は 16.0
Llamex.Backend.List.matvec_tensor(q4_tensor, ones)

2つの手作りテンソルの圧縮率を並べると、量子化の効果が見えます。

[
  {q8_tensor, "Q8_0(手作り)"},
  {q4_tensor, "Q4_0(手作り)"}
]
|> Enum.map(fn {tensor, label} ->
  contract = Llamex.CompactTensor.contract(tensor)

  %{
    テンソル: label,
    圧縮バイト: contract.payload_bytes,
    展開後バイト: contract.dense_f32_bytes,
    展開倍率: Float.round(contract.f32_expansion_ratio, 2)
  }
end)
|> Kino.DataTable.new(keys: [:テンソル, :圧縮バイト, :展開後バイト, :展開倍率])

5. 展開せずに計算する仕組み

前作の通常版は、量子化テンソルを読み込み時に全部 F32 リストへ展開していました。LLM は 1 トークン生成するたびに全層の重み行列で matvec を実行するので、重みをどんな形で持つかが、メモリと速度を直接決めます。

Sol 版の Llamex.Backend.List.Math は、展開する代わりに圧縮バイナリの上を歩きながら計算します。流れはこうです。

Kino.Mermaid.new("""
flowchart TD
  P["圧縮ペイロード(1行分のバイナリ)"] --> B1["先頭のブロックを切り出す<br/>(Q4_0 なら 18 バイト)"]
  B1 --> R1["32 個の値だけ一時的に復元"]
  R1 --> D1["ベクトルの対応する 32 要素と部分内積"]
  D1 --> ADD["行の合計に加算"]
  ADD -->|"次のブロックへ<br/>(復元した 32 値はすぐ捨てられる)"| B1
""")

matvec の実装(抜粋)を見てください。上の図がほぼそのままコードになっています。

def matvec(%CompactTensor{} = tensor, vector) do
  [input_size, output_size] = tensor.shape
  row_bytes = div(input_size, 32) * compact_block_bytes(tensor.type)

  for row <- 0..(output_size - 1) do
    # この行のバイナリだけを切り出し、
    payload = binary_part(tensor.payload, row * row_bytes, row_bytes)
    # ブロックごとに 32 値だけ復元しながら部分内積を足し込む
    compact_dot(payload, tensor.type, vector, 0, 0.0)
  end
end

defp compact_dot(payload, type, vector, offset, sum) do
  <<block::binary-size(compact_block_bytes(type)), rest::binary>> = payload
  values = compact_values(block, type, [])          # 32 値だけ一時的に復元
  block_sum = dot(values, slice_n(vector, offset, 32))
  compact_dot(rest, type, vector, offset + 32, sum + block_sum)
end

ポイントは3つです。

  • モデル全体の F32 行列は一度も作られない — メモリに存在するのは GGUF から切り出した圧縮バイナリそのもの
  • 復元されるのは常に 1 ブロック=32 値だけ。使い終わればすぐ GC される
  • この直接カーネルを持つのは Q4_0 / Q8_0。それ以外の量子化形式(Q5_0、Q2_K〜Q8_K など)は保持はできるが、バックエンド準備時に一度だけ展開される

Nx / NxEXLA バックエンドも同じ圧縮ペイロードを受け取り、ブロック単位の Nx 演算として処理します(blockwise_compact)。README はこれを「カスタム CUDA カーネルではない」と明記しています。GPU 直行はまだ未完了で、これが「圧縮Q対応(直GPU未完了)」の意味です。

6. バックエンド契約 — 実行経路を宣言する

Sol 版は「どのバックエンドが、どの量子化形式を、どの方式で実行できるか」を Llamex.Backend.Contract として明文化しています。

Llamex.Backend.Contract.matrix()
|> Enum.map(fn {backend, caps} ->
  %{
    バックエンド: backend |> inspect() |> String.replace("Llamex.Backend.", ""),
    ランタイム: inspect(caps.runtime),
    状態: inspect(caps.status),
    対応テンソル形式: caps.tensor_formats |> Enum.map(&to_string/1) |> Enum.join(", "),
    量子化の実行経路: inspect(caps.quantizations)
  }
end)
|> Kino.DataTable.new(
  keys: [:バックエンド, :ランタイム, :状態, :対応テンソル形式, :量子化の実行経路]
)

実行経路は2種類あります。

経路 意味 使うバックエンド
:direct 圧縮バイナリをブロック走査しながら純 Elixir で直接計算 List / AtomVM
:blockwise_compact 圧縮ペイロードをブロック単位の Nx 演算へ変換 Nx / NxEXLA

個別のテンソルについて問い合わせることもできます。

Kino.DataTable.new([
  %{
    バックエンド: "List",
    手作りテンソルの実行経路:
      inspect(Llamex.Backend.Contract.execution_path(Llamex.Backend.List, q8_tensor))
  },
  %{
    バックエンド: "Nx",
    手作りテンソルの実行経路:
      inspect(Llamex.Backend.Contract.execution_path(Llamex.Backend.Nx, q8_tensor))
  }
])

ゴールデン比較 — 経路が違っても答えは同じか

実行経路が2つあるなら、「両方が同じ答えを返すこと」の検証が必要です。Sol 版には、そのための関数が最初から入っています。

comparison = Llamex.Backend.Quantized.compare([Llamex.Backend.Nx], q8_tensor, ones)

Kino.DataTable.new([
  %{
    項目: "List(基準値・direct)",
    : inspect(comparison.reference)
  },
  %{
    項目: "Nx(blockwise_compact)",
    : inspect(comparison.results[Llamex.Backend.Nx].output)
  },
  %{
    項目: "基準値と一致するか",
    : inspect(comparison.results[Llamex.Backend.Nx].matches_reference?)
  },
  %{
    項目: "モデル全体のF32展開が起きたか",
    : inspect(comparison.results[Llamex.Backend.Nx].diagnostics.full_model_f32_expansion?)
  }
])

純 Elixir のブロック走査と Nx のブロック演算が、同じ 68 バイトから同じ [528.0, 32.0] を出しました。診断には「一度に密になるのは 1 ブロック分(128 バイト)だけ」という情報も含まれています。

7. GGUF 検証ツール — 読み込む前に調べ尽くす

Sol 版のもう1つの柱が「GGUF検証用 mix task」です。設計思想は README の一文に集約されています。

Compatibility is deliberately diagnosed before tensor payloads are loaded; a .gguf extension alone does not imply support. (互換性はテンソルを読み込む前に意図的に診断される。拡張子が .gguf だからといって対応しているとは限らない)

コマンドラインからは次のように使います。

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

Livebook からは、タスクが内部で呼ぶ関数を直接使えます。まずモデル不要の「対応面」から。

surface = Llamex.GGUF.Diagnostic.supported_surface()

Kino.DataTable.new([
  %{項目: "対応アーキテクチャ", : Enum.join(surface.supported_architectures, ", ")},
  %{項目: "対応テンソル型", : Enum.join(surface.supported_tensor_type_names, ", ")},
  %{項目: "対応トークナイザー", : Enum.join(surface.supported_tokenizer_models, ", ")}
])

実モデルを診断します。前作と同じ TinyStories の2つをダウンロードします。

models_dir = Path.join(System.tmp_dir!(), "llamex_models")
File.mkdir_p!(models_dir)

download = fn filename ->
  path = Path.join(models_dir, filename)

  unless File.exists?(path) do
    Req.get!(
      "https://huggingface.co/ggml-org/models/resolve/main/tinyllamas/#{filename}",
      into: File.stream!(path),
      redirect: true
    )
  end

  path
end

path_260k = download.("stories260K.gguf")
path_15m = download.("stories15M-q4_0.gguf")

Kino.DataTable.new([
  %{
    モデル: "stories260K.gguf",
    サイズ: "#{Float.round(File.stat!(path_260k).size / 1_048_576, 2)} MB"
  },
  %{
    モデル: "stories15M-q4_0.gguf",
    サイズ: "#{Float.round(File.stat!(path_15m).size / 1_048_576, 2)} MB"
  }
])

1つ注意点があります。Sol 版のファイル読み込み(Reader.read_file)はディレクトリ部分を 64KB ずつ増分で読む設計ですが、語彙 32,000 のモデルではメタデータが数 MB あり、値の途中で切れたときのリトライ判定に不備があるため失敗します(筆者環境では {:unsupported_metadata_type, 6} というエラーになりました)。ファイル全体を読んでから渡す read_binary / load_binary_result を使えば回避できます。

binary_15m = File.read!(path_15m)
reader_15m = Llamex.GGUF.read_binary(binary_15m)

diagnostic = Llamex.GGUF.Diagnostic.inspect(reader_15m)

Kino.DataTable.new([
  %{項目: "アーキテクチャ", : inspect(diagnostic.architecture)},
  %{項目: "読み込み可能か", : inspect(diagnostic.loadable?)},
  %{項目: "互換性の問題", : inspect(diagnostic.compatibility_issues)},
  %{
    項目: "GGUF ペイロード合計",
    : "#{Float.round(diagnostic.gguf_payload_bytes / 1_048_576, 1)} MB"
  },
  %{
    項目: "F32 に展開した場合",
    : "#{Float.round(diagnostic.eager_f32_bytes / 1_048_576, 1)} MB"
  },
  %{
    項目: "展開倍率",
    : "#{Float.round(diagnostic.eager_f32_expansion_ratio, 2)} 倍"
  }
])

読み込む前に「このモデルを展開すると 93MB、compact なら 17.5MB」と分かるのが診断の価値です。型ごとの内訳も見てみます。

type_rows =
  diagnostic.tensor_payload_by_type
  |> Enum.flat_map(fn {type, info} ->
    [
      %{
        : type,
        系列: "GGUF(圧縮のまま)",
        MB: Float.round(info.gguf_payload_bytes / 1_048_576, 2)
      },
      %{
        : type,
        系列: "F32 に展開した場合",
        MB: Float.round(info.eager_f32_bytes / 1_048_576, 2)
      }
    ]
  end)

LlamexSol.Visuals.bar_chart(
  type_rows,
  "stories15M-q4_0: テンソル型ごとの圧縮サイズと展開後サイズ",
  :型,
  :MB,
  color_field: :系列
)

Q4_0 の 43 テンソル(アテンションと FFN の重み)は 8.1MB → 展開すると 58MB。Q8_0 の 1 テンソル(トークン埋め込み)は 9.3MB → 35MB。モデルの本体はほぼ量子化テンソルなので、展開しない効果がそのまま効きます。

--config 相当の設計値レポートは、欠けているメタデータまで教えてくれます。

config_report = Llamex.GGUF.ModelLoader.model_config_report(reader_15m)

missing =
  config_report["missing_metadata"]
  |> Enum.map(fn entry ->
    %{設定名: entry.name, 期待されるメタデータキー: entry.metadata_key}
  end)

Kino.Layout.grid(
  [
    Kino.DataTable.new(
      Enum.map(config_report["config"], fn {key, value} -> %{設定: key, : inspect(value)} end)
    ),
    Kino.DataTable.new(missing, keys: [:設定名, :期待されるメタデータキー])
  ],
  columns: 1
)

このモデルには rope_thetaattention_head_count_kv のメタデータが無く、既定値で補われることが分かります。「黙って動く」のではなく「何を補ったか言う」のが Sol 版の一貫した態度です。

8. 前作の宿題 — 15M モデルを動かす

いよいよ本題です。前作で 5 分待っても読み込めなかった stories15M-q4_0.gguf を、tensor_format: :compact で読み込みます。

{load_microseconds, {:ok, model_15m}} =
  :timer.tc(fn ->
    Llamex.GGUF.ModelLoader.load_binary_result(binary_15m, tensor_format: :compact)
  end)

Kino.DataTable.new([
  %{項目: "読み込み時間", : "#{Float.round(load_microseconds / 1_000_000, 1)} 秒"},
  %{項目: "語彙数", : inspect(model_15m.config.vocab_size)},
  %{項目: "埋め込み次元", : inspect(model_15m.config.embedding_size)},
  %{項目: "ブロック数", : inspect(model_15m.config.block_count)}
])

筆者環境では約 42 秒でした。瞬時ではありませんが、「終わらない」から「1分弱で終わる」への変化です(時間の大半は、埋め込みテーブルなど一部テンソルの復元と 19MB のバイナリ走査です)。

読み込んだモデルの重みが本当に圧縮のままか確かめます。

model_15m.tensors
|> Enum.take(5)
|> Enum.map(fn {name, tensor} ->
  %{
    テンソル名: name,
    構造体: tensor.__struct__ |> inspect() |> String.replace("Llamex.", ""),
    : inspect(Map.get(tensor, :type))
  }
end)
|> Kino.DataTable.new(keys: [:テンソル名, :構造体, :型])

アテンションや FFN の重みが CompactTensor:q4_0 のまま保持されています。エンジンを準備して生成します。

{prepare_microseconds, {:ok, engine_15m}} =
  :timer.tc(fn -> Llamex.prepare_model(model_15m, backend: Llamex.Backend.List) end)

{generate_microseconds, generation} =
  :timer.tc(fn -> Llamex.generate(engine_15m, "Once upon a time", max_tokens: 4) end)

Kino.DataTable.new([
  %{項目: "エンジン準備", : "#{Float.round(prepare_microseconds / 1_000_000, 2)} 秒"},
  %{項目: "4トークン生成", : "#{Float.round(generate_microseconds / 1_000_000, 1)} 秒"},
  %{項目: "生成結果", : inspect(generation)}
])

{:ok, ", there was a"}1500 万パラメータの実モデルが、純 Elixir のブロック走査だけで文章を生成しました。約 10 秒/トークンなので実用速度ではありませんが、前作との差を整理すると意味がはっきりします。

前作(通常版・F32 展開) Sol 版(compact)
読み込み 5 分でも完了せず 約 42 秒
保持メモリ 展開できず測定不能(理論値 93MB + リストのオーバーヘッド) 圧縮ペイロード約 17.5MB
生成 到達できず 約 10 秒/トークン(List 直接カーネル)

9. 刷新された API

Sol 版は公開 API も再設計されています。前作の「map オプション + 結果 map」から「keyword オプション + {:ok, _} タプル」になりました。

操作 前作(通常版) Sol 版
読み込み ModelLoader.load(path) → model(失敗は例外) Llamex.GGUF.load(path, tensor_format: :compact){:ok, model}
準備 prepare_model(model, Backend.List) → PreparedModel prepare_model(model, backend: Backend.List){:ok, engine}
生成 generate(model, prompt, %{max_new_tokens: 8})%{text: ...} generate(engine, prompt, max_tokens: 8){:ok, text}
ストリーム チャンクの map を列挙 {:ok, stream} → 復号済みテキスト片を列挙

軽い 260K モデルで一通り確かめます(こちらはメタデータが小さいので、ファイルベースの Llamex.GGUF.load がそのまま使えます)。

{:ok, model_260k} = Llamex.GGUF.load(path_260k, tensor_format: :compact)
{:ok, engine_260k} = Llamex.prepare_model(model_260k, backend: Llamex.Backend.List)

Llamex.generate(engine_260k, "Once upon a time", max_tokens: 10)

{:ok, ", there was a little girl named Lily"} が返ります。前作の greedy(, there was a little gothip)と違うのは、Sol 版の既定サンプラーが seed 固定(既定 seed: 1)のサンプリングだからです。乱数を使いつつ、同じ入力からは同じ結果が再現されます。

[
  %{
    設定: "既定(seed 1)",
    結果: inspect(Llamex.generate(engine_260k, "Once upon a time", max_tokens: 12))
  },
  %{
    設定: "temperature 0.9 / top_k 40 / seed 2",
    結果:
      inspect(
        Llamex.generate(engine_260k, "Once upon a time",
          max_tokens: 12,
          temperature: 0.9,
          top_k: 40,
          seed: 2
        )
      )
  },
  %{
    設定: "stop_sequences [\".\"]",
    結果:
      inspect(
        Llamex.generate(engine_260k, "Once upon a time",
          max_tokens: 30,
          stop_sequences: ["."]
        )
      )
  }
]
|> Kino.DataTable.new(keys: [:設定, :結果])

ストリームは、復号済みのテキスト片を直接返すようになりました。

{:ok, stream} = Llamex.stream(engine_260k, "Once upon a time", max_tokens: 8)

Enum.to_list(stream)

prefill で、ロジット(次トークン候補ごとの生スコア。ソフトマックスで確率へ変える前の値)だけを取り出す低レベル操作も残っています。

{:ok, logits, _state} = Llamex.prefill(engine_260k, "Once upon a time", [])

logits
|> Enum.with_index()
|> Enum.sort_by(fn {value, _} -> -value end)
|> Enum.take(5)
|> Enum.map(fn {value, token_id} ->
  %{トークン番号: token_id, ロジット: Float.round(value, 2)}
end)
|> Kino.DataTable.new(keys: [:トークン番号, :ロジット])

バックエンドの切り替えは switch_backend/2 で、エンジンごと作り直せます。

{:ok, engine_nx} = Llamex.switch_backend(engine_260k, Llamex.Backend.Nx)

diagnostics = Llamex.Engine.diagnostics(engine_nx)

Kino.DataTable.new([
  %{項目: "バックエンド", : inspect(diagnostics.backend)},
  %{項目: "契約バージョン", : inspect(diagnostics.contract_version)},
  %{項目: "量子化の実行経路", : inspect(diagnostics.capabilities.quantizations)},
  %{項目: "EXLA が使えるか", : inspect(diagnostics.capabilities.raw[:exla_available])},
  %{
    項目: "生成",
    : inspect(Llamex.generate(engine_nx, "Once upon a time", max_tokens: 10))
  }
])

同じエンジン API のまま、実行経路だけが direct から blockwise_compact に切り替わります。exla_available: false のように、使えないものを使えないと申告する診断は、GPU(CUDA / ROCm)についても同じ方針で実装されています。EXLA が実デバイスを報告しない限り GPU 実行は成功扱いにならない——これが「直 GPU 未完了」を正直に表す仕組みです。

10. まとめ

このノートブックで見たこと

  • 量子化は「復元値 = スケール × 整数」への丸め。ビット数を削るほど復元は粗い階段になり、誤差が増える(波形・誤差・階段のグラフで確認)
  • Q8_0 / Q4_0 の量子化ブロックは「f16 スケール + 量子値」の単純な構造で、手でバイナリを組んで検算できる
  • Llamex.CompactTensor は展開前のバイナリをそのまま持つ依存ゼロの構造体。contract/1 が展開倍率(Q4_0 で 7.1 倍、Q8_0 で 3.8 倍)を教えてくれる
  • List バックエンドはブロックごとに 32 値だけ復元して部分内積を足す直接カーネル(:direct)を持ち、モデル全体の F32 行列を一度も作らない
  • Nx / NxEXLA は同じペイロードをブロック単位の Nx 演算(:blockwise_compact)で処理し、Quantized.compare で両経路の一致を検証できる
  • GGUF 検証タスク群は「読み込む前に診断する」思想。展開倍率・欠損メタデータ・互換性ブロッカーを事前に報告する
  • 前作で読み込めなかった 15M モデルが、compact 保持なら約 42 秒でロードでき、生成まで動く
  • API は {:ok, _} タプル + keyword オプションに刷新され、既定でも seed 固定で再現可能

シリーズの他のノートブック

ノートブック 内容
Llamex: Elixir 製 LLM 推論エンジンの中身を覗く 通常版の全体ツアー(前作)
Llamex Luna版: AtomVM 化とポータブル設計 組込向けのポータブル境界とバンドル作成