上一篇文章我们提取了之前实现的几种tick生成模块的共同特征,利用Chisel的面向对象编程特性,实现了一个抽象类Ticker
。对于这个Ticker
,我们通过继承的方法实现了几种不同的版本,还可以用统一的测试接口对这些版本进行测试,整个代码清晰了很多,对于实现和测试成本也小了很多。Scala作为支持函数式编程的语言,Chisel自然也可以利用函数式编程的特性,这一篇文章我们就共同学习利用函数式编程的硬件生成。
Scala支持函数式编程,因此Chisel也是支持的。我们可以用函数来表示硬件,然后把这些硬件组件用函数式编程的方式(即高阶函数)组合到一起。我们首先看下面这个例子,实现了一个向量的规约加法:
def add(a: UInt, b: UInt) = a + b
val sum = vec.reduce(add)
首先,我们定义了一个加法器函数add
。向量(Chisel中的Vec
类型的变量)存放在vec
变量中。Scala的reduce()
方法会将一个集合的所有元素进行二元操作,生成单个值,也就是规约运算。reduce()
方法从序列的左边开始规约,每次对前两个元素执行运算,运算结果和下一个元素继续运算,直到只剩下单个结果。
将元素组合到一起的函数通过reduce()
的参数给出,我们的例子中就是add
,它会返回一个加法器。最终得到的硬件是链式的加法器,可以计算向量vec
的元素的规约和。
除了定义上面的add
函数以外,我们还有种更简单的方法,那就是把加法作为一个匿名函数来提供给reduce
函数作为参数,使用的是Scala中的通配符_
来表示两个操作数:
val sum = vec.reduce(_ + _)
这样的话,我们只用一行就得到了链式加法器了。但是这种链式加法器并非最理想的配置,每次规约操作都得等待前两个元素运算结束才能进行,因此用树型规约的方法可以让组合电路的延时更短。如果我们不信任综合工具会把我们的链式规约加法器优化成树形规约加法器的话,那我们可以使用Chisel的reduceTree
方法来生成树形规约加法器:
val sum = vec.reduceTree(_ + _)
现在整个更详细的例子,实现一个找到vec
中最小值的电路。为了表达这个电路,我们可以使用另一种匿名函数的表达方式,在Scala中叫做函数字面值(Function Literal)。函数字面值的语法首先是用括号括住的参数列表,后面跟着一个=>
符号,再后面跟着函数体:
(param) => function body
最小值函数的函数字面值可以使用两个参数x
和y
然后返回一个Mux
,这个Mux
会比较两个参数然后返回更小的那个。因此,最终的电路也是一句话就可以搞定:
val min = vec.reduceTree((x, y) => Mux(x < y, x, y))
现在我们希望电路不光返回vec
中的最小值,还希望返回这个最小值的索引。为了返回两个值,我们先定义一个Two
,它是包含了值和索引的Bundle。然后我们定义一个vecTwo
,这个Vec
的元素都是Two
类型的,然后把这个vecTwo
和原始输入用一个链接到一起,完成值和索引的捆绑:
class Two extends Bundle {
val v = UInt(w.W)
val idx = UInt(8.W)
}
val vecTwo = Wire(Vec(n, new Two()))
for (i <- 0 until n) {
vecTwo(i).v := vec(i)
vecTwo(i).idx := i.U
}
接下来我们再用函数字面值作为reduceTree
的参数来实现电路,比较Bundle的v
字段,然后返回Bundle就行了:
val res = vecTwo.reduceTree((x, y) => Mux(x.v < y.v, x, y))
现在我们希望利用更多的Scala特性来避免像上面的例子那样创建Bundle来同时返回值和索引。Scala中定义了元组(tuple),这是一种不可变的序列,可以有不同类型的值。而链式函数是一种函数值编程中的典型模型,这种模式可以看作是操作的流水线。下面的代码就展示了对于原始序列的链式函数的应用:
val resFun = vec.zipWithIndex
.map ((x) => (x._1, x._2.U))
.reduce((x, y) => (Mux(x._1 < y._1, x._1, y._1), Mux(x._1 < y._1, x._2, y._2)))
第一个函数zipWithIndex
可以将原始序列转换成一个元组的序列,元组的第二个元素是就是索引。一般来说,zip
函数会合并两个序列为一个序列,序列的每个元素都是一个元组,分别包含原序列的两个元素。
第二个函数map
会将我们得到的Chisel的UInt
和Scala的Int
的元组的序列映射为两个Chisel的UInt
。
第三个函数reduce
和之前的一样,提供了寻找最小值的电路的生成。先分别在了两个Mux
中比较元组的第一个元素之间的大小关系,然后返回最小值对应的元组的Chisel的UInt
类型的值和索引。
需要注意的是,上面的例子中,整个函数式表达式都使用的是Scala的Vector
类型来存放中间结果,但只返回由Chisel类型组成的硬件(通过Mux
相互连接)。因为使用的是Scala的Vector
,因此我们的reduceTree
是不能使用的,它是Chisel的Vec
上的函数。我们可以进一步用个中间转换(MixedVecInit
)来使用reduceTree
:
val scalaVector = vec.zipWithIndex
.map((x) => MixedVecInit(x._1, x._2.U(8.W)))
val resFun2 = VecInit(scalaVector)
.reduceTree((x, y) => Mux(x(0) < y(0), x, y)
这里强调一下,MixedVecInit
得到的Vec
的元素是通过(idx)
来索引的,区别于Scala元组的._idx
。
这一篇文章介绍了利用Scala函数式编程特性编写硬件生成器的方法,可以为我们的代码编写带来极大的效率提高,开发的迭代速度也会更快。到这里Chisel语法相关的基础和进阶内容就全部介绍完了,从下一部分开始就是实战部分了。实战部分将以FIFO为例,由浅及深最终实现各种FIFO的变种,将会有比较大的代码量,但跟下来绝对有所收获,敬请期待。