Skip to content

Commit

Permalink
Update Prologue
Browse files Browse the repository at this point in the history
  • Loading branch information
jdroob authored Dec 19, 2023
1 parent 5d24a16 commit 1dac739
Showing 1 changed file with 14 additions and 8 deletions.
22 changes: 14 additions & 8 deletions content/blog/2023-12-11-lowering/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ name = "Arjun Shah"
TODO: Move these links

- [Trivial Register Allocation Logic](https://github.com/JohnDRubio/CS_6120_Advanced_Compilers/tree/main/rv32_backend/TrivialRegAlloc)
- Calling Convention Logic
- [Prologue Inserter](https://github.com/JohnDRubio/CS_6120_Advanced_Compilers/tree/main/rv32_backend/util/prologue.py)
- [Epilogue Inserter](https://github.com/JohnDRubio/CS_6120_Advanced_Compilers/tree/main/rv32_backend/util/epilogue.py)
- [Lowering function call](https://github.com/JohnDRubio/CS_6120_Advanced_Compilers/tree/main/rv32_backend/BrilInsns/BrilFunctionCallInsn.py)
- [Epilogue Inserter](https://github.com/JohnDRubio/CS_6120_Advanced_Compilers/tree/main/rv32_backend/util/epilogue.py)
- [Lowering function call](https://github.com/JohnDRubio/CS_6120_Advanced_Compilers/tree/main/rv32_backend/BrilInsns/BrilFunctionCallInsn.py)

# Summary
Bril (TODO: ADD LINK) is a user-friendly, educational intermediate language. Bril programs have typically been run using the Bril interpreter (TODO: ADD LINK). Compiling Bril programs to assembly code that can run on real hardware would allow for more accurate measurements of the impacts of compiler optimizations on Bril programs in terms of execution time or clock cycles. Thus, the goal of [this project](https://github.com/JohnDRubio/CS_6120_Advanced_Compilers/tree/main/rv32_backend) was to write a RISC-V backend. That is, to write a program that lowers the [core subset of Bril](https://capra.cs.cornell.edu/bril/lang/core.html) to TinyRV32IM (TODO: ADD LINK), a subset of RV32IM (TODO: ADD LINK). The objective was to ensure semantic equivalence between the source program and the generated RISC-V code by running it on a RISC-V emulator. At the outset of this project, one of the stretch goals was to use Crocus (TODO: ADD LINK) to verify the correctness of the Bril-to-RISC-V lowering rules. Another stretch goal was to perform a program analysis step that would aid in instruction selection, allowing the lowering phase to take place in an M-to-N fashion as opposed to the more trivial 1-to-N approach. The authors regret to inform you that these stretch goals were not completed during the semester, however, the primary goal was achieved. The primary goal was to generate semantically equivalent RISC-V assembly code from a Bril source program using a dead simple approach: 1-to-N instruction selection, trivial register allocation, and correct calling conventions.
Expand Down Expand Up @@ -110,13 +108,21 @@ Doing this for each RISC-V instruction gives us executable assembly that is comp

## Calling Conventions

By far, the biggest implementation obstacle was implementing the RISC-V calling conventions. We based our implementation off of the RISC-V calling conventions taught in Cornell's CS 3410: Computer System Organization and Programming. To oversimplify four lectures' worth of material, the essence of RISC-V calling conventions can be distilled into three main phases:
By far, the biggest implementation obstacle was implementing the RISC-V calling conventions. We based our implementation off of the RISC-V calling conventions taught in [Cornell's CS 3410: Computer System Organization and Programming](https://www.cs.cornell.edu/courses/cs3410/2019sp/schedule/). To oversimplify four lectures' worth of material, the essence of RISC-V calling conventions can be distilled into three main phases:

1. **Prologue (Function Entry Setup):** Before entering the function body, a set of preparatory actions, known as the prologue, take place. These include the creation of the stack frame. The size of the stack frame usually needs to be known ahead of time, considering the space needed to accommodate the return address, frame pointer, any overflow arguments, and local variables that must be pushed onto the stack. Additionally, all callee-save registers that will be utilized during the function execution should be pushed onto the stack at this stage.
1. **Prologue (Function Entry Setup):** Before entering the function body, a set of preparatory actions, known as the prologue, must take place. These include the creation of the stack frame. The size of the stack frame usually needs to be known ahead of time, considering the space needed to accommodate the return address, frame pointer, any overflow arguments, and local variables that must be pushed onto the stack. Additionally, all callee-save registers that will be utilized during the function execution should be pushed onto the stack at this stage.

2. **Function Call Instruction Preparation:** Prior to a function call instruction, certain steps must be taken to ensure the proper transfer of control and data. This involves placing function arguments in designated registers, following the argument-passing registers convention. For additional arguments or those that don't fit into the designated registers, space on the stack is allocated to hold these values. The function call instruction is then executed, initiating the transfer of control to the callee.
This is the approach we used in our [implementation](https://github.com/JohnDRubio/CS_6120_Advanced_Compilers/tree/main/rv32_backend/util/prologue.py). Recall that the source Bril program is converted to a list of BrilInsn objects on a per-function basis. That is, the initial _Bril JSON -> BrilInsn_ pass converts each Bril function to an individual list of BrilInsn objects. It is at this point, that we inserted a pass that calculates the frame size required for the function in question. The frame size is calculated by
counting the number of _slots_ the function requires where a slot is simply a four-byte space on the stack. The _get_frame_size_ pass counts the number of slots by first reserving slots for the return address and frame pointer. Next, slots are reserved for any
overflow arguments. As a quick aside, RISC-V reserves eight registers for arguments used in a function call. Additional arguments must be pushed to the stack. Therefore, the get_frame_size pass determines which function call within the current function uses the
highest number of arguments. If the number of arguments is greater than eight, then space on the stack will be reserved for all overflow arguments. Lastly, the get_frame_size pass counts the number of slots required for local variables. We decided to naively reserve
a slot for every local variable in the function. Undoubtedly, this is inefficient and future implementations will use live variable analysis to determine the maximum number of local variables live at any given time within the function in question.

3. **Epilogue (Clean-up):** The third key piece of this calling convention puzzle is the clean-up step, or the epilogue. After the instructions in the function body have finished executing, the epilogue is responsible for restoring the stack to its original state and releasing any resources allocated during the prologue. This includes popping the stack frame, restoring the values of callee-save registers, and ensuring a smooth return to the calling function.
With the frame size available, we were able to implement the rest of the prologue in the fashion described in the [CS 3410 Calling Convention Lectures](https://www.cs.cornell.edu/courses/cs3410/2019sp/schedule/). First, the frame is created by decrementing the stack pointer by the calculated frame size. Next, the return address and the old frame pointer are both pushed to the stack. The frame pointer register is then updated with the location of the top of the new stack frame. Referring to objects on the stack frame through the frame pointer instead of the stack pointer is a matter of preference, however, we found it easier to reference objects via the frame pointer since the frame pointer's location is fixed throughout the lifetime of the function being executed. Importantly, the callee-save registers are pushed to the stack at this point as this is how we avoid squashing the caller's local data. Finally, we move arguments that were passed to the function being executed into the [RISC-V saved registers](https://en.wikichip.org/wiki/risc-v/registers). This allows the argument registers to be overwritten during the lifetime of the function being executed without squashing the original arguments provided.

3. **Function Call Instruction Preparation:** Prior to a function call instruction, certain steps must be taken to ensure the proper transfer of control and data. This involves placing function arguments in designated registers, following the argument-passing registers convention. For additional arguments or those that don't fit into the designated registers, space on the stack is allocated to hold these values. The function call instruction is then executed, initiating the transfer of control to the callee.

4. **Epilogue (Clean-up):** The third key piece of this calling convention puzzle is the clean-up step, or the epilogue. After the instructions in the function body have finished executing, the epilogue is responsible for restoring the stack to its original state and releasing any resources allocated during the prologue. This includes popping the stack frame, restoring the values of callee-save registers, and ensuring a smooth return to the calling function.

# What were the hardest parts to get right?

Expand Down

0 comments on commit 1dac739

Please sign in to comment.