My FPGA Scalar Compute Unit Architecture
This article covers the initial FPGA architecture for my accelerator-compiler co-design project, focusing on the custom hardware components, how they communicate, and how I verified their functionality.
I'm using the Terasic DE10-Lite board, which features an Intel MAX 10 FPGA with approximately 50K logic elements. You can find the board specs and user manual here
This is my first independent FPGA project. I chose the DE10-Lite because I already owned one from a previous class, and its hardware resources are suitable for prototyping the initial architecture. The current implementation uses approximately 7.6K logic elements (14%) and 1% of the available embedded memory, leaving substantial resources for future additions such as a tensor unit and local buffers
The system uses a Nios V/m softcore processor running at 50 MHz, external SDRAM for the processor's main memory, a PLL for SDRAM clock generation, and a JTAG UART for host communication.
The NiosV/m loads compiler-generated instructions into a custom compute unit and manages its execution. The custom unit consists of an instruction memory, an execution controller, eight 32-bit registers, and a scalar arithmetic unit supporting integer addition, subtraction, and multiplication.
The custom hardware modules are implemented in Verilog.
Source code available on my GitHub
System Overview and Execution Flow
The diagram below illustrates the high-level architecture of the FPGA system and how its components are connected.

The processor communicates with the custom compute unit through a memory-mapped interface, allowing it to load instructions, start execution, and read back results.
Once started, the compute unit executes instructions independently using its execution controller and arithmetic unit. The processor handles program loading and communication with the host computer, while external SDRAM serves as the processor's main memory.
The custom compute unit consists of 5 RTL modules, each responsible for a different part of instruction execution:
controller_unit: top-level module that handles MMIO requests and coordinates execution
instruction_ram: stores instructions loaded by the processor
execution_controller: fetches, decodes, and executes instructions using a finite-state machine (FSM)
scalar_unit: manages arithmetic requests and results
scalar_arith_unit: performs the actual arithmetic operations
To demonstrate how the components work together, let's walk through a simple program that computes (7 + 3) x 2 = 20.
The program contains nine 32-bit words, including instructions for loading constants, performing arithmetic operations, and halting execution.
Instruction | Operation |
LOAD_IMM R0, 7 | Load 7 into R0 |
LOAD_IMM R1, 3 | Load 3 into R1 |
ADD R2, R0, R1 | R2 = 7 + 3 = 10 |
LOAD_IMM R3, 2 | Load 2 into R3 |
MUL R4, R2, R3 | R4 = 10 x 2 = 20 |
HALT | End execution |
Note: Each LOAD_IMM occupies two 32-bit words: an instruction followed by its immediate value.
Program Loading
The processor runs a C application that loads the 9 compiler-generated instruction words into instruction_ram via memory-mapped IO (MMIO) managed through controller_unit
It then writes the program length and sends a START command to begin execution
Instruction Execution
Once started, execution_controller independently fetches and decodes instructions from instruction_ram and manages the 8 general-purpose registers (R0 - R7).
For arithmetic instructions, the execution_controller sends the operands and operation to scalar_unit, which coordinates execution with scalar_arith_unit
The arithmetic result is returned to the execution controller and written into the destination register.
In this example, the addition produces R2 = 10, followed by multiplication producing R4 = 20
Completion and Result Readback
When execution_controller encounters HALT, it stops execution and sets the DONE status flag.
The processor polls the status register until execution completes or an error occurs. Once execution completes successfully, it reads R4 through MMIO
The result is printed to the host computer through JTAG UART:

Custom RTL Architecture
Controller Unit
The controller_unit serves as the top-level wrapper for the custom compute unit, connecting the processor's MMIO interface to the internal hardware modules.

It exposes a 4KB memory-mapped address space using 10-bit word addresses, divided into control/status registers and a 512-word instruction RAM window.
The lower address region contains registers for configuring execution and reading results, while the upper region (word addresses 512 - 1023) is used for loading instructions into instruction_ram
The address decoder uses the most significant address bit (address[9]) to select the instruction RAM window and the lower nine bits to select one of its 512 entries.
The module also connects instruction_ram, execution_controller, and scalar_unit, allowing the processor to manage execution while the hardware independently fetches and processes instructions
MMIO Address Map:
Byte offset | Register | Purpose |
0x000 | START | Begin execution |
0x004 | STATUS | Report BUSY, DONE, ERROR |
0x008 | PROGRAM_WORDS | Configure program length |
0x020 - 0x03C | R0 - R7 | Read execution results |
0x800 - 0xFFC | Instruction RAM | Load or read program words |
A note on word addressing
The RTL uses word addresses, while the processor's C app uses byte offsets. Since each word is 32 bits (4 bytes), the byte offset is four times the corresponding word address.
For example, when the processor writes to byte offset 0x808:
Byte offset: 0x808
RTL word address: 0x808/4 = 0x202 (514)
10-bit binary [9:0]: 1000000010
RAM index: 2
The MSB (address[9] = 1) selects the instruction_ram window, while the remaining 9-bits (address[8:0]) select RAM index 2.
Instruction RAM
The instruction_ram is 512 x 32-bit (2 KiB) dual-port memory that stores instructions for the custom compute unit
It has 2 separate interfaces: a load port for the processor to write instructions through MMIO and a fetch port for execution_controller to read instructions during execution. Both ports share the same clock.
Writes are blocked while the execution_controller is busy to prevent the processor from modifying instructions during execution.
A note on synchronous reads
The instruction RAM returns data after a clock edge rather than immediately when an address is provided. The execution_controller therefore uses separate fetch states to request an instruction and capture it once the data is available.
Execution Controller
The execution_controller manages instruction execution using an FSM, a 10-bit program counter (PC), and eight 32-bit general-purpose registers (R0 - R7).
It fetches and decodes instructions from instruction_ram, selects operands from the register file, dispatches arithmetic operations to scalar_unit, and writes the results back into the destination registers.
The controller supports a small custom instruction set consisting of HALT, LOAD_IMM, ADD, SUB, and MUL. Each instruction is encoded with a 32-bit word, with LOAD_IMM consuming an additional word for its constant
Execution Controller FSM

The FSM handles 3 main execution paths:
Instruction fetching: FETCH_REQUEST and FETCH_CAPTURE handle synchronous instruction RAM reads before moving to DECODE
Immediate loading: IMM_REQUEST and IMM_CAPTURE fetch the additional constant word required by LOAD_IMM
Arithmetic execution: ARITH_ISSUE sends an operation to scalar_unit, while ARITH_WAIT waits for a valid result.
Both immediate loading and arithmetic execution eventually reach WRITEBACK, where the result is stored in the destination register before advancing the PC to the next instruction.
The controller also tracks execution status through BUSY, DONE, and ERROR signals. It validates program length (1 - 512 words), detects unsupported opcodes or attempts to fetch beyond the program boundary, and terminates execution when it encounters HALT.
Instruction Decoding

Each instruction is encoded as a 32-bit word containing an opcode, destination register, and source register fields. The execution_controller extracts these fields to determine which operation to perform and which registers to read/write.
For example, ADD R2, R0, R1 is encoded as 0x24080000. The controller decodes the opcode as ADD, reads R0 and R1 as operands, and stores the arithmetic result in R2.
Scalar Unit
The scalar_unit acts as an interface between execution_controller and scalar_arith_unit, managing arithmetic requests and results.
When an operation is requested, it captures the two 32-bit operands and opcode, then passes them to scalar_arith_unit for computation
A 3-state FSM (IDLE, ISSUE, WAIT_RESULT) manages this process:
IDLE: waits for a request and captures the operands and operation
ISSUE: sends the operation to the arithmetic unit
WAIT_RESULT: waits for the computation to finish, captures the result, and asserts result_valid for one clock cycle
The busy signal prevents new requests from being accepted while an operation is in progress. Once completed, the result remains available until the next operation finishes or the unit is reset
Scalar Arithmetic Unit (scalar_arith_unit.v)
The scalar_arith_unit performs 32-bit int add, sub, and mul, based on the opcode received from scalar_unit.
When input_valid is asserted, the unit computes the selected operation and registers the result on the rising clock edge, asserting result_valid for one cycle to indicate completion.
Arithmetic uses 32-bit wraparound semantics, with results truncated to the lower 32-bits in the event of overflow. The reserved opcode (11) produces zero.
Verification and Board Bring-up
I used Questa to simulate the custom RTL modules before testing the integrated system on the physical FPGA board. The testbenches cover functional correctness, memory behavior, execution control, and error handling.
Module-Level Simulation
The instruction_ram_tb tests all 512 memory locations, including boundary addresses, and verifies synchronous reads, write protection during execution, and memory behavior during reset
An earlier version of scalar_unit was also tested using directed and pseudorandom test cases, including arithmetic overflow and underflow, multiplication truncation, and result/status handling. The testbench supports reproducible randomized testing through a configurable seed.
Note: The existing scalar_unit_tb targets an older implementation and has not yet been updated for the current controller-facing interface
Controller and Execution Testing
The controller_unit_tb tests the integrated execution path, including instruction loading, arithmetic execution, register readback, and execution status
It also covers several edge cases:
Invalid opcodes and program lengths
Missing immediate values or HALT instructions
Attempts to modify the program or restart execution while busy
Program-counter boundary behavior at the final instruction_ram location
Reset during execution and subsequent recovery
These tests help verify not only arithmetic correctness but also how the individual hardware modules interact during instruction execution

Physical FPGA Validation
After simulation, I programmed the DE10-Lite and successfully executed a small program: (7 + 3) x 2 = 20
The processor loaded 9 instruction words, started execution, polled the status register, and read the final result from R4.
The FPGA returned R4=20 with the DONE flag asserted, matching the software reference executor.

This demonstrates a working end-to-end execution path, although it does not establish comprehensive hardware correctness or timing closure
Resource Utilization and Timing
The initial design was synthesized and fitted using Quartus Prime Lite 25.1 for the Intel MAX 10 FPGA.
The current system uses approximately 14% of the available LEs, 1% of embedded memory, and 2% of multiplier resources, leaving substantial unused capacity for future extensions

Timing analysis reported an Fmax of 63.49 MHz for the 50 MHz system clock and 107.37 MHz for the PLL-generated clock configured at 80 MHz.
The worst reported setup path on the 50 MHz clock was within the Nios V/m processor rather than the custom compute unit, with a positive setup slack of 4.25 ns
However, timing verification is not yet complete, as some external SDRAM paths remain unconstrained and require further analysis.
Current Limitations and Future Extensions
The current architecture is intentionally simple, serving as a starting point for exploring compiler-hardware optimizations, particularly matmul
scalar-only execution: compute engine currently operates on individual 32-bit int values, with no specialized matrix operations. A tensor unit is planned for future development.
Eight 32-bit registers: limits the number of simultaneously live values and motivates register reuse
512-word instruction memory: limits program size; larger computations will require a more capable execution model
Limited data-memory access: the compute unit currently operates on immediate values and internal registers, without a dedicated interface for loading or storing matrix data. Future extensions will introduce data-memory access and on-chip buffers.
Sequential execution: the controller executes instructions sequentially, limiting opportunities for overlapping operations
Timing constraints not yet complete: some external SDRAM paths remain unconstrained, so successful function testing does not establish complete timing correctness
Lessons Learned
One of my biggest takeaways from this project so far is understanding how hardware architecture evolves around computation, control, and memory constraints.
Control Logic and Synchronization
I initially started with a simple scalar arithmetic unit, but as I connected more modules, I realized that computation was only part of the problem. Coordinating when operations were accepted, results became available, and instructions could proceed required explicit handshakes and FSMs, particularly to account for clock-cycle timing and avoid using stale data.
Memory Hierarchy and Architectural Design
I initially used on-chip RAM for the processor's main memory but moved to external SDRAM due to capacity limitations. I also introduced a dedicated instruction RAM for the custom compute unit. These decisions helped me better understand the tradeoffs between memory capacity, latency, and accessibility, which I plan to explore further when implementing on-chip buffers for tensor computation.
Hardware Verification and clock-cycle behavior
Writing testbenches taught me to think beyond checking final outputs and consider how signals and states evolve across clock cycles. While I've implemented directed and randomized simulation tests and performed basic FPGA validation, I still plan to explore timing verification, assertions, and formal methods.
Hardware and Software Integration through MMIO
Implementing MMIO address decoding and control registers helped me understand how software writes to specific memory addresses translate into hardware actions, such as loading instructions and triggering execution.

Comments