📌

RTLプログラミング考察

2023/01/03に公開

RTL言語(今回はSystemVerilogを使います)をハードウェア記述言語ではなく、C++ とか CUDA とかの延長にある、低レイヤで並列プログラミングする言語として捉えてみようという試みです。

はじめに

RTL(Register Transfer Level)で記述できるプログラミング言語として、SystemVerilog/Verilog、VHDL、Chisel、などなど様々な言語があります。

これらは、C/C++、Python、Rust、Go などの多くのプログラミング言語と同様にチューリング完全な言語であり、基本的にどんな計算でも記述できます。そもそもいわゆる CPU などの LSI の設計をターゲットにこれらの言語は設計されていますので、 CPU で出来る計算は基本的になんでも記述できます。

一方で、現在は LSI のようなハードウェアの設計だけでなく、FPGA をはじめとした Programmable Logic のプログラミング言語としても広く利用されています。加えて Zynq UltraScale+ MP SoC (以下、ZynqMP と呼びます) のような、PL(Programmable Logic)を内包した IoTプロセッサの登場によって、Ubuntu などのより馴染みのある IoT プログラミング環境から気軽にPLのプログラムを実行可能になりました。

RTL でプログラミングすることは、今まで LSI に実装されていなければ諦めるしかなかった低レイヤーの動作にまでプログラマが手を出せる可能性を秘めています。

CUDA などの GPGPU 用のプログラミング言語で、並列プログラミングに挑戦したことのある方も多いと思います。非常に多くのスレッドの並列プログラミングを記述できる一方で、基本的に 同じ命令を一斉に並列演算することしかできないという制約があります(正確にはできないというより SIMT:Single Instruction stream Multiple Thread の定義がそういう演算アーキテクチャなのですが)。そのほかに、レジスタ数、シェアードメモリ量、シャッフル命令などの各種制約に苦しんだことのある方も多いのではないかと思います。

また、 Raspberry PI や Arduino 、Jetson(Tegra) などの IoT プロセッサでプログラミングしたことのある方は、I2C や SPI や UART などのインターフェースを通じてセンサーなどの外部デバイスと通信した事があるかと思いますし、もっと直接的に GPIO を制御してLEDチカをはじめ様々な機器を制御したことがあるのではないでしょうか。そしてこの時に LSI がもともと持つ機能以上のことができなかったり、制御する CPU の速度が追い付かず歯がゆい思いをした経験がある人もいるのではないでしょうか。

そのほかにも、特に組み込みでは CPU 処理だと周期にジッタがでたり、そもそも処理が間に合わないケースがあったり、リアルタイム保証の観点で苦労したことのある方もおられるかもしれません。

プログラム設計の観点でも、C/C++ などのフローチャートと対応する文法を持つプログラムの場合、データフローダイアグラムを書くようなアプリケーションを設計しようとすると「データフローダイアグラムのデータの流れになるように各機能のフローチャートを考える」という流れになりますので、データの流れとプログラムの流れは別物 となりますので、時として見通しの悪いコードになるケースもあります。

RTLプログラミングの特徴をあげると

  • 文字通りレジスタ間の演算を直接記述する
  • 言語自体が並列記述を前提としており、様々な並列演算が明示的に書ける
  • 外部の I/O や CPU とのデジタル的なやり取りを直接記述する
  • すべて並列に計算されるのでフローチャートのような演算順序の記述ではなく、データフローをそのまま記述することになる
  • 動作周波数に応じたサイクル単位の記述なので厳格なリアルタイム保証ができる
  • デバイスの持つリソースの総量に収まりさえすれば、かなり自由に色んな用途に割り当て可能

などがあり、デメリットとしては

  • 1サイクル内に収まる複雑度まで演算を細かく分解しないといけない(逆に、細かく分解するほど高周波数で動作できる)
  • クロック周波数は CPU/GPU 程高くない
  • リソースが収まらないとコンパイルに失敗する(「遅くなるけど動く」ようなことはない)

などがあります。

RTLプログラミング

それではさっそく、RTLプログラミングを見ていきたいと思います。
ここでは ZynqMP 向けに SystemVerilog を用いる前提としたいと思います。

並列に計算する

早速ですが、CUDA を含めた C++ などとの一番の違いがわかる部分を抜き出して説明してみたいと思います。

always_ff @(posedge clk) begin
    a <= b + c;
    b <= a + c;
    c <= a + b;
end

ここで、変数として clk, a, b, c の 3 つが登場しています。

clk は 動作クロックを参照するもので、よく「この CPU のクロックは 3.6 GHz です」とか言うときのそれです。

ZynqMP 等の場合は、残念ながらそんなに速い速度では動いてくれませんが、例えば 250MHz であれば、別の場所で 2ns 周期ごとに 0 と 1 が交互に代入される変数だと思ってください。

1行目の always_ff の部分は後でもう少し詳しく説明しますが、要するにクロックの立ち上がり(0から1に変化)で後続を繰り返し実行することを指示する内容になっています。要するに 4ns 毎に後続の演算が行われ、250MHz での動作となるわけです。

残りの演算部分が RTL の醍醐味でして、例えば a, b, c に 1, 2, 3 が格納されていた場合、次のサイクルに 5, 4, 3 となり、その次のサイクルでは 7, 8, 9 、その次は 17, 16, 15 というように変化していきます。

Verilog の <= で示すノンブロッキング代入と言われる機能が特徴的な機能です。その段階では代入は行われず、計算後の値にアクセスできるのは次のサイクルとなる点が重要です。
ここで書いた3行の数式が並列に実行されるわけです。SystemVerilog を並列プログラミング言語として捉えることが可能だというのがなんとなく理解いただけるのではないでしょうか?

ここでもう一度、1行目の説明をしておきます。
Verilog の always 文は、後続の命令を繰り返し実行するもので、特に SystemVerilog で FF(要するにレジスタ)に対する演算に限定したのが always_ff です。always でも良いのですが、それだとうっかりレジスタ以外を生成する演算をうっかり混ぜて書いてしまってもエラーになってくれないため、SystemVerilog では always_ff を使うのが定石です。

@ はイベントが起こるまで待つ構文であり、clk の posedge(立ち上がり)を待って後続を実行するということを繰り返す動作となります。

always_ff の中で利用する変数は、レジスタとしてアサインされます。レジスタというと例えば x86 だと ax, bx, cx などのレジスタであったり、ARM であれば r0, r1, r2 などのように定義されている汎用レジスタを想像される方も多いのではないかと思います。

例えば ARM プロセッサで

add r1, r2, r3

という、命令を実行すると、r2 と r3 を足し算して r1 に格納するという動作が行われます。

上の SystemVerilog の RTL をあえて、アセンブラ風に書くなら 下記の 3つの add 命令が同時に実行されるようなものです。

add a, b, c
add b, a, c
add c, a, b

これが、100個でも1000個でも並べただけ全部同時に実行され、レジスタも r0~r15 までのように決まっておらず、デバイスのリソースが収まる限りいくらでも定義できるのが RTL です。

整理すると

  • CPUの汎用レジスタのようにあらかじめ用意されたレジスタを使いまわすのではなく、RTLでは変数宣言すると新規にレジスタが生成される
  • レジスタ間の演算は、記述した数だけすべてクロックごとに並列に実行される

というのが、RTL の重要なところです。

CPU 用のアセンブラに変換されるすべてのプログラミング言語では、「1つのスレッドに1000命令あれば、1000サイクルかけて順番に実行されるように見える」というのが基本のプログラミングモデルですが、RTLにおいては「1000個の代入演算を書けば、本当に書いたままに1サイクルに1000個の演算結果が格納される」ということが起こります。

非常に大きなレベルでの並列演算を記述するのに適していると言えると思います。

RTLの変数スコープ

分割統治法はすべてのプログラミングにおいて重要な概念です。SystemVerilog においても同様に、すべての変数がグローバル変数だったりするとプログラミングはとても大変な事になります。

Verilog には module という、プログラムを階層的に記述する仕組みがあります。module にはいろいろな機能があるのですが、基本的な事項として

  • module の中で定義された変数はモジュール内のみをスコープとするローカル変数となる
  • 同じ変数の書き込みは1つのalways文内のみ、参照は modue 内の他の always 文などどこでもできる
  • module 中の変数を外部から参照可能にするための output ポートが定義できる
  • module の外の変数を参照するための input ポートが定義できる
  • module 内には別のモジュールを無制限にインスタンス化でき、は無制限に入れ子にできる
  • インスタンス化された下位のモジュールの input/output ポートは変数を介して自在に接続できる
  • トップモジュールの input/output ポートは、基本的に外部デバイスの電気信号と直結される

などがあります。

先ほどの例を 変数 a だけを output とし、module としてコンパイル可能なコードにしてみましょう。

module foo(
            input   logic               reset,
            input   logic               clk,
            output  logic   [31:0]      a
        );

    logic   [31:0]      b;
    logic   [31:0]      c;

    always_ff @(posedge clk) begin
        if ( reset ) begin
            a <= 1;
            b <= 2;
            c <= 3;
        end
        else begin
            a <= b + c;
            b <= a + c;
            c <= a + b;
        end
    end

endmodule

先ほどは変数定義部分を省略して a, b, c の変数を使ってしまいましたが今回はちゃんとコンパイルできるように 32 bit 幅の logic 型として定義しています。
logic 型は、0 と 1 だけではなく不定値などを表すことが可能であり、最適化余地を増やしたり、シミュレーション時に未初期化の値の利用に気づきやすくなるなどの利点のある型になります。

今回は reset という変数も追加しております。PL での RTLプログラミングは変数に初期値を与えることも可能なのですが、並列に動作するプログラムを一斉に初期化するために reset 変数をすべてのモジュールに参照させて、一斉に初期化させることも良く行います。

しばし reset 変数は、トップネットからデバイス外部のリセットボタンの電気的な状態を参照するように設計され、いわつる「リセット」ができる仕組みです。そしてなんでもかんでも初期化すればいいかというとそんなことはなく、C++ などで初期化されない変数が多数あるように、本当に必要なものだけリセットで初期化するのが効率の良い設計となります。

module foo(
            input   logic               reset,
            input   logic               clk,
            output  logic   [31:0]      a
        );

    logic   [31:0]      b;
    logic   [31:0]      c;

    always_ff @(posedge clk) begin
        if ( reset ) begin
            a <= 1;
            b <= 2;
            c <= 3;
        end
        else begin
            a <= b + c;
            b <= a + c;
            c <= a + b;
        end
    end

endmodule

デバイス外部とのやりとり

トップネットのポートはデバイス外部端子と繋がっていきます。

例として LED をチカチカさせるプログラムの実践をこちらで別記事にしているのでご参照ください。

CPUとPLのやりとり

ZynqMP には CPU として ARM プロセッサが内蔵されており、ARM プロセッサは AXI4 というバス規格で外部のメモリ や I/O にアクセスします。

CPU が扱う多くの変数はメモリ上にマップされるので、PL がメモリを読みに行けばいいのではないかという考え方はまずかります。
しかしながら、キャッシュなどの一貫性担保をややこしくする存在や、外部メモリ帯域という貴重なリソースを往復分占有してデータ交換するのもロスが大きいケースもあります。

そこで、ここせはメモリマップドI/O として、CPUから直接PLに定義した変数(すなわちPLのFFを割り当てられたレジスタ)とデータ交換することを考えます。
ZynqNPの合、CPU から決まった番地(0xa0000000など)にロード/ストアなどのメモリアクセス命令を実行すると、PL のロジックに AXIバスの信号規格でアクセスがなされます。

この AXI4のバス規格はARM社から入手できますが、外部の DDR4-SDRAM などへのアクセスにも使われる規格で、なかなか複雑です。

大雑把に行ってしまうと

  • 書き込みアドレス (awで始まる信号名)
  • 書き込みデータ (wで始まる信号名)
  • 書き込み完了 (bで始まる信号名)
  • 読み込みアドレス (arで始まる信号名)
  • 読み込みデータ (rで始まる信号名)

がそれぞれ分かれており、合計5チャンネルのやり取りが必要です。なおかつ各チャネル個別に、アクセスが有効な時に valid が 1 になり、その時受信側の ready が 1 だとアクセスが完了するというハンドシェークが行われます。

シンプルな昔のCPUであれば、1個だけ有効信号あり、そこが1の時にアドレスとデータを受け取れば済んでしまうレベルだったのですが、昨今の高度なキャッシュのシステムや、DDR4-SDRAM のようなメモリで効率を出すためには様々な仕組みが必要で、このように複雑化しています。

とはいえ、イメージだけでもお伝えしたいので、CPU 側が限定的なアクセスしかしない前提で、LED の値を CPU から読み書きする簡単なコード例を書いてみました(動作未確認ですが)。

ある程度 SystemVerilog の文法は別途勉強していただいている前提です。

module bar(
            // AXIバス(要所だけ抜粋)
            input   logic               aresetn,
            input   logic               aclk,
            input   logic   [31:0]      awaddr,
            input   logic               awvalid,
            output  logic               awready,
            input   logic   [31:0]      wdata,
            input   logic               wvalid,
            output  logic               wready,
            output  logic               bvalid,
            input   logic               bready,
            input   logic   [31:0]      araddr,
            input   logic               arvalid,
            output  logic               arready,
            output  logic   [31:0]      rdata,
            output  logic               rvalid,
            input   logic               rready,

            // LED
            output  logic               led,
            
        );

    // アドレスとデータ両方有効になった時に書き込みを受け付ける
    always_comb begin
        awready = (awvalid && wvalid);
        wready  = (awvalid && wvalid);
    end

    always_ff @(posedge aclk) begin
        if ( ~aresetn ) begin  // AXIバスの論理は負論理
            // 初期化(初期化不要なものは不定値にしておく)
            bid    <= 'x;   // 書き込み完了ID
            bresp  <= 'x;   // 書き込み完了の応答
            bvalid <= 1'b0; // 書き込み完了
            rid    <= 'x;   // 読み込みID
            rresp  <= 'x;   // 読み込みの応答
            rlast  <= 'x;   // 読み込みデータの末尾
            rdata  <= 'x;   // 読み込みデータ
            rvalid <= 1'b0; // 読み込み
            led    <= 0;
        end
        else begin
            // valid を出しているときに ready であれば受け付けられたとして valid を倒す
            if ( bready ) bvalid <= 1'b0;
            if ( rready ) rvalid <= 1'b0;

            // 書き込みの受付
            if ( awvalid && wvalid ) begin
                led    <= wdata;    // 書き込まれた値をLED値とする
                bid    <= awid;     // 対応するIDを返す
                bresp  <= '0;       // 正常
                bvalid <= 1'b1;
            end

            // 読み込みの受付
            if ( arvalid ) begin
                rid    <= arid;
                rresp  <= '0;       // 正常
                rlast  <= 1'b1;     // データ末尾(シングルリード時は常に1)
                rdata  <= led;      // LEDの状態を返す
                rvalid <= 1'b1;
            end
        end
    end

endmodule

アドレスその他も無視しておりますが、要は

  • CPU から受け取るデータは、受信可能な時に ready を 1 にしておき、 valid が 1 の時だけその中身をこちらのレジスタにコピーする
  • CPU へ送りたいデータは、準備ができたら valid を 1 にし、ready が来たら 0 にする

という事を行っているだけです。

基本的にサイクル単位で逐次処理を行うCPUと、サイクル単位で並列演算を行うPLとの通信というのは、プログラミングを行う上で非常に重要です。

特にCPU側は汎用レジスタを使った汎用プロセッサですので、あらゆるリソースが時分割で共用されるので「このサイクルの値」というところをピンポイントで抜き出してコピーする必要があります。
しかし、このあたりのコツさえ理解してしまえば、決まった汎用リソースをプログラムの実行サイクルごとに使い分けるCPUと、専用にも汎用にも割り当ててレジスタを作ることのできるPLとで連携することが可能です。

それぞれが得意な処理をすればよい

筆者が以前、CPUとPLの間で性能計測を行ったときの記事を張っておきます。
ZynqMP は非常に面白いプロセッサであり、特に組み込みでマイクロプロセッサとFPGAを同じボードに乗せるだけでは達成できない高いレベルでの密結合性があります。

CPU と PL が DDR4-SDRAM に対するデータ帯域と同じオーダーの性能で密結合出来るということは、単にメモリコピーしなくてよいいなどというレベルではなく、CPU が得意な処理と PL が得意な処理を分割割り当てするのになんら他のボトルネックが介在する余地がないということを意味します。

PL のメリットには

  • シンプルな論理演算や整数演算は効率的に効率が高い
  • 外部の I/O を非常に低レイテンシで正確なタイミングで制御できる
  • リアルタイム保証が容易

など、様々なものがありますので、実装したいシステムの一部を PL 向けにプログラミングすることは十分価値があることかと思います。

逆に、CPU や GPU などは

  • 数GHzなどの高周波数で動作できる
  • ハードマクロの浮動小数点演算ユニットを持っていたりする

などの特徴がありますので、そもそもアルゴリズムの並列性が低く周波数頼みとなる部分や、複雑な演算を行う部分では PL よりも有利になってきます。

実行するプロセッサの計算リソースに対して、適材適所で計算を進行できるようにアルゴリズム設計や、プログラム分割を行っていくのもソフトウェアアーキテクチャを考える上でとても大切な事であり、楽しみの一つかと思います。
従来の マイコンとFPGAを組みわせただけの組み込みボードだと、どうしてもマイコンとFPGAの通信の手間も多く、このような発想にはなりにくかったと思います。ZynqMP では、CPU と PL が1つの SoC の中に統合されているので、PL プログラミングができるとこのような新しいプログラミングが可能となります。

特に、エッジコンピューティングの醍醐味として、単なる演算の効率化だけでなく、外部デバイスとの連携も考えたシステムプログラミングが可能になるところが、ZynqMP プログラミングの醍醐味と言えると思います。

おわりに

ざっくりとした記事になってしまいましたが、低レイヤーのプログラミング言語として RTL が書ける言語は今後ますますいろいろな用途で利用可能ではないかと思います。
特に エッジコンピューティングにおいては、リアルタイム性であったり、電力/コスト面での演算効率であったり、PLプログラミングが役立つ場面は多いように思います。

IoTプログラミングの延長上に、是非 「RTL プログラミングもちょっとやってみると面白そうだな」と思って頂けると嬉しいです。「CPU でのプログラミングしかやったことなかったけど GPU のプログラミングをやってみたら面白かった」 といったようなものと似たことが、RTLプログラミングにも待って居るのではないかと思います。

おまけ

Twitter に張った2枚の絵をリンクしておきます。

GitHubで編集を提案

Discussion