FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

自作Binary Translation型RISC-VエミュレータのTCG Block Chaining実装

QEMUにおけるTCG Block Chainingというのは、TCG(Tiny Code Generator)というゲスト命令のブロックを次々と繋げて、制御側に戻すことなく連続的にTCGを実行することで高速化を図るための技法である。

前回はこれを実装しかけていたのだが、バグに遭遇して最後までテストが動いていなかった。

これを実装して直ちにテストを実行してみたが、実は途中で落ちてしまった。よくよく考えてみると、過去の直近のデコードしたTCGブロックを書き換えた場合には上手く行かない場合が発生することに気が付いた。これについては、「最後に実行したTCGのPCはいくらか?」という情報を集めておく必要がある。これについては以降で実装して行く。

今回はこれを修正する。まず、最後に実行したTCGのPCについての情報を必ず格納するようにTCGを変更する。

    fn tcg_exit_tb(emu: &EmuEnv, guest_init_pc: u64, host_pc_address: u64, tcg: &TCGOp, mc: &mut Vec<u8>) -> usize {
        let blk_chain_en = tcg.arg0.unwrap();
        assert_eq!(blk_chain_en.t, TCGvType::Immediate);

        let mut gen_size: usize = host_pc_address as usize;

        // Store Guest Block PC Address to translate_pc region
        gen_size += Self::tcg_gen_imm_u64(X86TargetRM::RAX, guest_init_pc, mc);
        gen_size += Self::tcg_modrm_64bit_out(X86Opcode::MOV_EV_GV, X86ModRM::MOD_10_DISP_RBP, X86TargetRM::RAX, mc);
        gen_size += Self::tcg_out(emu.calc_last_block_pc_address() as u64, 4, mc);

        // + 11  is the offset of address fields
        // eprint!("tcg_exit_tb = {:08x}\n", gen_size);
        gen_size += Self::tcg_gen_imm_u64(X86TargetRM::RAX, ((gen_size as u64 + 11) << 2) + blk_chain_en.value, mc);

        gen_size += Self::tcg_out(X86Opcode::JMP_JZ as u64, 1, mc);
        gen_size += Self::tcg_out(0x00, 4, mc);

        let ptr = emu.m_prologue_epilogue_mem.data() as usize + emu.m_host_prologue.len();
        gen_size += Self::tcg_gen_imm_u64(X86TargetRM::RCX, ptr as u64, mc);
        gen_size += Self::tcg_modrm_64bit_raw_out(X86Opcode::JMP_R64_M64, X86ModRM::MOD_11_DISP_RCX as u8, X86TargetRM::RAX as u8, mc);

        return gen_size;
    }

ここで、guest_init_pcを制御側のlast_block_pc_addressというメンバ変数に格納するためのX86命令を生成している。

この情報を使い、書き換えるべきTCGブロックを検索し、オフセット情報に基づいてジャンプ先の書き換えを行う。実際の書き換えルーチンがこちら。

            match self.last_text_mem {
                Some(mem) => {
                    let text_diff = (tb_text_mem.borrow().data() as u64).wrapping_sub(mem as u64).wrapping_sub(self.last_host_update_address as u64).wrapping_sub(4);

                    unsafe {
                        *mem.offset(self.last_host_update_address as isize + 0) = ((text_diff >>  0) & 0x0ff) as u8;
                        *mem.offset(self.last_host_update_address as isize + 1) = ((text_diff >>  8) & 0x0ff) as u8;
                        *mem.offset(self.last_host_update_address as isize + 2) = ((text_diff >> 16) & 0x0ff) as u8;
                        *mem.offset(self.last_host_update_address as isize + 3) = ((text_diff >> 24) & 0x0ff) as u8;
                    }
                },
                None => {},
            }

TCGブロックの検索ルーチンがこちら。

            if (ans & 1) == 1 {
                match self.m_tb_text_hashmap.get(&self.m_last_block_pc_address[0]) {
                    Some((_, mem_map)) => {
                        // eprint!("Last executed Block Address = {:08x}\n", self.m_last_block_pc_address[0]);
                        self.last_text_mem = Some(mem_map.borrow().data());
                    },
                    None => { panic!("Not found appropriate address {:08x}", self.m_last_block_pc_address[0]); },
                };

これにより、常に正しい位置のTCGブロックを書き換えることができるようになった。これでDhrystoneも完走することができた。やったぞ!

動作速度比較を以下に示す。TCG Block Chainingによりかなり速くなった。しかしまだQEMUには及ばない。どの部分が遅くなっているのだろうと観察していたのだが、やはりレジスタ指定似るジャンプ(JALR, RET命令など)が遅くなっているように感じる。

f:id:msyksphinz:20201226015201p:plain

これを回復するために、TCGではtb_ptr_lookupという技法が使われているようだ。技法と言っても大した話ではなく、ゲスト命令のジャンプ先PCと、そのPCを先頭とするTCGブロックををペアとするテーブルを作成しておき、レジスタジャンプの場合でもレジスタ値をベースにアセンブリ命令内で(といっても実際にはHelper関数にジャンプしている)探索し、制御を戻さずにそのまま次のブロックにジャンプしてしまうという技法だ。これを実装できればより高速化できそうな気がするので、次はこれを試行してみようかな。