前面的部分已经看到过很多函数了,现在我们可以自己定义函数然后高效利用它们了。最后,我们会利用Scala中函数式编程的特性,用Chisel实现可配置激活函数的神经网络神经元。
由于这一篇文章用到了Chisel的定点数类型FixedPoint
,所以需要导入相关的包,目前定点数类型在experimental
包里面。
import chisel3.experimental._
很早的时候就已经讲过Scala的函数了,也一直在用。这里会讲一些函数中的新鲜玩意儿。函数接受任意数量的输入并产生一个输出。输入通常叫作函数的参数,如果不返回值的话,那就返回一个Unit
类型。
下面是Scala中一些自定义函数的例子:
// 没有参数也没有返回值(两个版本)
def hello1(): Unit = print("Hello!")
def hello2 = print("Hello again!")
// 数学运算,一个参数和一个返回值
def times2(x: Int): Int = 2 * x
// 参数可以有默认值,而且显式指定返回值是不必须的
// 但还是强烈推荐指定返回值,防止奇怪的bug和惊喜
def timesN(x: Int, n: Int = 2) = n * x
// 下面是函数的调用
hello1()
hello2
times2(4)
timesN(4) // 没有必要指定n,这里会直接使用默认值2
timesN(4, 3) // 参数顺序应该与定义时的一致
timesN(n=7, x=2) // 也可通过显式指定参数变量来重排序参数
函数在Scala中是一阶对象。这意味着我们可以把函数赋值给一个val
并传递给类、对象或者其他以函数为参数的函数。
下面是两组相同的函数,但分别实现为函数和对象:
// 这是常规的函数实现方式
def plus1funct(x: Int): Int = x + 1
def times2funct(x: Int): Int = x * 2
// 这里将函数实现为val对象
// 其中第一个显式指定了返回值类型
val plus1val: Int => Int = x => x + 1
val times2val = (x: Int) => x * 2
// 调用的时候看起来都差不多
plus1funct(4)
plus1val(4)
plus1funct(x=4)
// plus1val(x=4) // 不能这么用
这四个函数的数据类型如下:
defined function plus1funct
defined function times2funct
plus1val: Int => Int = ammonite.$sess.cmd9$Helper$$Lambda$3116/899868673@39d111b1
times2val: Int => Int = ammonite.$sess.cmd9$Helper$$Lambda$3117/1752724410@64e78c98
这个ammonite
是fossil
的意思,就是说不能改变,如果用x=4
这种赋值,就会报错reassignment to val
。
为什么上面要创建val
而不是用def
呢?如果用val
的话,就可以把它传递给其他参数,就像前面提到的高阶函数一样。你也可以创建自己的函数,接受其它函数作为参数,也就是创建自己的高阶函数。
现在我们再一次提到map
,我们同时也创建一个新的函数opN
,接受函数op
作为参数:
// 创建自己的函数
val plus1 = (x: Int) => x + 1
val times2 = (x: Int) => x * 2
// 传递给List的map函数
val myList = List(1, 2, 5, 9)
val myListPlus = myList.map(plus1)
val myListTimes = myList.map(times2)
// 创建一个自定义函数,它可以以递归的方式在x上执行n次op操作
def opN(x: Int, n: Int, op: Int => Int): Int = {
if (n <= 0) { x }
else { opN(op(x), n-1, op) }
}
opN(7, 3, plus1)
opN(7, 3, times2)
输出为:
res4_6: Int = 10
res4_7: Int = 56
那现在有种令人困惑的情形就发生了,那就是函数没有参数的情况。区别在于函数在每次被调用的时候执行计算,而val
在实例化的时候就计算了,看下面这个例子:
import scala.util.Random
// x和y都是调用nextInt函数,但是x马上就会计算,而y是作为一个函数的
val x = Random.nextInt
def y = Random.nextInt
// x因为已经计算过了,所以结果是个常数
println(s"x = $x")
println(s"x = $x")
// y是个函数,所以每次调用都会重新计算,所以生成的结果都不一样
println(s"y = $y")
println(s"y = $y")
输出如下:
x = -533261865
x = -533261865
y = -677744498
y = -149476692
其中x
和y
的类型分别为:
x: Int = -533261865
defined function y
可以看到,x
直接就是个Int
了。
看着名字就知道,匿名函数是没有名字的。如果只用一次的话我们是不需要为函数创建一个val
的,用匿名函数就可以。
下面的例子演示了匿名函数的用法,匿名函数通常是有范围的(放在大括号里面而不是放在圆括号里面):
val myList = List(5, 6, 7, 8)
// 用匿名函数给列表中的每一个值都加1
// 参数通过下划线传递
// 这俩做的事情是一样的
myList.map( (x:Int) => x + 1 )
myList.map(_ + 1)
// 一种常见情况是在匿名函数中使用case语句
val myAnyList = List(1, 2, "3", 4L, myList)
myAnyList.map {
case (_:Int|_:Long) => "Number"
case _:String => "String"
case _ => "error"
}
你常会用到的一套高阶函数有scanLeft
/scanRight
,reduceLeft
/reduceRight
,和foldLeft
/foldRight
。所以理解他们是如何工作的、何时应该使用他们很重要。scan
、reduce
和fold
默认的方向是从左到右,但不是所有情况都是这样的。
这里的List[A].scan
是上一篇里面没讲的,用法是scan[b](z: A)(f: (A) ⇒ B): List[B]
,会从左向右应用,返回的结果为[z, f(z, A(0)), f(f(z, A(0)), A(1)), ..., f(f(f(f(z, A(0)), A(1))...), A(n-1))]
。
val exList = List(1, 5, 7, 100)
// 写一个自定义函数完成两个值相加,然后用reduce实现exList的累加和
def add(a: Int, b: Int): Int = ???
val sum = ???
// 用一个匿名函数完成累加和的计算
val anon_sum = ???
// 用scan从右向左执行找到exList的移动平均数,结果ma2应该是一个double的列表
def avg(a: Int, b: Double): Double = ???
val ma2 = ???
这里解释一下移动平均数的概念,直接在val exList = List(1, 5, 7, 100)
上解释,从右向左就是 0.0 0.0 0.0(初始值), ( 100 + 0 ) / 2 = 50.0 (100+0)/2=50.0 (100+0)/2=50.0, ( 50 + 7 ) / 2 = 28.5 (50 + 7)/2=28.5 (50+7)/2=28.5, ( 28.5 + 5 ) / 2 = 16.75 (28.5+5)/2=16.75 (28.5+5)/2=16.75, ( 16.75 + 1 ) / 2 = 8.875 (16.75+1)/2=8.875 (16.75+1)/2=8.875。就是一边移动,一边计算前面的结果和后面数的平均数。
那么就很简单了,直接开写:
val exList = List(1, 5, 7, 100)
// 写一个自定义函数完成两个值相加,然后用reduce实现exList的累加和
def add(a: Int, b: Int): Int = a + b
val sum = exList.reduce(add)
// 用一个匿名函数完成累加和的计算
val anon_sum = exList.reduce(_ + _)
// 用scan从右向左执行找到exList的移动平均数,结果ma2应该是一个double的列表
def avg(a: Int, b: Double): Double = (a + b) / 2.0
val ma2 = exList.scanRight(0.0)(avg)
现在看一些用Chisel创建硬件生成器时如何使用函数式编程的例子。
怎么又是你?首先我们回顾一下之前的例子,要么是把系数以参数方式传递了,要么就是让它们在运行时可配置。现在我们可以尝试把一个函数传递给FIR,这个函数定义窗口的系数该如何计算。这个函数会接受窗口的长度和位宽来生成一个缩放的系数列表。下面是两个示例窗口。为了避免分数,我们会缩放系数到最大和最小整数值之间。
// 导入一些数学函数
import scala.math.{abs, round, cos, Pi, pow}
// 简单的三角窗口(类似于在y = 1-|x-1|在x轴的上半部分取值)
val TriangularWindow: (Int, Int) => Seq[Int] = (length, bitwidth) => {
// 取值
val raw_coeffs = (0 until length).map( (x:Int) => 1-abs((x.toDouble-(length-1)/2.0)/((length-1)/2.0)) )
// 缩放并取整
val scaled_coeffs = raw_coeffs.map( (x: Double) => round(x * pow(2, bitwidth)).toInt)
scaled_coeffs
}
// 汉明窗口(类似于在y = (cos2*Pi*(x-1/2) + 1)/2在x轴的上半部分取值)
val HammingWindow: (Int, Int) => Seq[Int] = (length, bitwidth) => {
// 取值
val raw_coeffs = (0 until length).map( (x: Int) => 0.54 - 0.46*cos(2*Pi*x/(length-1)))
// 缩放并取整
val scaled_coeffs = raw_coeffs.map( (x: Double) => round(x * pow(2, bitwidth)).toInt)
scaled_coeffs
}
// 第一个参数是窗口的长度,第二个参数是位宽
TriangularWindow(10, 16)
HammingWindow(10, 16)
现在我们创建一个FIR滤波器,这个滤波器以一个窗口函数作为参数。这么做允许我们在不修改FIR生成器的情况下定义新的窗口。这也允许我们独立地给出FIR的尺寸,因为对于不同的长度和位宽,窗口是需要重新计算的。因为我们在编译的时候就选择窗口,所以这些系数是固定的。
// 现在的FIR有参数化的窗口长度、IO位宽和窗口函数
class MyFir(length: Int, bitwidth: Int, window: (Int, Int) => Seq[Int]) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(bitwidth.W))
val out = Output(UInt((bitwidth*2+length-1).W)) // 预计位宽会增长,很保守但是也很懒
})
// 用提供的窗口函数计算系数,然后转换为UInt
val coeffs = window(length, bitwidth).map(_.U)
// 创建一个数组保存延迟的输出
// 注意,这里我们没有使用Vec是因为不需要动态索引
val delays = Seq.fill(length)(Wire(UInt(bitwidth.W))).scan(io.in)( (prev: UInt, next: UInt) => {
next := RegNext(prev)
next
})
// 乘,结果放到mults里面
val mults = delays.zip(coeffs).map{ case(delay: UInt, coeff: UInt) => delay * coeff }
// 允许位宽增长的累加
val result = mults.reduce(_ +& _)
// 连接到输出
io.out := result
}
当然了,计算结果这三行我们也可以跟之前说的一样用一行写出来:
// 现在的FIR有参数化的窗口长度、IO位宽和窗口函数
class MyFir(length: Int, bitwidth: Int, window: (Int, Int) => Seq[Int]) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(bitwidth.W))
val out = Output(UInt((bitwidth*2+length-1).W)) // 预计位宽会增长,很保守但是也很懒
})
// 用提供的窗口函数计算系数,然后转换为UInt
val coeffs = window(length, bitwidth).map(_.U)
// 创建一个数组保存延迟的输出
// 注意,这里我们没有使用Vec是因为不需要动态索引
val delays = Seq.fill(length)(Wire(UInt(bitwidth.W))).scan(io.in)( (prev: UInt, next: UInt) => {
next := RegNext(prev)
next
})
// 三行直接变一行
io.out := coeffs.zip(delays).map {case (a, b) => a * b}.reduce(_ +& _)
}
注意,reduce
里面的加法我们用的是+&
,这种加法允许位增长,可以避免损失。
现在测试我们的FIR滤波器。前面我们写了个自定义的黄金模型,这一次我们用Breeze
。Breeze
是个Scala库,里面有很多有用的线性代数和信号处理函数,非常适合做我们FIR滤波器的黄金模型。下面的代码将Chisel的输出和黄金模型的输出进行比较,如果有任何错误都会导致测试失败。
// 导入各种库
import scala.math.{pow, sin, Pi}
import breeze.signal.{filter, OptOverhang}
import breeze.signal.support.{CanFilter, FIRKernel1D}
import breeze.linalg.DenseVector
// 测试参数
val length = 7
val bitwidth = 12 // 必须小于15,不然Int就表示不了,只能用BigInt
val window = TriangularWindow // 先用三角窗口
// 开始测试
test(new MyFir(length, bitwidth, window)) { c =>
// 测试数据
val n = 100 // 输入长度
val sine_freq = 10
val samp_freq = 100
// 采样数据,缩放到0到2^bitwidth之间
val max_value = pow(2, bitwidth)-1
val sine = (0 until n).map(i => (max_value/2 + max_value/2*sin(2*Pi*sine_freq/samp_freq*i)).toInt)
//println(s"input = ${sine.toArray.deep.mkString(", ")}")
// 获取系数
val coeffs = window(length, bitwidth)
//println(s"coeffs = ${coeffs.toArray.deep.mkString(", ")}")
// 用breeze的滤波器实现作为黄金模型,需要翻转系数
val expected = filter(
DenseVector(sine.toArray),
FIRKernel1D(DenseVector(coeffs.reverse.toArray), 1.0, ""),
OptOverhang.None
)
expected.toArray // 不知道为什么要有这一步,但是注释了就会报错
//println(s"exp_out = ${expected.toArray.deep.mkString(", ")}") // this seems to be necessary
// 用我们的FIR滤波器处理数据并检查输出
c.reset.poke(true.B)
c.clock.step(5)
c.reset.poke(false.B)
for (i <- 0 until n) {
c.io.in.poke(sine(i).U)
if (i >= length-1) { // 要等到所有寄存器都被初始化值,因为我们没有用0填充数据
val expectValue = expected(i-length+1)
//println(s"expected value is $expectValue")
c.io.out.expect(expected(i-length+1).U)
//println(s"cycle $i, got ${c.io.out.peek()}, expect ${expected(i-length+1)}")
}
c.clock.step(1)
}
}
可以试试把窗口函数改成HammingWindow
,也可以试试把一些打印的注释取消掉看看。
这一部分会实现一个神经网络的神经元,要求是用一个函数作为硬件生成器的参数,并且要避免使用易变的数据。
神经元是人工神经网络中全连接层的基本构建块,由于人工智能相关背景知识现在应该是常识了,就不展开介绍了,只讲实现相关的内容。神经元会接受一个一组输入、一组权重(weight),每个权重对应一个输入,然后生成一个输出。权重和输入之间的运算是乘累加,结果会给一个激活函数(Activation Function)。这里会实现一个神经元的生成器,把激活函数以参数的形式传进去,以此生成支持不同激活函数的神经元。
首先是神经元生成器部分。参数inputs
会给出输入的数量,参数act
会给出激活函数的逻辑实现,这里输入和输出都设置为16位定点数(fixed point),有8个小数位。
class Neuron(inputs: Int, act: FixedPoint => FixedPoint) extends Module {
val io = IO(new Bundle {
val in = Input(Vec(inputs, FixedPoint(16.W, 8.BP)))
val weights = Input(Vec(inputs, FixedPoint(16.W, 8.BP)))
val out = Output(FixedPoint(16.W, 8.BP))
})
io.out := act(io.in.zip(io.weights).map {case (in: FixedPoint, weight: FixedPoint) => in * weight}.reduce(_ + _))
}
看起来有点复杂,其实很容易的,就是前面的乘累加然后套个act
激活函数就行了。
现在来创建两个激活函数,这里我们使用0作为阈值。典型的激活函数有Sigmoid函数和线性激活单元(Rectified Linear Unit,ReLU)。
Sigmoid函数用的是Logistic函数,定义为:
l o g i s t i c ( x ) = 1 1 + e − β x logistic(x) = \cfrac{1}{1+e^{-\beta x}} logistic(x)=1+e−βx1
其中, β \beta β是斜率因子。然而用硬件计算指数函数有点难,代价也很高,因此我们用阶梯函数近似替代:
s t e p ( x ) = { 0 if x ≤ 0 1 if x > 0 step(x) = \begin{cases} 0 & \text{if } x \le 0 \\ 1 & \text{if } x \gt 0 \end{cases} step(x)={01if x≤0if x>0
第二个函数ReLU,定义如下:
r e l u ( x ) = { 0 if x ≤ 0 x if x > 0 relu(x) = \begin{cases} 0 & \text{if } x \le 0 \\ x & \text{if } x \gt 0 \end{cases} relu(x)={0xif x≤0if x>0
先实现上面这两个函数,可以用类似-3.14.F(8.BP)
来指定一个定点数。实现如下:
Step: FixedPoint => FixedPoint = x => Mux(x <= 0.F(8.BP), 0.F(8.BP), 1.F(8.BP))
ReLU: FixedPoint => FixedPoint = x => Mux(x <= 0.F(8.BP), 0.F(8.BP), x)
注意这里的Mux
,它是个两输入的多路选择器,Mux(condition, in0, in1)
可以根据条件选择输出,成立则输出in0
,不成立则输出in1
。
最后,我们创建一个测试来检查我们神经元生成器的正确性。用阶梯激活函数时,神经元可能用于逻辑门近似。选择合适的权重和偏移量可以等价于二进制函数。
这里我们用AND逻辑来测试神经元。
// 测试Neuron
test(new Neuron(2, Step)) { c =>
val inputs = Seq(Seq(-1, -1), Seq(-1, 1), Seq(1, -1), Seq(1, 1))
// 因为测试的是AND逻辑,所以权重应该是两个1
val weights = Seq(1.0, 1.0)
// 传入数据
// 注意,因为是纯组合逻辑电路,因此`reset`和`step(5)`这种调用是不必要的
for (i <- inputs) {
c.io.in(0).poke(i(0).F(8.BP))
c.io.in(1).poke(i(1).F(8.BP))
c.io.weights(0).poke(weights(0).F(16.W, 8.BP))
c.io.weights(1).poke(weights(1).F(16.W, 8.BP))
c.io.out.expect((if (i(0) + i(1) > 0) 1 else 0).F(16.W, 8.BP))
c.clock.step(1)
}
}
测试通过。
神经元完整的实现和测试代码如下:
import chisel3._
import chisel3.util._
import chisel3.tester._
import chisel3.tester.RawTester.test
import chisel3.experimental._
object MyModule extends App {
class Neuron(inputs: Int, act: FixedPoint => FixedPoint) extends Module {
val io = IO(new Bundle {
val in = Input(Vec(inputs, FixedPoint(16.W, 8.BP)))
val weights = Input(Vec(inputs, FixedPoint(16.W, 8.BP)))
val out = Output(FixedPoint(16.W, 8.BP))
})
io.out := act(io.in.zip(io.weights).map {case (in: FixedPoint, weight: FixedPoint) => in * weight}.reduce(_ + _))
}
val Step: FixedPoint => FixedPoint = x => Mux(x <= 0.F(8.BP), 0.F(8.BP), 1.F(8.BP))
val ReLU: FixedPoint => FixedPoint = x => Mux(x <= 0.F(8.BP), 0.F(8.BP), x)
// 测试Neuron
test(new Neuron(2, Step)) { c =>
val inputs = Seq(Seq(-1, -1), Seq(-1, 1), Seq(1, -1), Seq(1, 1))
// 因为测试的是AND逻辑,所以权重应该是两个1
val weights = Seq(1.0, 1.0)
// 传入数据
// 注意,因为是纯组合逻辑电路,因此`reset`和`step(5)`这种调用是不必要的
for (i <- inputs) {
c.io.in(0).poke(i(0).F(8.BP))
c.io.in(1).poke(i(1).F(8.BP))
c.io.weights(0).poke(weights(0).F(16.W, 8.BP))
c.io.weights(1).poke(weights(1).F(16.W, 8.BP))
c.io.out.expect((if (i(0) + i(1) > 0) 1 else 0).F(16.W, 8.BP))
c.clock.step(1)
}
}
}