Chisel教程——12.Scala中的函数式编程(用Chisel实现可配置激活函数的神经网络神经元)

Scala中的函数式编程(用Chisel实现可配置激活函数的神经网络神经元)

动机

前面的部分已经看到过很多函数了,现在我们可以自己定义函数然后高效利用它们了。最后,我们会利用Scala中函数式编程的特性,用Chisel实现可配置激活函数的神经网络神经元。

设置

由于这一篇文章用到了Chisel的定点数类型FixedPoint,所以需要导入相关的包,目前定点数类型在experimental包里面。

import chisel3.experimental._

Scala中的函数式编程

很早的时候就已经讲过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

这个ammonitefossil的意思,就是说不能改变,如果用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
函数 vs. 对象

那现在有种令人困惑的情形就发生了,那就是函数没有参数的情况。区别在于函数在每次被调用的时候执行计算,而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

其中xy的类型分别为:

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/scanRightreduceLeft/reduceRight,和foldLeft/foldRight。所以理解他们是如何工作的、何时应该使用他们很重要。scanreducefold默认的方向是从左到右,但不是所有情况都是这样的。

这里的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中的函数式编程

现在看一些用Chisel创建硬件生成器时如何使用函数式编程的例子。

FIR滤波器

怎么又是你?首先我们回顾一下之前的例子,要么是把系数以参数方式传递了,要么就是让它们在运行时可配置。现在我们可以尝试把一个函数传递给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滤波器的测试

现在测试我们的FIR滤波器。前面我们写了个自定义的黄金模型,这一次我们用BreezeBreeze是个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,也可以试试把一些打印的注释取消掉看看。

Chisel实现神经网络的神经元

这一部分会实现一个神经网络的神经元,要求是用一个函数作为硬件生成器的参数,并且要避免使用易变的数据。

神经元是人工神经网络中全连接层的基本构建块,由于人工智能相关背景知识现在应该是常识了,就不展开介绍了,只讲实现相关的内容。神经元会接受一个一组输入、一组权重(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 x0if 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 x0if 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)
    }
  }
}
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值