これはCPU実験 Advent Calendar 2015の17日目の記事です。
CPU 実験の 1st アーキテクチャとして実装したインオーダパイプラインコアについて
- 構成
- ハマったポイント
を中心にまとめました。
完全なソースコードは https://github.com/arcturu/CarteletV1 にあります。
twoprocとは
twoproc とは VHDL のデザイン手法のひとつで、適当に書くとしっちゃかめっちゃかになりがちな VHDL コードの構造化と、バグの予防に非常に役立ちます。
詳細については twoprocの書き方 と、リンク先ブログ内にあるスライドが非常にわかりやすいのでそちらを参照して下さい。
僕が感じた twoproc を採用する直接的なメリットとしては
- 意図しないラッチが発生しにくい
- ラッチを消しやすい
- 構造化されてわかりやすい (twoproc のメリットというよりも record 文のメリット)
- データの流れが理解しやすい
などがあります。
パイプライン構成
を参考に以下の四段構成にしました。
- 命令フェッチ (Instruction Fetch: IF)
- デコード・レジスタアクセス (Instruction Decode / Register Read: ID)
- 実行 (Execute: EX)
- レジスタ書き戻し (Write Back: WB)
パタヘネでは五段目としてメモリアクセスフェーズがありますが、Cartelet v1 (1st アーキテクチャ) ではメモリリード命令も実行フェーズ内で完結させるようにしました。このようにした理由は、キャッシュメモリをつけるとヒット時とミス時でアクセスに必要なクロック数が変わり、それを違うパイプライン段で受けるのがしんどかったためです。
Cartelet v1 ではリードに 0 クロックしかかからないフリップフロップのキャッシュを 16 個つけていますが、実行フェーズでの挙動は
- キャッシュミス時 => 3 clk バブルが入る (4 clk で実行終了)
- キャッシュヒット時 => バブルは入らない (1 clk で実行終了)
となります。
パイプライン化の方法
パイプラインという概念は本や講義では当たり前のように出てきて、「それ名前つけるほどのものでもなくね?」という感じがしつつも、実際に VHDL でどう書くのかと言われると少し詰まってしまうのではないでしょうか。僕はそうでした。

こんな感じのパイプラインの概念図はよく目にしますが、以下の Cartelet v1 の cpu.vhd の抜粋と見比べるとほぼそのまま対応する部分があることがわかると思います。
https://github.com/arcturu/CarteletV1/blob/master/cpu.vhd
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; use work.types.all; entity CPU is port ( clk : in std_logic; rst : in std_logic; cpu_in : in cpu_in_type; cpu_out : out cpu_out_type); end entity; architecture struct of CPU is type fetch_readreg_reg_type is record pc : std_logic_vector ((PMEM_ADDR_WIDTH - 1) downto 0); fetched : std_logic; data : std_logic_vector (31 downto 0); end record; type readreg_ex_reg_type is record pc : std_logic_vector ((PMEM_ADDR_WIDTH - 1) downto 0); data : std_logic_vector (31 downto 0); ex_op : std_logic_vector (5 downto 0); dest_num : std_logic_vector (4 downto 0); dest_value : std_logic_vector (31 downto 0); lhs_num : std_logic_vector (4 downto 0); lhs_value : std_logic_vector (31 downto 0); rhs_num : std_logic_vector (4 downto 0); rhs_value : std_logic_vector (31 downto 0); imm : std_logic_vector (15 downto 0); end record; type ex_wb_reg_type is record pc : std_logic_vector ((PMEM_ADDR_WIDTH - 1) downto 0); dest_num : std_logic_vector (4 downto 0); data_source : data_source_type; sram_state : sram_state_type; sram_addr : std_logic_vector (19 downto 0); sram_data_to_be_written : std_logic_vector (31 downto 0); write : std_logic; data : std_logic_vector (31 downto 0); next_pc : std_logic_vector ((PMEM_ADDR_WIDTH - 1) downto 0); end record; type wb_mem_reg_type is record sram_state : sram_state_type; sram_data_to_be_written : std_logic_vector (31 downto 0); dest_num : std_logic_vector (4 downto 0); end record; -- (cache などの type 宣言) type reg_type is record sram_cache : sram_cache_type; sram_in_buf : std_logic_vector (31 downto 0); pc : std_logic_vector ((PMEM_ADDR_WIDTH - 1) downto 0); bubble_counter : std_logic_vector (3 downto 0); repeat : std_logic; load_size : std_logic_vector ((PMEM_ADDR_WIDTH - 1) downto 0); load_counter : std_logic_vector ((PMEM_ADDR_WIDTH - 1) downto 0); forwarder_file : forwarder_file_type; fetch_counter : std_logic_vector (3 downto 0); fetch_readreg_reg2 : fetch_readreg_reg_type; readreg_ex_reg : readreg_ex_reg_type; ex_wb_reg : ex_wb_reg_type; wb_mem_reg : wb_mem_reg_type; regs : registers_type; fregs : registers_type; fpcond : std_logic; alu_in : alu_in_type; fpu_in : fpu_in_type; cpu_state : cpu_state_type; end record; constant reg_init : reg_type := ( sram_cache => (others => ( data => (others => '0'), valid => '0', tag => (others => '0'))), -- (略) cpu_state => ready); begin -- pmem : dmem_sim port map ( pmem : mem port map ( clka => clk, wea => pmem_we, addra => pmem_addr, dina => pmem_din, douta => pmem_dout); alu_0 : alu port map ( clk => clk, rst => '0', alu_in => alu_in, alu_out => alu_out); fpu_0 : fpu port map ( clk => clk, rst => '0', fpu_in => fpu_in, fpu_out => fpu_out); comb : process (cpu_in, r, pmem_dout, alu_out, fpu_out) variable v : reg_type; -- (略: reg_type に入れる程でもない(次のクロックまで記憶する必要が無い) variables) begin v := r; cpu_ex_rst8 := '0'; cpu_ex_go := '0'; -- (略: variable の初期化) inst := (others => '0'); case r.cpu_state is -- この辺はプログラムローダ ---------------------------------------------------------------------- when ready => -- (略: プログラムローダと実行モードの切り替えをしている) when ploading => -- (略: プログラムメモリにプログラムを書き込む) when dloading => -- (略: データメモリ(SRAM) に .data 領域のデータを書き込む) when running => pmem_we <= (others => '0'); v_forwarder := r.forwarder_file(0); -- write back ----------------------------------------------------------------------------- if r.repeat = '0' then v.forwarder_file(1) := r.forwarder_file(0); if r.ex_wb_reg.write = '1' then case r.ex_wb_reg.data_source is when src_alu => tmp_data := alu_out.data; v.regs (to_integer(unsigned(r.ex_wb_reg.dest_num))) := tmp_data; v.forwarder_file(0).floating := '0'; v_forwarder.floating := '0'; -- (略: alu 以外の演算器からの出力データを処理 end case; v.forwarder_file(0).reg_num := r.ex_wb_reg.dest_num; v_forwarder.reg_num := r.ex_wb_reg.dest_num; v.forwarder_file(0).valid := '1'; v_forwarder.valid := '1'; v.forwarder_file(0).value := tmp_data; v_forwarder.value := tmp_data; elsif v.repeat = '0' then v.forwarder_file(0).valid := '0'; v_forwarder.valid := '0'; end if; end if; -- execute ------------------------------------------------------------------------------------- -- forwarding ex_lhs_value := r.readreg_ex_reg.lhs_value; ex_rhs_value := r.readreg_ex_reg.rhs_value; ex_dest_value := r.readreg_ex_reg.dest_value; -- (略: フォワーディングされてきたデータを ex_lhs_value などに入れる) flush_read := '0'; if r.bubble_counter = x"0" then v.ex_wb_reg.pc := r.readreg_ex_reg.pc; v.ex_wb_reg.write := '0'; v.ex_wb_reg.sram_state := idle; end if; case r.readreg_ex_reg.ex_op is when OP_ADD => v.ex_wb_reg.dest_num := r.readreg_ex_reg.dest_num; v.ex_wb_reg.write := '1'; v.ex_wb_reg.data_source := src_alu; v.alu_in.command := ALU_ADD; v.alu_in.lhs := ex_lhs_value; v.alu_in.rhs := ex_rhs_value; when OP_ADDI => -- (略: 個々の命令の処理) when others => end case; if v.bubble_counter = x"0" and flush_read = '0' and v.repeat = '0' and no_fetch = '0' then v.pc := std_logic_vector(unsigned(v.pc) + 1); -- pc すすめる end if; -- read and decode ----------------------------------------------------------------------------- -- (略: デコードして v.readreg_ex_reg を更新) -- fetch --------------------------------------------------------------------------------------- v_pmem_addr := v.pc; if v.bubble_counter = x"0" and flush_read = '0' and v.repeat = '0' and no_fetch = '0' then -- v なのは意図的です -- v.fetch_readreg_reg2.pc := r.fetch_readreg_reg.pc; v.fetch_readreg_reg2.pc := v.pc; v.fetch_readreg_reg2.fetched := '0'; end if; v.sram_in_buf := cpu_in.sram_din; when others => pmem_we <= (others => '0'); end case; -- 計算結果を出力 ------------------------------------------------------------------------------- cpu_out.ex_pop8 <= cpu_ex_pop8; cpu_out.sram_we <= cpu_ex_sram_we; cpu_out.sram_addr <= cpu_ex_sram_addr; cpu_out.sram_dout <= cpu_ex_sram_dout; cpu_out.ex_rst8 <= cpu_ex_rst8; cpu_out.state <= r.cpu_state; cpu_out.ex_go <= cpu_ex_go; cpu_out.ex_data <= cpu_ex_data; pmem_addr <= v_pmem_addr; pmem_din <= v_pmem_din; fpu_in <= v.fpu_in; alu_in <= v.alu_in; v.regs (0) := (others => '0'); -- must be zero rin <= v; end process; reg : process (clk) begin if rising_edge(clk) then r <= rin; end if; end process; end struct;
ここで、データが流れる順序としては IF, ID, EX, WB なのにコード中では WB, EX, ID, IF の順に書いてあるのはこうすることでフォワーディングが自然に書ける(WB に来た計算済みデータを variable として EX に流し、EX のオペランドの値としてつかえるようにすればフォワーディングできる)ためです。ただあんまりステージ間を跨いだ variable をつかいまくるとそれがクリティカルパスになり、何のためにパイプライン化しているのかわからなくなってくるので、ステージを跨いだ variable は可能な限り減らさないと周波数が上がりません。Cartelet v1 ではフォワーディング周りがクリティカルパスになっています。
また、CPU モジュールから ALU, FPU などは切り出していますが、ここへの入力には v.alu_in や v.fpu_in という variable をつないでいます。v.alu_in などは EX フェーズで変更され、そのクロックのうちに ALU に入れたいからです。これを v.alu_in でなく r.alu_in とすると、EX フェーズで v.alu_in を変更した次のクロックで ALU にデータが入る事になり、1クロック遅れます。そもそも reg_type の中に alu_in などを入れずに、ALU の入力に直に EX フェーズで入れてしまえばいいような気がしますが、それをすると ALU を使わない命令 (fadd など) 時にも ALU の入力をいれる事を忘れがちです。これを忘れると ALU の入力にはラッチが生える(クロックの立ち上がり時にデータを拾い、次のクロックの立ち上がり時まで記憶するフリップフロップ動作ではなく、クロックの立ち上がり後も、Hi の間はデータの変化を拾い続けるラッチ動作になる)ことで xst (合成ツール) のタイミング制約判定が甘くなり?本来あるべきクリティカルパスが見えなくなる事があります(このあたりの原理はよく理解していませんが問題が起きることは確かです)。ALU の場合はとくに問題になりませんが、パスが伸びがちな FPU との接続部分にラッチが生えるとクリティカルなバグになることがあります。僕は FPU をつなぐときにラッチができていたのが原因で、マンデルブロ集合を描くサンプルプログラムを実行した時に、実行するたびに結果が変わった(しかも破壊されている)ことがあります。
〜破壊された mandelbrot の例〜

ラッチへの対処法としては、signal への代入が全場合分けで尽くされているかどうかをチェックすることと、ラッチができると xst が
“WARNING:Xst:737 – Found n-bit latch for signal
のような warning を出すのでこれを見落とさずにしっかり直すことに尽きます。コードが長くなってくると数百個の warning が出ることがザラにあるので warning への感度がさがりますが、ラッチだけは絶対に消すように心がけています。
まとめ
- インオーダパイプラインでキャッシュ付きのメモリをうまいことするのは難しい
- データパスと逆向きに記述するとうまくいく
- ラッチは消す
Cartelet v1 のスペックは 39億命令 88MHz 102.7s (IPC 0.43) です。
今は OoO superscalar な 2nd アーキテクチャ Cartelet 2 を書いています。メモリ周りと分岐を上手いことしたいと思います。
おまけ
(左)フォワーディングのバグによって壊れたレイトレ画像
(右)正しい画像
一見まともそうですが、ゴミがついていたりグラデーションが変だったりします。
原因を調べた所、出力ユニットのキューがあふれた場合に実行がストールするとフォワーディングが壊れて RAW ハザードが起きていたようでした。
comments powered by Disqus