(rontolisp) docs

GPU アクセラレーション(--gpu)

--gpulinalg の行列積・要素ごとの超越関数・ブロードキャスト / 軸方向の畳み込み / 軸リストの転置の形を GPU へ振り向けます。CUDA ドライバを直接叩く NVIDIA のデバイス、あるいは Metal 経由で Mac 自身の GPU です。直交する 3 つのアクセラレーションフラグの 1 つです。--simd はベクトル化可能な vec: / linalg: カーネルを CPU のベクトル命令へロワリングし、--blas は行列積をチューニング済みライブラリの呼び出しに置き換え、--gpu は計算そのものを CPU の外へ出します。3 つのどの組み合わせでも、どれも付けなくても構いません。複数を同時に付けたときに何が起きるかは3 つのフラグはどう合成されるのかを参照してください。

--blas は行列積を CPU が持つ最速のものへ載せます。--gpu はそれをまったく別のマシンへ載せます。

rontolisp prog.lisp --gpu                 # インタプリタ
rontolisp prog.lisp -o Prog.class --gpu   # JVM のクラス出力
rontolisp prog.lisp --simd --blas --gpu   # 3 つとも連鎖する。最初に尋ねられるのはデバイス

GPU は推奨であって必須ではありません。 チューニング済み BLAS とまったく同じ姿勢です。同梱もダウンロードもせず、CUDA ツールキットのインストールも要りません。実行時に必要なのは NVIDIA ドライバに同梱される libcuda.so.1 だけで、カーネルはテキストとして rontolisp の中に同梱され、ドライバがその場のカードに合わせてコンパイルします。Mac ではインストールするものが何一つありません。フレームワークもシェーダのコンパイラも macOS の一部だからです。デバイスがない・ドライバがない・Turing(compute capability 7.5)より古いカードしかないマシンでも、同じプログラムが同じ出力を出します。遅いだけです。インタプリタは失敗するのではなく標準エラーにその旨を伝えます。

次の節は NVIDIA のカードを前提に書いてあります。同じフラグが何を変えるかは Apple Silicon ではにまとめてあり、その差は計画に織り込む価値があるだけの大きさです。

何が加速され、何が辞退するのか

行列積の 2 つの形。 1 つはランク 2 の配列 2 つに対する linalg:dot、したがってそれで書かれているランク 2 の linalg:matmullinalg:solve。もう 1 つはランク 3 以上の linalg:matmul の背後にあるスタックされた積、すなわち torch.bmm です。あらゆるアテンション層が、そして (B T C) のアクティベーションに対するあらゆる torch:linear がこれです。スタックは何枚の行列を含んでいても往復 1 回・起動 1 回で済みます。デバイスがバッチを専用の軸に載せるからです。バッチ方向にブロードキャストされるオペランド — ランク 3 のアクティベーションの下にあるランク 2 の重み行列 — は行列ごとにではなく 1 度だけデバイスへ転送されます。

そして要素ごとの超越関数 12 個、すなわち explogtanhsincostanasinacosatansinhcosherf です。これらの上に書かれている torch:gelutorch:softmaxtorch:log-softmax もデバイスに届きます。このフラグ全体でもっとも比の大きいメンバは行列積ではなくこちらです。150 万個の倍精度浮動小数点数に対する linalg:erf は SIMD の CPU で 103 ミリ秒、デバイスでは 0.9 ミリ秒です。

さらに 10 個のメンバが、それぞれ 1 つの呼び出し形でだけ加速されます。 addsubmuldivmaximumminimum は、2 つのオペランドの形状が異なり numpy のブロードキャストが働くとき — (4 256 256) に対する (4 256 1)、つまり配列とその行ごとの縮約 — これは torch:softmaxtorch:layer-norm を組み立てている形そのものです。sumamaxamin:axis 形のとき、transpose は軸リストを取る形のときです。meanvarstdlinalg:softmaxlinalg:log-softmax はこれらを経由してデバイスに届きます。CPU 上でレーンカーネルに届くのとまったく同じ形です。これらの形に共通しているのは演算ではありません。CPU がこれらをベクトルレーンではなく添字のオドメータで 1 要素ずつ歩くということです。したがって上回るべき CPU 側のコストは、同じ形状の配列 2 つに対する同じ演算の 5〜8 倍になります。JVM のクラス出力で、トランスフォーマ本来の形状・単精度で測ると、39 万 3216 要素に対するブロードキャストの sub は CPU で 660 マイクロ秒・デバイスで 118 マイクロ秒、sum :axis は 297 対 70、軸リストの transpose は 335 対 75、そして linalg:softmax 全体 — これらのメンバ 5 個の連鎖、往復 5 回 — は 1915 対 402 です。

さらにシード付き乱数生成器の充填も加速されます。 linalg:randlinalg:randnlinalg:uniform — つまりすべての重み初期化とドロップアウトマスク — は 8192 要素以上なら配列をデバイス上で充填します。そしてこれはデバイスの結果が CPU とバイト単位で同一な唯一のメンバです。各スレッドは閉形式 a^k s mod m(正確な整数演算)で自分の要素が始まる生成器の状態へ跳び、そこから逐次の歩行とまったく同じ手順で抽選するので、linalg:seed の約束はフラグをまたいでも守られます。何も送り込まず、抽選結果だけが戻ってくるため、しきい値はメンバ中で最も低くなっています。39 万 3216 個の単精度に対するドロップアウトマスクは CPU で約 4 ミリ秒・デバイスで約 50 マイクロ秒、100 万要素の標準正規分布の充填(1 要素につき 12 回の抽選)は 95 ミリ秒対 2.1 ミリ秒です。

さらに linalg の外にメンバが 1 つ — 行列 × ベクトルの積 vec:matvec です。 デコードのループを構成している GEMV そのものです(examples/llama2 はトークン 1 つにつき各層で 13 回これを呼び、それ以外はほとんど何もしません)。GEMV はメモリ律速で、そのコストのすべては行列を 1 回なめることです。だから行列をデバイスへ運ばなければならない往復は、およそ 50 万要素までは CPU に負けます。したがってこのメンバは、行列がすでにデバイス上にあるときにだけ受け取られます。ある行列が初めて差し出されたとき、呼び出しは辞退してそれを覚えておきます。2 度目 — その間に行列が書き換えられていなければ — に転送して計算し、それ以降の呼び出しはそこにある行列を見つけて、ベクトルの転送・起動・結果の回収だけ、約 10 マイクロ秒を払います。呼び出しの合間に書き換えられる行列は一度も転送されず、負けるはずの往復を払うこともありません。行列の要素数(rows * cols)が 131072 以上なら、常駐した 384x384 の単精度 GEMV は CPU の 23 マイクロ秒に対し 10.7 マイクロ秒です。llama2 の 288x288 の射影は CPU に留まり(12.5 対約 10 で引き分け)、768x288 のフィードフォワード行列と 32000x288 の分類ヘッドは移ります(30 対 12、そして 1467 対 169)。カーネルは両方の幅で倍精度に累積します。移植版の vec.lisp の定義がそうしていて --simd のレーンカーネルがそうしていないとおりで、したがって単精度では実際上、移植版の定義そのもののビットに着地します(対象バックエンドと精度)。対象は vec:matvec そのものだけで、vec:matvec-into とそれ以外の vec: は今までの場所に留まります。

同じ名前でも形状が等しい場合は、往復としては辞退します。しかもそれは測定による辞退です — そして 2026-08-23 以降、オペランドがすでにデバイス上にあるときはメンバです。 sqrtabsnegativesign、両オペランドの形状が等しいか片側が数である addsubmuldiv、比較マスクの greatergreater-equallessless-equalequalwhere(したがって torch:masked-fill)、torch:adam / torch:adamw の背後の融合された Adam 更新、そしてコピー — reshape(したがって expand-dimssqueezeflatten)、素の行列 transposesliceconcatenate(torch:cat)、勾配クリッピングの in-place のスケール — は、オペランドをデバイスへ運ばなければならないときはいつでも辞退します。そこでは CPU はベクトルレーンのループか memcpy を走らせるので、そのコストはすでに配列を歩くコストそのものであり、デバイスは始める前にメモリより遅いリンク越しに配列を 2 回歩かなければなりません。150 万要素で測ると、linalg:sqrt は CPU の 700 マイクロ秒に対してデバイスは 502(単精度では 500 対 245)、linalg:add は 900 対 780 — そして単精度では CPU が勝ちます(350 対 382)。しかしデバイスメンバの結果はどこへも運ばれません。2026-08-23 以降、結果はホスト上の何かがそれを読むまでデバイスに留まり、オペランドがすでにそこにあるメンバはコピーのまったくない起動になります — したがってそのようなオペランドの上では、これらの名前はどれもサイズによらずデバイスで走り、その結果もまた留まります。それが softmaxlayer-norm・アテンション・オプティマイザのステップを、リンク上で何も動かさない連鎖として走らせるものです。これらはどれも double で計算してストア時に丸めるという CPU カーネル自身の規則に従うので、すべて CPU が計算したであろう値とビット単位で同一です(対象バックエンドと精度)。

そして常駐したオペランドの上では、添字で駆動されるコピー 3 つと勾配クリッピングの総和も。 linalg:take-rows(torch:index-select の背後にある埋め込みの参照)、linalg:gather(クラス添字の torch:cross-entropy-loss が目標ロジットを取り出す行ごとの選択)、そして take-rows の in-place の随伴 linalg::%la-scatter-rows は、ストライドではなく添字ベクトルで選ばれるコピーです。したがって上のコピー群とまったく同じく往復の元は取れず、すでにデバイス上にあるオペランドの上では起動 1 回で済みます。3 つとも CPU カーネルとビット単位で同一です。2 つは並べ替えるものが何もない純粋な gather です。scatter-add はそうではありません。添字の重複 — トークン埋め込みの勾配はまさにそれでできています — が、累積する順序をその値の一部にするからです。この順序は、アトミック演算に頼るのではなく、宛先のセル 1 つにつきスレッド 1 つを割り当て、添字をあらかじめ宛先ごとにまとめておくことで保たれます。これらと並んで、モデルのすべての勾配にわたる torch:clip-grad-norm の二乗和も加速されます。学習ステップがホスト上で読み戻していた最大のものです。ただしそれだけはビット単位で同一ではなく、このフラグでビット単位で同一でない唯一のメンバです — 対象バックエンドと精度を参照してください。

それ以外はすべて辞退し、それまでと同じものが動きます。--blas も付いていればチューニング済みライブラリ、--simd が付いていればレーンカーネル、どちらもなければ移植版の linalg.lisp の定義です。辞退するものには、--blas が受け取る linalg の行列 × ベクトルの 2 つの形(メモリ律速なので、行列を運ぶ往復の元が取れません。上の vec:matvec が常駐した行列に対してだけ受け取られるのも同じ理由です)、スタックされた積の片側にあるランク 1 のオペランド、単一のストライドでは各スラブに届かないバッチ形状(ブロードキャストされる軸がそうでない軸の内側にある場合)、一般(ボックス化)配列、幅の混在、スカラーのオペランド、そして従来どおり同じエラーを送出する形状の不一致が含まれます。

小さいものすべても含まれます。仕事の種類が 2 つあるので、しきい値も 2 つあります。デバイスへの往復は、載せるデータがどれだけ少なくても約 15 マイクロ秒かかります。したがっておよそ 51x51x51(n * m * p が 131072 未満)より小さい積は辞退し、CPU に留まります。スタックの場合は同じしきい値が合計の仕事量 batch * n * m * p に適用されます。往復は行列ごとにではなくスタック全体で 1 回だからです。要素ごとの呼び出しは代わりに要素数で測られ — 1 要素につきライブラリ呼び出し 1 回です — 16384 要素未満なら辞退します。ブロードキャストと軸リストの transpose は結果の要素数が 32768 未満なら、軸方向の畳み込みは入力の要素数が 131072 未満か出力スライスが 256 個未満なら — オペランドがすでにデバイス上にある場合は 32 個未満なら。そこでは代替が無料の CPU の走査ではなくホストへのコピーだからです —(出力スライスが 1 つしかない畳み込みはデバイス上では単一スレッドのループであり、どんな CPU にも負けます)、乱数の充填は 8192 要素未満なら、vec:matvec は行列の要素数が 131072 未満なら — あるいはどの行列でも初めて差し出されたときは — 辞退します。どのしきい値も独立した仕組みではなく、辞退が 1 つ増えただけです。だからこそ、このリポジトリの例はすべて — どれもそれよりはるかに小さい形状で動きます — フラグの有無にかかわらずバイト単位で同一の出力を出します。

Apple Silicon では

フラグは同じフラグ、プログラムも同じプログラムです。違うのはデバイスがどの呼び出しを受け取るかです。

単精度専用です。 Metal のシェーディング言語には double がまったくないので、倍精度浮動小数点数の配列 — linalg が既定で作るもの — は常に CPU に残ります。torch: は既定で単精度のテンソルを作るので何もする必要がありません。linalg のプログラムは :element-type 'single-float#f のリーダ構文で明示的に頼む必要があります。そうしなければ Mac ではフラグは何もしませんし、そのことを何も言いません。辞退はエラーではなく通常の結果だからです。

小さいものはより長く CPU に残ります。 ここでの往復は 1 回あたり約 77 マイクロ秒で、NVIDIA のカードの約 16 マイクロ秒に対して 5 倍です。Metal ではそのコストが起動ごとではなくコマンドバッファごとに 1 回かかるからです。しきい値もそれに合わせて上がります。積は 51 の 3 乗ではなくおよそ 166x166x166(n * m * p が 4194304)から、要素ごとの呼び出しは 16384 個ではなく 131072 個から、ブロードキャストと軸リストの転置は 32768 個ではなく結果 262144 要素から差し出されます。

軸方向の畳み込みは、ここでは往復のメンバではありません。 :axis 形式の sumamaxamin は、オペランドがすでにデバイス上にある場合を除き、どんなサイズでも CPU に残ります。独立した理由が 2 つあります。移植版の定義がそれらを倍精度で積算しており、単純な単精度の和ではビット単位で再現できないこと。そして再現する必要がなかったほう — 比較しかしない amaxamin — は、CPU に対して勝ちではなく引き分けを計測したことです。meanvarstdlinalg:softmaxlinalg:log-softmax は、それ以外のリンクを通じて引き続きデバイスに届きます。

それ以外は同じメンバの集合です。積の 2 つの形、超越関数 12 個、そしてブロードキャストの二項演算と軸リストの転置。およそ 512x512x512 を超えるランク 2 の積は MetalPerformanceShaders に渡されます。これは OS の中にあり、そのサイズでは手書きのカーネルより 1.5〜4 倍速いからです。それ未満と、スタックされた積のすべては、rontolisp が同梱するカーネルが実行します。2 つはビット単位で一致するので、どちらが走ったかは結果からは観測できません。

vec:matvec もここでのメンバです。しきい値はより高く、そして倍精度なしで同じビットに着地します。 ルールは上と同じです — 行列は、その間に書き換えられずに 2 回差し出されて初めて受け取られます。行列をデバイスへ運ぶことは、CPU がなめるはずだったまさにそのバイト列のコピーだからです — そしてこのバックエンドがデバイス上に保持する配列はその行列だけです。他のオペランドはすべて呼び出しごとにコピーされます。ユニファイドメモリではそれは単なる memcpy で、保持するより安いと計測されました。ただしフロアはコマンドバッファごとなので、常駐した単精度の GEMV はどれだけ小さくても約 80 マイクロ秒かかり、このメンバは行列の要素数(rows * cols)が 2097152 以上で差し出されます。常駐した 1536x1536 の GEMV は CPU の 267 マイクロ秒に対して 94、2048x2048 は 500 に対して 105、llama2 の 32000x288 の分類ヘッドは 800 に対して 185 です — 一方 1024x1024 は引き分け(100 に対して 90)で CPU に留まります。デコードのループが出会う注意点が 1 つあります。この GPU は 1 ミリ秒ほど以上アイドルだとクロックを下げ、その空白の後の最初のコマンドバッファはおよそ 0.5 ミリ秒余計にかかります。だから合間に CPU の仕事を挟んで数ミリ秒に 1 回呼ばれる GEMV の勝ち幅は、上の数字よりずっと小さくなります — examples/llama2 は M4 Max で stories15M をフラグの有無にかかわらず同じ速さでデコードします(どちらも毎秒約 370 トークン、物語は変わりません)。しきい値を超える唯一の行列である分類ヘッドが、2.7 ミリ秒のトークンごとに 1 回しか呼ばれないからです。Metal のシェーディング言語には積算に使う倍精度がありません。代わりにカーネルは走行中の和を補償付きの単精度のペアとして保持します。これは約 48 ビットを運び、計測した 768 列 × 1024 行のすべての行で移植版の定義のビットに着地します。NVIDIA のカードで倍精度の累積器がそうするのとまったく同じです(対象バックエンドと精度)。ここにある他のすべてと同じく、単精度のみです。

結果はここでは呼び出しのたびにホストへ戻ります。これも計測の結果です。 2 節上のルール — デバイスの結果はホスト上の何かがそれを読むまでデバイスに留まり、オペランドがすでにそこにあるメンバはコピーのない起動として走る — は Apple Silicon 向けにも作られています(単精度のみで、CPU が倍精度で計算する箇所でも CPU とビット単位で同一です。シェーダがその算術をソフトウェアで持っているからです)。しかしインタプリタとコンパイル済みクラス出力はここではそれを有効にしません。M4 Max で train-gpt-soseki を測ると、ノートの形状では引き分け(1 ステップ 0.104 秒に対して 0.102 秒)、本の形状では負け(安定した 8.9 秒に対して 10〜19 秒)だからです。Metal の呼び出しは CUDA の起動と違ってコマンドバッファを毎回待つこと、ユニファイドメモリでは節約されるコピーが単なる memcpy であること、そしてデバイスに留めた結果は Java ヒープにすでにある配列の 2 つ目のコピーになることが理由です。したがって Mac では、従来どおり各デバイス結果は呼び出しが返った時点で配列に入っています。

Apple M4 Max で、トランスフォーマ自身の形における単精度の価値 — JVM のクラス出力での 1 回あたりのマイクロ秒、3 回の計測ラウンドの最良値:

単精度、1 回あたり--simd--gpu --simd
erf, (4 256 1536) — 厳密な gelu56700950
exp, (4 256 256)752152
sub, (4 256 256) に対する (4 256 1)475155
mul, (4 256 384) に対する (384)720245
softmax :axis -1, (4 256 256)1982685
sum :axis 0, (4 256 384)225242
transpose '(0 2 1), (4 256 192)357397

最後の 2 行は辞退です。軸方向の畳み込みはここでは決して差し出されず、その転置は 196608 要素でしきい値をわずかに下回ります。辞退される呼び出しはフラグなしのときより少しだけ余計にかかります。サイズの検査で数マイクロ秒、そして 2 つのしきい値の間にある形ではその形を記述する作業のぶんも加わります。転置の行の 40 マイクロ秒がそれです。

そしてインタプリタでの n x nlinalg:matmul 1 回、単精度、1 回あたりのマイクロ秒、ウォーム:

n x n--simd--gpu --simd
128178183
192571130
2561287141
3844190210
5129975220

n=128 はしきい値未満で辞退します。n=192 からデバイスは CPU の 4 倍、n=512 では 45 倍です。あなたのマシンも、プログラムが実際に走らせる形も違います。計測してください。

実行できる例

examples/ml/gpu-matmul.lisp は 256x256 の行列に対する linalg:matmul 1 回と計測ループだけ、全部で 9 行のプログラムです。幅は single-float -- Mac の GPU が受け取れる唯一の幅 -- です。別のサイズはプログラム自身の引数です -- rontolisp examples/ml/gpu-matmul.lisp --gpu --simd -- 2048 のように書きます。-- がコンパイラのオプションの終わりであり、プログラムの引数の始まりです。3 通りに実行してください:

rontolisp examples/ml/gpu-matmul.lisp               # the portable definition
rontolisp examples/ml/gpu-matmul.lisp --simd        # CPU vector lanes
rontolisp examples/ml/gpu-matmul.lisp --gpu --simd  # the device, lanes below it

Apple M4 Max での 256x256 の積 1 回あたり: ネイティブのインタプリタは 14846 ms -> --simd で 2.54 ms -> --gpu --simd で 0.24 ms、JVM クラス出力は 1.60 ms -> 0.18 ms です。外部関数インタフェースがなく --gpu も使えない wasm-GC にコンパイルした場合は 474 ms -> --simd だけで 6.02 ms になります。プログラム自身が計測します -- 0.5 秒間だけ積を繰り返して割ります -- ので、終わるまで時間がかかるのはインタプリタでフラグなしの実行だけです: 積 1 回で約 15 秒。

3 つのフラグはどう合成されるのか

各フラグは他のものの前に試行を 1 つ足すだけで、辞退した試行は同じ引数を次へ渡します。

--gpu --blas --simd   ->   device -> library gemm -> lane kernel -> portable definition
--gpu --simd          ->   device ->                 lane kernel -> portable definition
--gpu                 ->   device ->                                portable definition

--parallel を付けると、行列積についてはレーンカーネルの段が行並列のものになります。それ以外は何も動きません。デバイスは依然として最初に、呼び出し元のスレッドで尋ねられ、デバイスが辞退したものだけがスレッドに分割されます。--blas が受け取るのはランク 2 の積だけなので、スタックされた積 — および要素ごとの呼び出し — にはライブラリの段がそもそもありません。--gpu --blas --simd でも device -> lane kernel -> portable definition と連鎖します。デバイスを最初に尋ねるのは、そのサイズしきい値がチューニング済みライブラリのものより 3 桁大きいからです。小さいものはドライバに触れる前にすべて断り、NVIDIA のカードではおおよそ n=256 以上で両方の精度でマルチスレッドの CPU BLAS より速くなります。ですからデバイスが辞退したものは、その実行が要求した中で最速の CPU 経路に落ちます。移植版の定義まで戻ることはありません。例外はしきい値のすぐ上の狭い帯域です。おおよそ n=64 から n=96 では、--gpu --blas を併用するとライブラリ単独のほうが早く終わったはずの積をデバイスが引き受けてしまいます。そこはどちらも 1 ミリ秒を大きく下回りますし、逆にライブラリを先に尋ねるようにすると、このフラグが存在する理由であるサイズでの数倍の差を手放すことになります。

Apple Silicon では、おおよそ n=1500 より下でこの順序は成り立ちません。 Accelerate の sgemm はそもそもベクトルレーンで動いていません。M4 Max で 2.1 TFLOP/s に達し、これは --simd 列の 1 コアあたりの速度で 16 コアを回しても届かない値です。つまりレーンでもスレッドでもなく、CPU クラスタの行列コプロセッサです。しかも同じメモリ上の単なる関数呼び出しなので、Metal の起動が払う約 80 マイクロ秒のコマンドバッファの下限もありません。したがって小さいものはすべてライブラリが勝ち、デバイスが前に出るのは n^3 の仕事量が往復の固定費を追い越してからです。インタプリタでの n x n single-float の積 1 回あたりのミリ秒:

n--simd--gpu--blas
2562.490.240.06
51222.80.430.20
10241731.190.99
204813544.659.96
40961069320.964.9

つまり Metal のしきい値(おおよそ n=166)から n=1500 あたりまでは、--gpu --blas は 2 つのうち遅いほうに積を渡します。行列積がすべてこの帯域に収まる Mac では、--blas 単独のほうが速い実行になります。

対象バックエンドと精度

--gpu が届くのはインタプリタ(ネイティブバイナリを含む)と JVM のクラス出力です。デバイスは foreign function API 経由で呼ばれ、WASM にはそれがないので、.wasm 出力に対する --gpu は黙って何もしないのではなくエラーになります。WASM のプログラムには --simd があります。

--gpu でコンパイルしたクラスも単体で完結しています。どちらの種類のマシンでコンパイルしても両方のバインディングがその中に入って運ばれるので、クラスパスに置くものは何もなく、どちらの種類のマシンでも java Prog だけで動きます。ただし制限付きメソッドを呼ぶので、JVM の警告を標準エラーに出さないためには java --enable-native-access=ALL-UNNAMED Prog として実行してください。NVIDIA のカードでは現状、ネイティブバイナリでのデバイス呼び出し 1 回のコストが JVM の 20〜50 倍かかり(n=512 の倍精度の積 1 回で 0.74 ms に対し 17.4 ms)、そのビルドでは測定したすべてのサイズで --gpu --blas--blas 単独より遅くなります(Metal のバインディングはこのコストを払いません。上の Apple Silicon の数値はネイティブバイナリのものです)。それでもそこで --gpu--simd の 2 倍以上、移植版の定義の 4 桁速いままです。このコストを回避する方法はプログラムをクラスにコンパイルすることです。ネイティブバイナリが出すクラスは java -jar が出すクラスと同じもので、後掲の 2 つ目の表の速度で動きます。

--gpu は、結果が他のバックエンドと桁単位で一致することを期待してはいけない最初のフラグです。 理由は 2 つあり、2 つ目が新しいものです。

  • 加速されたは移植版の定義と等しくはなく、近いだけです。デバイスのカーネルは出力セルごとの畳み込みを移植版の定義と同じ順序で行いますが、乗算と加算を 1 命令に融合するため、移植版が 2 回丸めるところを 1 回しか丸めません。オペランドの幅で正確に表せる入力(整数、2 のべき乗)ではその差は現れず、結果は厳密に一致します。正確でない入力では差が出ます。NVIDIA GB10 で大きさ 1 のオペランドに対して測ったところ、#d で最大 5e-15、#f で 3e-6 でした。

  • 加速された超越関数にはそのような免除される入力の類がありません。デバイスが experf の実装を自前で持っているからです。正しい実装 2 つは最後の桁で食い違い、どちらも間違ってはいません。#f ではその上にもう 1 つ理由が乗ります。デバイスはオペランドの幅で評価しますが、ここにある CPU カーネルはどれも倍精度で評価して格納時にだけ丸めるからです。同じマシンで各メンバ固有の範囲にわたって測ると、移植版の定義に対する相対差の最悪値は #d で 2e-16 〜 1e-15(最終桁で 1〜5 単位)、#f で 1.1e-7 〜 1.7e-7(1〜2 単位)です。#d で最大なのは erf ですが、これはデバイス側ではなく rontolisp 側の事情です。移植版の定義は級数展開であって、正しく丸められた erf ではないからです。1 つだけ極小ではなく目に見える違いがあります。負のゼロに対する加速された erf は、移植版の定義が 0.0 と表示するところで -0.0 と表示します。

  • 行列 × ベクトルの積 vec:matvec はその 2 つの中間にあります。そのカーネルは両方の幅で倍精度に累積し、格納時にだけ丸めます — 移植版の定義そのもののルールです。したがって #f では要素 2 つの積はすべて累積器の中で正確で、移植版の定義と違うのは倍精度の和の順序だけです。それが丸めた結果を動かすのは、その和が丸めの境界から約 1e-16 以内にあるときだけで、測定では 768 列 × 1024 行のうち 1 行もありませんでした。一方、単精度で累積する --simd のレーンカーネルは大半の行で移植版の定義と食い違います。つまり --gpu --simd で受け取られた GEMV は、置き換えたレーンカーネルより移植版の定義に近いのであって、遠いのではありません。#d では積と同じ最終数桁の話です。どちらもバイト単位の同一性として約束しているわけではありません。Apple Silicon ではカーネルに倍精度がありません。その累積器は約 48 ビットを運ぶ補償付きの単精度のペアで、計測した同じ行(1024 行中 1024 行)で移植版の定義のビットに着地します — 弱い契約ではなく、同じ契約です。

  • torch:clip-grad-norm のノルムは、このフラグで唯一、結果が移植版の定義の演算を移植版の定義の順序で行ったものではないメンバです。移植版の定義は二乗を左から 1 要素ずつ畳み込みますが、配列全体の総和は出力セルが 1 つしかないので、デバイスが結合順序を変えずに分割できるものが何もありません。そこでカーネルは代わりにブロックで畳み込みます — 各ブロックがストライドされたスライスを double に足し込み、木で合計し、ブロックごとの部分和をブロック順に足す — これは各項を移植版の定義が丸めるのとまったく同じ位置で丸め、まとめ方だけを変えます。厳密な総和に近いのは 2 つのうちこちらのほうであり、そして等しくはありません。最終桁で数単位の差を見込んでください。ブロックの個数は配列の長さだけで決まるので、答えは実行のたびに同じです。ノルムは max-norm / (norm + 1e-6) というスケールとして使われるので、この差は加速された exp がすでにそうしているのと同じように学習を動かします。

  • ブロードキャスト軸方向の畳み込み軸リストの transpose のメンバ — そして 2026-08-23 以降に常駐オペランドの上で受け取るすべてのメンバ(2 項演算とマスクの等形状・スカラー形、sqrt / abs / negative / signwhere、Adam ステップ、コピー、そして添字で駆動されるコピー 3 つ)— は例外で、両方の幅で移植版の定義とバイト単位で同一のままです。これらのカーネルは各要素を倍精度に広げて読み、倍精度で計算し、格納時にだけ丸めます。これは移植版の定義そのもののルールであり、実装 2 つが食い違えるようなライブラリ関数はどこにも入っていません。加速される呼び出しがこれらだけのプログラムは、フラグなしのときとまったく同じものを表示します。

したがって加速された erf の値を 100 万個足し合わせるプログラムは、フラグありでは少し違う数値を表示します。学習の実行も、十分なステップ数を経れば CPU での実行から乖離します。2 つの GPU の間でそうなるのとまったく同じです。移植版の定義は引き続きバックエンド横断のオラクルであり、--gpu はバックエンド横断のテストスイートから意図的に外してあります。同一性が必要ならフラグを渡さないでください。それ以外の点でプログラムが変わっていないことを確かめたいなら、CUDA_VISIBLE_DEVICES= を設定して実行してください。すべてのデバイス呼び出しが辞退し、出力はバイト単位で同一になります。Mac ではフラグなしで走らせることが同じ検査になります。GPU を隠す環境変数がないからです。

どれだけの価値があるのか

インタプリタ上での n x nlinalg:matmul 1 回、1 呼び出しあたりのマイクロ秒、ウォーム状態です。マシンは NVIDIA GB10(Grace Blackwell、CPU 20 コア)で、--blas の列はこのマシンが持つ最速のもの、20 コア全部を使う OpenBLAS です。デバイスもドライバもライブラリもあなたの環境では違うので、必ず測ってください。

n x n--simd f64--blas f64--gpu f64--simd f32--blas f32--gpu f32
644621139271142
12835942531952636
256264716415614538571
51220267116073510567510200
1024--64505150--3083700
2048--8920038000--446004200

読み方は 2 通りあります。レーンカーネルに対しては n=128 で 7 倍、n=512 で 28 倍、単精度の n=512 では 53 倍 — 桁が違い、それがこのフラグの狙いです。20 コアのチューニング済み BLAS に対しては n=256 あたりまでは互角で、そこから倍精度で 1.6〜2.3 倍、単精度で 2.5〜10 倍です。倍精度はこの種のデバイスがもっとも苦手とする幅なので、--gpu がもっとも報われるのは single-float のデータであり、それは torch: が既定で作る幅です。n=512 以上の単精度の行は 2026-08-22 に測り直したものです。この日、f32 の積にレジスタタイル化されたカーネルが 2 つ加わりました(64x64 と 128x128 のブロックタイル。デバイスの SM 数から形状ごとに選ばれます)。各セルを 16x16 カーネルとまったく同じ順序で畳み込むので、ビットもまったく同じに着地します — カーネル単体で n=1024 で 2.7 倍、n=2048 で 3.5 倍です。倍精度の積は変わっていません。このデバイスでは数少ない倍精度ユニットがどのタイルも同じ速さに抑えるからです。

同じ積を .class にコンパイルして JVM で動かしたものです。400 回のウォームアップのあと、計測ラウンド 3 回のうちの最良値です。

n x n--simd f64--blas f64--gpu f64--simd f32--blas f32--gpu f32
645017107328106
12834530502063434
256261317014513809565
51220760114074010480530160
1024--69335367--4433850
2048--9175039000--446254200

同じ表であり、そこが要点です。積が 1 回のデバイス呼び出しになってしまえば、その周りのバックエンドはもう問題になりません。しきい値付近を比較するときは十分にウォームしてください。n=64 と n=128 では呼び出しの合間にデバイスがアイドルクロックまで落ちるので、冷えた 1 ラウンドではここに挙げた値の数倍を計測することがあります。

そしてスタックされた積、すなわち Transformer を構成している形です。n x n のスラブ batch 枚に対する linalg:matmul 1 回、1 呼び出しあたりのマイクロ秒、インタプリタ、マシンもウォームアップも上と同じです。--blas の列がないのは、このメンバを受け取らないからです。

batch x n--simd f64--gpu f64--simd f32--gpu f32
256 x 860484630
64 x 1675437129
16 x 32110456929
4 x 641764910131
16 x 647108640056
16 x 12855803003040130
12 x 25631740124016660300

デバイスが本領を発揮するのはバッチです。CPU はスタック内のすべての行列に対して支払うのに、往復は 1 回しか支払われないので、比は行列の大きさと同じくらいバッチの大きさでも伸びます。しきい値では 1.25 倍、12 x 256 の倍精度で 26 倍、単精度で 55 倍です(最後のセルはレジスタタイル化カーネルのもので、2026-08-22 に測り直しました。その 3 分の 1 は 51 マイクロ秒のカーネルを囲む 9 MB のコピーです)。

要素ごとのメンバを JVM のクラス出力で測ったものです。150 万要素 — 後述の Transformer のフィードフォワードのアクティベーション — に対する linalg: 呼び出し 1 回、1 呼び出しあたりのマイクロ秒、30 回のウォームアップのあと計測ラウンド 5 回のうちの最良値です。

150 万要素--simd f64--gpu f64--simd f32--gpu f32
exp73008337933333
log72678007800333
tanh95337679733333
erf103400900101233333
sin76677339233333

倍精度で 9〜12 倍、単精度で 23〜29 倍、そして erf では 115 倍です。erf は CPU がもっとも苦手とするメンバであり、厳密な torch:gelu が書かれている先でもあります。デバイスの列が平らなのは、このサイズでは演算ではなく転送が支配的だからです。どのメンバも 12 MB を往復させるぶんのコストしかかかりません。単精度が倍精度の 2 倍の価値になるのも同じ理由で、演算量から予想される割合にはなりません。辞退されるメンバを表に載せていないのは、それらについてフラグが実行内容を変えないからです。辞退を決めた数値は前の節にあります。これを追試するなら幅ごとに別プロセスで測ってください。CPU 側では、同じ呼び出し地点で 2 番目に測った幅は最初の幅より 1.5〜2 倍遅くなります。これは JIT の副作用であって、幅そのものの性質ではありません。

そして CPU 側の相方がレーンループではなく添字のオドメータであるメンバ、JVM のクラス出力、トランスフォーマ本来の形状で、1 呼び出しあたりのマイクロ秒、50 回のウォームアップ後に 3 ラウンド測った最良値です。

単精度、1 呼び出し--simd--gpu --simd
sub、(4 256 256) に対する (4 256 1)44288
sub、(4 256 384) に対する (4 256 1)660118
mul、(4 256 384) に対する (384)665115
sum :axis 2、(4 256 256)20275
sum :axis 0、(4 256 384)29770
var :axis 2、(4 256 384)1387475
transpose '(0 2 1)、(4 256 192)33575
softmax :axis -1、(4 256 256)1915402
sub、(4 256 384) に対する (4 256 384)8585

3〜6 倍です。最後の行は同じ演算を等しい形状に対して行ったものです。フラグはこれを辞退するので、どちらの列も CPU が同じレーンループを回しています。代わりにデバイスへ差し出すと 112 マイクロ秒かかり、だからこそ辞退します。この対比がこのグループの選定基準そのものです。

エンドツーエンドでは、examples/llm-from-scratch/chapter03/train-gpt-soseki.lisp をノートブック本来の形状(*n-embd* 384、*block-size* 256。ファイル自身が 1 行の変更だと書いています)で動かすと、JVM のクラス出力での学習 1 ステップがフラグありでおよそ 16 倍速くなります(0.79 秒に対し 0.050 秒)。5 ステップと 40 ステップの実行から傾きを取って準備とサンプリングを外し、交互に 3 ラウンド実行した中央値です(フラグが最初に入ったときは 0.89 秒対 0.21 秒、デバイス常駐の直前は 0.80 秒対 0.13 秒、この数字の前日は 0.11 秒でした。その後の差は、AdamW の更新・乱数生成器・torch:masked-fill/torch:index-select/勾配クリッピングの背後にある選択とコピーがアクセラレーションの継ぎ目に載り、乱数生成器がデバイスに載り、2026-08-22 以降は配列そのものが呼び出しをまたいでデバイスに留まるようになり、そして 2026-08-23 以降は結果がホストに読まれるまでそこに留まり、オペランドがそこにあるメンバはコピーのない起動として走るようになり、さらに同日の夜からは起動のパイプラインがデバイスの先を走るようになった — キューを空にしてしまっていた起動後の待ちとストライド層のレイアウトコピーが消えた — ことによります)。20 コアの --simd --parallel(0.37 秒)に対しては 4 倍です。--blas はこのプログラムでは何も変えません。すべての積がライブラリの受け取らないスタックされた積だからです。桁の数字ではなく比のほうを引用してください。このマシンでは同じプログラムが実行ごとに 15% ほどぶれます。最初の 100 ステップを過ぎるとステップは 0.016 秒近くに落ち着き — そこでの --simd の 0.85 秒に対して 50 倍 — そこに残っているのはコピーでも起動でもホストでもなく、デバイスのカーネルそのものです。200 ステップの実行がデバイスからホストへ動かすのは 6737 回のコピーで 2.3 GB、前日の 37534 回・44 GB に対してです。前日はどの結果も呼び出しのたびにホストへ戻っており、2026-08-22 のレジスタタイル化積カーネル — このプログラムのフィードフォワードの形状でカーネル単体では 2〜4 倍 — はステップを測定できるほどには動かしませんでした。フラグごとの表は example の README にあります。

linalg の外のメンバを、それが測定されたプログラムで。 examples/llama2stories15M — 60 MB の単精度の重み、トークン 1 つにつき 79 回の GEMV — をデコードするとき、JVM のクラス出力・貪欲法で 256 トークン・同じマシンで 3 回ずつ: --simd が毎秒 220〜226 トークン、--gpu --simd が 282〜292、およそ 1.3 倍で、物語はバイト単位で同一です。重みの帯域幅から期待されるより小さく、理由はデバイスが受け取らないものにあります。各層 4 つの 288x288 の射影は約 12 マイクロ秒で引き分けなので CPU に留まり、GEMV の周りのアテンション・RoPE・サンプリングはそもそも GEMV ではありません。移ったのは各層 3 つのフィードフォワード行列と分類ヘッド — 積和の 3 分の 2 — で、分類ヘッドだけで 1.5 ミリ秒が 0.17 ミリ秒になりました。

コピーの代価と、残っているもの。2026-08-22 以降、このフラグは呼び出しをまたいで各オペランドと結果のコピーをデバイス上に保持します。配列の同一性をキーに弱参照で持つので、プログラムが手放した配列はコピーごと消えます。直前の呼び出しが送った、あるいは生み出したオペランドは二度と送られません。2026-08-23 以降、連鎖が走っている間はホスト側の配列はもう真の値ではありません。結果はデバイスに留まり、ホストがそれを最初に読むときに戻ってきますaref、印字、write-sequence、ホストで走るカーネルのときです。パック配列への in-place の書き込み((setf (aref ...))fill-into 系、一括の read-sequence)は、そのような結果をまず戻してからデバイスにコピーを忘れさせます。書くコードは何も変わらず、途中の結果を一度も読まないプログラムはその代価を払いません。常駐コピーはデバイスの空きメモリの 4 分の 1 で抑えられ(追い出される結果はダウンロードされるのであって捨てられはしません)、ダウンロードはピン留めしたバッファを経由します。測定したマシンでは、割り当てたばかりの配列へのデバイスコピーが暖まった配列への 100 倍かかるからです。デバイスに留まる結果には、何かが読むまでホスト側の配列が存在しません。プログラムが保持しているのは配列のヘッダだけで、要素は最初のホスト側読み出しの時点で割り当てられます。そのため、活性化がホストで一度も読まれない学習ステップはそのぶんのヒープを一切使わず、書籍の形状ではこれでステップが 6.3 秒から 0.65 秒になりました(大半はコレクタでした)。すべてが入った今、学習 1 ステップはデバイス律速です。プロファイルの先頭に来るのはカーネルそのもので、起動は重なって隠れます — 起動の後に待つものはなく、ストライドカーネルのレイアウトはキューを空にする同期コピーではなくパラメータブロックに載って渡ります(.kb/gpu.md)。それでもデバイスを空回りさせうるのは、ホストが送らなければならないオペランドです。転送はキューに溜まったカーネルを待ってからコピーするので、1 ステップに数十メガバイトを送り出すプログラムでは、デバイスが動いているのはほぼ全部ではなく 1 ステップの 4 分の 3 ほどになります。2 つ目の例のヘッドごとのトランスフォーマがそれで、その転送がどこから来るのかはその README にあります。インタプリタでは同じプログラムが相変わらずまったく変わりません(1 ステップ 26.1 秒に対し 25.5 秒)。理由はデバイスではありません。同じ形状でインタプリタの 1 ステップはコンパイル済みの 32 倍かかっており、支配しているのはカーネルではなくその周りの構文木の歩行だからです。フラグを測る前にプログラムをコンパイルしてください。

NVIDIA のカードでは、長い実行でヒープの「ページ」が効いてきます。JVM のフラグ 1 つが 15% に相当することもあります。デバイス上に残った結果は、プログラムがそれを手放したことをコレクタが気づくまでデバイスメモリを押さえ続けます。そのためフラグは予算が埋まった時点でコレクションを要求します。要求そのものの代価はどのコレクタでもおよそ 50 ミリ秒、学習の実行全体の 3% 程度で、逆に要求を拒む(-XX:+DisableExplicitGC)と実行時間は何倍にもなります。ただで済まないのはどう応えるかの方です。圧縮を伴うコレクションは生き残った配列をすべて新しい番地へ動かし、伸び直したヒープには新しいページを与えます。そして GPU が一度も触れていないページとの間のデバイスコピーは、暖まったページの約 100 倍かかります。ですから長い実行では、既定のコレクタに要求時の圧縮をやめさせる(-XX:+ExplicitGCInvokesConcurrent。13 M パラメータのモデルの形状で 15% に相当し、予算に届かないほど小さい形状ではまったくの無風です)か、プログラムが埋め切って再利用する若い世代を与える(1 ステップが数 GB を確保する形状での -XX:+UseParallelGC -Xmn8g。そこでは実測で最速です)かのどちらかにしてください。やってはいけないのは、プログラムが埋め切らない若い世代を手でサイズ指定することです。1 ステップが数 MB しか確保しない形状でのパラレルコレクタの -Xmn4g は、デバイスが一度も触れていない 4 GB 分のページであり、同じプログラムの実行時間を 1.5 倍にします。**Apple Silicon ではこの話は一切当てはまらず、従うと損をします。**そこでは結果はデバイス上に残らないので、ライブラリはコレクションを要求すること自体がありません(実測したどの構成でも、学習の実行中に System.gc() は 1 度も現れません)。またデバイスが読むバッファはヒープが書き込むのと同じユニファイドメモリなので、「デバイスが触れていないページ」というものが存在しません。1 ステップが数 MB しか確保しない形状では、上のどのフラグの組み合わせもコレクタに何もしない場合の 1.5% 以内に収まり、1 ステップが数 GB を確保する形状では -XX:+UseParallelGC -Xmn8g は何もしない場合より 13% 遅く、その差の大半はそのフラグ自身の停止時間です。ですから Mac では、プログラムに足りるヒープだけを与えて、あとは触らないでください。