VivadoのBlockRAM推論がおかしい

東大のプロセッサ実験のwiki *1を見ると、読み出しアドレスをクロックに同期してregに書き込めば、(write-firstの)BlockRAMに推論されると書かれています。

そこで、1read/1write・読み書き両方ともクロック同期のRAMを、以下のように書いてみます。

module ram(raddr, rdata, we, waddr, wdata, clk);
    input  wire[9:0] raddr;
    output wire[7:0] rdata;
    input  wire      we;
    input  wire[9:0] waddr;
    input  wire[7:0] wdata;
    input  wire      clk;

    reg[7:0] mem[0:1023];
    reg[31:0] i; initial for(i=0; i<1024;i=i+1) mem[i] = 0;

    reg[9:0] raddr_reg;
    always @(posedge clk) begin
        if (we) mem[waddr] <= wdata;
        raddr_reg <= raddr;
    end
    assign rdata = mem[raddr_reg];
endmodule

これをVivadoで合成してみると、確かにBlockRAMに推論されます。

しかし、verilogシミュレーションとタイミングシミュレーションの結果(≒FPGAに焼いた時の結果)がまるで違います!いったいどういうことなのでしょうか。

観察

まず、このRAMを使うモジュールを定義します。書き込むアドレスは0番地だけですが、愚直にそう書いてしまうとうまくいきません。 最適化で他の番地が消えてしまうからです。よってあらゆる番地にアクセスできそうな雰囲気を出しておく必要があります(ptr+1の部分)。

module top(sysclk, cpu_resetn, sw, led);
    input  wire sysclk;
    input  wire cpu_resetn;
    input  wire[2:0] sw;
    output wire[7:0] led;

    reg[9:0] ptr;
    wire[9:0] next_ptr;
    assign next_ptr = sw[2] ? ptr + 1 : 0;

    ram ram0(
        .raddr(next_ptr),
        .rdata(led),
        .we(sw[0]),
        .waddr(ptr),
        .wdata(led + sw[1]),
        .clk(sysclk)
    );

    always @(posedge sysclk or negedge cpu_resetn) begin
        if (!cpu_resetn) 
            ptr <= 0;
        else
            ptr <= next_ptr;
    end    
endmodule

次に、テストベンチを書きます。20サイクルの間、RAMの0番地から読みだした値+1を書き込むということを繰り返します。

`timescale 1ns / 1ps

module test(led);
    output [7:0] led;
    
    reg      sysclk;
    reg      cpu_resetn;
    reg[2:0] sw;
    
    top top0(sysclk, cpu_resetn, sw, led);

    initial begin
        sysclk <= 0;
        cpu_resetn <= 1;
        sw <= 0;
        #100 cpu_resetn <= 0;
        # 99 cpu_resetn <= 1;
        #100 sw <= 3;
        #200 $finish;
    end

    always #5 sysclk <= ~sysclk;
endmodule

これを実行してみて、RAMの0番地に何が書かれているかを確認してみます。すると、verilogシミュレーションでは20、タイミングシミュレーションでは10になっています。

verilogシミュレーションの結果は、verilog HDLの記法上もそうであるように、ちゃんとwrite-firstになっていることを示唆しています。つまり、書き込んだ値が即読み出せるということです。

一方、タイミングシミュレーションでは、どうもread-firstになっているようです。書き込んだ値は、直後のサイクルではまだ見えないということになります。

解決法

Xilinx社のマニュアルでは、write-firstなBlockRAMに推論してほしい時の書き方として、以下の書き方が推奨されていました。

always @(posedge clk) begin
    if(we) begin
        do <= data;
        mem[address] <= data;
    end else
        do <= mem[address];
end

実際、この書き方をした場合は、おかしな問題は発生しません。

結論

Xilinx社のマニュアルに記載してある通りに書けば、おかしなことは発生しません。

しかし、そうでない書き方をした時に不完全な推論が行われ、verilog HDLの記述どおりに合成されないのは、不親切とかいう次元を超えて明らかにVivadoのバグだと思います。

とはいっても、RAMに書いた直後に読みだすというパターンは普通は存在しないため、問題につながることは少ないようです。 キャッシュメモリをBlockRAMで書いたりすると、書いた*2直後に読み出すということは十分あり得そうです(12/24 指摘を受け追記)。 そもそも、BlockRAMを書くつもりでなくても、クロック同期なメモリっぽいものを書いてしまうと勝手にBlockRAM化されてわけのわからないバグにつながるということも考えられます。

1read1writeのRAMの正しい作り方(12/24 追記)

「解決法」のところで書いたものはreadポートとwriteポートが共有されているものになっています。1read1writeのRAMを作るにはどうしたらいいか?という疑問があったので作ってみました。

まず、以下のBlockRAMのマニュアルの20ページ目を見てみると、たとえwrite-firstを指定したとしても、readポートで指定した番地とwriteポートで指定した番地が同じ場合、readポート側に何が読みだされるかは保証されないと記載されています。 https://www.xilinx.com/support/documentation/user_guides/ug473_7Series_Memory_Resources.pdf

よって、このBlockRAMプリミティブを使うだけでは絶対に1read1writeのRAMを作ることはできません。

そこで、readアドレスとwriteアドレスが一致している場合のフォワーディング回路を自前で用意する必要がありそうです(自動で推論してほしいですね)。

以下のように書くと、ちゃんとBlockRAMに推論され、最初の記述と論理的に同じ動作になり、合成してもこの問題は発生しなくなります。

module ram(raddr, rdata, we, waddr, wdata, clk);
    input  wire[9:0] raddr;
    output wire[7:0] rdata;
    input  wire      we;
    input  wire[9:0] waddr;
    input  wire[7:0] wdata;
    input  wire      clk;

    reg[7:0] mem[0:1023];
    reg[31:0] i; initial for(i=0; i<1024;i=i+1) mem[i] = 0;

    reg[9:0] last_raddr, last_waddr;
    reg[7:0] rdata1, rdata2;
    always @(posedge clk) begin
        if (we) begin
            mem[waddr] <= wdata;
            rdata1 <= wdata;
        end else begin
            rdata1 <= mem[waddr];
        end
        rdata2 <= mem[raddr];
        last_raddr <= raddr;
        last_waddr <= waddr;
    end
    assign rdata = last_raddr == last_waddr ? rdata1 : rdata2;
endmodule

writeポートの方は、「解決法」のところで書いたような、Xilinx社マニュアル推奨のwrite-firstな読み出しになる書き方をしています。 よってそこから読み出した結果rdata1は直前の書き込み値last_wdataの代わりとして使えます。

これによる無駄なフリップフロップ削減効果に加え、そもそもwrite-firstにしたほうが高速と書かれているので、たぶんこれが一番いい書き方なのだと思います。

*1:12/26追記:見れなくなっていました。アーカイブRAMの書き方 - マイクロプロセッサの設計と実装

*2:キャッシュメモリへのwriteアクセス(直後に読み出すことは少なさそう)ではなく、キャッシュラインの置換時のRAM書き込み