Chisel logo

Module 3.2: Generators: Collections

Prev: Generators: Parameters
Next: Interlude: Chisel Standard Library

Motivation

Generators will frequently have to deal with variable numbers of objects, whether they are IOs, modules, or test vectors. Collections are important building blocks for dealing with situations like this. This module will introduce Scala collections and how to use them with Chisel generators.

Setup

In [ ]:
val path = System.getProperty("user.dir") + "/source/load-ivy.sc"
interp.load.module(ammonite.ops.Path(java.nio.file.FileSystems.getDefault().getPath(path)))

Note we add a new import here because mutable.ArrayBuffer lives in scala.collections.

In [ ]:
import chisel3._
import chisel3.util._
import chisel3.iotesters.{ChiselFlatSpec, Driver, PeekPokeTester}
import scala.collection._

Generators and Collections

In this section, we will focus on the concept of generators and the use of Scala collections as a tool to implement them. Instead of looking at Chisel code as an instance of a circuit, i.e. a description of a particular circuit, we will instead consider it to be a generator of a circuit.

We will start by considering the FIR filter from previous exercises.

In [ ]:
class My4ElementFir(b0: Int, b1: Int, b2: Int, b3: Int) extends Module {
  val io = IO(new Bundle {
    val in = Input(UInt(8.W))
    val out = Output(UInt(8.W))
  })

  val x_n1 = RegNext(io.in, 0.U)
  val x_n2 = RegNext(x_n1, 0.U)
  val x_n3 = RegNext(x_n2, 0.U)
  io.out := io.in * b0.U(8.W) + x_n1 * b1.U(8.W) +
    x_n2 * b2.U(8.W) + x_n3 * b3.U(8.W)
}

This circuit is a simple case of a generator because it can generate versions of this 4-tap filter with different coefficients. But what if we want the circuit to have more taps? We are going to do this in several steps.

  • Build a software Golden Model of a tap configurable FIR.
  • Redesign our test to use this model, and confirm that it works.
  • Refactor our My4ElementFir to allow an configurable number of taps.
  • Test the new circuit using our new test harness.

**Example: FIR Golden Model**
Below is a Scala software implementation of a FIR circuit.

In [ ]:
/**
  * A naive implementation of an FIR filter with an arbitrary number of taps.
  */
class ScalaFirFilter(taps: Seq[Int]) {
  var pseudoRegisters = List.fill(taps.length)(0)

  def poke(value: Int): Int = {
    pseudoRegisters = value :: pseudoRegisters.take(taps.length - 1)
    var accumulator = 0
    for(i <- taps.indices) {
      accumulator += taps(i) * pseudoRegisters(i)
    }
    accumulator
  }
}

Seq

Note that taps has become a Seq[Int] which means that the user of the class can pass an arbitrarily-long sequence of Ints when constructing the class.

Registers

With var pseudoRegisters = List.fill(taps.length)(0) we create a List that will hold values from previous cycles. List was chosen because its syntax of adding an element to the head and removing the last element is very simple. Just about any member of the scala collections family could be used. We are also initializing this list to contain all zeros.

Poke

Our class adds a poke function/method that emulates putting a new input into the filter and cycling the clock.

Updating the registers

The line pseudoRegisters = value :: pseudoRegisters.take(taps.length - 1) first uses the take method of list to keep the all but the last element of the list, then uses the :: list concatentation operator to add value to the head of the reduced version of the list.

Computing the output

A simple for loop with an accumulator sums each element of the list times its corresponding tap coefficient. The line with just accumulator returns that value as the function result.

Adapting our previous test for testing our golden model

We will now use our previous work to confirm that our golden model works. A bit of editing magic takes our previous tests harness and morphs it into...

In [ ]:
val filter = new ScalaFirFilter(Seq(1, 1, 1, 1))

var out = 0

out = filter.poke(1)
println(s"out = $out")
assert(out == 1)  // 1, 0, 0, 0

out = filter.poke(4)
assert(out == 5)  // 4, 1, 0, 0
println(s"out = $out")

out = filter.poke(3)
assert(out == 8)  // 3, 4, 1, 0
println(s"out = $out")

out = filter.poke(2)
assert(out == 10)  // 2, 3, 4, 1
println(s"out = $out")

out = filter.poke(7)
assert(out == 16)  // 7, 2, 3, 4
println(s"out = $out")

out = filter.poke(0)
assert(out == 12)  // 0, 7, 2, 3
println(s"out = $out")

Executing the previous block demonstrates that our software model returns the same results as My4ElementFir did.

Test circuit using the golden model.

Now that we are reasonably confident about our golden model, we re-write our test to compare the circuit outputs with the output of the golden model, instead of using laboriously hand-crafted examples. What follows is a quick first pass to do it.

In [ ]:
val goldenModel = new ScalaFirFilter(Seq(1, 1, 1, 1))

Driver(() => new My4ElementFir(1, 1, 1, 1)) {
  c => new PeekPokeTester(c) {
    for(i <- 0 until 100) {
      val input = scala.util.Random.nextInt(8)

      val goldenModelResult = goldenModel.poke(input)

      poke(c.io.in, input)

      expect(c.io.out, goldenModelResult, s"i $i, input $input, gm $goldenModelResult, ${peek(c.io.out)}")

      step(1)
    }
  }
}

Our test runs for 100 cycles, and checks that the two different methods, hardware and software, are in sync at each step.

Things to watch out for

(i.e., mistakes we actually committed while writing this.)

  1. Getting the step in the right place. Software and hardware execute differently; it's easy to get this wrong.
  2. This test is weak because it is very sensitive to how the IOs and registers are sized. Implementing a software golden model that observes wrapping behavior at arbitrary data bit widths can be complicated. Here we just make sure that we only pass in values that fit.

**Example: Parameterized FIR Generator**
Below we have created a new Filter class, MyManyElementsFilter that takes a Seq of constants to use for taps. This list can be any number of elements. For good measure a bitWidth has been added that allows us to control the sizes of numbers that can be handled by our circuit. In response the variable length we have had to refactor the creation of registers and how they are connected. The methodology used below uses a simple subset of the available library of collection functions. Later sections show how to more succinctly express the behavior in a way that also makes what is happening clearer.

In [ ]:
class MyManyElementFir(consts: Seq[Int], bitWidth: Int) extends Module {
  val io = IO(new Bundle {
    val in = Input(UInt(bitWidth.W))
    val out = Output(UInt(bitWidth.W))
  })

  val regs = mutable.ArrayBuffer[UInt]()
  for(i <- 0 until consts.length) {
      if(i == 0) regs += io.in
      else       regs += RegNext(regs(i - 1), 0.U)
  }
  
  val muls = mutable.ArrayBuffer[UInt]()
  for(i <- 0 until consts.length) {
      muls += regs(i) * consts(i).U
  }

  val scan = mutable.ArrayBuffer[UInt]()
  for(i <- 0 until consts.length) {
      if(i == 0) scan += muls(i)
      else scan += muls(i) + scan(i - 1)
  }

  io.out := scan.last
}

How we did it

There are three parallel sections starting at lines 7, 13, and 18. We are using a Scala collection type called ArrayBuffer. ArrayBuffer allows you to append elements using the += operator (also insert and delete, but we don't need this). First, we create an ArrayBuffer regs whose elements will be UInts. Then iterate over the taps, adding the input as the first element followed by creating registers using RegNext which connect the input of the register to the previous element (regs(i-1)) and initializes it to unsigned zero (0.U). These registers will hold the previous values of inputs as they are needed.

Next, we create another ArrayBuffer muls of UInts. Each element of muls will be a node whose i-th element is the product of the regs(i) and const(i).

Note the use of the scan.last method. It takes the last element of a collection, and is a more elegant alternative to regs(i - 1) used during the regs construction.

Does it behave the same as My4ElementFir?

A good first test of our new version is to see if it can pass the test we just applied to the My4ElementFir. We create an instance of MyManyElementFir and run even more data through it.

In [ ]:
val goldenModel = new ScalaFirFilter(Seq(1, 1, 1, 1))

Driver(() => new MyManyElementFir(Seq(1, 1, 1, 1), 8)) {
  c => new PeekPokeTester(c) {
    for(i <- 0 until 100) {
      val input = scala.util.Random.nextInt(8)

      val goldenModelResult = goldenModel.poke(input)

      poke(c.io.in, input)

      expect(c.io.out, goldenModelResult, s"i $i, input $input, gm $goldenModelResult, ${peek(c.io.out)}")

      step(1)
    }
  }
}

Now let's test a bunch of different sized FIR filters

We create some helper functions: r which gets a random number; runOneTest which creates a golden model and a hardware simulation of a filter for a particular set of taps, and then runs at least twice the number of taps worth of data through the filter.

In [ ]:
/** a convenience method to get a random integer
  */
def r(): Int = {
  scala.util.Random.nextInt(1024)
}

/**
  * run a test comparing software and hardware filters
  * run for at least twice as many samples as taps
  */
def runOneTest(taps: Seq[Int]) {
  val goldenModel = new ScalaFirFilter(taps)

  Driver(() => new MyManyElementFir(taps, 32)) {
    c => new PeekPokeTester(c) {
      for(i <- 0 until 2 * taps.length) {
        val input = r()

        val goldenModelResult = goldenModel.poke(input)

        poke(c.io.in, input)

        expect(c.io.out, goldenModelResult, s"i $i, input $input, gm $goldenModelResult, ${peek(c.io.out)}")

        step(1)
      }
    }
  }
}

for(tapSize <- 2 until 100 by 10) {
  val taps = Seq.fill(tapSize)(r())  // create a sequence of random coefficients

  runOneTest(taps)
}

Just for fun, let's make a bigger one

The following will run a single test on a 500 tap FIR filter. It can take a minute or so to run. (Hint: Watch for the Scala ● to change to Scala ○ on the Toolbar when the execution completes.)

In [ ]:
runOneTest(Seq.fill(500)(r()))
In [ ]:
val taps = Seq.fill(500)(r())

val goldenModel = new ScalaFirFilter(taps)

Driver(() => new MyManyElementFir(taps, 32)) {
  c => new PeekPokeTester(c) {
    for(i <- 0 until 100) {
      val input = r()

      val goldenModelResult = goldenModel.poke(input)

      poke(c.io.in, input)

      expect(c.io.out, goldenModelResult, s"i $i, input $input, gm $goldenModelResult, ${peek(c.io.out)}")

      step(1)
    }
  }
}

Hardware Collections

**Example: Add run-time configurable taps to our FIR**
The following code adds an additional consts vector to the IO of our FIR generator which allows the coefficients to be changed externally after circuit generation. This is done with the Chisel collection type Vec. Vec supports many of the scala collection methods but it can only contain Chisel hardware elements. Vec should only be used in situations where ordinary Scala collections won't work.
Basically this is in one of two situations.

  1. You need a collection of elements in a Bundle, typically a Bundle that will be used as IO.
  2. You need to access the collection via an index that is part of the hardware (think Register File).
In [ ]:
class MyManyDynamicElementVecFir(length: Int) extends Module {
  val io = IO(new Bundle {
    val in = Input(UInt(8.W))
    val out = Output(UInt(8.W))
    val consts = Input(Vec(length, UInt(8.W)))
  })

  // Reference solution
  val regs = RegInit(VecInit(Seq.fill(length - 1)(0.U(8.W))))
  for(i <- 0 until length - 1) {
      if(i == 0) regs(i) := io.in
      else       regs(i) := regs(i - 1)
  }
  
  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)
}
In [ ]:
val goldenModel = new ScalaFirFilter(Seq(1, 1, 1, 1))

Driver(() => new MyManyDynamicElementVecFir(4)) {
  c => new PeekPokeTester(c) {
    poke(c.io.consts(0), 1)
    poke(c.io.consts(1), 1)
    poke(c.io.consts(2), 1)
    poke(c.io.consts(3), 1)
    for(i <- 0 until 100) {
      val input = scala.util.Random.nextInt(8)

      val goldenModelResult = goldenModel.poke(input)

      poke(c.io.in, input)

      expect(c.io.out, goldenModelResult, s"i $i, input $input, gm $goldenModelResult, ${peek(c.io.out)}")

      step(1)
    }
  }
}

**Exercise: 32-bit RISC-V Processor**

A register file is an important building block for making a processor. A register file is an array of registers that can be read from or written to via a number of read or write ports. Each port consists of an address and data field.

The RISC-V instruction set architecture defines several variants, the simplest of which is called RV32I. RV32I has a size-32 array of 32-bit registers. The register at index 0 (the first register) is always zero when you read from it, regardless of what you write to it (it's often useful to have 0 handy).

Implement a register file for RV32I with a single write port and a paramterized number of read ports. Writes will only be performed when wen (write enable) is asserted.

In [ ]:
class RegisterFile(readPorts: Int) extends Module {
    require(readPorts >= 0)
    val io = IO(new Bundle {
        val wen   = Input(Bool())
        val waddr = Input(UInt(5.W))
        val wdata = Input(UInt(32.W))
        val raddr = Input(Vec(readPorts, UInt(5.W)))
        val rdata = Output(Vec(readPorts, UInt(32.W)))
    })
    
    // A Register of a vector of UInts
    val reg = RegInit(VecInit(Seq.fill(32)(0.U(32.W))))

}
In [ ]:
chisel3.iotesters.Driver(() => new RegisterFile(2) ) { c => new PeekPokeTester(c) {
    def readExpect(addr: Int, value: Int, port: Int = 0): Unit = {
        poke(c.io.raddr(port), addr)
        expect(c.io.rdata(port), value)
    }
    def write(addr: Int, value: Int): Unit = {
        poke(c.io.wen, 1)
        poke(c.io.wdata, value)
        poke(c.io.waddr, addr)
        step(1)
        poke(c.io.wen, 0)
    }
    // everything should be 0 on init
    for (i <- 0 until 32) {
        readExpect(i, 0, port = 0)
        readExpect(i, 0, port = 1)
    }

    // write 5 * addr + 3
    for (i <- 0 until 32) {
        write(i, 5 * i + 3)
    }

    // check that the writes worked
    for (i <- 0 until 32) {
        readExpect(i, if (i == 0) 0 else 5 * i + 3, port = i % 2)
    }

}}
    when (io.wen) {
        reg(io.waddr) := io.wdata
    }
    for (i <- 0 until readPorts) {
        when (io.raddr(i) === 0.U) {
            io.rdata(i) := 0.U
        } .otherwise {
            io.rdata(i) := reg(io.raddr(i))
        }
    }


You're done!

Return to the top.