.. Kenneth Lee 版权所有 2018-2020
:Authors: Kenneth Lee :Version: 1.0
快速学习
这个专栏已经终结了,但物理上它还是存在,我用它作为分类,放相关的一些总结。
最近家里的两位小姑娘开始学立体几何,觉得有很多困扰,和她们讨论了一下怎么学习的 问题,我突然意识到,其实我也算是“学习”的专家了,不少同事都说我学新领域特别快, 所以,我分享一下我“学习”的技巧,看看这个经验对她们是否有帮助。
我自己是做软件的,但常常和做芯片的同事讨论如何更好支持他们的功能,所以对芯片表 现出来的行为有一些了解,但我对芯片本身的设计限制,或者在设计中会遇到什么问题, 一直没有什么了解。最近Chisel大火,我就花了一点业余的时间简单学习了一下。
这正好可以作为一个例子,来说明面对一个新的领域,我的学习策略。
首先,在我没有开始学之前,我通常就会开始做笔记。这个最初的笔记我忘了留版本了, 我现在复原一下,它大概是这样的:::
Chisel是一种和Verilog竞争的新的芯片结构描述语言。
Chisel相对Verilog据称可以用更少的语义,描述更多的逻辑概念。
Chisel据称可以解决Verilog没有控制参数的问题,换句话说,对于两个分别是32位和
64位的芯片模块,Verilog只能写两个模块分别描述,Chisel可以在一个源文件中统一
描述,这为提高源文件的复用度带来了很大的优势。
疑问:Chisel如何描述一个电路?Chisel如何描述时钟的节奏?
这里面的信息,有些是我从和其他人的交流和一些广告式的新闻中听回来的,有些是我推 理出来的,我对它们并没有细节上的认知,但我已经取了我觉得最可信的部分来描述了, 很多“断语”,背后是有投资支撑我这样判断的。
然后我开始看细节,我先看两个东西,一个是:generator-bootcamp工程,另一个是 chisel3项目。然后我首先换掉了上面的第一个判断,我写成了这样:::
Chisel,Constructing Hardware In a Scala Embedded Language。换句话说,
它是用Scala作为基本语言,描述一个电路的连线应该是怎么样的。相当于一种
DSL,我猜他会像其他寄生DSL一样,会用Scala语法的一个子集,在这个之上构建
一个扩展语义,从而实现对电路的描述。
然后我就专心开始分析Scala的语法是什么样的:
.. figure:: _static/学习总结1.jpg
这里的例子很多直接来自介绍材料,但大部分我都根据我的理解进行了一个调整。这有两 个目的:一个是通过改变描述我可以知道对方的重点在哪里;另一个是引入一些变化,就 会导致我后面的逻辑不通,这样我更容易发现我“误会”了什么概念了。
这个笔记我通过自己写一些例子程序来运行来校验(这有点像学立几的时候做一些习题) ,校验一段时间后,大概就知道scala的主要行为特征了。
然后我开始总结什么是Chisel:
.. figure:: _static/学习总结2.jpg
这样总结了一把以后,我会尝试去运行一些具体的Chisel模块,顺便把Chisel3跑起来,这 个具体的过程让我意识到,Scala是用Java实现的另一种描述语言,而Chisel并非用作一种 语言,而是作为一个Scala的模块引进去。然后通过调用这些模块,形成对RTL的定义。
几次调整以后,我的总结就会变成这样:::
Chisel Module
=============
Chisel是基于Scala写的硬件DSL。有点像我们的TIK,是用这个脚本定义了一种
RTL的需求,然后根据这个定义,用Scala生成Verilog,FIRRTL这类描述语言,然
后最终生成RTL。
RTL说到底最后就是数字电路,简单把它想象为一个门电路组合就好了。定义一个
RTL,说到底就是在输入定义每个引脚的真值,然后定义一个电路,说明它的输出
的真值是怎么计算出来的,对输入做什么样的电路组合才能得到那个输出,这是
个可以固化的方法,这就给了硬件电路建模软件一个机会了。
Chisel通过定义一个Module来定义一个电路,类似这样:
.. code-block:: scala
// import库
import chisel3._
import chisel3.util._
import chisel3.iotesters.{ChiselFlatSpec, Driver, PeekPokeTester}
// 定义一个模块
clasi Passthrough extends Module {
val io = IO(new Bundle {
val in = Input(UInt(4.W))
val out = Output(UInt(4.W))
})
io.out := io.in
}
// 输出下游的代码
println(getVerilog(new PassthroughGenerator()))
println(getFirrtl(new Passthrough))
这定义了一个叫Passthrough的模块,IO定义了它的输入输出,里面的in和out分
别是Input和Output的对象,用于表示输入输出线。要多少根线Chisel可以自动计
算。比如:
* 1.U 是一位
* 4.U 是两位
* 4.S 是三位
* 'h1a'.U 是5位(前面的1只需要一位)
x.W也可以直接表示你要多少位,比如前面的1.U是一位,但你想要四位,你可以
这样写:
* 1.U(4.W)
生成的verilog代码是这样的:
.. code-block:: verilog
module Passthrough(
input clock, <-- 注意了,这两个是默认有的
input reset, <--
input [3:0] io_in,
output [3:0] io_out
);
assign io_out = io_in; // @[cmd2.sc 6:10]
endmodule
[FIRRTL]_ 的代码是这样的:
.. code-block:: firrtl
circuit Passthrough :
module Passthrough :
input clock : Clock
input reset : UInt<1>
output io : {flip in : UInt<4>, out : UInt<4>}
io.out <= io.in @[cmd2.sc 6:10]
里面的各种运算就是电路连接(称为 [DAG]_ )。比如:
* a:=b 是直连
* (a & b) | (~c & d) 这样是组合逻辑
* 也可以用更复杂的直接用乘法,比如我们可以用y:=3.U*x*x+2.U*x+1.U计算
.. math:: y = 3x^2 + 2x + 1
组合这些逻辑,就得到一个静态的电路连接,但还没有考虑时钟,相当于你在输
入上放固定的电平,我给你输出固定的电平。
Bundle和Vec用于生成类型,可以定义为类,在电路组织的时候,Bundle是集合,
Vec是向量。这样可以组合使用软件。这样判断电路可以这样写:
.. code-block:: scala
when (c1) { u1 }
.elsewhen (c2) { u2 }
.else { u3 }
switch (idx) {
is (v1) { u1 }
is (v2) { u2 }
}
函数可以看做是一个门的集合,比如你可以这样:
.. code-block:: scala
def Add (c1: UInt, c2: UInt): UInt = { c1 + c2 }
def Add[ T <: Bits ] (c1: T, c2: T): T = { c1 + c2 }
后一种形态定义了类似模板的东西,当Add被调用的时候决定了入口参数的位宽,
它就可以直接用那个长度作为输入。比如Add(10.U, 11.U)。
用专用的 [HCL]_ 工具可以把代码转化为Verilog,这个过程叫elaboration。
println(getVerilog(...))就是一种elaboration。
Chisel相比Verilog这些工作的最大的好处在于,可以通过一些控制参数的变化,
让Verilog不一样。比如,上面的代码可以修改一下:
.. code-block:: scala
class Passthrough(width: Int) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(width.W)) //里面放的是类型,决定了字长
val out = Output(UInt(width.W))
})
io.out := io.in
}
width就变成一个控制变量了,你可以修改这个控制变量来调整输出的Verilog代码:
.. code-block:: scala
println(getVerilog(new PassthroughGenerator(10)))
println(getVerilog(new PassthroughGenerator(20)))
chisel提供测试Bench,比如这样:
.. code-block:: scala
val testResult = Driver(() => new Passthrough()) {
c => new PeekPokeTester(c) {
poke(c.io.in, 0) // 从端口上灌数据进去
step(1) // 这会走一步时钟
expect(c.io.out, 0) // 测试输出端口
poke(c.io.in, 2)
expect(c.io.out, 2)
}
}
assert(testResult) // Scala Code: if testResult == false, will throw an error
println("SUCCESS!!") // Scala Code: if we get here, our tests passed!
这样可以模拟一跳一跳,但这个依赖外部输入来控制跳动。要在时钟的驱动下发出动作。
到这里为止,组合电路的定义方法我大概理解了,大概就是你直接对输入的信号线进行运 算,建模工具帮你生成对应的电路。比如这样:
.. figure:: _static/组合电路.jpg
in1, in2通过不同电平输入两个数字,建模工具自动根据你给出的计算要求,
帮你连成电路,让你输入电平是某种数字的时候,数据按要求得到另一个数
字(out)
但我仍无法理解这个怎么和时钟共同配合,于是,我拿着例程中的时钟定义去找了一位做 芯片的同事,问他这个时钟定义是什么意思。他其实也没有用过chisel,他的经验都是用 Verilog,所以他给我介绍了他们在Verilog中是怎么使用时钟的,然后我就形成这样一个 总结了:::
这样可以模拟一跳一跳,但这个依赖外部输入来控制跳动。要在时钟的驱动下发出动作,
就需要寄存器。
寄存器通过这样的语法定义:
.. code-block:: scala
val register = Reg(UInt(12.W))
register = io.in + 1.U
io.out = register
你在模块中放这么一个语句,就是在电路中放一个寄存器,输入输出对它的影响就是每次
输入进入模块,寄存器就会把它的内容加1再存入寄存器。
这个生成的代码是这样的:(包括完成的模块定义代码)
.. code-block:: verilog
module RegisterModule(
input clock,
input reset,
input [11:0] io_in,
output [11:0] io_out
);
reg [11:0] register; // @[cmd2.sc 7:21]
reg [31:0] _RAND_0;
assign io_out = register; // @[cmd2.sc 9:10]
`ifdef RANDOMIZE_GARBAGE_ASSIGN
`define RANDOMIZE
`endif
`ifdef RANDOMIZE_INVALID_ASSIGN
`define RANDOMIZE
`endif
`ifdef RANDOMIZE_REG_INIT
`define RANDOMIZE
`endif
`ifdef RANDOMIZE_MEM_INIT
`define RANDOMIZE
`endif
`ifndef RANDOM
`define RANDOM $random
`endif
`ifdef RANDOMIZE_MEM_INIT
integer initvar;
`endif
initial begin
`ifdef RANDOMIZE
`ifdef INIT_RANDOM
`INIT_RANDOM
`endif
`ifndef VERILATOR
`ifdef RANDOMIZE_DELAY
#`RANDOMIZE_DELAY begin end
`else
#0.002 begin end
`endif
`endif
`ifdef RANDOMIZE_REG_INIT
_RAND_0 = {1{`RANDOM}};
register = _RAND_0[11:0];
`endif // RANDOMIZE_REG_INIT
`endif // RANDOMIZE
end
always @(posedge clock) begin
register <= io_in + 12'h1;
end
endmodule
除了Reg,Chisel还提供了比如RegNext和RegInit这些基础封装。比如前面这个功
能,创建一个RegNext(io.in + 1.U)就可以了,它除了生成一个寄存器,还控制
每次时钟变化的时候,这个寄存器怎么变化。相应地RegInit控制了复位信号线来
的时候,怎么给初值。
寄存器变化需要一个时机,这个时机是时钟信号,Module的IO Bundle里面默认就
放了一个Reset和Clock信号,所以你在外面做poke,其实就是触发这个时钟跳动
,跳动的这个时刻io.in当前的电平信号被投入做布尔运算,输出到io.out或者寄
存器。下一个时钟进来,它可以用寄存器里的值和输入信号组合计算,得到新的
io.out和新的寄存器状态。Reset信号线同理。
你可以用多时钟驱动,或者有多个复位信号。这样只需要在IO的Bundle里定义这
条信号线,在内部逻辑中,可以定义当这条信号线有信号线的时候,内部走的逻
辑是什么。比如这样:
.. code-block:: scala
class MyModule extends Module {
val io = IO(new Bundle {
val in = Input(UInt(10.W)
val alternateReset = Input(Bool())
...
})
...
withReset(io.alternateReset) {
val altRst = RegInit(0.U(10.W))
altRst := io.in
}
}
当然,这样这个复位和主时钟信号就有一个如何同步的问题,这是电路逻辑本身
的问题,就需要设计者自己关心了。另外需要注意的是,那个测试Bench对这种情
形的支持是不感知的,所以有可能测试结果是不对的。
这样我对整个工具的基础框架就有所了解了。
有了这样一个理解,我就开始回去看我原来做的《Computer Architecture: A Quantitative Approach》学习笔记,然后我现在就补全我原来缺失的逻辑。比如我原来在 逻辑上无法理解为什么Data Path和Control Path要分开讨论。现在我就在里面补上了这段 逻辑:::
Data Path和Control Path进行分离,是一个显而易见的设计思路。好比我现在知
道你要把两个寄存器加起来,我的加法器(作为一个模块),从上一级拿到两个
寄存器的值作为输入,然后经过一个静态电路的计算过程,输出到下一级。每次
时钟跳动,就会产生一次完整的计算,只要信号一致性能保证(电路相移不超出
范围),一个cycle就可以完成整个连续的计算过程。这种情况下,如果我对加法
器有另一个要求(比如要求加上进位位),我就需要用另一个信号来控制加法器
的行为,这在设计上,就会很自然分成了Data Path,单独考虑主数据流是如何被
计算的,而Control Path,在计算的时候如何微调对计算的要求。
由此也可以理解流水线的目的:如果每个指令都可以一个cycle完成计算,完全没
有必要有流水线。但如果我某个步骤需要多个cycle(比如输入信号的线宽不够)
,第二个cycle有一堆的电路停下来没有用,我就有必要让这些部件独立出来,让
它在这个“第二cycle”上,提前执行其他动作了。
把这个打通了,我又可以为Chisel这边的学习提出更复杂的问题了,比如:存储分层设计 的时候,CPU的时钟和总线的时钟匹配怎么描述?
通过这样的一个学习过程,我就大概掌握了Chisel的设计框架了,更多的细节知识就要靠 每个具体的设计过程,解决问题的过程,交流的过程来刷新我对基础框架的认识,从而让 更多的细节可以组织到我的总体框架上,我就“掌握”这个知识了。
最后让我总结一下我的思路:
从学习开始的时候,就要开始整理自己的逻辑,让你的知识有一个“框架”可以依附,否则 你会一直是离散的状态。
但不要指望你的框架一开始就是完善或者对的,只要有效把你当前的认知总结出来就好 ,甚至只花10分钟的时间都可以,因为没有细节去填充,你花的时间越多,你就越被自 己迷惑了。
然后开始看教材,修正你原来的逻辑框架,这种修正,既可以是对框架整个认识的修正 ,也可能是对框架“断语”的修正。比如,你一开始认为立体几何是“计算体积的几何”, 后来看到细节后,发现它是计算线性三维空间中位置关系的几何,你可以调整你原来的 范围定义。你一开始“断言”:理解一个立体形状,需要找到一些和视线垂直的面才能获 得那个面的真正长度。但后面你在教材中找到了从任意切面计算非垂直切面的计算方法 ,这个断言可以改变或者进行补充。
当教材中,或者我们生活中,实验中,你发现和你的框架不符的东西,作为一个和逻辑 不一致的断言记录下来,它们就像当初说的“物理大厦的最后两朵乌云”一样,会成为你 更进一步的关键逻辑的。这一点很重要:不要为了模型的完美,而拒绝对事实的认知。 是事实定义模型,而不是模型定义事实。
用自己的语言或者典型例子重新描述教材的概念,好记比严谨更重要,因为这可以是两 件事:用你好记的语言记住概念,然后用严谨的表述去解决问题,这不需要统一在一起 的。
在自己重新描述教材概念的时候,尽量和教材的概念不一样,尝试用“其实就是XXXX嘛” 这种方法去表述它,这样能让你最终明白教材为什么要那样定义。
不要指望模型可以取代细节知识和经验,模型知识帮助你整理知识,让你快速发现知识 细节,它不能取代你去不断学习和实习细节知识。模型只属于你自己,其他人看你的抽 象,也学不会你掌握的知识;反过来,你看别人的总结,可能对你有所帮助,但一定无 法取代你本身去学习那些细节,所以,反复实习,反复刷题,仍是你进一步学习进去的 必要条件,那是不可取代的,但模型可以避免你无效刷题,刷了半天一点进步没有。
说到底,学习这个过程,不能是“记住”最优表述(或者老师讲课,教材说明)的过程,这 样的记住过程,知识只是一段无意义的文字本身,而不是知识的本体,你要学习知识的本 体,必须去蹂躏它,去反复折叠和展开它,你才会从这个蹂躏它的过程中摸到哪部分才是“ 教材说明”想说的,哪部分只是表述的时候因为文字的局限性不得不带入的。这个知识才最 终到你脑子里了。我反对补课其实也是这个原因,上课老师给你传递一次,补课老师再给 你传递一次,如果没有经过你自己去组织去质疑,去思考这么一个过程,这个知识进不了 你的脑子。
这其实同样是我们进行架构分析的一般方法。