top of page

My FPGA Scalar Compute Unit Architecture

1 day ago
8 min read

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.

High-level system architecture
High-level system architecture

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.

typo: PROGRAM_WORD register --> PROGRAM_WORDS register
typo: PROGRAM_WORD register --> PROGRAM_WORDS register

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


Joan Kusuma

bottom of page