# Assignment 1: RISC-V Assembly and Instruction Pipeline contributed by < [geumbo](https://github.com/geumbo/ca2025-quizzes) > >[!Note] AI tools usage I used ChatGPT to fix code issues and enhance the readability of my writing. ## Problem B ### Optimize overflow calculation from uf8_encode The original C code calculates the `overflow` using a for loop. The execution time of this loop is directly proportional to the value of the input `exponent`. Original C Code: ```c= uint32_t overflow = 0; /* Calculate overflow for estimated exponent */ for (uint8_t e = 0; e < exponent; e++) overflow = (overflow << 1) + 16; } ``` Direct RV32I Assembly Translation: ```c= mv t2, x0 # t2 = overflow = 0 mv t3, x0 # t3 = e = 0 calc_overflow_loop: bge t3, t1, adjust # if e >= exp, goto adjust (t1 = exp) slli t2, t2, 1 # overflow << 1 addi t2, t2, 16 # overflow = (overflow << 1) + 16 addi t3, t3, 1 # e + 1 j calc_overflow_loop ``` Let $O_k$ be the value of `overflow` after the $k$\-th iteration. The relation is: $$O_k = 2 \times O_{k-1} + 16$$ The initial condition is $O_0 = 0$, representing the value before the first iteration. - After 1 iteration (exponent = 1): $O_1 = 2 \times O_0 + 16 = 2 \times 0 + 16 = 16$ - After 2 iterations (exponent = 2): $O_2 = 2 \times O_1 + 16 = 2 \times (16) + 16 = 16 \times 2^1 + 16 \times 2^0$ - After 3 iterations (exponent = 3): $O_3 = 2 \times O_2 + 16 = 2 \times (16 \times 2^1 + 16 \times 2^0) + 16 = 16 \times 2^2 + 16 \times 2^1 + 16 \times 2^0$ We can generalize the formula for `n` iterations: $$O_n = 16 \times 2^{n-1} + 16 \times 2^{n-2} + \dots + 16 \times 2^1 + 16 \times 2^0$$ We can factor out the constant `16` and express the pattern using summation notation: $$O_n = 16 \times (2^{n-1} + 2^{n-2} + \dots + 2^1 + 2^0) = 16 \times \sum_{i=0}^{n-1} 2^i $$ The summation part, $\sum_{i=0}^{n-1} 2^i$, is a standard geometric series with: - First term, $a = 2^0 = 1$ - Common ratio, $r = 2$ - Number of terms, $n$ The formula for the sum of a geometric series is: $$S_n = a \frac{r^n - 1}{r - 1}$$ Substituting our values into the formula: $$S_n = 1 \times \frac{2^n - 1}{2 - 1} = 2^n - 1$$ We substitute this simplified sum back into our expression for $O_n$: $$O_n = 16 \times (2^n - 1)$$ The derived formula can be implemented with just a few efficient bitwise operations, completely eliminating the need for a loop. Optimized C Code: ```c= uint32_t overflow = ((1u << exponent) - 1u) << 4; ``` Optimized RV32I Assembly: ```c= calc_overflow: li t2, 1 sll t2, t2, t1 # t2 = 1 << exponent addi t2, t2, -1 # t2 = (1 << exponent) - 1 slli t2, t2, 4 # overflow = ((1 << exponent) - 1) << 4 j adjust ``` #### Evaluation (Ripes 5-stage processor) Original method cycles: 46,709 > Commit: [2bfcc07](https://github.com/geumbo/ca2025-quizzes/commit/2bfcc07986bb5cdd45197d389d668ef0b5b2825e) ![CleanShot 2025-10-12 at 15.18.21@2x](https://hackmd.io/_uploads/H1ZIURuall.png) Improved method cycles: 34,147 (-26.9%) > Commit: [e21b192](https://github.com/geumbo/ca2025-quizzes/commit/e21b192c41da0320b2e3c8483c94719511aae158) ![CleanShot 2025-10-12 at 15.25.27@2x](https://hackmd.io/_uploads/rkYlORdaex.png) #### Conclusion The original loop has a time complexity of $O(n)$, where `n` is the value of `exponent`. Its execution time grows linearly with the input. In contrast, the optimized version executes a fixed number of instructions, achieving a constant time complexity of $O(1)$. The optimized version also results in more compact machine code. It eliminates the need for instructions related to loop management, replacing them with simple arithmetic/logical instructions. ### Optimize exponent calculation from uf8_encode We already established the relationship between `overflow` and `exponent` as: $$\text{overflow} = 16 \times (2^{\text{exponent}} - 1)$$ The encoding rule requires us to find the largest exponent such that value is greater than or equal to the corresponding overflow. This means we need to solve the following inequality for exponent: $$\text{value} \ge 16 \times (2^{\text{exponent}} - 1)$$ 1. Divide both sides by 16: $$\frac{\text{value}}{16} \ge 2^{\text{exponent}} - 1$$ 2. Add 1 to both sides: $$\frac{\text{value}}{16} + 1 \ge 2^{\text{exponent}}$$ 3. Take the $\log_2$ of both sides: $$\log_2(\frac{\text{value}}{16} + 1) \ge \text{exponent}$$ Since we are looking for the **largest integer** `exponent` that satisfies this inequality, the solution is obtained by taking the floor of the left-hand side expression. $$\text{exponent} = \lfloor \log_2(\frac{\\text{value}}{16} + 1) \rfloor$$ For any positive 32-bit integer `x`, the value of `floor(log₂(x))` is equivalent to `31 - clz(x)`. The base-2 logarithm `log₂(x)` represents the power to which 2 must be raised to obtain `x`. Taking the floor, `floor(log₂(x))`, gives the **index of the most significant set bit (MSB)** in the binary representation of `x`. The function `clz(x)` returns the number of leading zero bits in the 32-bit binary representation of `x`. Therefore, by subtracting this count from 31 (since we are dealing with a 32-bit value), we obtain the position of the MSB which is exactly `floor(log₂(x))`. Optimized C Code: ```c= uf8 uf8_encode(uint32_t value) { if (value < 16) return value; // Directly calculate the optimal exponent using the formula uint8_t exponent = 31 - clz((value >> 4) + 1); if (exponent > 15) exponent = 15; // Use the formulaic approach to calculate the corresponding overflow uint32_t overflow = ((1u << exponent) - 1u) << 4; uint8_t mantissa = (value - overflow) >> exponent; return (exponent << 4) | mantissa; } ``` Optimized RV32I Assembly: ```c= encode: addi sp, sp, -16 sw ra, 12(sp) sw s0, 8(sp) sw s1, 4(sp) sw s2, 0(sp) mv s0, a0 # s0 = a0 = value li t0, 16 # value = 16 bltu s0, t0, encode_return_value # if value < 16, return value # Calculate exponent = 31 - clz((value >> 4) + 1) srli a0, s0, 4 # value >> 4 addi a0, a0, 1 # (value >> 4) + 1 jal ra, clz # call clz li t0, 31 sub s1, t0, a0 # exponent = 31 - clz # if (exponent > 15) exponent = 15 li t0, 15 bge t0, s1, calc_overflow mv s1, t0 # s1 = exponent calc_overflow: # overflow = ((1u << exponent) - 1u) << 4 li t0, 1 sll t0, t0, s1 # t0 = 1 << exponent addi t0, t0, -1 # t0 = (1 << exponent) - 1 slli s2, t0, 4 # s2 = overflow = ((1 << exponent) - 1) << 4 calc_mantissa: # mantissa = (value - overflow) >> exponent sub t0, s0, s2 # t0 = mantissa = value - overflow srl t0, t0, s1 # mantissa >> exp slli s1, s1, 4 # exp << 4 or a0, s1, t0 # return (mantissa | (exp << 4)) j encode_end encode_return_value: mv a0, s0 # return value (a0) encode_end: lw ra, 12(sp) lw s0, 8(sp) lw s1, 4(sp) lw s2, 0(sp) addi sp, sp, 16 ret ``` #### Evaluation (Ripes 5-stage processor) Original method cycles: 34,147 (Optimize overflow calculation) > Commit: [e21b192](https://github.com/geumbo/ca2025-quizzes/commit/e21b192c41da0320b2e3c8483c94719511aae158) ![CleanShot 2025-10-12 at 15.25.27@2x](https://hackmd.io/_uploads/rkYlORdaex.png) Improved method cycles: 29,753 (-12.9%) > Commit: [07dea06](https://github.com/geumbo/ca2025-quizzes/commit/07dea06525ee72df8084e8a485da0e5a73115cfd) ![CleanShot 2025-10-12 at 17.47.48@2x](https://hackmd.io/_uploads/BJKwKeYpgl.png) #### Conclusion Through two stages of mathematical optimization, the `uf8_encode` function was transformed from an iterative algorithm into a direct computation. As a result, the total execution cycles decreased from **46,709** to **29,753**, achieving an overall performance improvement of **36.3%**. ### Optimize clz The `clz` function is to count the number of consecutive zero bits starting from the most significant bit (MSB) of an integer. The original C code implements `clz` using a binary search algorithm. It repeatedly halves the search space to quickly locate the most significant '1' bit. Original C Code: ```c= static inline unsigned clz(uint32_t x) { int n = 32, c = 16; do { uint32_t y = x >> c; if (y) { n -= c; x = y; } c >>= 1; } while (c); return n - x; } ``` Direct RV32I Assembly Translation: ```c= clz: li t0, 32 # t0 = n = 32 li t1, 16 # t1 = c = 16 mv t2, a0 # t2 = x clz_1: srl t3, t2, t1 # t3 = y = x >> c beq t3, x0, clz_2 # if y == 0, goto clz_2 sub t0, t0, t1 # n = n - c mv t2, t3 # x = y clz_2: srli t1, t1, 1 # c = c >> 1 bne t1, x0, clz_1 # if c != 0, goto clz_1 sub a0, t0, t2 # return n - x ret ``` Since the loop runs for a fixed number of iterations (5 times, for `c = 16, 8, 4, 2, 1`), we can **unroll** it into a linear sequence of instructions. The new algorithm works by checking progressively smaller blocks of bits from the most significant side. If a block is found to be all zeros, its size is added to a counter, and the input value is shifted left to expose the next block of bits for examination. Optimized C Code: ```c= static inline unsigned clz(uint32_t x) { if (x == 0) return 32; unsigned n = 0; if ((x >> 16) == 0) { n += 16; x <<= 16; } if ((x >> 24) == 0) { n += 8; x <<= 8; } if ((x >> 28) == 0) { n += 4; x <<= 4; } if ((x >> 30) == 0) { n += 2; x <<= 2; } if ((int32_t) x >= 0) { n += 1; } return n; } ``` Optimized RV32I Assembly: ```c= clz: # if (x == 0) return 32 bne a0, x0, clz_start li a0, 32 ret clz_start: li a1, 0 # a1 = n = 0 srli t0, a0, 16 bne t0, x0, check_8 addi a1, a1, 16 # n += 16 slli a0, a0, 16 # x <<= 16 check_8: srli t0, a0, 24 bne t0, x0, check_4 addi a1, a1, 8 # n += 8 slli a0, a0, 8 # x <<= 8 check_4: srli t0, a0, 28 bne t0, x0, check_2 addi a1, a1, 4 # n += 4 slli a0, a0, 4 # x <<= 4 check_2: srli t0, a0, 30 bne t0, x0, check_1 addi a1, a1, 2 # n += 2 slli a0, a0, 2 # x <<= 2 check_1: blt a0, x0, clz_end # if (x < 0), MSB is 1, we are done addi a1, a1, 1 # n += 1 clz_end: mv a0, a1 # move result to a0 for return ret ``` #### Evaluation (Ripes 5-stage processor) Original method cycles: 29,753 (Optimize overflow and exponent calculation) > Commit: [07dea06](https://github.com/geumbo/ca2025-quizzes/commit/07dea06525ee72df8084e8a485da0e5a73115cfd) ![CleanShot 2025-10-12 at 17.47.48@2x](https://hackmd.io/_uploads/BJKwKeYpgl.png) Improved method cycles: 25,321 (-14.9%) > Commit: [d513160](https://github.com/geumbo/ca2025-quizzes/commit/d513160cba15191d32a8d82da732eca3748abe04) ![CleanShot 2025-10-12 at 23.36.35@2x](https://hackmd.io/_uploads/rJ1msrtpxx.png) #### Conclusion Unrolling the fixed five-step search removes loop-control overhead. On the Ripes 5-stage pipeline, cycles decrease from **29,753** to **25,321** (**−14.9%**) ### 5-stage Pipelined Processor A 5-stage pipelined Processor is a architecture that divides instruction execution into five sequential stages — **Fetch**, **Decode**, **Execute**, **Memory Access**, and **Write Back**. Each stage performs a distinct operation on the instruction before passing it to the next stage. By allowing multiple instructions to occupy different stages simultaneously, the pipeline enables overlapping execution: while one instruction is being executed, another can be decoded, and a third can be fetched. This parallelism significantly increases the instruction throughput, improving overall processor performance by completing more instructions per clock cycle. 1. Instruction Fetch (IF) ![CleanShot 2025-10-12 at 21.09.20@1x](https://hackmd.io/_uploads/BJEc_mYpel.png) - The `PC` initially holds the value `0x00000000`, which is used as the address for the **Instruction Memory**. - At address `0x00000000`, the instruction memory outputs the 32-bit machine code `0xff010113` which corresponds to the instruction `addi x2 x2 -16`, so the signal `instr` = `0xff010113` - The `instr` is sent to the decode stage for further processing in the next pipeline cycle. - The adder connected to the `PC` automatically increments its value by 4. - The multiplexer selects the sequential PC update path, since there is no branch or jump in this stage. 2. Instruction Decode (ID) ![CleanShot 2025-10-12 at 21.19.08@2x](https://hackmd.io/_uploads/SkT0qQKpxx.png) Instruction `0xff010113` is decoded to four part: - `opcode` = `ADDI` - `R1 idx` = `0x02` → selects **x2** as the source register. - `Wr idx` = `0x02` → sets **x2** as the destination. - `imm` = `0xfffffff0` = `-16` means: > Take the value from register **x2**, > add the immediate constant **−16**, > and write the result back to **x2**. - The Registers block outputs the current value of `x2` (`0x7ffffff0`), which will be sent to the next stage (**Execute**) for arithmetic computation. 3. Instruction Execute (IE) ![CleanShot 2025-10-12 at 21.52.09@2x](https://hackmd.io/_uploads/S1Kqz4Kalg.png) Inputs to the ALU: - **Op1** (Operand 1): `0x7FFFFFF0` — the value read from source register **x2** - **Op2** (Operand 2): `0xFFFFFFF0` — the sign-extended immediate value **−16** The **ALU result (Res)**, `0x7FFFFFE0`, along with the destination register index (**x2**), is forwarded to the next pipeline stage (**MEM**) for subsequent memory access or write-back operations. 4. Memory Access (MEM) ![CleanShot 2025-10-12 at 22.02.46@2x](https://hackmd.io/_uploads/r1FMSVtpel.png) - Addr: `0x7FFFFFE0` – forwarded from the ALU result - Data in: `0x00000000` – no data to be written - Write enable (Wr en): `0` – memory write is **disabled** Since `ADDI` is an arithmetic instruction that does not perform any memory access, the **Data Memory** remains inactive. 5. Write Back (WB) ![CleanShot 2025-10-12 at 22.07.06@2x](https://hackmd.io/_uploads/B1nGUNFagx.png) - Destination register: `0x02` → **x2** - Write data: `0x7FFFFFE0` - Write enable: active (`1`) Thus, the value `0x7FFFFFE0` is written back into **register x2**, updating its contents. ## Problem C > [Full RV32I Assembly](https://github.com/geumbo/ca2025-quizzes/blob/main/q1-bfloat16.s) The implementation successfully runs on Ripes and passes all test suites, with a total cycle count of **10,186**. ![CleanShot 2025-10-13 at 09.44.54@2x](https://hackmd.io/_uploads/HkwnYCtpel.png) ### Optimize normalization process by replacing loop with `clz` In the `bf16_add` function, when two numbers with different signs are subtracted, the resulting mantissa (`result_mant`) may become denormalized (its MSB is not set). The original code uses a while loop to correct this by repeatedly left-shifting the mantissa until it is normalized. The goal is to replace this iterative search with a direct, constant-time calculation using the `clz` function. Original C Code: ```c= if (!result_mant) return BF16_ZERO(); while (!(result_mant & 0x80)) { result_mant <<= 1; if (--result_exp <= 0) return BF16_ZERO(); } ``` Direct RV32I Assembly Translation: ```c= normalize_loop: # while (!(result_mant & 0x80)) andi t0, s8, 0x80 bne t0, x0, build_result slli s8, s8, 1 # result_mant <<= 1 addi s7, s7, -1 # result_exp-- # if (--result_exp <= 0) bge x0, s7, return_zero j normalize_loop ``` The number of left shifts required for normalization is equal to the number of leading zeros within the mantissa's 8-bit window. We can calculate this in a single step using a 32-bit clz function: $$\text{shift_amount} = clz(\text{result_mant}) - 24$$ - If `result_exp <= shift_amount`, normalization would underflow → return zero. - Otherwise, `result_exp -= shift_amount; result_mant <<= shift_amount;`. This replaces the iterative normalization loop with a constant-time normalization process. Optimized C Code: ```c= if (!result_mant) return BF16_ZERO(); int shift_amount = clz(result_mant) - 24; if (result_exp <= shift_amount) { return BF16_ZERO(); } result_exp -= shift_amount; result_mant <<= shift_amount ``` Optimized RV32I Assembly: ```c= beq s8, x0, return_zero # if (!result_mant) return BF16_ZERO mv a0, s8 # a0 = result_mant jal ra, clz # shift_amount = clz(result_mant) addi t0, a0, -24 # t0 = clz(result_mant) - 24 ble s7, t0, return_zero # if (result_exp <= shift_amount) return zero sub s7, s7, t0 # result_exp -= shift_amount sll s8, s8, t0 # result_mant <<= shift_amount j build_result ``` ## Reference [How to Find the Sum of Geometric Series](https://www.geeksforgeeks.org/maths/how-to-find-the-sum-of-a-geometric-series/) [Floor log2 n in O(1) for C++](https://codeforces.com/blog/entry/89220) [2017q3 Homework4 (改善 clz)](https://hackmd.io/@3xOSPTI6QMGdj6jgMMe08w/Bk-uxCYxz)