Try   HackMD

2020q3 專題: RISC-V 模擬器

contributed by < sammer1107 >

目標

  1. 理解給定的 RISC-V 模擬器運作原理
  2. 整合 RISC-V Compliance 到上述模擬器,並排除不相容的議題
  3. 量化分析直譯器效率,並尋求效能改進的方案
  4. 分析模擬器效能的瓶頸,並尋求改進方案

rv32emu-next

參考標的: NanoRVI

  • NanoRVI 提供相當精簡的 RV32I 指令集的模擬,可通過 RISC-V Compliance
  • 需要自行編譯 GNU Toolchain for RISC-V

Computed Goto


模擬器運作原理

若要模擬一個 RISC-V 處理器,我們勢必要有一個資料結構來儲存機器的狀態,這個最重要的狀態儲存在 riscv_private.hstruct riscv_t 當中。各欄位的意義如下:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

欄位
作用
bool halt 機器是否還在運行,若 true 則停止模擬
riscv_word_t X[RV_NUM_REGS] 用來代表 32(RV_NUM_REGS) 個 32 bit 的 register (因為模擬的是 RV32I)
riscv_word_t PC; 用來紀錄 program counter ,也就是要抓取下一個指令的位置
riscv_user_t userdata 1. 用來紀錄 CPU 之外的環境的狀態,包含了記憶體(儲存了程式與變數與常數)等等的資訊。
2. riscv_user_t 其實是 void*,也就是說 userdata 是什麼很彈性,依據我們想要提供的執行環境 (EEI, execution environment interface) 可以再作調整。
3. 當前的實作使用 state_t 作為 userdata 的結構,還儲存了 stdio 讓模擬的程式可以透過 system call 輸出結果
struct riscv_io_t io userdata 決定了模擬記憶體 / IO設備的資料結構,io則是儲存了許多函式介面,規範了 RISC-V CPU 如何真的從模擬的記憶體中讀寫資料,以及如何作 system call。
uint32(64)_t csr_... 這些為 Control and Status Registers,有各種特殊功能,其中包含紀錄 cycle 數的 csr_cycle,剩下的 CSR 與 interrupt 與 Exception handling 相關,皆為 machine-mode CSR,因此以 m 開頭。

IO/Memory 介面

再上一部份提到 riscv_t 中的 io 成員作為 riscv_t 與 memory 溝通的介面,以下為 io 的定義:

// RISC-V emulator I/O interface
struct riscv_io_t {
    // memory read interface
    riscv_mem_ifetch mem_ifetch;
    riscv_mem_read_w mem_read_w;
    riscv_mem_read_s mem_read_s;
    riscv_mem_read_b mem_read_b;

    // memory write interface
    riscv_mem_write_w mem_write_w;
    riscv_mem_write_s mem_write_s;
    riscv_mem_write_b mem_write_b;

    // system commands
    riscv_on_ecall on_ecall;
    riscv_on_ebreak on_ebreak;
};
  • 這邊包含了 memory 讀/寫與系統呼叫的介面
  • 每一個成員都是一個 function pointer ,規範了函式的介面。這樣子的設計讓我們可以輕易的將 riscv_t 的實作與剩下的執行環境實作分開,只要透過 io 的介面接和起來就行了。

IO 實作

有了介面之後,當然還要有對應的實作。 IO 介面的實作要與 userdata 的設計結合,而目前的 userdata 設計為 state_t:

/* state structure passed to the runtime */
typedef struct {
    memory_t *mem;

    /* the data segment break address */
    riscv_word_t break_addr;

    /* file descriptor map: int -> (FILE *) */
    c_map_t fd_map;
} state_t;
  • memory_t 為 riscv 的模擬記憶體,基本上就是很多塊 buffer。在 io.h 中的定義如下:
    ​​​​typedef struct {
    ​​​​    uint8_t data[0x10000];
    ​​​​} chunk_t;
    
    ​​​​typedef struct {
    ​​​​    chunk_t *chunks[0x10000];
    ​​​​} memory_t;
    
    整個 32bit 的 address space 被分成 64k 個大小為 64k 的 chunk。
  • break_addr 為記憶體中 data segment 區段終點,概念跟 linux 的 break address 很像。linux 提供了 brk 與 sbrk 的 system call,目前的實作也有提供 riscv 一個 brk 的 syscall,作用就是移動這個 break address。
  • fd_map 則是用來紀錄 file descriptors 的紅黑樹 map。透過 file descriptors 的管理,我們就可以讓 riscv 也可以透過 stdin/stdout 作輸入輸出,並且可以讀寫檔案,這些都實作在 syscall.c 中。

修正 memory_delete 的錯誤

  • memory_delete 中有這一段 FIXME,說著 for 迴圈的範圍需要修正。
void memory_delete(memory_t *m)
{
    if (!m)
        return;

    /* FIXME: assign the correct range */
    for (int i = 0; sizeof(m->chunks) / sizeof(chunk_t) >> 16; i++) {
        chunk_t *c = m->chunks[i];
        if (c)
            free(c);
    }
    free(m);
}
  • 當 memory_t 被創造時,chunks 並沒有被 allocated ,而是之後在 memory_fill 或是 memory_write 中要寫入到 chunk 時才會檢查 chunk 是否存在,因此在這裡我們應該要確定每個 chunk 都被釋放掉。
  • 基本上就只要把整個 chunk array 一一檢查並釋放就可以了。因為 m->chunksarray of chunk_t*,所以這部份的範圍應該給成:
void memory_delete(memory_t *m)
{
    if (!m)
        return;

    for (uint32_t i = 0; i < (sizeof(m->chunks) / sizeof(chunk_t *)); i++) {
        chunk_t *c = m->chunks[i];
        if (c)
            free(c);
    }
    free(m);
}
  • 修改完用 valgrind 檢查是否還有 memory leak:
    ​​​​$ valgrind --leak-check=full ./build/rv32emu ./build/puzzle.elf 
    ​​​​==5490== Memcheck, a memory error detector
    ​​​​==5490== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
    ​​​​==5490== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
    ​​​​==5490== Command: ./build/rv32emu ./build/puzzle.elf
    ​​​​==5490== 
    ​​​​success in 2005 trials
    ​​​​inferior exit code 0
    ​​​​==5490== 
    ​​​​==5490== HEAP SUMMARY:
    ​​​​==5490==     in use at exit: 0 bytes in 0 blocks
    ​​​​==5490==   total heap usage: 29 allocs, 29 frees, 888,001 bytes allocated
    ​​​​==5490== 
    ​​​​==5490== All heap blocks were freed -- no leaks are possible
    ​​​​==5490== 
    ​​​​==5490== For counts of detected and suppressed errors, rerun with: -v
    ​​​​==5490== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
    
    看起來是沒問題了。

利用 computed go 加速 main loop 運作

我們先看一下目前的 rv_step 實作原理:

static const opcode_t opcodes[] = {
//  000        001          010       011          100        101       110   111
    op_load,   op_load_fp,  NULL,     op_misc_mem, op_op_imm, op_auipc, NULL, NULL, // 00
    op_store,  op_store_fp, NULL,     op_amo,      op_op,     op_lui,   NULL, NULL, // 01
    op_madd,   op_msub,     op_nmsub, op_nmadd,    op_fp,     NULL,     NULL, NULL, // 10
    op_branch, op_jalr,     NULL,     op_jal,      op_system, NULL,     NULL, NULL, // 11
};

void rv_step(struct riscv_t *rv, int32_t cycles)
{
    assert(rv);
    const uint64_t cycles_target = rv->csr_cycle + cycles;

    while (rv->csr_cycle < cycles_target && !rv->halt) {
        // fetch the next instruction
        const uint32_t inst = rv->io.mem_ifetch(rv, rv->PC);

        // standard uncompressed instruction
        if ((inst & 3) == 3) {
            const uint32_t index = (inst & INST_6_2) >> 2;

	    // dispatch this opcode
            const opcode_t op = opcodes[index];
            assert(op);
            if (!op(rv, inst))
                break;

            // increment the cycles csr
            rv->csr_cycle++;
        } else {
            // TODO: compressed instruction
            assert(!"Unreachable");
        }
    }
}
  • rv_step 會一次總共執行 cycles 個指令才會回傳,在達到目標的指令數以前,上面的程式碼就是不斷的:

    • 抓取下一個指令
    • 根據 OPCODE 從 opcodes table,拿到對應的函式位置並且呼叫完成指令執行
    • 增加 cycle counter CSR
  • 這樣的跳躍機制會會讓 CPU 的 branch prediction 機制無法有效執運作,原因為 op(rv, inst) 這個呼叫對應到一個跳躍指令,但往往跳躍到不同的位置,會讓 CPU 分支預測器頻繁猜錯。

  • 解決辦法是將每個 op 的呼叫分開,並在每個呼叫後放置一個獨立的跳躍指令,如下:

    ​​​​op_1:
    ​​​​    op_1_handler();
    ​​​​    /* get and jump to next instruction */
    ​​​​op_2: 
    ​​​​    op_2_handler();
    ​​​​    /* get and jump to next instruction */
    ​​​​op_3: 
    ​​​​    op_3_handler();
    ​​​​    /* get and jump to next instruction */
    ​​​​op_4: 
    ​​​​    op_4_handler();
    ​​​​    /* get and jump to next instruction */
    

    以往寫程式注重的是程式的重複利用,但在這裡我們可以透過產生重複的跳躍指令來更好的發揮 CPU 的 branch prediction 機制。
    這樣做的原理是由於 CPU 是透過地址來區分每個跳躍指令,而現在每個 op handler 之後都有了獨立的跳躍指令,所以 CPU 相當於多了一個資訊(上一個執行的指令)來判斷下一個跳躍位置。

  • 舉例來說,假設我們使用一個非常簡單的 branch prediction,總是預測跳躍與上次一樣。而且,op1 之後常常接著 op2。

    • 在原本的機制下,我們執行完 op1 之後回到同一個跳躍指令,這時候 CPU 又預測了接下來是 op1,這幾乎會是錯的,因為比較少指令(這邊是 RISCV 的指令)會重複出現。
    • 在新的機制下,我們執行完 op1 後,來到了 op1 專屬的跳躍指令,如果上次 op1 之後是跳躍到 op2,則我們這次也會猜測 op2,如此就猜中了。
  • TODO: 研讀 Threaded CodeFast VMs without assembly,以理解上述手法的原理
    • indirect threading, instructions index an array containing pointers to the code handling them
    • direct threading, instructions are pointers to the code handling them

    direct threading 可能會比 indirect threading 來得快,因為更少的記憶體存取,但在 x86_64 上,若每個 opcode 都要對應 64-bit 指標操作,會是可觀的開銷

  • TODO: 研讀 Stack Caching for Interpreters,理解高效率的直譯器如何藉由快取 top-of-stack (TOS) 達到加速

    啟發: RISC-V 指令之間是否存在相關順序?也就是說,例如 lw 指令之後會不會伴隨著 add 呢?是否我們可先把指令的實際執行頻率統計出來,再分析相鄰的指令,最後將若干個指令對應到特定的程式碼

  • 整合 threaded code / super-instruction 來提昇給定的 RISC-V 模擬器的效率
  • TODO: 確定已在 Makefile 加入 CFLAGS += -fno-gcse -fno-crossjumping,如此才能讓 gcc 產生更好的程式碼
  • TODO: 改用 CoreMark 作為效能評比的基準。在 commit 179d878a4f5180ce5d03732e05e223db57b06418 已加入 build/coremark.elf,可執行 build/rv32emu build/coremark.elf (需要等待),預期可見以下輸出:
    ​​​​2K performance run parameters for coremark.
    ​​​​CoreMark Size    : 666
    ​​​​Total ticks      : 15238641
    ​​​​Total time (secs): 15
    ​​​​Iterations/Sec   : 200
    ​​​​Iterations       : 3000
    ​​​​Compiler version : GCC8.3.0
    ​​​​Compiler flags   : -O2
    ​​​​Memory location  : STACK
    ​​​​seedcrc          : 0xe9f5
    ​​​​[0]crclist       : 0xe714
    ​​​​[0]crcmatrix     : 0x1fd7
    ​​​​[0]crcstate      : 0x8e3a
    ​​​​[0]crcfinal      : 0xcc42
    ​​​​Correct operation validated. See README.md for run and reporting rules.
    ​​​​inferior exit code 0
    

    詳閱 CoreMark 以得知其具體意義。

:notes: jserv

修改後的版本

  • version 1: 我直接將整個 code 改寫為 computed goto 的版本,這樣的缺點是 gcc 以外的編譯器未必能編譯。除此之外,我忘記考慮到開啟或關閉 atomic、Zicsr、Zifencei extension 的情況。所以下面新增了改善的版本。
    version 1
    ​​​​void rv_step(struct riscv_t *rv, int32_t cycles)
    ​​​​{
    ​​​​    assert(rv);
    ​​​​    const uint64_t cycles_target = rv->csr_cycle + cycles;
    ​​​​    uint32_t inst, index;
    
    ​​​​    // clang-format off
    ​​​​    const void *jump_table[] = {
    ​​​​    //  000        001          010       011          100        101       110   111
    ​​​​        &&op_load,  op_load_fp,  NULL,     op_misc_mem, &&op_op_imm, &&op_auipc, NULL, NULL, // 00
    ​​​​        &&op_store, op_store_fp, NULL,     op_amo,      &&op_op,     &&op_lui,   NULL, NULL, // 01
    ​​​​        op_madd,    op_msub,     op_nmsub, op_nmadd,    op_fp,       NULL,       NULL, NULL, // 10
    ​​​​        &&op_branch,&&op_jalr,   NULL,     &&op_jal,    &&op_system, NULL,       NULL, NULL, // 11
    ​​​​    };
    ​​​​    // clang-format on
    
    ​​​​#define DISPATCH()                                     \
    ​​​​    {                                                  \
    ​​​​        if (rv->csr_cycle > cycles_target || rv->halt) \
    ​​​​            goto exit;                                 \
    ​​​​        else {                                         \
    ​​​​            /* fetch the next instruction */           \
    ​​​​            inst = rv->io.mem_ifetch(rv, rv->PC);      \
    ​​​​            /* standard uncompressed instruction */    \
    ​​​​            if ((inst & 3) == 3) {                     \
    ​​​​                index = (inst & INST_6_2) >> 2;        \
    ​​​​                goto *jump_table[index];               \
    ​​​​            } else {                                   \
    ​​​​                /* TODO: compressed instruction*/      \
    ​​​​                assert(!"Unreachable");                \
    ​​​​            }                                          \
    ​​​​        }                                              \
    ​​​​    }
    
    ​​​​#define EXEC(op)                      \
    ​​​​    {                                 \
    ​​​​        /* dispatch this opcode */    \
    ​​​​        assert(op);                   \
    ​​​​        if (!op(rv, inst))            \
    ​​​​            goto exit;                \
    ​​​​        /* increment the cycles csr*/ \
    ​​​​        rv->csr_cycle++;              \
    ​​​​    }
    
    ​​​​#define TARGET(x) \
    ​​​​x:                \
    ​​​​    EXEC(x);      \
    ​​​​    DISPATCH();
    
    ​​​​    DISPATCH();
    
    ​​​​    // main loop
    ​​​​    TARGET(op_load)
    ​​​​    TARGET(op_op_imm)
    ​​​​    TARGET(op_auipc)
    ​​​​    TARGET(op_store)
    ​​​​    TARGET(op_op)
    ​​​​    TARGET(op_lui)
    ​​​​    TARGET(op_branch)
    ​​​​    TARGET(op_jalr)
    ​​​​    TARGET(op_jal)
    ​​​​    TARGET(op_system)
    
    ​​​​exit:
    ​​​​    return;
    
    ​​​​#undef DISPATCH
    ​​​​#undef EXEC
    ​​​​#undef TARGET
    ​​​​}
    
  • version 2:
    ​​​​void rv_step(struct riscv_t *rv, int32_t cycles)
    ​​​​{
    ​​​​    assert(rv);
    ​​​​    const uint64_t cycles_target = rv->csr_cycle + cycles;
    ​​​​    uint32_t inst, index;
    
    ​​​​#define OP_UNIMP op_unimp
    ​​​​#ifdef ENABLE_COMPUTED_GOTO
    ​​​​    #define OP(instr) &&op_##instr
    ​​​​    #define TABLE_TYPE const void *
    ​​​​#else
    ​​​​    #define OP(instr) op_##instr
    ​​​​    #define TABLE_TYPE const opcode_t
    ​​​​#endif
    
    ​​​​    // clang-format off
    ​​​​    TABLE_TYPE jump_table[] = {
    ​​​​    //  000         001           010        011           100         101        110   111
    ​​​​        OP(load),   OP(load_fp),  OP(unimp), OP(misc_mem), OP(op_imm), OP(auipc), OP(unimp), OP(unimp), // 00
    ​​​​        OP(store),  OP(store_fp), OP(unimp), OP(amo),      OP(op),     OP(lui),   OP(unimp), OP(unimp), // 01
    ​​​​        OP(madd),   OP(msub),     OP(nmsub), OP(nmadd),    OP(fp),     OP(unimp), OP(unimp), OP(unimp), // 10
    ​​​​        OP(branch), OP(jalr),     OP(unimp), OP(jal),      OP(system), OP(unimp), OP(unimp), OP(unimp), // 11
    ​​​​    };
    ​​​​    // clang-format on
    
    ​​​​#ifdef ENABLE_COMPUTED_GOTO
    ​​​​#define DISPATCH()                                      \
    ​​​​    {                                                   \
    ​​​​        if (rv->csr_cycle >= cycles_target || rv->halt) \
    ​​​​            goto exit;                                  \
    ​​​​        else {                                          \
    ​​​​            /* fetch the next instruction */            \
    ​​​​            inst = rv->io.mem_ifetch(rv, rv->PC);       \
    ​​​​            /* standard uncompressed instruction */     \
    ​​​​            if ((inst & 3) == 3) {                      \
    ​​​​                index = (inst & INST_6_2) >> 2;         \
    ​​​​                goto *jump_table[index];                \
    ​​​​            } else {                                    \
    ​​​​                /* TODO: compressed instruction*/       \
    ​​​​                assert(!"Unreachable");                 \
    ​​​​            }                                           \
    ​​​​        }                                               \
    ​​​​    }
    
    ​​​​#define EXEC(instr)                   \
    ​​​​    {                                 \
    ​​​​        /* dispatch this opcode */    \
    ​​​​        if (!op_##instr(rv, inst))    \
    ​​​​            goto exit;                \
    ​​​​        /* increment the cycles csr*/ \
    ​​​​        rv->csr_cycle++;              \
    ​​​​    }
    
    ​​​​#define TARGET(instr)         \
    ​​​​op_##instr :                  \
    ​​​​    EXEC(instr);              \
    ​​​​    DISPATCH();
    
    ​​​​    DISPATCH();
    
    ​​​​    // main loop
    ​​​​    TARGET(load)
    ​​​​    TARGET(op_imm)
    ​​​​    TARGET(auipc)
    ​​​​    TARGET(store)
    ​​​​    TARGET(op)
    ​​​​    TARGET(lui)
    ​​​​    TARGET(branch)
    ​​​​    TARGET(jalr)
    ​​​​    TARGET(jal)
    ​​​​    TARGET(system)
    ​​​​#ifdef ENABLE_Zifencei
    ​​​​    TARGET(misc_mem)
    ​​​​#endif
    ​​​​#ifdef ENABLE_RV32A
    ​​​​    TARGET(amo)
    ​​​​#endif
    ​​​​    TARGET(unimp)
    
    ​​​​exit:
    ​​​​    return;
    
    ​​​​#undef DISPATCH
    ​​​​#undef EXEC
    ​​​​#undef TARGET
    ​​​​#else   // ENABLE_COMPUTED_GOTO = 0
    ​​​​    while (rv->csr_cycle < cycles_target && !rv->halt) {
    ​​​​        // fetch the next instruction
    ​​​​        inst = rv->io.mem_ifetch(rv, rv->PC);
    
    ​​​​        // standard uncompressed instruction
    ​​​​        if ((inst & 3) == 3) {
    ​​​​            index = (inst & INST_6_2) >> 2;
    
    ​​​​            // dispatch this opcode
    ​​​​            TABLE_TYPE op = jump_table[index];
    ​​​​            assert(op);
    ​​​​            if (!op(rv, inst))
    ​​​​                break;
    
    ​​​​            // increment the cycles csr
    ​​​​            rv->csr_cycle++;
    ​​​​        } else {
    ​​​​            // TODO: compressed instruction
    ​​​​            assert(!"Unreachable");
    ​​​​        }
    ​​​​    }
    ​​​​#endif  // ENABLE_COMPUTED_GOTO
    ​​​​}
    
  • 透過 #ifdef ENABLE_COMPUTED_GOTO 的檢查。若沒有開啟 computed goto,程式相當於以前的實作。若開啟了則會使用新的實作。
    • TABLE_TYPE: 因為兩個實作都需要表格,computed goto 需要 label address 的表格,原本的實作則需要 function pointer 的表格。所以我用了 TABLE_TYPE macro 來根據情形設定型別。
    • OP: 表格內每一個元素都改成用 OP(...) macro 來包裝,如此可以根據開啟 computed goto 與否,來決定要變成 label address 或是 function。
    • OP_UNIMP: 比較棘手的部份是原本未實作的指令被定義為 NULL,如此在開啟 computed goto 的情況下經過 OP(...) 展開後變成不合法的 &&NULL 。為了更好的處理無實作的指令以及可能會開關的 extension,我將未實作的指令定義為 OP_UNIMP。如此像是 OP(msub) 這個未實作的指令會以這樣的展開順序被導向專門處理未實作指令的 handler: OP(msub)&&op_msub&&OP_UNIMP&&op_unimp
    • 除此之外,我讓表格原本為 NULL 的地方也都填滿了 OP(unimp),如此當程式真的出現這樣的指令時,也不會讓程式直接跳到 NULL 去而直接死掉。
  • 在 ENABLE_COMPUTED_GOTO 生效時:
    • 我用了 TARGET macro 來產生對應每個 opcode 的 goto label,每個 goto label 底下都有 EXEC 來直接呼叫對應的 handler(而不是以前用 function pointer 呼叫的方式)。在每一個 goto 區塊的尾巴,用 DISPATCH macro 來跳躍到下一個指令的 label。
    • 雖然看不到有 while 迴圈,但是程式會不斷在 goto block 間不斷跳躍直到執行了 cycles 個指令。
  • TODO: 考慮到多樣的編譯器支援,前述 computed-goto 僅在 gcc/clang/icc 存在,像是 Microsoft Visual C++ Compiler 就缺乏該特徵,因此最好透過條件編譯 (即引入 #if defined(ENABLE_COMPUTED_GOTO 一類的敘述) 區隔新加入的程式碼,並確保原本 switch-case 的敘述可編譯。

    可定義新的巨集,例如 OP,這樣就可依據是否 ENABLE_COMPUTED_GOTO,產生對應的程式碼:

    ​​​​#if defined(ENABLE_COMPUTED_GOTO)
    ​​​​#define OP(instr) &&op_##instr
    ​​​​#else
    ​​​​#define OP(instr) op_##instr
    ​​​​#endif
    

同理,可針對 switch-case 撰寫對應的 DISPATCH 巨集。
:notes: jserv

搭配 compiler flags

  • gcc 線上文件提及使用 computed goto 時可以加入 -fno-gcse 這個選項來加優化效能。我想原因是我們透過 macro 刻意產生了重複的程式碼,但是編譯器可能會想把重複的程式法優化掉,因此可以加入這個選項來避免編譯器搗亂。另外 -fno-crossjumping 的作用也類似。
  • 不過在我的電腦與編譯器上實測有沒有加此 flag 似乎沒有太大的差別。甚至效能還有小降低。這部份的比較一起放在下面了。

效能比較

puzzle.elf 效能比較

在這裡我以 puzzle.elf 作為比較標準。

Without computed goto:

 Performance counter stats for './build/rv32emu ./build/puzzle.elf':

        287.189777      task-clock (msec)         #    0.999 CPUs utilized          
                 1      context-switches          #    0.003 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               554      page-faults               #    0.002 M/sec                  
       790,588,889      cycles                    #    2.753 GHz                    
     1,734,331,710      instructions              #    2.19  insn per cycle         
       443,094,344      branches                  # 1542.863 M/sec                  
         5,104,669      branch-misses             #    1.15% of all branches        

       0.287570598 seconds time elapsed

With computed goto:

 Performance counter stats for './build/rv32emu ./build/puzzle.elf':

        232.798804      task-clock (msec)         #    0.998 CPUs utilized          
                 3      context-switches          #    0.013 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               551      page-faults               #    0.002 M/sec                  
       655,306,005      cycles                    #    2.815 GHz                    
     1,546,222,148      instructions              #    2.36  insn per cycle         
       386,354,011      branches                  # 1659.605 M/sec                  
         2,061,776      branch-misses             #    0.53% of all branches        

       0.233230881 seconds time elapsed

可以看到 branch-misses 的數目少了至少一半,而執行時間則有了 23% 的提升。

With Computed goto plus compiler flag:

 Performance counter stats for './build/rv32emu ./build/puzzle.elf':

        240.205450      task-clock (msec)         #    0.999 CPUs utilized          
                 2      context-switches          #    0.008 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               555      page-faults               #    0.002 M/sec                  
       673,981,276      cycles                    #    2.806 GHz                    
     1,546,230,035      instructions              #    2.29  insn per cycle         
       386,401,617      branches                  # 1608.630 M/sec                  
         2,939,340      branch-misses             #    0.76% of all branches        

       0.240522902 seconds time elapsed

雖然預期加入 -fno-gcse -fno-crossjumping 之後會有比較好的效能,可是不知道為何在 riscv.c 的編譯加入這兩個選項後,效能不僅沒有提升,好像還弱了一點。執行時間平均起來有稍慢,且 branch-miss 數量也有增加。

CoreMark 效能比較

因為 coremark 似乎會自動調整 iteration 次數,我發現跑無 computed goto 時 coremark 會跑 3000 個 iteration,跑有 computed goto 時 coremark 會跑 4000 個 iteration,所以無法只用執行時間比較。

Without Computed goto

perf 結果:
 Performance counter stats for './build/rv32emu ./build/coremark.elf' (5 runs):

      18057.519364      task-clock (msec)         #    1.000 CPUs utilized            ( +-  0.10% )
                41      context-switches          #    0.002 K/sec                    ( +- 20.44% )
                 0      cpu-migrations            #    0.000 K/sec                    ( +-100.00% )
               556      page-faults               #    0.031 K/sec                    ( +-  0.14% )
    56,162,651,843      cycles                    #    3.110 GHz                      ( +-  0.03% )
   114,196,468,123      instructions              #    2.03  insn per cycle           ( +-  0.00% )
    28,565,733,983      branches                  # 1581.930 M/sec                    ( +-  0.00% )
       272,290,570      branch-misses             #    0.95% of all branches          ( +-  0.01% )

      18.059598286 seconds time elapsed  
CoreMark output
2K performance run parameters for coremark.
CoreMark Size    : 666
Total ticks      : 13119711
Total time (secs): 13
Iterations/Sec   : 230
Iterations       : 3000
Compiler version : GCC8.3.0
Compiler flags   : -O2
Memory location  : STACK
seedcrc          : 0xe9f5
[0]crclist       : 0xe714
[0]crcmatrix     : 0x1fd7
[0]crcstate      : 0x8e3a
[0]crcfinal      : 0xcc42
Correct operation validated. See README.md for run and reporting rules.
inferior exit code 0

With Computed goto

perf 結果:
 Performance counter stats for './build/rv32emu ./build/coremark.elf' (5 runs):

      19371.050871      task-clock (msec)         #    1.000 CPUs utilized            ( +-  0.21% )
               130      context-switches          #    0.007 K/sec                    ( +- 38.08% )
                 1      cpu-migrations            #    0.000 K/sec                    ( +- 66.67% )
               557      page-faults               #    0.029 K/sec                    ( +-  0.12% )
    60,194,999,594      cycles                    #    3.107 GHz                      ( +-  0.02% )
   130,293,629,663      instructions              #    2.16  insn per cycle           ( +-  0.00% )
    31,067,140,137      branches                  # 1603.792 M/sec                    ( +-  0.00% )
       179,106,693      branch-misses             #    0.58% of all branches          ( +-  0.11% )

      19.377928786 seconds time elapsed 
CoreMark output
2K performance run parameters for coremark.
CoreMark Size    : 666
Total ticks      : 15105008
Total time (secs): 15
Iterations/Sec   : 266
Iterations       : 4000
Compiler version : GCC8.3.0
Compiler flags   : -O2
Memory location  : STACK
seedcrc          : 0xe9f5
[0]crclist       : 0xe714
[0]crcmatrix     : 0x1fd7
[0]crcstate      : 0x8e3a
[0]crcfinal      : 0x65c5
Correct operation validated. See README.md for run and reporting rules.
inferior exit code 0

因為 iteration 次數不一定,所以比較的重點在於 branch-miss 數目以及 CoreMark 的 Iterations/Sec

no computed goto computed goto
branch-misses 272,290,570 179,106,693
Iterations/Sec 230 266
如果以 CoreMark 分數計算,效能提升為 15%。此外 compiler flag 對效能則沒有顯著的影響,所以沒列出來。

"jump" 的繁體中文翻譯是「跳躍」,而非「跳轉」。請愛用教育部重編國語辭典修訂本
:notes: jserv

整合 Riscv-Compliance

整合的時候剛好遇到 riscv-compliance 專案大更新,所以以下是整合 2.0 的版本。

model_test.h

Riscv-Compliance 為了要讓 test case 可以被各種 riscv 實作執行,在每一個 test case 內都有一些 macro 可以根據不同的測試對象去定義。
而新的 riscv-compliance 如果要整合新的測試對像,只需要在 model_test.h 內定義一些必要的 macro 即可。

#define RVMODEL_HALT    \
    add a7, x0, 93;     \
    add a0, x0, 0;      \
    ecall

#define RVMODEL_BOOT

#define RVTEST_RV32M       
        
#define RVMODEL_DATA_BEGIN \
        .align 4; .global begin_signature; begin_signature:

#define RVMODEL_DATA_END \
        .align 4; .global end_signature; end_signature:


#define RVMODEL_IO_INIT
#define RVMODEL_IO_WRITE_STR(_SP, _STR)
#define RVMODEL_IO_CHECK()
#define RVMODEL_IO_ASSERT_GPR_EQ(_SP, _R, _I)
#define RVMODEL_IO_ASSERT_SFPR_EQ(_F, _R, _I)
#define RVMODEL_IO_ASSERT_DFPR_EQ(_D, _R, _I)
#define RVMODEL_SET_MSW_INT
#define RVMODEL_CLEAR_MSW_INT
#define RVMODEL_CLEAR_MTIMER_INT
#define RVMODEL_CLEAR_MEXT_INT
  • RVMODEL_HALT:
    這個 macro 用來定義如何讓機器停止運作,根據目前的實作。就是編號 93 的 syscall。所以我實作如上,目前總是讓他 return 0。
  • RVMODEL_DATA_BEGINRVMODEL_DATA_END:
    這兩個 macro 定義了測試結果要放的開頭與結尾 symbol。這是為了讓模擬器可以在測試結束之後藉由 begin_signature、end_signature 這兩個 symbol 找到測試結果並將測試結果輸出。之後 compliance 即可再比對輸出內容與正確答案,來決定測試是否通過。
  • RVMODEL_IO_*:
    這一系列 macro 讓 test case 可以在測試過程就檢查並輸出文字,但我目前沒有實作 IO 功能,因為透過比對 signature 來檢查正確性目前已經夠用。
  • RVMODEL_*_INT:
    這些 macro 跟設定 interrupt 有關,但目前模擬器沒有 interrupt 功能,所以應該不需要。

新增 signature 輸出功能

/* RISCV compliance test mode */
static bool opt_test = false;
static char *signature_out_file;

void dump_test_signature(struct riscv_t *rv, elf_t *elf)
{
    const struct Elf32_Sym *sig_start, *sig_end;
    FILE *f = fopen(signature_out_file, "w");
    if (!f) {
        fprintf(stderr, "Cannot open signature output file.\n");
        return;
    }

    sig_start = elf_get_symbol(elf, "begin_signature");
    sig_end = elf_get_symbol(elf, "end_signature");

    state_t *s = rv_userdata(rv);

    for(Elf32_Addr addr = sig_start->st_value; addr < sig_end->st_value; addr += 4) {
        fprintf(f, "%08x\n", memory_read_w(s->mem, addr));
    }

    fclose(f);
}

int main(int argc, char **args)
{
    /* ... */
    
    /* run based on the specified mode */
    if (opt_trace) {
        run_and_trace(rv, elf);
    } else {
        run(rv);
    }

    /* dump test result in test mode */
    if (opt_test) {
        dump_test_signature(rv, elf);
    }
    
    /* ... */
}
  • 要讓 rv32emu 可以再測試後輸出測試結果,首先我實作了 --test filename 的執行選項,可以透過這個選項指定測試輸出檔案
    • TODO: 將 --test 換為 --compliance,對應的說明訊息是 "Generate a compliance signature"
      可準備一個 helper function,例如: (下方程式碼需要適度調整)
    ​​​​/* in elf.c */
    ​​​​enum { SHT_NOBITS = 8 };
    ​​​​/* get the load range of a section */
    ​​​​bool elf_get_data_section_range(uint32_t *start, uint32_t *end) {                                
    ​​​​    const Elf32_Shdr *shdr = get_section_header(".data");
    ​​​​    if (!shdr) return false;
    ​​​​    if (shdr->sh_type == SHT_NOBITS) return false;
    ​​​​    *start = shdr->sh_addr;
    ​​​​    *end = start + shdr->sh_size;
    ​​​​    return true;
    ​​​​ }
    
    ​​​​/* in main.c */
    ​​​​void print_signature(riscv_t *rv, state_t *state, elf_t *elf) {
    ​​​​    uint32_t start = 0, end = 0;
    ​​​​    /* use the entire .data section as a fallback */
    ​​​​    elf_get_data_section_range(elf, &start, &end);
    ​​​​    /* try and access the exact signature range */
    ​​​​    if (Elf32_Sym *sym = elf_get_symbol(elf, "begin_signature"))
    ​​​​        start = sym->st_value;
    ​​​​    if (Elf32_Sym *sym = elf_get_symbol(elf, "end_signature"))
    ​​​​        end = sym->st_value;
    ​​​​    /* dump it word by word */
    ​​​​    for (uint32_t i = start; i < end; i += 4) {
    ​​​​        uint32_t value = state->mem_read_w(i);
    ​​​​        printf("%08x\n", value);
    ​​​​    }
    ​​​​}
    

    修改 main.c,在 { run(rv); }之後補上 print_signature:

    ​​​​ /* print execution signature */
    ​​​​if (arg_compliance)                     
    ​​​​    print_signature(rv, state, elf);
    

    這樣執行時期就可輸出 compliance test 的結果。
    :notes: jserv

  • dump_test_signature:
    因為我們在 model_test.h 中定義了對應的 symbol,即 begin_signature 與 end_signature。所以要輸出其實很簡單,就是在執行之後找出對應的 symbol 位置,並把這些中間的資料從模擬的記憶體中讀出來即可。

Makefile 整合

我整合 compliance 的方法是將 compliance 當成一個 git submodule 加進來。這麼做是因為怕 compliance 頻繁改版,以 submodule 的方式整合就可以控制使用的版本。
我也在 Makefile 中加入 compliance target,來方便重跑所有 test case。
Makefile 新增:

# variables for compliance
COMPLIANCE_DIR ?= ./riscv-compliance
export RISCV_PREFIX ?= riscv32-unknown-elf-
export RISCV_TARGET = test-rv32emu
export TARGETDIR = $(shell pwd)
export XLEN = 32
export JOBS ?= -j
export WORK = $(TARGETDIR)/$(RISCV_TARGET)/work

compliance: $(BIN)
	$(Q)$(MAKE) --quiet -C $(COMPLIANCE_DIR) clean;
	$(Q)$(MAKE) --quiet -C $(COMPLIANCE_DIR);

因為 compliance 可以支援透過設定 TARGETDIR 來更改搜尋 target 的位置,所以我是將我的 target 設定資料夾放在專案根目錄中的 test-rv32emu 資料夾中。

測試結果與修正

目前 rv32emu 在 I、M 和 Zifencei 都可以全數通過。但 privilege 中有許多無法通過:

  • ecall1
  • ebreak1
  • misalign2-jalr
  • misalign-beq
  • misalign-bge
  • misalign-bgeu
  • misalign-blt
  • misalign-bltu
  • misalign-bne
  • misalign-jal

以上正在嘗試解決當中。不過根據最新的 commit message 來看,似乎有些 test case 目前是沒有正確答案的。