Pipelines and Timing Agility
Timing, be it the maximum frequency that your hardware reaches, or the number of clock cycles a module needs to output its result is an unavoidable aspect of hardware design, yet most HDLs give you very little help when reasoning about timing. The pipelining system in Spade gives you a way to reason about timing together with the compiler, often allowing you to refactor your code to accommodate new functionality or improve your \(f_{max}\) without much thought. That is what we will explore in this first part of the tutorial.
Agile hardware design is all about gradually building up a project piece by piece, and accommodating any changes to requirements that arise as you develop. With this in mind, we will start out with an existing project which, if you haven't done so already can be cloned from
git clone https://gitlab.com/spade-lang/agile-tutorial.git
Inside this repo is a game folder which is where we will do the rest of this part of the tutorial. First, run simulation to see what we are working with so far
cd game
swim build && swim cmd cargo run
The first time you run it, it may take some time so let's discuss what this
will do in the meantime. swim build will first compile the Spade compiler,
then compile the Spade code to Verilog which can be fed to simulators and
synthesis tools.
swim cmd cargo run then simulates the resulting code, and interprets the VGA
signals that it the simulation generates to render the result in a window.
Once the compilation is done, it should show a purple box moving around in a circle over a background of stars and for this "sprint", we are going to make the graphics a bit nicer.
Exploring the Existing Code
Before we do that however, let us familiarize ourselves with the code in the
project. All the code is in the src folder and most of the code is in
main.spade, with a few utilities in other files that we will discuss if
needed. There is also a main.rs file in there which contains the logic that is used
to interpret the VGA signals that the simulation emits, and show them to you in a window.
At the top of main.spade are a few mod and use statements which specify which other files are part of the project, and import a few things from other libraries. The details are not important for this tutorial.
After the imports is the definition of a GameState struct which, as the name
implies, stores the current state of the game.
struct GameState {
Currently, it has three fields
recording the position and angle of the player.
The type of these fields is
Fp<26, 8> which is a FixedPpoint number with 26 bits in total, and 8
fractional bits.
You can do most normal arithmetic operations on fixed point
numbers, though due to language limitations, it is currently not done with +,
- etc., but with methods such as .add and .sub.
The next block we encounter is an impl block which is used to add methods to the GameState struct.
impl GameState {
The first method, called next, is a pipeline with a latency of 1, and its
job is to compute the next game state from the current game state.
pipeline(1) next(...)
Currently, it does some trigonometry to the new location and angle of the player.
The second method, called draw_player, is (predictably) responsible for
drawing the player.
pipeline(0) draw_player(...)
It gets passed a pixel and its job is to return the Color of the player at
that pixel.
Since the player is not present everywhere it returns an Option<Color> so
it can return None on the pixels where the player should not be drawn
The logic to draw the purple box that currently represents the player works
like this. First, the coordinates of the player are converted to integers so we
can compare them to the pixel coordinates. Then, the player coordinates are
subtracted from the pixel coordinates.
If the result of that subtraction on the x-axis is greater than 0, we are on
the left side of the player, and if it is smaller than the player width, the
coordinate is inside the player. With the same logic on the y-axis we get
in_x and in_y which are both true if the player should be drawn on the
current pixel.
The next block is responsible for converting a pixel to a color for the whole screen, not just the player.
pipeline(0) pixel_to_color(...)
Its implementation is relatively simple, it computes both the background color and player color, and selects which one to draw based on whether or not the player color is present.
The previous functions take a pixel coordinate and convert it to a color
pipeline(1) render_pipeline(...)
In VGA and HDMI, there are some additional signals that must be generated. The
render_pipeline pipeline get passed all the VGA signals with pixel
coordinates, uses the pixel_to_color pipeline to convert from pixels to
colors, while passing along the rest of the control signals. The details of
this unit are slightly out of scope for this tutorial. You will have to come in
here to adjust some of the pipelining numbers, but you shouldn't have to make
functional changes.
The rest of the file contains the top level module that ties everything together. If you are curious, feel free to glance through it, but it shouldn't require any changes in this tutorial.
entity top(...)
Task 1
Your first task is to make the "player sprite" a little bit more interesting
than the current purple block. To do so, there is a texture pipeline
available in sprite.spade. You pass it an x and a y coordinate, and it
returns the color value of the texture at that point, if any. Since it is read
from a memory, it has a latency of 1.
Update the drawing code to use this texture for the player
Hint: To convert between
intanduint, use.to_uint()
Task 2
One big advantage of the pipelining abstraction is that it allows you to add register and move existing registers around without affecting what is being computed. This is especially useful when trying to reach a desired target frequency, so let's try that!
The current background of stars is a bit boring, but there is a more fancy one available in src/background.rs called fancy_background. Let's try using it instead of the existing stars by replacing the stars instantiation in main.rs
// Replace
let background_color = inst(0) background::stars(clk, pixel);
// With
let background_color = inst(0) background::fancy_background(clk, pixel);
If you run this as usual, it should look different. It will most likely also run noticeably slower in simulation, since it has to simulate a more complex circuit for every pixel. This is something we can live with, but there is a bigger problem which becomes apparent if you synthesize the circuit
# Run synthesis and place & route
swim pnr
it reveals that we're quite far from hitting our target frequency of 50 MHz
[INFO] Place and route maximum frequencies:
$glbnet$_e_1739[88]: 306.1 MHz (target: 250 MHz)
$glbnet$_e_1739[89]: 13.4 MHz (target: 50 MHz)
(Ignore the strange names, those are an annoying consequence of how the code for clock generation in this example is written.)
Your job now is to fix this and make sure we can reach the required 50 MHz. Start by looking at the timing report to try to work out which part of the design is taking the longest.
Once you're done, re-simulate the circuit, does it still work?
NOTE: The second 250 MHz clock sometimes also fails timing. Don't worry about this for now, it is only used for driving the serial HDMI signal and is unrelated to what you are doing. 250 MHz is on the edge of what the ECP5 FPGA we're targeting is capable of so the randomness in the PnR process means it fails occasionally.
Extra task 1
With us back to reasonable timing, we can go on to more fancy graphics. The
game state has an angle in addition to the x and y coordinates. It would
be helpful to the player if the ship is drawn at that angle so let's do that.
src/trig.spade contains implementations of sin and cos which you can use
with trig::sin and trig::cos. Use these functions to rotate the player
sprite according to its angle.