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?

Rustでsafetensorsのヘッダだけ読む — 12GBのモデルを172KBで判定する

1
Last updated at Posted at 2026-07-25

この記事では、safetensors のヘッダだけを HTTP Range で抜き取る小さな Rust プログラムを書いて、実在するモデル 4 本で確かめます。ついでに、ComfyUI が量子化の設定をファイルの中にテンソルとして埋めているという、あまり知られていない仕様も出てきます。

safetensors のレイアウト

safetensors は拍子抜けするほど素直な形式です。

[ 8 バイト: ヘッダ長 N (little-endian u64) ]
[ N バイト: UTF-8 の JSON ]
[ テンソル本体 ]

JSON の中身はこうなっています。

{
  "__metadata__": { "modelspec.title": "Flux.1-dev", "...": "..." },
  "double_blocks.0.img_attn.qkv.weight": {
    "dtype": "I8",
    "shape": [9216, 3072],
    "data_offsets": [0, 28311552]
  }
}

テンソル名も、dtype も、ファイル内のどこに置かれているか (data_offsets) も、全部ヘッダにあります。本体を 1 バイトも読まずに構成が分かる、というのはここから来ています。

そして Hugging Face の resolve/ URL は HTTP Range に対応しています。つまり先頭だけ要求できます。

Rust で書く

依存は 2 つだけです。非同期にする必要がないので ureq を使います。

[package]
name = "stpeek"
edition = "2024"

[dependencies]
ureq = "3.3"
serde_json = "1"
use std::collections::BTreeMap;
use std::error::Error;

type R<T> = Result<T, Box<dyn Error>>;

/// 指定バイト範囲だけを取りに行く。206 が返らなければ全体が飛んでくるので中断する。
fn fetch_range(url: &str, start: u64, end: u64) -> R<Vec<u8>> {
    let mut res = ureq::get(url)
        .header("Range", &format!("bytes={start}-{end}"))
        .call()?;
    if res.status() != 206 {
        return Err(format!("Range 非対応 (status {})", res.status()).into());
    }
    Ok(res.body_mut().read_to_vec()?)
}

/// Hugging Face のモデルページ URL は blob/ なので、Range が使える resolve/ に直す。
fn normalize(url: &str) -> String {
    match url.split_once("/blob/") {
        Some((repo, rest)) => format!("{repo}/resolve/{rest}"),
        None => url.to_string(),
    }
}

fn main() -> R<()> {
    let arg = std::env::args().nth(1).ok_or("usage: stpeek <url>")?;
    let url = normalize(&arg);

    // ① 先頭 8 バイト = ヘッダ長
    let head = fetch_range(&url, 0, 7)?;
    let n = u64::from_le_bytes(head[..8].try_into()?);

    // ② ヘッダ JSON 本体
    let raw = fetch_range(&url, 8, 8 + n - 1)?;
    let hdr: serde_json::Value = serde_json::from_slice(&raw)?;
    let obj = hdr.as_object().ok_or("ヘッダが JSON オブジェクトではない")?;

    let mut dtypes: BTreeMap<&str, usize> = BTreeMap::new();
    let mut total: u64 = 0;
    let mut quant: Vec<(&String, u64, u64)> = Vec::new();
    // `__metadata__` は任意なので、obj.len() - 1 で数えると無いファイルで 1 本ずれる。
    let mut tensors = 0usize;

    for (name, v) in obj {
        if name == "__metadata__" {
            continue;
        }
        let Some(dtype) = v.get("dtype").and_then(|d| d.as_str()) else {
            continue;
        };
        *dtypes.entry(dtype).or_default() += 1;
        tensors += 1;

        let (s, e) = match v.get("data_offsets").and_then(|o| o.as_array()) {
            Some(a) if a.len() == 2 => (a[0].as_u64().unwrap_or(0), a[1].as_u64().unwrap_or(0)),
            _ => (0, 0),
        };
        total = total.max(e);
        if name.ends_with(".comfy_quant") {
            quant.push((name, s, e));
        }
    }

    let read = 8 + n;
    println!("{}", url.rsplit('/').next().unwrap_or(&url));
    println!(
        "  取得 {read} B で判定 / 全体 {:.0} MiB ({:.4}%)",
        total as f64 / 1048576.0,
        read as f64 / total.max(1) as f64 * 100.0
    );
    println!("  テンソル {tensors} 本  dtype {dtypes:?}");

    match quant.first() {
        None => println!("  量子化テンソルなし(素の fp16/bf16 と思われる)"),
        Some((name, s, e)) => {
            // ③ 量子化設定だけを追加取得する。data_offsets は本体先頭からの相対値。
            let cfg = fetch_range(&url, 8 + n + s, 8 + n + e - 1)?;
            println!("  量子化テンソル {} 本", quant.len());
            println!("  ⭐ {name}\n     = {}", String::from_utf8_lossy(&cfg));
        }
    }
    Ok(())
}

注意点は 2 つ。

206 を必ず確認すること。 Range ヘッダを付けても、サーバーが対応していなければ 200 と一緒に本体が全部飛んできます。12GB が流れ始めてから気づくのは悲しいので、ステータスコードで弾きます。

__metadata__ は任意。 obj.len() - 1 でテンソル数を数えると、__metadata__ を持たないファイルで 1 本ずれます。

実行結果

実在するモデルに対して動かします。数値は 2026-07-25 の実測です。

$ stpeek https://huggingface.co/AX1Y2JP/FLUX.1-dev-INT8-ConvRot/blob/main/flux1-dev-int8-convrot.safetensors

flux1-dev-int8-convrot.safetensors
  取得 172000 B で判定 / 全体 11767 MiB (0.0014%)
  テンソル 1312 本  dtype {"BF16": 514, "F32": 266, "I8": 266, "U8": 266}
  量子化テンソル 266 本
  ⭐ double_blocks.0.img_attn.qkv.comfy_quant
     = {"format": "int8_tensorwise", "per_row": true, "convrot": true, "convrot_groupsize": 256}

11.5GB のモデルを 172KB、全体の 0.0014% だけ読んで判定できました。

対照として、量子化していない素の bf16 モデルも見てみます。

$ stpeek https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/non_official/diffusion_models/qwen_image_distill_full_bf16.safetensors

qwen_image_distill_full_bf16.safetensors
  取得 229312 B で判定 / 全体 38968 MiB (0.0006%)
  テンソル 1933 本  dtype {"BF16": 1933}
  量子化テンソルなし(素の fp16/bf16 と思われる)

39GB のファイルが 229KB で片付きました。

4 本まとめて測るとこうなりました。

モデル 全体 取得 比率 判定
Qwen-Image(対照・素の bf16) 38,968 MiB 229 KB 0.0006% 量子化なし
FLUX.1-dev-INT8-ConvRot 11,767 MiB 172 KB 0.0014% int8_tensorwise + per_row + convrot
Z-Image-Turbo-INT8-ConvRot 5,888 MiB 92 KB 0.0015% int8_tensorwise + convrot
Qwen3-4B-INT8-ConvRot 4,211 MiB 102 KB 0.0023% int8_tensorwise + convrot

ComfyUI は量子化設定を「テンソル」として埋めている

上の出力に出てきた comfy_quant について。

ComfyUI は v0.27.0 で int8 ConvRot モデルに対応しました。このとき、量子化のパラメータを __metadata__ ではなく、層ごとの U8 テンソルとして本体に書き込むという設計を採っています。読み出し側は PR #14636 にそのまま出てきます。

saved_conf = json.loads(saved["layer.comfy_quant"].numpy().tobytes())

テンソルなので名前がヘッダに載ります。だから「量子化されているか」はヘッダを読むだけで確定しますし、data_offsets も分かるので、設定の JSON 本体だけを 3 回目の Range で数十バイト取れます。上のコードがやっているのはこれです。

INT8 でも、全部が INT8 ではない

dtype の分布を見ると、I8U8 が必ず同数で並び、その横に BF16 が残っています。I8 が量子化された重み、U8 がこの設定、F32 がスケール、そして残りは量子化されなかった層です。

FLUX.1-dev では I8 が 266 本に対して BF16 が 514 本。本数で言えば大半は元の精度のままです。ファイル名によくある bf16emixed / fp16emixed は、この「残った側」の精度を指しています。

ファイル名は当てにならない

これは実際に引っかかった例です。

$ stpeek .../qwen_3_06b_base-int8cr.safetensors

qwen_3_06b_base-int8cr.safetensors
  取得 83652 B で判定 / 全体 717 MiB (0.0111%)
  テンソル 702 本  dtype {"BF16": 114, "F32": 196, "I8": 196, "U8": 196}
  量子化テンソル 196 本
  ⭐ model.layers.0.mlp.down_proj.comfy_quant
     = {"format": "int8_tensorwise"}

ファイル名は int8cr、リポジトリ名にも convrot と入っているのに、設定に convrot がありません。念のため 12 層をサンプリングしましたが、すべて同じでした。

1 つのファイルの中で層ごとに設定が割れる例は、いま測った範囲では出ていません。差が出るのはファイル間です。いずれにせよ、名前ではなくヘッダを見るのが確実です。

Mac で動くのか

comfy/quant_ops.py を読むと、高速な INT8 経路が有効になる条件はこうです。

  • NVIDIA: PyTorch が cu130 以上。それ未満は警告付きで無効化されます(Turing 以降が対象)
  • AMD: Triton 3.7 以上、かつ行列演算ユニットを持つ GPU(RDNA3 以降の WMMA、CDNA の MFMA)。RDNA1/RDNA2 は WMMA を持たず、INT8 経路に入れると GPU がハングするため明示的に除外されています

では Mac はどうなるのか。手元の M2 Max(torch 2.13)で実際に ComfyUI を入れて確かめました。

M2 Max で動いている ComfyUI

ComfyUI 自体は Apple Silicon で普通に動きます。上は量子化していない SDXL を 1024×1024・20 steps で回したところで、1.94 s/it、モデルのロード込みで 109 秒でした。問題はここに量子化モデルを載せたときです。

_CK_AVAILABLE : True
QUANT_ALGOS   : ['convrot_w4a4', 'float8_e4m3fn', 'float8_e5m2',
                 'int8_tensorwise', 'mxfp8', 'nvfp4']
ファイル読込   : 成功(702 tensors・重みは torch.int8)

backends:
  eager  : available   ← dequantize_int8_convrot_weight などを実装
  cuda   : unavailable — Extension file not found: .../backends/cuda/_C.abi3.so
  triton : unavailable — No module named 'triton'

読み込めます。量子化アルゴリズムも登録されます。
ただし速くはなりません。INT8 のまま行列積を回そうとすると、こうなります。

NotImplementedError: The operator 'aten::_int_mm' is not currently
implemented for the MPS device.

(pytorch/pytorch#141287)

comfy-kitchen の CUDA 拡張は macOS 版に同梱されておらず、Triton も入らないので、残るのは eager バックエンドだけ。そこに PyTorch 側の INT8 行列積が無いので、重みは float に戻してから計算されます。容量は小さくなりますが、速度の利得はありません。

ブラウザでも同じことができます

同じヘッダ読みを、ブラウザ内で完結する形で置いてあります。

safetensors ビューア.safetensors をドロップするか Hugging Face の URL を貼ると、dtype の分布・テンソル構成・メタデータが出ます。ローカルファイルは File.slice、URL は同じ HTTP Range 2 回なので、ファイルはアップロードされません

量子化フォーマットの見分け方だけをまとめた解説はこちらです。

safetensors の量子化を見分ける — INT8・ConvRot をヘッダーだけで判定

まとめ

  • safetensors は [8 バイトのヘッダ長][JSON][本体]構成情報は全部ヘッダにある
  • Hugging Face は HTTP Range に対応しているので、先頭だけ要求すれば済む。12GB のモデルが 172KB
  • ComfyUI の量子化設定は <layer>.comfy_quant という U8 テンソルとしてファイルの中にある。だからヘッダに名前が出る
  • ファイル名は当てにならない。 int8cr を名乗って convrot が入っていないファイルが実在します
  • INT8 ConvRot が速くなるのは cu130+ の NVIDIA か、Triton 3.7+ の matrix-core AMD。Mac は読めるが速くならない

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?