Tenstorrent WormholeでFFT (DFT)
9月くらいにTenstorrentのWormhole n300sというPCIeカード型のAIアクセラレータを購入したが、ジェット機並みの騒音の冷却ファンに辟易してあまり触れていなかった。 屋外に設置する用のPCを準備できた、ということで開帳して年末休みに色々試していく。
Tenstorrent Wormholeとは?
Tenstorrent Wormholeは、RISC-Vマルチコアプロセッサ (Tensix-Core) を64基ほど搭載する、PCIeカード型AIアクセラレータ。
MIMD型メニーコアに分類され、特徴としてはスクラッチパッドメモリ、2Dトーラス状インターコネクトを備える点だろう。
中間結果のストアにはスクラッチパッドメモリを使用し、コア間のデータ共有には相互結合網を用いることでメモリ律速を回避することに全力を注いだプロセッサという印象。
tenstorrent.com
プロセッサアーキテクチャの詳細に関しては、以下の記事が詳しい
Tenstorrent Wormhole Series Part 1: Physicalities
プログラムインターフェース
Tenstorrentは抽象度の異なる3つのSDKをいずれもOSSとして公開している。
tt-budaはPyTorchやTensorflowで既成のモデルを用いる場合と似たPython環境を提供する。
tt-nnはnumpyほどの抽象度を提供するPython環境で、深層学習モデルの記述を行う場合を想定している。
tt-metalは低レベル開発環境で、スクラッチパッドメモリからインターコネクト、個々のコアまでをプログラムする環境を提供する。
RISC-Vコア上で動作するプログラムはC/C++で記述し、行列演算器やSIMD演算器は独自のAPIでプログラムする仕組みである。
ファンのノイズについて
ファンのノイズは掃除機と同じかそれ以上の音量といったところ。
付属するブロアファンクーラーは2線式なのだが、実はクーラーのケースの一部を切断し基板を露出させると4線式のPWM信号、回転パルス信号の信号線が切断されていることが確認できる。
PWM信号線 (入力) は内部でプルアップされており、(意図的に)常に100%の回転数となるよう出荷時に設定しているようだ。
温度を監視する限り、チップ温度はアイドル状態であれば余裕があるので、信号線をはんだ付けしてマザーボード内蔵のファンコンから回転数を制御できるよう改修した。
常に計算を実行する想定では無いので問題はないだろう。
これに関しても、後で記事にしようと思う。
FFT実装方針
大域フィルタ層 (Global Layer Filter) は大域的な特徴の抽出を目的とした畳み込み層の一種で、
入力テンソルと同じ形状のフィルタを用いる点が特徴だ。
このように大型のフィルタを用いることは、TransformerのAttention機構と同様、大域的特徴の学習を可能とする一方で の計算量を要求する。
この計算量の削減手法として、入力データとフィルタを周波数空間上に変換し、要素積を求め、逆変換する手法が提案されている。
FFTで周波数空間に変換・逆変換する場合、その計算量はどちらも
であり、
が十分に大きければ計算量の削減が可能だ。
一方で、現在のGPUは密行列の演算能力は高い一方、FFTを比較的苦手とすることから効果は限定されるのが現状である[1]。
Wormhole上のインターコネクトやスクラッチパッドメモリを活かした効果的なFFT実装を提案出来れば価値はあるだろう。
なお今回、はじめにPE1基のFFT実装を実施し、その後複数PEの並列処理を実装してく予定だ。
FFTのアルゴリズムとしては、基本的な周波数間引きCooley-Turkeyのアルゴリズムを踏襲する。
FFTをN=2,4まで分割する実装が有名で、この場合、N=2や4の計算ステップ以降は、乗算を符号反転や複素共役で代替できるトリックが採用される。
(このトリックを指して"バタフライ演算"と呼ぶこともあるが、この記事では便宜上計算フロー上の交差を総称するものとする)
このトリックは、乗算器が限定される通常のプロセッサであれば有効だが、今回使用するWormholeは乗算器を大量に集積しており、
8x16 @ 16x16 の密行列積を1サイクルで計算できる。
tt-metalのAPI上基本となる行列サイズのに換算すると、16サイクルで計算可能だ。
その為、ある程度のNでFFTを打ち切って、通常のDFTに切り替える複合的なアプローチを採用した方が高速かもしれない、という推測が立つ.
さて、どこまでのNで打ち切れば最も演算に必要なサイクル数を抑えられるか初めに考察しよう.
DFTに行列乗算器を使用した場合の必要サイクル数
| [cycle] | Matmul & Eltwise-FMA (8x16)@(16x16) | Eltwise Add (8x16) | Eltwise Mul (8x16) | Sum |
|---|---|---|---|---|
| N=64 | 128 | 0 | 0 | 128 |
| N=32 | 16 | 0 | 0 | 16 |
| N=16 | 4 | 0 | 0 | 4 |
| N=8 | 1 | 0 | 0 | 1 |
FFTに行列乗算を使用した場合の必要サイクル数
| [cycle] | Matmul & Eltwise-FMA (8x16)@(16x16) | Eltwise Add (8x16) | Eltwise Mul (8x16) | Sum |
|---|---|---|---|---|
| N=1024 | 16 | 0 | 8 | 24 |
| N=64 | 16 | 0 | 8 | 24 |
| N=32 | 16 | 0 | 8 | 24 |
| N=16 | 4 | 0 | 2 | 6 |
| N=8 | 1 | 0 | 0 | 1 |
この結果から、でDFTに切り替えた方が計算に必要なサイクル数を最小限に抑えることがわかる。
FFTの行列演算実装
Wormholeのアーキテクチャに沿ったFFTの計算方法を考えていく。
サンプリング点の各要素を行列
に以下のように格納する。
ここで
は行列の行・列の長さを示す。
バタフライ演算のたすき掛け状のデータフローを行列乗算で実現する。
シフト量 に応じたバタフライ演算行列
を右から掛けることで実現する.
この行列の要素
は以下である.
このときのシフト量は以下である。
ここでは行列の要素数の平方根である。
回転因子を要素として持つ行列も定義する.
以上で定義した行列を用いて、FFTは次のように計算できる。
16点FFTにおける、計算グラフと行列演算の対応を次の画像に示す.

このアルゴリズムの注意点としては、の結果に対してFFTを実施する場合、計算結果に対して転置が必要となることだ.
高速な行列レジスタ上で終始完結する処理とはいえ、
tt-metalのtranspose APIはどの程度のサイクル数が必要かは不明なため、注意が必要だ。
通常のFFT同様、計算結果の系列は入力系列の順序と異なることからbit-revertでインデックスを変換する必要がある。
DFTの行列演算実装
通常のDFT同様、GEMM (点を一斉に計算) で計算する。
回転因子は次式で示される.
この回転因子行列に関しては、再計算する必要もない定数なのでコンパイル時算出するパラメータとして与える。
スクラッチパッドメモリ使用戦略
キャッシュメモリと異なり、PEのL1はプログラマ自身が制御しなければならないスクラッチパッドメモリだ。 計算に必要な行列と必要な容量について整理しておく.
| 行列名 | 形状 | サイズ | 内容 |
|---|---|---|---|
| |
|
4KiB | 1024点の入力データ |
| |
|
4KiB | N=32の回転因子行列 |
| |
|
4KiB | N=64の回転因子行列 |
| |
|
4KiB | N=128の回転因子行列 |
| |
|
4KiB | N=256の回転因子行列 |
| |
|
4KiB | N=512の回転因子行列 |
| |
|
4KiB | N=1024の回転因子行列 |
| |
|
4KiB | N=64のバタフライ演算行列 |
| |
|
4KiB | N=128のバタフライ演算行列 |
| |
|
4KiB | N=256のバタフライ演算行列 |
| |
|
4KiB | N=512のバタフライ演算行列 |
| |
|
4KiB | N=1024のバタフライ演算行列 |
| |
|
4KiB | 1024点の結果・中間結果格納行列 |
Wormholeは、半精度浮動小数点数を基本として扱うため、数値1つにつき2Byteが必要だ。
加えて、複素数であることを考慮すると4Byte必要である。
KiB (52KiB)程度の容量をL1に一度に格納できればプログラムが格段に組みやすくなる。
WormholeはPE1基あたり1MiB程度のL1を持つので、かなり余裕がある。
使用メモリ量に対して16倍ほどの空きがあることから、おおよそ1基のPEあたり16384点のFFTに対応する余地があるだろう。
今日のまとめ
アーキテクチャへの落とし込みは一通り目途がついた。 次回以降実際に実装する。
参考図書
vai_q_tensorflowをソースからビルドできない問題
vai_q_tensorflowはGithubにリポジトリが公開されており,ビルド方法もREADME.mdに記されているが,実行したところBUILDファイルが見つからないと怒られる.vai_q_tensorflowはTensorflow r1.15からのフォークであり,これと同様にbazelを用いてビルドするがレシピであるBUILDファイルがなぜか見当たらない..gitignoreを参照したところ,どうやら間違って一部のBUILDファイルを含めないよう設定されているようだ(適当すぎる...).そこでフォーク元のtensorflowからBUILDファイルを取得し配置する必要があった.その他にも問題が複数発生したので対処法を記す.
↓修正済みのリポジトリ
github.com
手順1
tensorflowをgit pull.vai_q_tensorflowと同階層のディレクトリに配置する.その後tensorflowのブランチをr1.15にcheckoutしておく.
手順2
次のシェルを実行する
手順3
私の環境では次のエラーも発生した.これはフォーク元のtensorflow側の問題の様だ.それぞれTensorflowのGithubに同様のissueが存在した.
#34197
github.com 3つのソースを書き替える必要がある.
#41061
github.com
上記のissueで述べられているが,次の変更を加えることでコンパイルが成功する.
github.com
RT(TTU)コアの処理内容・内部構造の調査
図などは上記の明細書を参考のこと
TTU - Tree Traversal Unit (nVidiaのTuringコアアーキテクチャから採用された"RTコア"の開発名)
レイトレーシング(Ray Tracing)において、1つの光線毎に、のプリミティブ(オブジェクトの最小単位,たいてい三角形)への衝突判定を行う必要があるが、愚直に計算するのは非効率.
処理数の削減を行うためにBVH(Bounding Volume Hierarchy)と呼ばれる木構造が導入される.下記のサイトを参照.
shinjiogaki.github.io
TTUは,BVHを辿る処理(AABB (Axis Aligned Bounding Box) への衝突判定を含む,数千命令に相当)をハードウェアで実現する.
TTUの具体的処理
用語
SM - Streaming Multi-processor
Compression Block - BVHの部分木に対応するデータブロック
処理
SMから,対象のRay,Compression Blockへのポインタを含んだ命令を受け取る.
Schudulerに命令本体が転送される.Ray,Compression Blockへのポインタはそれぞれ別のLocal Storageに保管される.
Setup Unitに命令本体とCompression Blockへのポインタが転送される.
Setup Unitは、受け取った命令を基にLocal Storageに保存されたRayの情報と,受け取ったCompression Blockへのポインタを基にメモリから取得(キャッシュがヒットすれば即座に取得できる)したCompression Blockのデータを取得する.
Setup Unitは、各Traversal UnitにRayとCompression Blockを送信しCompression Block毎に並列に処理を実行する.疑似コードのOuter Loopに相当するLoopを回す.
Traversal Unitは、処理が完了するとStack Management Unit内部のResultキューにRayが衝突する可能性のあるプリミティブへのポインタを入れる.疑似コードのInner Loopに相当するLoopを回す.後述するがTraversal UnitはAABBへの衝突判定は行うがプリミティブへの衝突判定は行わない.プリミティブへの衝突判定は別のハードウェアで実現する.Resultキューに入ったプリミティブ(へのポインタ)は,プリミティブ専用の衝突判定処理を待機する.
Stack Management Unitは、処理が完了したTraversal Unitが有り,Local StorageにCompression Blockが存在するならばSchedulerにTraversal Unitが再度処理可能であることを通知する.
疑似コード
procedure outerTraversal(Ray, *bvhRoot) traversalStack traversalStack.push(bvhRoot) while traversalStack is not empty do *node = traversalStack.pop intersectedExternalNodes = innerTraversal(ray,node) traversalStack.push(intersectedExternalNodes) end do end
procedure innerTraversal(Ray, *blockRoot) intersectedExternalNodes localStack.push(blockRoot) while localStack is not empty do *node = localStack.pop() if ray intersects node then addToResultQueue(node) end else if node is TransitionNode then intersectedExternalNodes.add(node.externalNodePointer) end else then childNodes = node.childNodes sort childNodes localStack.push(childNodes) end return intersectedExternalNodes end do end
内部データ構造
やや具体的な話、実装によって多少変わってくると思う.
Compression Blockのアラインメントの例
struct Compression_Block{ // is restricted to Cache Size struct Node nodes[N]; };
Nodeのアラインメントの例
// double fixedは2ワード長の固定小数点型を想定 struct Node{ // Root Node char type_id : 2; char AABB_Bitmask : 6; double x_1; // 2word floating point data double x_2; double y_1; double y_2; double z_1; double z_2; struct Compression_Block *child_ptr; }; struct Node{ // Internal/Leaf Node char type_id : 2; char AABB_Bitmask : 6; char x_1; // 1Byte=256 step data char y_1; char y_2; struct Compression_Block *child_ptr; };
nodes 各ノードのデータ,連続配置される.順番は深さ優先,幅優先どちらでもよい.
type_id ノードの種別(root,leaf,transition)を表す
AABB_Bitmask 後述
x_1,x_2,y_1...等 座標の数値
child_ptr 葉ノードでなければ子ノードへのポインタ,葉ノードであればプリミティブへのポインタ(メモリ上を指す)
各ノードはAABBの情報(3次元直交座標系でおいては2つのベクトル,つまり6つの数値)を持つ.このうち,BVH全体のrootノードは全体座標系(Global Coordinate)における6つの数値を必ず持つ.しかしその子孫であれば,親ノードからの相対座標(Local Coordinate)で指定できる.さらに親ノードの数値を継承する場合、省略することが出来る.これらの工夫により数値の表現に必要なビット数が節約できる.上の構造体を模した疑似コードにおいて,AABB_Bitmaskは親ノードの数値を引き継ぐので省略する座標をビットマスクで表現している.
疲れたのでここまで