A Tale of Two FIRs ¶
From the last module, we had the convolution part of the FIR filter written like this:
val muls = Wire(Vec(length, UInt(8.W)))
for(i <- 0 until length) {
if(i == 0) muls(i) := io.in * io.consts(i)
else muls(i) := regs(i - 1) * io.consts(i)
}
val scan = Wire(Vec(length, UInt(8.W)))
for(i <- 0 until length) {
if(i == 0) scan(i) := muls(i)
else scan(i) := muls(i) + scan(i - 1)
}
io.out := scan(length - 1)
As a recap, the idea is to multiply each element of io.in
with the corresponding element of io.consts
, and store it in muls
.
Then, the elements in muls
are accumulated into scan
, with scan(0) = muls(0)
, scan(1) = scan(0) + muls(1) = muls(0) + muls(1)
, and in general scan(n) = scan(n-1) + muls(n) = muls(0) + ... + muls(n-1) + muls(n)
.
The last element in scan
(equal to the sum of all muls
) is assigned to io.out
.
However, it's very verbose for what might be considered quite a simple operation. In fact, all that could be written in one line:
io.out := (taps zip io.consts).map { case (a, b) => a * b }.reduce(_ + _)
What is it doing?! Let's break it down:
- assume
taps
is the list of all samples, with taps(0) = io.in
, taps(1) = regs(0)
, etc.
(taps zip io.consts)
takes two lists, taps
and io.consts
, and combines them into one list where each element is a tuple of the elements at the inputs at the corresponding position. Concretely, its value would be [(taps(0), io.consts(0)), (taps(1), io.consts(1)), ..., (taps(n), io.consts(n))]
. Remember that periods are optional, so this is equivalent to (taps.zip(io.consts))
.
.map { case (a, b) => a * b }
applies the anonymous function (takes a tuple of two elements returns their product) to the elements of the list, and returns the result. In this case, the result is equivalent to muls
in the verbose example, and has the value [taps(0) * io.consts(0), taps(1) * io.consts(1), ..., taps(n) * io.consts(n)]
. You'll revisit anonymous functions in the next module. For now, just learn this syntax.
- Finally,
.reduce(_ + _)
also applies the function (addition of elements) to elements of the list. However, it takes two arguments: the first is the current accumulation, and the second is the list element (in the first iteration, it just adds the first two elements). These are given by the two underscores in the parentheses. The result would then be, assuming left-to-right traversal, (((muls(0) + muls(1)) + muls(2)) + ...) + muls(n)
, with the result of deeper-nested parentheses evaluated first. This is the output of the convolution.
Functions as Arguments¶
Formally, functions like map
and reduce
are called higher-order functions : they are functions that take functions as arguments.
As it turns out (and hopefully, as you can see from the above example), these are very powerful constructs that encapsulate a general computational pattern, allowing you to concentrate on the application logic instead of flow control, and resulting in very concise code.
Different ways of specifying functions¶
You may have noticed that there were two ways of specifying functions in the examples above:
- For functions where each argument is referred to exactly once, you may be able to use an underscore (
_
) to refer to each argument. In the example above, the reduce
argument function took two arguments and could be specified as _ + _
. While convenient, this is subject to an additional set of arcane rules, so if it doesn't work, try:
- Specifying the inputs argument list explicitly. The reduce could have been explicitly written as
(a, b) => a + b
, with the general form of putting the argument list in parentheses, followed by =>
, followed by the function body referring to those arguments.
- When tuple unpacking is needed, using a
case
statement, as in case (a, b) => a * b
. That takes a single argument, a tuple of two elements, and unpacks it into variables a
and b
, which can then be used in the function body.
Practice in Scala¶
In the last module, we've seen major classes in the Scala Collections API, like List
s.
These higher-order functions are part of these APIs - and in fact, the above example uses the map
and reduce
API on List
s.
In this section, we'll familiarize ourselves with these methods through examples and exercises.
In these examples, we'll operate on Scala numbers (Int
s) for the sake of simplicity and clarity, but because Chisel operators behave similarly, the concepts should generalize.
Example: Map
List[A].map
has type signature map[B](f: (A) ⇒ B): List[B]
. You'll learn more about types in a later module. For now, think of types A and B as Int
s or UInt
s, meaning they could be software or hardware types.
In plain English, it takes an argument of type (f: (A) ⇒ B)
, or a function that takes one argument of type A
(the same type as the element of the input List) and returns a value of type B
(which can be anything). map
then returns a new list of type B
(the return type of the argument function).
As we've already explained the behavior of List in the FIR example, let's get straight into the examples and exercises: