PYNQ-Z1 · 段階6 → 11 · 2026-09-28

通しで動いた

前の資料の最後に「言えないこと」を 3 つ書きました。 通しで 1 トークンも生成していない、流しているのはランダムな数値、 指数表の温度は仮の値。この資料はその 3 つを片付けた記録です。

いまは文章を渡すと、ボードが 1 トークンずつ計算して続きを返します。 行列積と attention は自作の回路、残りは CPU。 Mac で同じ計算をさせると、logits が最後のビットまで一致します。

PYNQ-Z1 · bitnet_b1_58-large(0.7B・三値)実機の出力そのまま
$ python3 bitnet_run.py gen "The capital of France is" 24
The capital of France is the city of Paris. The city has been the home of many people, from the ancient Greeks and the Romans
1 トークン 125.9 ms → 7.94 tok/s(文脈 30 前後)・文脈 512 で 136〜138 ms → 7.3 tok/s

01本物の重みを丸めたら壊れた

stage 6 · the circuit was right, the model was not

最初にやったのは、これまで使ってきた SmolLM2-135M の本物の重みを三値 (−1 / 0 / +1)に丸めて回路に流すことでした。回路の答えは 239,232 語すべて numpy と一致しました。回路は正しく動いています。

ところがモデルとしては完全に壊れていました。

SmolLM2-135MperplexityThe capital of France is →
そのまま(fp32)18.31まともな英語
三値に丸めた9,176,689makers emergmakermakermaker…
参考:語彙 49,152 から無作為に選ぶ49,152—

でたらめに選ぶより 187 倍悪い。丸め方を行ごとに変えてもほとんど変わりません。 重み 1 個あたりの誤差が 5〜6 割あり、それが 30 層ぶん積み重なるからです。

分かったこと

BitNet b1.58 は三値のまま学習したモデルの名前です。 普通に学習した重みを後から三値に丸めたものとは別物でした。 回路の正しさとは関係のない話ですが、載せるモデルを選び直す必要があるということです。

この段階ではもう一つ見つかりました。回路は道幅の 100.0% で動いているのに、 1 トークンの時間の 83% が DMA の呼び出しの固定費でした。 当時の形式は「1 転送に x(入力ベクトル)が 1 つ」なので、行列が変わるたびに転送を切るしかなく、 1 トークンで 181 回も呼んでいたからです。これが 3 節の宿題になります。

02三値で学習したモデルに乗り換える

stage 6.5 · the only one that fits in 512 MB

三値で学習済みで、このボードの 512 MB に載るものを探すと、候補は 1bitLLM/bitnet_b1_58-large(0.7B)だけでした。 numpy で回路と同じ量子化手順を書いて確かめると、 perplexity 17.90。三値のまま、SmolLM2 の fp32(18.31)より良い値です。

ただし形が違うので、回路もデータの詰め方も作り直しになります。

SmolLM2-135Mbitnet_b1_58-large回路への影響
hidden / FFN / 層576 / 1536 / 301536 / 4096 / 24重みが 6 倍(三値で 136 MB)
head_dim6496attention の回路を作り直し
q : kv ヘッド9 : 316 : 16GQA が無い。1 バイトの再利用が 3 回→1 回
lm_head—fp16(三値ではない)そのままでは 98 MB を毎トークン読む

lm_head(最後に語彙 32,002 個の点数を出す行列)はメモリの都合で三値に落としました。 INT8 なら品質はそのまま(17.90 → 17.89)ですが、DMA で読める領域(CMA)に収まりません。 三値にすると perplexity は 17.90 → 35.59 と倍になりますが、文章は壊れません。 これはこの構成で払っている品質の代償で、最後の節でもう一度触れます。

03渡し方を直す

stages 7 & 8 · a header, then a variable row length

181 回の呼び出しをどう減らすか。回路は速いので、回路への渡し方を変えます。 ストリームの頭に小さな見出しを付けて「この後 R 行続く」と書けば、 x が変わっても転送を切らずに次の組へ進めます。次に、見出しに1 行の長さも書けるようにしました。

DMA 1 回ぶんのストリーム 段階6 x は 1 つだけ x 640 B 行 × R(1 行 640 重み) ■ TLAST 181 転送 固定費が 83% 段階7 見出し R R x 行 × R R x 行 × R … ■ 12 転送 HP 2 本で 6 往復 段階8 見出し R, BPR R,BPR x 1560 B 行 × R(1560 重み) R,BPR x 4120 B 行 × R … ■ 5 往復 76.87 ms 段階7 は行長 640 固定。入力 1536 を 640 × 3 = 1920 に詰め物をして割り、部分和を CPU で足していた 段階8 は入力次元に合わせて 1560・4120 で切れる。組 387 → 97、詰め物 20% → 1.3%、重み 169.7 → 141.3 MiB
図1 / 回路の外側を変えた。 計算の部分(三値の加算木)は段階4 のままで、変えたのは入口の状態機械だけです。 LUT は 9,557 → 9,720、ほぼ増えていません。 それでも 1 トークンの行列積は、HP 2 本で 96.51 ms → 76.87 ms、 DDR の壁の 95.9% まで来ました。

行長を可変にした効果は 3 つ重なりました。詰め物が減って読む量が 17% 減り、 組の切れ目が 4 分の 1 になり、CPU で部分和を足す後始末が消えました。 そしてもう一つ、重みが 141.3 MiB に縮んだおかげで、後で attention と同じチップに同居できるようになります(5 節)。

04attention を作り直す

stages 7c & 9 · head_dim 96, no GQA, and a multiplier

作る前に、比較相手を測り直した

bitnet-large の attention を CPU で測ったら 96.22 ms(文脈 512)でした。 それまで使っていた換算値は 180 ms。 古いベンチを NEON 無効(-O2)で焼いていたためで、前の資料も訂正しました。 これで行列積(96.51 ms)と attention(96.22 ms)がほぼ半々だと分かりました。

段階5b の回路は head_dim 64・GQA 3:1 専用なので使えません。 作り直す前に numpy でもう一つ確かめたことがあります。 段階5b はスコアを s >> 8 という固定のシフトで 8 bit にしていましたが、 これは q と K の量子化の尺度を無視しています。本物のモデルに当てると 指数の温度が層・ヘッドごとにずれます。

attention の作り(lm_head は三値)perplexity
float の本来の softmax35.56
段階5b のまま(固定シフト)58.25
見出しに倍率 m を載せる(掛け算器 1 個)35.90

m = TAU · sq · sk / √96 を 224 倍した整数を見出しに書き、 スコアに掛けてから指数表を引きます。実際の値の範囲は 10,818〜79,505 で、 16 bit には入らず、DSP の 18 bit 入力にちょうど収まりました。 これで前の資料の「指数表の温度は仮の値」が片付きました。

段階9 の attention 回路(HP 2 本)値
照合37,248 語すべて一致
文脈 512・1 トークン(24 層 × 16 ヘッド)20.22 ms
読み出し1.872 GB/s(DDR の壁の 93.2%)
1 バイトあたり1.00 MAC(GQA が無いので)
掛け算器17 個(段階5b は 48 個)
CPU(NEON 有効)との比4.8 倍

142.86 MHz では 2 回ともタイミング違反になりました。違反したのは自作の回路ではなく AXI DMA の中の転送長カウンタです。125 MHz に下げました。 2 本ぶんの道幅は 2.000 GB/s で、DDR の壁 2.009 とほぼ同じです。 帯域で頭打ちの回路なので、クロックを下げても損はほとんどありません。

051 枚のチップで交互に動かす

stage 10 · four DMAs, no switch

行列積の回路と attention の回路を、同じビットストリームに載せました。 1 トークンの中で両者は必ず交互に動きます。 だから切り替え回路は作らず、DMA を 4 台置いて、使うほうを叩くだけにしました。 同時には動かないので、DDR の取り合いも起きません。

1 層の流れ(× 24 層、最後に lm_head) 正規化 INT8 に 行列積 q・k・v RoPE q・K・V を INT8 に attention 16 ヘッド ÷ 分母・正規化 INT8 に 行列積 o 残差・正規化 INT8 に 行列積 gate・up silu × up・正規化 INT8 に 行列積 down 残差 次の層の正規化 次の層 行列積 ── DMA 0・1(単純転送)→ 三値の回路 × 2 attention ── DMA 2・3(SG 転送・6 節)→ attention の回路 × 2 CPU ── 浮動小数の小物(C + NEON・7 節) 1 層 5 転送 1 トークン 121 転送
図2 / PL と CPU が 10 回ずつ入れ替わる。 色のついた箱が回路、点線の箱が CPU です。どの箱も前の箱の結果を待つので、 転送をまとめることはできません。1 トークンに 121 回、回路と CPU の間を往復します。 段階10 ではまだこの流れを動かしておらず、記録した 1 トークンぶんを再生して PL だけで 98.57 ms(450,178 語一致)を測りました。

同居の代償は 1.4% でした(別々のビットストリームなら 107.8 ms、同居で 109.3 ms の見込み)。 その差のほぼ全部は、DMA の転送長を 8 MiB 以下に切ったぶん往復が増えたことによるものです。 資源は LUT 34%。チップにはまだ 3 分の 2 が空いています。

06伸びる KV を動かさずに流す

stage 11 · scatter-gather instead of copying

通しで動かすには、1 トークンごとに K と V を 1 行ずつ積み増す必要があります(KV キャッシュ)。 ここで attention の回路の入力形式が問題になりました。

回路が受け取るのは [見出し][q][K を T 行][V を T 行] を 1 ヘッドずつつなげたストリームです。 T が 1 増えると、V の開始位置と、後ろのヘッド全部の位置がずれます。 単純な DMA は「連続した 1 領域」しか読めないので、毎トークン KV を丸ごと詰め直す(T=512 で 36 MB)か、 ヘッドごとに転送を切る(1 トークン 384 回)しかありません。

単純転送のまま(連続した 1 領域) 見q K 0 … T−1 V 0 … T−1 見q K V … 新しい K 行はここ ここから後ろが全部ずれる(毎トークン、KV 全体の半分を動かす) SG 転送(記述子で綴じる)── 1 ヘッドの置き場は固定 見q K 0 … T−1 空き(〜511) V 0 … T−1 空き(〜511) 新しい行は末尾に書くだけ 同じく末尾に書くだけ 記述子 A 104 + 96T バイト 記述子 B 96T バイト 1 層の鎖: A₀ B₀ A₁ B₁ … A₇ B₇ → 回路には 1 本のストリームに見える 毎トークン書き換えるのは長さ欄だけ。層ごとに TAILDESC を 1 回書けば走る
図3 / データを動かさず、読む順番の表を書き換える。 SG(scatter-gather)は、DMA が「どこから何バイト読むか」の表(記述子)を順にたどる機能です。 K と V を別々の場所に置いたまま、回路には 1 本のストリームとして届けられます。 変えたのは DMA の設定 1 行だけで、attention の回路は 1 ビットも変えていません。 費用は LUT +3,471(全体で 40%)でした。
もう一つの決めごと ── KV の尺度は固定にする

段階9 までは、K と V を INT8 にする尺度を「全位置の最大値」から決めていました。 これはまだ来ていない未来の K を見ています。しかも T が伸びるたびに最大値が変わるので、 過去の INT8 を全部作り直すことになり、積み増しができません。

そこで層・ヘッドごとに尺度を固定しました。評価文とは別の文章 144 トークンを流して max|K| と max|V| を測り、それを使い続けます。perplexity は 35.90 → 35.61 と、固定したほうがむしろ良くなりました。

07Mac とボードで同じ答えを出す

bit-identical logits on two machines

通しで動かすと、確かめるべきものが増えます。 回路の答えが正しいかだけでなく、CPU 側の浮動小数の計算まで含めて、1 トークン全体が正しいかです。 そこで同じ計算を Mac でも走らせ、両方の logits(語彙 32,002 個ぶんの点数)を比べることにしました。

Mac(M4) PYNQ-Z1 両方で同じファイル e2e_core.py 1 トークンの骨組み e2e_cpu.c 正規化・RoPE・silu・量子化(NEON) NumpyBackend 三値の行列を 整数のまま掛ける ボードと同じ .bin から 復元(詰め方の検査も兼ねる) FpgaBackend DMA 4 台で 自作の回路を叩く KV は CMA に INT8 で常駐 179.1 MiB 回路への呼び出しを全部記録して送る → Mac が整数で再計算 結果 logits(perplexity 130 トークン + 生成 2 本) 191 トークン すべてビット一致 回路への呼び出し(生成 1 本・KV も記録から積み直す) 3,509 回・13,055,162 語 一致 perplexity 35.690103 = 35.690103
図4 / 違うのは「回路の代役」だけ。 Mac とボードは、回路の部分だけを差し替えて同じコードを走らせています。 回路の答えは整数なので一致するのが当然ですが、浮動小数の部分まで最後のビットが揃ったことで、 1 トークン全体を丸ごと比べられるようになりました。 どこか 1 か所でもずれていれば、logits のどこかに必ず出ます。

Mac(Apple M4)もボード(Cortex-A9)も ARM なので、arm_neon.h の同じ命令で書けます。 それでも、何もしなければ結果は揃いません。揃えるために決めたことはこれです。

08遅かったのは Python だった

the hardware was fine all along

最初に通しで動いたとき、1 トークンは 225.6 ms かかりました。 段階10 の見込み(109 ms)の倍です。原因を一つずつ潰した記録がこの表です。

何を変えたか1 トークン効いた理由
(参考)CPU の小物を numpy で小物だけで 305 msボードの numpy は NEON を使わず、1 要素 20〜100 ns
(参考)素直な C に小物だけで 55 msrintf が関数呼び出し、浮動小数の比較で流れが止まる
(参考)C + NEON に小物だけで 25 ms丸めは小細工、exp は多項式、割り算は逆数の推定
通しの初版225.6 ms—
PYNQ の配列を素の numpy 配列に164〜172 msPYNQ の配列は切り出すたびに約 100 µs かかっていた
DMA の操作を C に125.9 msPython からのレジスタ操作は 1 回 6〜14 µs。1 転送で数百 µs になっていた
途中で一度、ハードを疑って外した

DMA の 1 転送が理論値より 150 µs ほど長くかかる、という測定が出ました。 状態レジスタを読み続けると転送そのものが遅れるのだろうと考え、 「終わりそうな時刻までは時計だけ見て待つ」版を作りましたが、何も変わりませんでした。

DMA の操作を C に移すと、転送は理論どおりの時間で終わりました。 ハードは最初から正常で、遅れていたのは Python の側でした。 ただ、「時計で待ってから 1 回だけ読む」実験で出た 250 µs の余分は、いまも説明がついていません。

段階6 でも「回路は道幅の 100% で動いているのに、呼び出しの固定費が 83%」という話がありました。 ここでも同じことが起きています。回路を速くするのと同じくらい、回路への渡し方に手間がかかる、 というのがこの段階でいちばん時間を使ったところです。

09文脈が伸びると何が伸びるか

only attention grows with T

文脈 512(ボードの KV の置き場の上限)まで生成を続け、 部品ごとの時間を 1 トークンずつ記録しました。

0 50 100 150 ms 64 128 256 384 512 文脈長 T 行列積 87.3 ms(文脈によらない) CPU の小物 26.7 ms attention 5.3 → 21.9 ms(T に比例) 119.2 135.9
図5 / 伸びるのは紫だけ。 行列積と CPU の小物は文脈の長さによらず一定で、attention だけが T に比例して伸びます。 文脈 512 でも attention は全体の 16% です。 段階5 のころは「attention を CPU に残すと文脈 2048 で全体の 96% を占める」という話でしたが、 回路にしたことで一番小さい塊になりました。(文脈 16 付近は最初の数トークンで遅く、グラフから外しています)
文脈長 T行列積attentionCPU合計tok/s
6487.35.326.6119.28.39
12887.37.726.6121.68.22
25687.212.726.7126.77.89
38487.317.426.7131.57.60
500〜51287.422.026.7136.07.35

単位は ms。各行は前後 8 トークンの中央値。同じ条件でもう一度走らせた別の回では、文脈 512 で 138.0 ms(7.25 tok/s)でした。

10見込みと実物の差

where the budget went

段階10 では、部品を別々に測って足し、文脈 512 で 109.2 ms と見込んでいました。 実物は 136.0 ms。差の 26.8 ms がどこから来たかを並べます。

行列積(PL) attention(PL) CPU の小物 段階10 の見込み 部品を別々に測って足した 78.4 20.2 109.2 ms 通しの実物 文脈 500〜512 の中央値 87.4 22.0 26.7 136.0 ms +9.0 +1.9 +16.0 行列積 +9.0 は転送が 10 回から 97 回に増えたぶん(うち約 6.6 ms は結果を非キャッシュ領域から写す時間) CPU +16.0 はベンチに無かった仕事(活性値と K・V の INT8 化、語彙 32,002 個からの最大値探し)
図6 / いちばん外れたのは CPU。 見込みの「小物 10.68 ms」は、正規化・RoPE・silu・残差だけを C で測った値でした。 実際には、回路に渡すたびに活性値を INT8 に丸め、K と V を INT8 にし、最後に語彙から最大値を探す仕事があります。 部品を別々に測って足すと、部品と部品の間の仕事が抜け落ちます。 通しで動かさないと見えない種類の誤差です。

11ここまでで言えること・言えないこと

what is measured and what is not

言えること。すべて実機での実測です。

言えないこと。

次に大きい塊は CPU の小物 26.7 ms(全体の 20%)です。 行列積を回路に移したら attention が目立ち、attention を移したら CPU が目立つ。 いちばん長い棒から手をつけるのは、ここでも同じです。