Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Apply branch prediction for branch instructions #268

Closed
jserv opened this issue Nov 18, 2023 · 3 comments
Closed

Apply branch prediction for branch instructions #268

jserv opened this issue Nov 18, 2023 · 3 comments
Assignees

Comments

@jserv
Copy link
Contributor

jserv commented Nov 18, 2023

To speed up the emulation of branch instructions, the following C code serves as a PoC:

#include <stdio.h>
#include <stdbool.h>
#include <stdint.h>

#define HISTORY_SIZE 16

/* Define a structure for the branch history buffer */
typedef struct {
    uint32_t instruction_address;
    bool branch_taken;
} branch_history_entry;

/* Initialize the branch history buffer */
static branch_history_entry history_buffer[HISTORY_SIZE];
static int buffer_index = 0;

/* Function to predict the outcome of a branch instruction */
static bool predict(uint32_t instruction_address)
{
    /* Check if the instruction address is in the history buffer */
    for (int i = 0; i < HISTORY_SIZE; i++) {
        if (history_buffer[i].instruction_address == instruction_address) {
            /* Make a prediction based on historical behavior */
            return history_buffer[i].branch_taken;
        }
    }
    /* Default prediction if no history is available */
    return false;
}

/* Function to update the branch history buffer */
static void update_history(uint32_t instruction_address, bool branch_outcome)
{
    /* Update the branch history buffer with the outcome of the instruction */
    history_buffer[buffer_index].instruction_address = instruction_address;
    history_buffer[buffer_index].branch_taken = branch_outcome;
    buffer_index = (buffer_index + 1) % HISTORY_SIZE;
}

int main()
{
    /* Emulation loop */
    while (!end_of_program) {
        uint32_t current_instruction = fetch_instruction();

        if (is_branch_instruction(current_instruction)) {
            bool prediction = predict(current_instruction.instruction_address);
            if (prediction) {
                jump_to(predicted_jump_target);
            } else {
                continue_execution();
            }
        }

        /* Execute the instruction as usual */

        /* Update branch history based on the actual outcome */
        update_history(current_instruction.instruction_address, actual_outcome);
    }

    return 0;
}

The history_buffer stores the history of branch instructions, including their addresses and whether they were taken or not. This allows the emulator to learn from past behavior. The predict function looks up the history buffer to predict the outcome of the current branch instruction. If the instruction's address is found in the history buffer, it makes a prediction based on historical behavior. After executing a branch instruction and determining the actual outcome, the update_history function updates the history buffer with the actual outcome. This information helps refine future predictions.

By predicting branch outcomes and potentially skipping unnecessary instructions, the emulator can reduce the number of executed instructions, improving execution speed. This is especially useful for JAL and JALR instructions, which involve jumps in the program flow. Predicting correctly may lead to performance gains.

Dynamic branch prediction uses runtime behavior to make predictions. In an emulator, this can be implemented by tracking historical branch behavior and using more advanced prediction algorithms. Some common dynamic predictors include:

  • Two-level adaptive prediction: This predictor maintains a history of branch outcomes and uses a combination of local and global history to make predictions.
  • Tournament predictor: It combines the outputs of multiple predictors (e.g., static, local, global) to make the final prediction.

See: https://github.com/bucaps/marss-riscv/tree/master/src/riscvsim/bpu

@jserv
Copy link
Contributor Author

jserv commented Nov 19, 2023

A common challenge often arises when computing jump target addresses, particularly for frequently traversed code paths such as branches and function calls. To streamline and enhance this procedure, the adoption of Look-Up Tables (LUTs) can prove remarkably efficacious.

Upon encountering a branch or jump instruction during execution, rather than recomputing the target address, the emulator directly references the precomputed table using the instruction's parameters as the index or key. While this approach can accelerate execution, it does come at the cost of increased memory usage. Emulators operating on memory-limited devices must carefully weigh the advantages of enhanced performance against the additional memory demands.

Suppose we have a frequently used branch instruction like beq, which takes two source registers (rs1 and rs2) and an immediate value (imm). We can precompute and store the target addresses for all possible combinations of rs1, rs2, and imm in a lookup table. When encountering a beq instruction during execution, we directly retrieve the target address from the table, saving computation time.

@jserv
Copy link
Contributor Author

jserv commented Nov 20, 2023

Once a branch predictor is properly implemented, we can then proceed to speculative execution, a technique in processor design that enhances performance by predicting the outcome of branch instructions and executing instructions before the branch target is determined. This approach has the potential to accelerate the emulation of JAL instruction (and other branch instructions) in the RISC-V architecture.

Consider the following PoC:

#include <stdbool.h>
#include <stdio.h>

/* Define a simple RISC-V instruction structure */
typedef struct {
    int opcode;   /* Instruction opcode */
    int rs1, rs2; /* Source Register 1, 2 */
    int rd;       /* Destination Register */
    int imm;      /* Immediate Value */
} risc_v_instruction;

/* Function to emulate RISC-V instructions */
void emulate_instruction(risc_v_instruction instruction, int *registers)
{
    /* Check the opcode to determine the type of instruction */
    switch (instruction.opcode) {
    case 0x6F: /* JAL (Jump and Link) */
        /* Speculative execution: Assume the branch is taken */
        int target_address = registers[instruction.rs1] + instruction.imm;

        /* Create a checkpoint for speculative execution */
        int checkpoint_registers[32];
        for (int i = 0; i < 32; i++)
            checkpoint_registers[i] = registers[i];

        /* Speculatively execute instructions along the predicted path */
        registers[instruction.rd] =
            target_address;  // Update the link register (x1)

        /* Execute more instructions along the predicted path ... */

        /* Validation: Check if the prediction was correct */
        bool prediction_correct = /* Check the actual branch outcome */;
        if (!prediction_correct) {
            /* Restore the checkpoint if the prediction was wrong */
            for (int i = 0; i < 32; i++)
                registers[i] = checkpoint_registers[i];
        }

        break;

        /* Handle other RISC-V instructions... */

    default:
        /* Handle other instructions here */
        break;
    }
}

int main()
{
    /* Initialize RISC-V registers */
    int registers[32] = {0};

    /* Create a sample JAL instruction */
    risc_v_instruction jal_instruction = {0x6F, 1, 0, 1, 8};  // JAL x1, 8

    /* Emulate the instruction (speculative execution) */
    emulate_instruction(jal_instruction, registers);

    return 0;
}

Here is how it works:

  1. We start with a sample JAL instruction: JAL x1, 8. This instruction jumps to a target address and saves the return address in register x1.
  2. In speculative execution, we assume that the branch is taken. We calculate the target address by adding the immediate value (8 in this case) to the value in register x1.
  3. To manage speculative execution, we create a checkpoint of the processor state before executing the instruction speculatively. This checkpoint includes the values of all registers.
  4. We speculatively execute instructions along the predicted path, which means we update the link register x1 with the calculated target address and execute subsequent instructions. This can lead to improved performance by overlapping execution of instructions.
  5. After speculative execution, we validate whether our prediction was correct. If it was incorrect (i.e., the branch was not taken), we revert to the checkpointed state. This ensures that the processor state remains consistent.

In this way, speculative execution allows us to start executing instructions before knowing the actual branch outcome, potentially reducing dispatch delays and improving emulation performance. However, it is important to handle incorrect predictions gracefully to maintain correctness.

@jserv
Copy link
Contributor Author

jserv commented Nov 25, 2023

Commits b24431c and f82f133 represent the practical implementation of branch prediction for RISC-V instruction emulation. Let's go ahead and close this issue.

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants