kaedeasset記事 · Notes霧走町 流氷に眠る

Windows と Linux で焼いた埋め込みが、1 バイトも違わなかった

Embeddings baked on Windows and Linux matched to the last byte

 D04-3-T07-bitwise-embedding・技術・日本語入力でプレイする仕組み

入力を解いて返すONNX Runtimedynamic quantization (int8)bitwise reproducibilityclaude自然言語処理

言い換えと検査の入力、4,459 文の埋め込みを、10 月 29 日に Windows と Linux で焼き直しました。
出てきた 7,034,307 byte のファイルは、10 月 27 日に焼いて使っているものも含めて 3 つとも、1 バイトも違いませんでした。
int8 に量子化したモデルを、版の違う二つの ONNX Runtime で動かした結果です。

『霧走町 流氷に眠る』は、入力された日本語を書き下ろした証言のどれかに当てます。遊んでいる間に文は作りません。
当てる物差しの一つが、文の埋め込み(multilingual-e5-small・昨日の T05)です。
同じ入力に必ず同じ答えを返すには、UE のゲームでも、コマンドライン版でも、ブラウザ版の Linux のサーバでも、同じ文が同じベクトルにならないといけません。この回はその話です。

この回の位置と、前後の回#

区切り: T03〜T24 日本語入力を解いて返すまで(22 回のうち 5 回目)
先に読むとよい回: T05(文の埋め込み)
次に読む回: T08(二つの点を混ぜる)

名前 — ONNX Runtime#

正式名: ONNX Runtime(略称 ORT)/dynamic quantization(動的量子化・int8)/bitwise reproducibility(ビット単位の再現性)
初出:
ONNX Runtime は Microsoft が 2018 年 12 月に GitHub で MIT ライセンスで公開しました(Azure のブログ "ONNX Runtime is now open source")。
動的量子化は ONNX Runtime の文書 "Quantize ONNX Models" と、ONNX の演算子 DynamicQuantizeLinear の定義を見ました。
整数だけで推論する量子化の代表的な論文は Jacob ほか, "Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference", CVPR 2018 です(こちらは学習時に量子化を考える方式で、動的量子化の初出ではありません)。
bitwise reproducibility は一般の呼び名で、初出は確かめていません。

一般の解説 — ONNX Runtime#

ONNX は、学習した計算グラフを書き出す共通の形式です。ONNX Runtime(略称 ORT)とは、その形式を実行する推論エンジンで、CPU・GPU などの実行先(execution provider)を差し替えられます。
CPU では MLAS という自前の行列演算のカーネルを持ち、CPU の命令セット(AVX2・AVX-512・VNNI など)に合わせてカーネルを選びます。

量子化は、重みや中間の値を 32bit の浮動小数点から 8bit の整数にする手法です。
大きさがおよそ 4 分の 1 になり、CPU では速くもなります。
静的量子化は、代表的な入力で中間の値の範囲を前もって測り、係数を固定します。
動的量子化は、重みだけを前もって int8 にし、中間の値(activation)は実行のたびに範囲を測って量子化します。
ONNX の形で言えば、DynamicQuantizeLinear が入力の FP32 のテンソルから最小・最大を取り(0 を含むように広げ)、scale と zero point を計算して uint8 にします。MatMulInteger が整数のまま掛けて int32 に足し込み、最後に浮動小数点へ戻します。
BERT 型のモデルを CPU で動かすときの定番の手です。

よく知られた弱点があります。
精度が少し落ちます。
そして、動的量子化の係数は「そのテンソル全体の範囲」で決まります。パディングでテンソルの形や中身が変われば、同じ文の位置の値まで変わりえます。

ビット単位の再現性は、同じ入力から同じビット列が出ることです。
浮動小数点の和は結合則が成り立たないので、足す順番が変われば最後のビットが変わります。
足す順番は、スレッド数、カーネルの選択(命令セット)、ライブラリの版で変わります。
並列化した推論は、ふつう、ビット単位では再現しないものと考えます。

うちの構成#

三つの外側が、それぞれ別の呼び方で ORT を使います。

UE のゲーム: UE 5.8 の NNE(Neural Network Engine)の CPU ランタイム NNERuntimeORTCpu を呼びます。中身は UE 同梱の onnxruntime.dll です。
Windows のコマンドライン版 adv-sim: 同じ UE 同梱の onnxruntime.dll を、自分で動的ロードして C API を直接叩きます(.lib が配られていないため)。
Linux のサーバ(ブラウザ版)と WSL: Microsoft 配布の libonnxruntime.so.1.24.1 を、同じコードで動的ロードします。

今日、DLL の版を見ました。
UE 同梱の onnxruntime.dll のファイルの版は 1.24.20260304.11.3a728b7(Epic のビルド)で、中に「1.24.3」という文字列が 1 つありました。
Linux 側は 1.24.1 です。
ビルド設定(CMakeLists.txt)の注記は「UE 同梱と同じ 1.24.1 に固定」と書いていますが、少なくとも同じビルドではありません。C API の版(ORT_API_VERSION)はどちらも 24 です。

モデルは int8 の動的量子化済みの ONNX です。
今日、ファイルの中の演算子名を数えると、DynamicQuantizeLinear が 48、MatMulInteger が 72 でした(節点の名前に含まれる分も数えた概算です)。12 層 × 4 と 12 層 × 6 に合います。

コマンドライン版の初期化と推論は、ソースからこう写せます(OrtEmbedder.hpp)。

// 決定論を優先(スレッド数で結果が揺れないように)
Api->SetIntraOpNumThreads(Opts, 1);
Api->CreateSession(Env, OnnxPathC, Opts, &Session);
…
const std::vector<int32_t> Ids = Tok.encode(EmbedInputText(TextUtf8), kEmbedMaxTokens);
std::vector<int64_t> InputIds(Ids.begin(), Ids.end());
std::vector<int64_t> Mask((size_t)T, 1), TypeIds((size_t)T, 0);
const int64_t Shape[2] = { 1, T };
const char* InNames[3] = { "input_ids", "attention_mask", "token_type_ids" };
const char* OutNames[1] = { "last_hidden_state" };
Api->Run(Session, nullptr, InNames, In, 3, OutNames, 1, &Out);
std::vector<float> V = MeanPoolNormalize(Data, (int)T, kDim);

要点は三つです。
スレッドを 1 本にする。足す順番を固定するためです。
1 文ずつ、パディングなしで流す。テンソルの形がその文だけで決まるので、動的量子化の範囲も文だけで決まります。
トークナイザ・接頭辞・プーリングは、三つの外側で同じヘッダ(AdvTokenizer.hpp・AdvEmbedMath.hpp)を使う。

UE 版も同じ並びで、NNE の SetInputTensorShapes で毎回 [1, T] を渡し、RunSync で推論します。NNE のスレッド数は、こちらからは設定していません。

なぜ一致させるか#

辞書の言い換えのベクトルは、前もって焼いておきます(vectors.bin)。プレイヤーが入力する新しい文は、その場で計算します。
焼いた側と、その場で計算した側がずれると、辞書の文と同じ文を入力しても点が 1 にならず、閾値の近くで判定がぶれます。
しかも、ずれは例外を出しません。静かに精度が落ちるだけです。

実際に一度、それが起きていました。
最初は、開発用の Python で言い換えをバッチにまとめ、バッチの最長に合わせてパディングして焼いていました。
同じ文でも、同じバッチに何が入っていたかでベクトルが変わり、実行時の C++(1 文ずつ・パディングなし)と常にずれていました(2026-08-04 に分かりました)。
上に書いた動的量子化の性質から見れば、自然なことです。テンソル全体の範囲が、隣の文とパディングで変わるからです。
それ以来、焼くのは adv-sim bake の役目で、実行時とまったく同じ経路(同じ関数・1 文ずつ・スレッド 1 本)を通します。

一致を見る仕組み(三段)#

一段目。モデルの指紋。
vectors.bin(形式 KADVVEC2)の頭に、焼いたときのモデルのファイル名とバイト数を書きます。

"KADVVEC2" | dim u32 | count u32 | fingerLen u32 | 指紋 UTF-8 |
count 回: { len u32, 本文 UTF-8, f32 × dim(L2 正規化済み)}

指紋 = "e5-small.onnx=118308185,vocab.bin=4081385"

読み込んだ時点で、いま置いてあるモデルと違えば「焼いたときと違う」と言えます。
SHA-256 にしなかったのは、UE の起動のたびに 117MB のハッシュを計算したくないからです。別の版・別の量子化のモデルはバイト数で全部落ち、バイト単位の改変は二段目の方が強く見ます。

二段目。全文の突き合わせ(adv-sim embedcheck)。
言い換えをすべて、その場で計算し直して、焼いたベクトルとのコサインを取ります。0.99 を下回る文が 1 つでもあれば失敗です。
今日、Windows で 2,845 文、平均も最小も cos=1.000000 でした(16 秒)。Linux(WSL)でも同じでした。

三段目。起動時の自己照合。
UE は起動のたびに、言い換えの 1 文だけをその場で計算し、焼いた値とのコサインをログに出します。0.99 未満ならエラーです。
ブラウザ版のサーバは、起動時に配る組の manifest(実行ファイル・作品データ・vectors.bin・モデルの SHA-256)を照合し、違えば起動しません。

今日、ビットまで比べた#

embedcheck は、コサインを小数 6 桁で見ているだけです。ビットが同じとは言っていません。
そこで今日は、焼いたファイルそのものを比べました。

作品データを 2 組、作業用の場所に写し、
Windows の adv-sim.exe(UE 同梱の DLL)で 1 組、
WSL の adv-sim(Linux・1.24.1 の .so)でもう 1 組を焼きました。
4,459 文で、Windows は 24.7 秒、WSL は 31.4 秒でした。

結果です。

Windows で焼いた vectors.bin
Linux で焼いた vectors.bin
10 月 27 日に Windows で焼いて使っている vectors.bin

3 つとも 7,034,307 byte で、cmp で 1 バイトの差もありませんでした。
SHA-256 は 3 つとも 261531c5…b076f3 で、ブラウザ版に配る組の manifest に書いた値とも同じです。
版の違う二つの ORT(Epic のビルドの DLL と、Microsoft 配布の 1.24.1)で、4,459 × 384 個の float が全部同じビットでした。

名前どおりでない所 — ONNX Runtime#

いつも回している検査(embedcheck・起動時の照合)は、ビット単位の再現性ではなく、コサインが 0.99 以上かを見ています。
0.99 は「前処理かプーリングがずれた」を見つけるための線で、ビットのずれは見逃します。
今日のビットの一致は、検査の外で、手で比べた結果です。

もう一つ。今日比べたのは、同じ CPU(AMD Ryzen 7 6800H・AVX2 あり・AVX-512 なし)の上の Windows と WSL です。
ORT は CPU の命令セットでカーネルを選ぶので、AVX-512 や VNNI を持つ CPU では、別のカーネルを通って最後のビットが変わることがありえます。
ブラウザ版の VPS の CPU では、まだ比べていません。
動的量子化のモデルは、中間の値を整数で掛けるので、浮動小数点の足す順番の影響を受ける場所が少ない。今日の一致は、そのおかげもあると見ています(測って確かめたことではありません)。

UE の NNE 側は、今日は起動していないので、今日の数字はありません。

弱点と、採らなかった案 — ONNX Runtime#

スレッドを 1 本にしたぶん、推論は遅くなります。今日のこの PC のコマンドライン版で、新しい文 1 つの 1 手が 16〜18ms(照合の全体を含む)なので、困ってはいません。

FP32 のモデル(約 4 倍の大きさ)は採っていません。配布物のモデルを 117MB に収めるためです。
静的量子化は試していません。代表的な入力で範囲を固定する手間に対して、いまの動的量子化で足りています。
GPU の実行先も使っていません。決定論と、どの PC でも同じに動くことを優先しました。

ORT の版を上げるときは、焼き直しと embedcheck が要ります。版が違うと int8 のカーネルが変わり、同じ文が別のベクトルになりうるからです。
UE を上げると、同梱の DLL も上がります。今日の一致は、今日の DLL どうしでの話です。

検索語#

ONNX Runtime dynamic quantization MatMulInteger
DynamicQuantizeLinear
onnxruntime deterministic results SetIntraOpNumThreads
bitwise reproducibility neural network inference

入力した問いがどの証言に当たるかは、遊ぶ環境では変わりません。第一章はブラウザ版で試せます。プロフィールのリンクから。

#claude #自然言語処理