FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

Chiselで作る最小構成のDiplomacy

Diplomacyとは、モジュール間のパラメータを調停するためのフレームワーク。Chisel自体の機能ではなく、Chisel+Scalaを使って実装されている。「Diplomacy」という名前が示す通り、モジュール間でパラメータを「外交」して、調停することができるフレームワークとなっている。

このDiplomacyは、Chiselを使ったことのある人なら名前は聞いたことがあると思う。Diplomacyを使うためには、TileLinkが必要だと思われがちだが違う。実際にはTileLinkの実装はDiplomacyの上に構成されており、TileLinkを使わずともDiplomacyを使うことは可能だ。

また、DiplomacyはRocket-Chipの一部として実装してありRocket-Chipのリポジトリをダウンロードする必要があると思われがちだが、これも違う。Rocket-Chipのソースコード自体をダウンロードすることなく、MavenリポジトリからRocket-Chipのライブラリをダウンロードして、Scalaソースコード上でライブラリをインポートすることで使用できるようになる。

例として2ポートを持つ加算器をDiplomacyで実現する。前述したようにここではTileLinkを使用せず、Diplomacyの機能のみを使用する。また後で説明するように、2ポートの加算器を複数ポートにDiplomacyの力を使って拡張したり、パラメータを使って簡単にバス幅を変更することができるようになるのも見る。

Diplomacyを構成するためには、「何を調停するのか」を決める必要がある。私たちが取り上げる加算器の例であれば「加算器のビット幅」ということになるだろう。この例では2つのオペランドに対して乱数発生器を接続し、加算の結果はモニターを使って監視することにする。今回の例で、登場人物は以下のようになるだろう。

  • AdderDriver : 乱数を発生して加算器に送り込むためのモジュール。また、加算器だけでなくモニターにも同じ値を送り込んで、加算器の結果を検証するのにも使用する。
  • Adder : 加算器そのもの。AdderDriverから受け取った乱数を加算して結果をモニターに渡す。
  • AdderMonitor:モニター。AdderDriverからの乱数を受け取るのと、Adderの結果を受け取って答え合わせを行う役割を担っている。
f:id:msyksphinz:20201104232032p:plain:w400

これらのモジュール間で、どのようなパラメータを受け渡せばよいだろうか?すぐに思いつくのはバス幅だろう。AdderDriverからAdderに繋げるバス幅が決まれば、それに応じて加算器のビット幅も決まってくる。

Diplomacyでは、接続するモジュールに対して「Node: ノード」というものを定義する。またノード間を接続するための「Edge: エッジ」も定義される。ノードとノードをエッジで接続するわけだが、これには方向が定められており、送信元のノードを「Source: ソース」と呼び、受信側のノードを「Sink: シンク」と呼んでいる。

Diplomacyは、このノード間でパラメータを渡していくことにより調停を行っている。

まず、どんなパラメータを転送するか、ということだが、Scalacase classを使って以下のようなクラスに情報を集約する。

case class UpwardParam(width: Int)
case class DownwardParam(width: Int)
case class EdgeParam(width: Int)

3種類のクラスを定義していることが分かる。それぞれ、メンバ変数としてwidthを持っており、これが受け渡しを行うパラメータとなる。それぞれのクラスの意味は以下のようになる。

  • UpwardParam:SinkからSourceへ向かうパラメータ
  • DownwardParam:SourceからSinkへ向かうパラメータ
  • EdgeParam:Edgeが保持すべきパラメータ

という訳でこれらの情報を渡さなければならない。まずはノードから作り込んでいくことにしよう。ノードはSimpleNodeImpを継承して作りあげる。

AdderNodeImpというクラスは、SimpleNodeImpを継承しており、4つのパラメータを持っている。DownwardParamUpwardParamEdgeParam, UIntについては、後で説明する。

3つのメソッドを定義しているが、それぞれについて説明する。これらは必ず実装しなければならないものだ。

  • edge(pd: DownwardParam, pu: UpwardParam, p: Parameters, sourceInfo: SourceInfo)Edgeが持つべきパラメータを決定する。今回の場合には、UpwardDownwardのパラメータを比較して、widthの小さな方を採用している。
// PARAMETER TYPES:                       D              U            E          B
object AdderNodeImp extends SimpleNodeImp[DownwardParam, UpwardParam, EdgeParam, UInt] {
  def edge(pd: DownwardParam, pu: UpwardParam, p: Parameters, sourceInfo: SourceInfo) = {
    if (pd.width < pu.width) EdgeParam(pd.width) else EdgeParam(pu.width)
  }
  def bundle(e: EdgeParam) = UInt(e.width.W)
  def render(e: EdgeParam) = RenderedEdge("blue", s"width = ${e.width}")
}

AdderDriverNodeの実装

AdderDriverNodeAdderDriverのノードの役割を持っており、いわゆるマスターの役割(SourceNode)として動作する。このためSourceNodeを継承して定義される。

/** node for [[AdderDriver]] (source) */
class AdderDriverNode(widths: Seq[DownwardParam])(implicit valName: ValName)
  extends SourceNode(AdderNodeImp)(widths)

AdderDriverNodeはマスターノードであるので、DownwardParamを引数として取っており、接続されているノードに対してこのパラメータ情報を共有することになる(AdderDriverNodeは複数の出力に接続されるためSeq[DownwardParam]として表現される。

AdderDriverの実装は以下のようになる。DiplomacyのノードとしてAdderDriverNodeを使っている。

class AdderDriver(width: Int, numOutputs: Int)(implicit p: Parameters) extends LazyModule {
  val node = new AdderDriverNode(Seq.fill(numOutputs)(DownwardParam(width)))

  lazy val module = new LazyModuleImp(this) {
...

このノードに対してランダムパタン生成器を接続してパタンを発生させる。

  val node = new AdderDriverNode(Seq.fill(numOutputs)(DownwardParam(width)))

  lazy val module = new LazyModuleImp(this) {
    // check that node parameters converge after negotiation
    val negotiatedWidths = node.edges.out.map(_.width)
    require(negotiatedWidths.forall(_ == negotiatedWidths.head), "outputs must all have agreed on same width")
    val finalWidth = negotiatedWidths.head

    // generate random addend (notice the use of the negotiated width)
    val randomAddend = FibonacciLFSR.maxPeriod(finalWidth)

    // drive signals
    node.out.foreach { case (addend, _) => addend := randomAddend }
  }

AdderMonitorNodeの実装

AdderMonitorNodeAdderMonitorのノードの役割を持っており、いわゆるスレーブの役割(SinkNode)として動作する。このためSinkNodeを継承して定義される。

/** node for [[AdderMonitor]] (sink) */
class AdderMonitorNode(width: UpwardParam)(implicit valName: ValName)
  extends SinkNode(AdderNodeImp)(Seq(width))

AdderMonitorモジュールは複数のノードを持っている。具体的にはAdderDriverからの複数のノードと、Adderからのノードを受け取るようになっている。

class AdderMonitor(width: Int, numOperands: Int)(implicit p: Parameters) extends LazyModule {
  // nodeSeqはAdderMonitorNodeを複数個配置する
  val nodeSeq = Seq.fill(numOperands) { new AdderMonitorNode(UpwardParam(width)) }
  // AdderMonitorNodeを1つ配置する
  val nodeSum = new AdderMonitorNode(UpwardParam(width))

  lazy val module = new LazyModuleImp(this) {
    val io = IO(new Bundle {
      val error = Output(Bool())
    })

    // print operation
    printf(nodeSeq.map(node => p"${node.in.head._1}").reduce(_ + p" + " + _) + p" = ${nodeSum.in.head._1}\n")

    // basic correctness checking
    io.error := nodeSum.in.head._1 =/= nodeSeq.map(_.in.head._1).reduce(_ + _)
  }

AdderNodeの実装

AdderNodeAdderのためのノードで、複数のSinkNodeと複数のSourceNodeが接続される。

/** node for [[Adder]] (nexus) */
class AdderNode(dFn: Seq[DownwardParam] => DownwardParam,
                uFn: Seq[UpwardParam] => UpwardParam)(implicit valName: ValName)
  extends NexusNode(AdderNodeImp)(dFn, uFn)

このAdderAdderNodeを持っており、DownwardParamUpwardParamでそれぞれのパラメータを比較している。

/** adder DUT (nexus) */
class Adder(implicit p: Parameters) extends LazyModule {
  val node = new AdderNode (
    { case dps: Seq[DownwardParam] =>
      require(dps.forall(dp => dp.width == dps.head.width), "inward, downward adder widths must be equivalent")
      dps.head
    },
    { case ups: Seq[UpwardParam] =>
      require(ups.forall(up => up.width == ups.head.width), "outward, upward adder widths must be equivalent")
      ups.head
    }
  )
...