2018年11月19日 星期一

Spatial Tutorial - Inner Product

在本節中, 將會學到下列 Spatial 的元件:
  • Tiling
  • Reduce and Fold
  • Sequential execution and Coarse-grain pipelining
  • Parallelization
  • Basic buffering and banking
請注意在這裡有一些 Spatial 的應用程式.

Overview

內積 (或稱為 dot product) 是基本的線性代數 kernel, 定義為向量資料的每個單元相乘後的總和. 在此例中, 將假設資料為 signed 32-bit 定點數, 24 bits 負責整數部份, 8 bits 負責小數點以下部份. 然而相同的操作能在任何的型別上.

Basic Implementation

import spatial.dsl._

@spatial object InnerProduct extends SpatialApp {
  def main(args: Array[String]): Void = {
    // Define data type
    type T = FixPt[TRUE,_24,_8]
    
    // Set vector length
    val len = 64
    
    // Generate data
    val vec1 = Array.tabulate[T](len){i => i.to[T]}
    val vec2 = Array.tabulate[T](len)[i => (len - i).to[T]]
    val d1 = DRAM[T](len)
    val d2 = DRAM[T](len)
    setMem(d1, vec1)
    setMem(d2, vec2)

    // Allocate reg for the answer
    val x = ArgOut[T]
    Accel {
      // Create local SRAMs
      val s1 = SRAM[T](len)
      val s2 = SRAM[T](len)
      
      // Transfer data
      s1 load d1
      s2 load d2
      
      // Multiply and accumulate
      x := Reduce(Reg[T](0))(len by 1){i => 
        s1(i) * s2(i)
      }{_+_} 
    }
    
    // Compute "correct" answer
    val gold = vec1.zip(vec2){_*_}.reduce{_+_}
    
    // Check answer
    assert(gold == getArg(x), r"Expected ${gold}, got ${getArg(x)}!")
  }
}
這裡介紹最基本的向量內積實作. 下節中, 將使用這個例子來展示如何使用 Spatial API 在此應用中建立更有效率與複雜的功能. 首先簡短說明上面的程式碼.
在此實作中, 向量的長度是固定寫死的. 這尺寸用來設定 DRAMs 以及 SRAMs. 使用了Array.tabulate以函數的索引i來建立向量的資料.
接著使用 Reduce 控制結構來定義一個 “map” 與一個 “reduce” 函數. 即為以 s1(i) * s2(i) 為 map 函數並以加法作為 reduce 函數. 對更複雜的函數, 能藉由使用 {case (a,b) => f(a,b)} 語法, 這裡 f 能表示任何使用者想要的函數. 這裡 Reduce 函數使用一個 Register 作為其第一個參數. 此例中, 使用了 Reg[T](0) 來表示它必須操作其 reduction 至一個新的型別為 T 的暫存器. 也可能直接使用 x 作為 reduction 暫存器, 這樣 reduction 即是對 ArgOut 作讀取與寫入. Reduce 接著傳回這個暫存器作為其輸出. 在這裡選擇使用一個新的 Reg 作為 reduction 暫存器並複製其值到 ArgOut 並沒有什麼特別的理由.
在 Accel 區塊之後, 撰寫了在 Host 端用以計算相同功能的程式碼, 如此來確認 FPGA 的結果是否正確. 可以檢視此代碼來了解它的作法.
若要針對特定平台編譯此應用程式, 請參考 Targets 頁面

Tiling, Reduce, and Fold

import spatial.dsl._

@spatial object InnerProduct extends SpatialApp {
  def main(args: Array[String]): Void = {
    type T = FixPt[TRUE,_24,_8]
    
    // *** Set tile size as compile-time constant
    val tileSize = 64
    
    // *** Allow dynamically sized array
    val len = ArgIn[Int]
    setArg(len,args(0).to[Int])
    
    // *** Use ArgIn to generate data and configure DRAM
    val vec1 = Array.tabulate[T](len){i => i.to[T]}
    val vec2 = Array.tabulate[T](len)[i => (len - i).to[T]]ArgIn
    val d1 = DRAM[T](len)
    val d2 = DRAM[T](len)
    setMem(d1, vec1)
    setMem(d2, vec2)

    val x = ArgOut[T]
    Accel {
      // *** Create local SRAMs with fixed tile size
      val s1 = SRAM[T](tileSize)
      val s2 = SRAM[T](tileSize)
      
      // *** Loop over each tile
      x := Reduce(Reg[T](0))(len by tileSize){tile => 
        s1 load d1(tile::tile+tileSize)
        s2 load d2(tile::tile+tileSize)
        
        // *** Return local accumulator to map function of outer Reduce
        Reduce(Reg[T])(0))(tileSize by 1){i => 
          s1(i) * s2(i)
        }{_+_}
      }{_+_}
    }
    
    val gold = vec1.zip(vec2){_*_}.reduce{_+_}
    
    assert(gold == getArg(x), r"Expected ${gold}, got ${getArg(x)}!")
  }
}
在多數的應用程式中, 有著一些在編譯時期未知的演算屬性. 資料結構可能是動態配置大小且 FPGA 的 synthesize 可能耗時數小時, 因此會希望儘可能保有演算法的彈性.
第一個帶入彈性的方法是藉由 “tiling”. 這表示允許在 Host 端使用動態資料結構, 而保持在 Accel 中保持固定屬性. 上面重新撰寫的程式展示了支援 tiling 所需要的更動. 接著來討論這些修改.
首先必須在編譯時期固定一個 tile size. 在本例中, 設定為 64. 請參考 DSE (Design Space Exploration)中的教學來學習如何如此般自動調整參數. 接著必須以 ArgIn 將向量大小傳給 FPGA. 在此透過第一行指令參數來設定.
當建立資料結構, 能夠交替使用 ArgIn 或是指令行的 args(0). 請注意若選擇使用 ArgIn 必須在使用前設定. 這是因為這部份程式是依序在 CPU 端運作. 在複寫之前預設的 ArgIn 數值為 0.
在 Accel 中, 必須增加另一個迴圈來走過所有的 tiles. 在一個 tile 中, 必須執行在先前所展示的相同程式碼. 在 outer Reduce, 以步距為 tile size 的方式走過完整的長度. 而在 local memory 中, 一次僅讀取一個 tile.
一開始有件稍微棘手的事, inner Reduce 將傳回的純量放在一個暫存器中. 而這個在暫存器的純量數值被 outer Reduce 使用作為其 map function 的結果.
另外也有可能向量的長度無法被 tile size 所整除. 此時必須處理邊界的情況. 在這程式中有著兩個方式來處理. 首先, 能夠加一行程式碼到 outer Reduce:
val actualTileSize = min(tileSize, len - tile)
接著使用這來取代 tileSize, 由於這在最後一次 iteration 中能夠選擇有多少剩下的單位.
有一些與硬體複雜度相關的 tile 傳輸, 其大小與邊界並非靜態已知. 另一個有效的選項為遮蔽掉在 inner Reduce的 map 函數數值. 精確地說, 能夠將函數改為:
mux(i + tile <= len, s1(i) * s2(i), 0.to[T])
這方式, 當手上單位的 global index 落在原始向量的範圍時, 會傳回兩個單位的乘積. 其他的部份將會以型別 T 來回傳數值 0.
最後在此範例介紹 Fold 的概念. Fold 類似於 Reduce, 除了它在迴圈的第一次 iteration 並不使用累加暫存器的初始值. 在打算累加數值到已存在於暫存器中的數值的情況下, 能夠使用 Fold. 能藉由改變外部迴圈為 Fold(x)(…){…}{…} 而不改變結果, 由於 Fold 僅執行一次, 而由於是個未初始化參數 x 自 0 開始. 這並非一個與義上有用的範例, 但這是另一個簡單的方式來撰寫這個應用程式.

Coarse-Grain Pipelining

import spatial.dsl._

@spatial object InnerProduct extends SpatialApp {
  def main(args: Array[String]): Void = {
    type T = FixPt[TRUE,_24,_8]
    
    val tileSize = 64
    
    val len = ArgIn[Int]
    setArg(len,args(0).to[Int])
    
    val vec1 = Array.tabulate[T](len){i => i.to[T]}
    val vec2 = Array.tabulate[T](len)[i => (len - i).to[T]]
    val d1 = DRAM[T](len)
    val d2 = DRAM[T](len)
    setMem(d1, vec1)
    setMem(d2, vec2)

    val x = ArgOut[T]
    Accel {
      val s1 = SRAM[T](tileSize)
      val s2 = SRAM[T](tileSize)
      
      // *** Enfore pipelined execution
      x := Pipe.Reduce(Reg[T](0))(len by tileSize){tile => 
        s1 load d1(tile::tile+tileSize)
        s2 load d2(tile::tile+tileSize)
        
        // *** Enforce pipelined execution
        Pipe.Reduce(Reg[T])(0))(tileSize by 1){i => 
          s1(i) * s2(i)
        }{_+_}
      }{_+_}
    }
    
    val gold = vec1.zip(vec2){_*_}.reduce{_+_}
    
    assert(gold == getArg(x), r"Expected ${gold}, got ${getArg(x)}!")
  }
}
在本節重新撰寫此應用, 會發現兩項改變. 即以 Pipe 標籤註明兩個 Reduce 迴圈. 這讓它們以 pipelined 的方式運作, 而 compiler 並不允許複寫這個行為. 若沒有任何標明, compiler 預設使用 Pipe 但若 DSE 發現一個更有效的設計則可能會使用不同的標籤. 現在討論 controller 如被排程, 以及這在 inner controller 與 outer controller 所代表的意義.
在 Spatial 中, 一個 controller 不是個 inner 就是個 outer controller. 可以複習一下 Control Flow 教學,
  • Inner controllers 僅包含指令 (i.e.- arithmetic, memory access, muxing, etc.)
  • Outer controllers 包含最少一個其他的 controller (i.e.- Foreach, Reduce, FSM, etc.) 以及不消耗 FPGA 資源的 “短暫” 操作 (i.e.- bit slicing, struct concatenation, etc.)
在上面程式碼的例子, outer Reduce 包含兩個 loads (在 compiler 中會以特定的 controller sub-tree 取代來處理到 DRAM 的 transaction) 以及其他 Reduce controller. 由於僅包含一個為數學指令的乘法運算, 這個 Reduce 為一個 inner controller.
對此兩個 controller 增加了 Pipe 標示, 這表示期望 compiler 儘可能地對這些 controller 作 pipeline 平行化. 在 outer controller 中, 這代表這些 stage 會以這方式執行:
兩個 loads 將平行地執行 tile = 0 (由於並沒有資料相依性). 當兩者都完成時, Reduce 相會以 tile = 0 來執行, 而 loads 將以 tile = 64 執行, 以此類推. 以軟體的術語, 這特定的 coarse-grain pipelining 的例子為 “prefetching,” 但是這概念能被廣泛地應用在 Spatial 中來以任意深度的 pipeline 來重疊任何的運算.
Outer controller 的 Pipelining 需要 compiler 插入 N-buffered 記憶體. 在這個特定的例子中, 兩個 SRAMs 都將會是 double buffered, 如此在 controller 的第一個 stage 中資料載入到 SRAM 不會覆蓋到 controller 的第二個 stage 所正在自 SRAM 讀取出的資料.
對於 inner controller, 這表示硬體將在每個 cycle 發出新的i 值. 儘管這乘法運算可能有著 6 cycles 的延遲, 一旦 pipeline 以 steady-state 狀態運作, 能夠預期在 6 cycles 中得到 6 個新結果. 在更為複雜的例子中, 甚至可能對整個主體或是部份有著 loop-carry 相依性, 這時 compiler 會選擇 controller 的 initiation interval來保證正確性.
除了Pipe, Spatial 支援 SequencedStream 的標示.
Sequenced 表示在 outer controllers 中完全沒有任何 pipelining, 以及 initiation interval 等同於所有主體中 inner controllers 的延遲. 使用這個標示, counter 在增加之前, 會將單一數值的傳播給每一個 stage. 雖然較 Pipe 為緩慢, 其消耗較少的資源, 且能作為解決 loop-carry 相依性問題的方案.
Stream 代表一個 control schedule 在資料層級作 pipelined. 一個 two-stage pipeline 的 Stream controller 表示任何在這 pipeline 的 stage, 一旦輸入的 steam 型態的記憶體 (FIFOs, DRAM Buses, etc.) 有可取的資料且其輸出的stream-style 記憶體準備好接收資料就會運行. 將在後續的教學中有更詳細的討論.
下面的動畫演示了以 PipeSequenced 標示來執行這個程式的過程.
Sequential execution
Pipelined execution

Parallelization

import spatial.dsl._

@spatial object InnerProduct extends SpatialApp {
  def main(args: Array[String]): Void = {
    type T = FixPt[TRUE,_24,_8]
    
    val tileSize = 64
    
    val len = ArgIn[Int]
    setArg(len,args(0).to[Int])
    
    val vec1 = Array.tabulate[T](len){i => i.to[T]}
    val vec2 = Array.tabulate[T](len)[i => (len - i).to[T]]
    val d1 = DRAM[T](len)
    val d2 = DRAM[T](len)
    setMem(d1, vec1)
    setMem(d2, vec2)

    val x = ArgOut[T]
    Accel {
      
      // *** Parallelize tile
      x := Reduce(Reg[T](0))(len by tileSize par 2){tile =>
        // *** Declare SRAMs inside loop
        val s1 = SRAM[T](tileSize)
        val s2 = SRAM[T](tileSize)
        
        s1 load d1(tile::tile+tileSize)
        s2 load d2(tile::tile+tileSize)
        
        // *** Parallelize i
        Reduce(Reg[T])(0))(tileSize by 1 par 4){i => 
          s1(i) * s2(i)
        }{_+_}
      }{_+_}
    }
    
    val gold = vec1.zip(vec2){_*_}.reduce{_+_}
    
    assert(gold == getArg(x), r"Expected ${gold}, got ${getArg(x)}!")
  }
}
在此例中, 將加入兩個平行化標示. 以(平行化因子為) 2 的方式平行化 outer loop, 且以(平行化因子為) 4 的方式平行化 inner loop. 當在 Spatial 之中使用平行化, 必須思考相關的 iterator (在此例中的 tilei) 被平行化參數所向量化. 這個 iterator 並不會一次顯示一個數值, 而將會一次顯示多個連續數值. 這表示外部迴圈的第一個 iteration, 將平行地處理 tile = 0tile = 64.
Compiler 將會以一次 2 個的方式展開(unroll)外部迴圈, 這表示將會複製整個在其中的控制結構來提供平行運作的硬體. 請注意必須將 SRAM 的宣告搬至迴圈內部, 如此 compiler 才會一併展開存在其中的記憶體宣告. 對於此 controller 的兩條 lane 都需要專屬的 SRAMs, 因此需要展開 SRAMs 來搭配所有的一切.
而 inner loop 有著 4 的平行因子, 這表示一次將發出 4 個單元到 map 函數中.(i.e.- i = 0, i = 1, i = 2, and i = 3), 以及一個有著深度為 log2(4) = 2 的總和樹將產生來以最小延遲累加來自所有 lane 的數值.
下圖動畫顯示了此應用以 outer parallelization 與 inner parallelization (個別地) 所生成的硬體.
Inner parallelization
Outer parallelization

2018年11月18日 星期日

Spatial Tutorial - Hello, Spatial!

在這一節, 將學習在 Spatial 中有關下列元件:
  • 應用程式骨架( import 宣告, 應用程式創建, Accel 範圍, Host 範圍)
  • DRAM
  • SRAM
  • ArgIn
  • ArgOut
  • HostIO
  • Reg
  • 型別系統(Typing system)
  • host 與 accel 間的資料傳輸
  • 基本的 debugging 攔截
請注意大量的 Spatial 應用程式可以在此找到.

Overview

在本節中, 將了解如何組合建立最基本的 Spatial 應用程式. 由於這些程式碼並沒有做任何”有意義” 的工作, 它演示了幾乎所有應用程式都使用的基本指令, 並且旨在作為硬體的 “Hello, world!” 程式. 以產生作為加速器與 host 互動的輸入與輸出暫存器作開始, 接著加入在 off-chip DRAM 與 on-chip SRAM 之間的 tile 傳輸. 然後學習哪些利用 Host 提供作為測試的功能. 最後將學習針對不同輸出基本的編譯流程: 測試演算的功能性, 所產生 RTL 的 cycle-accurate simuation, 以及對支援的 FPGA 與架構產生佈署用的 bitstream.
上圖為在本教學中的操作的視覺化. 一開始有著 Host 與 FPGA, 兩者都連接著 DRAM. 然後將實體化所有可以讓兩個處理器互動的不同方法. 將創建位於 FPGA 之中的 RTL, 以及一些在 Host 中的 C++ 程式碼. Spatial 自動地實體化一個稱為 “Fringe” 的盒框, 這是一個 FPGA-agnostic (champ: 表示著可以跨越不同的 FPGA) 的硬體設計, 允許 RTL 與周邊, DRAM, PCIe 匯流排以及在 SoC 或 FPGA 板上俱備的其他硬體作互動.

Application Template

所有 Spatial 程式有著一些基本元件. 下列的程式碼在一個稱為 HelloSpatial 的應用中示範了每一個那些元件. 這是一個完整的應用, 能夠任意嘗試與編譯它. 它並沒有作任何事情, 但也不會阻止你如何地使用它.
針對特定平台編譯此應用程式, 請參考 Target 頁面
import spatial.dsl._
@spatial object HelloSpatial extends SpatialApp {
  def main(args: Array[String]): Void = {
    // Host Code
    Accel {
      // Acceleratable Code
    }
    // More Host Code
  }
}

ArgIn/ArgOut Interfaces

接著繼續發展這個應用程式, 加入 ArgIns, ArgOuts 與 HostIOs.
最基本讓資料進出 FPGA 的方式是在 Accel 與 Host 之牽傳遞個別參數. 一些 ArgIns 的使用範例像是: 傳遞應用參數到 Accel, 像是在 PageRank 中的 damping factor 或是像是 GEMM 等應用的資料結構的維度; 以及 ArgOuts 的使用範例像是: 輸出內積的純量結果. 將在 Accel 區塊之上定義一些暫存器, 以便 CPU 能夠配置它們.
這時, 可能已經注意到持續以中括號指定所有為 Int. 中括號是 Scala 如何傳遞型別參數的方式. Spatial 是一個硬體語言, 支援了一些標準 32-bit 整數外的型別, 能夠任意的定義使用他們. 一些範例包含了:
type T = FixPt[FALSE,_16,_0]
type Flt = Float
請注意在 println, 雙引號前帶有 “r” 字母. 這會在程式運作時透過 ${value} 以其實際數值的形式替換字串.
import spatial.dsl._
@spatial object HelloSpatial extends SpatialApp {
  def main(args: Array[String]): Void = {
    // Create Args
    val x = ArgIn[Int]
    val y = ArgOut[Int]
    val z = HostIO[Int]
    
    // Set `x` to the value of the first command line argument
    setArg(x, args(0).to[Int])
    // Set `z` to the value of the second command line argument
    setArg(z, args(1).to[Int])
    
    Accel {
      // Set `y` to the sum of `x` and `z`
      y := x + z
    }
    // Report the answer
    println(r"Result is ${getArg(y)}")
  }
}

Reg, SRAM, and Basic Control

接著將展示如何加入記憶體結構到 Accel. 對於一個完整的記憶體列表, 請參考說明文件. 在這個範例中僅示範 Reg 與 SRAM.
在下列程式碼中, 取得參數 x. 並且使用此來索引 Accel 中的一個記憶體. 範例中建立了記憶體 s 與 r. 在 Spatial 中 SRAM 最高能以 5D 的方式創建, 而 Reg 為 single-element 資料結構.
這裡建立了 Foreach 迴圈來存取 s 的每個單位. 第一個針對 Foreach 的參數集合指定了迴圈的 counter 範圍. 在此範例中, 指出第一個迴圈索引最高到 16, 而第二個應最高到 32. 最常見的是,迴圈範圍可以指定為:
 until  by  par 
在此例中, 16 by 1 等同於 0 until 16 by 1 par 1. 下限, 上限與步距都允許負值,然而 par 必須為正值. 後續的教學將討論更多複雜的控制結構與 counter 的使用情況. 但現在不會平行或巢狀任何迴圈.
第二個針對Foreach 迴圈的參數綁定了 counter 數值到此例中的變數ij中.這能指定每個 iteration 所採取的行為.
請注意為了寫入到SRAM , 必須指定位置並且使用=. 為了寫入到Reg, 必須使用:=.要自SRAM 讀取能夠直接傳入位置, 而要自Reg讀取必須使用.value.
import spatial.dsl._

@spatial object HelloSpatial extends SpatialApp {
  def main(args: Array[String]): Void = {
    // Create ArgIn
    val x = ArgIn[Int]
    
    // Set `x` to the value of the first command line argument
    setArg(x, args(0).to[Int])
    
    Accel {
      // Create 16x32 SRAM and a Register
      val s = SRAM[Int](16,32)
      val r = Reg[Int]
      
      // Loop over each element in SRAM
      Foreach(16 by 1, 32 by 1){(i,j) => 
        s(i,j) = i + j
      }
      // Store element into the register, based on the input arg
      r := s(x,x)

      // Print value of register (only shows in Scala simulation)
      println(r"Value of SRAM at (${x.value},${x.value}) is ${r.value}")
    }

  }
}

DRAMs and Transfers

在多數的情形, 會想使用 Spatial 來撰寫利用 FPGA 作為加速器來分擔 CPU 計算的應用程式. 這表示將有大量的資料結構想要在此兩著間共享. 為此目的導入了DRAM, 藉此資料結構能夠被 CPU 所配置並且能夠被 FPGA 所讀寫.
在下面的例子, 創建了 DRAM , 在 Host 端設定了 DRAM 的內容,告知加速器修改內容與自 Host 讀回. 建立一個 DRAM 實際上是一個 malloc, Host 配置一個記憶體區域並且傳遞此區域的 pointer 到 FPGA. 接著在 Host 端使用Array建立一個資料結構, 並且對此陣列每個單位填入數值 0. 請參見文件來以更複雜的方式建立 Arrays, Matrices, and Tensors. 以Array 的內容來設定 DRAM,本質上為memcpy此記憶體區域.
藉由DRAM的設定與載入, 能夠建立一個在加速器中的 local memory s, 並且傳送d的內容到此SRAM. 請注意資料並不能在 DRAM 中直接操作, 並且自 DRAM 到 SRAM 的傳輸是以 burst-granularity 的方式進行. 這表示對一個像是 ZC706 SoC, 一個自 DRAM 的 burst 提供了 512 bits. 若載入了一個無法有效以 burst 大小整除的單位數目, 將犧牲一些頻寬在被加速器所忽略的資料上.
在此例中, 使用(0::16)來載入並存取自d. 這表示自位置 0 到位置 16 的單位將依序傳輸. 更常見的是, 可能以語法(:: by par )加入了平行化. 有著平化參數允許傳輸硬體在 bus 上同時放置更多單位, 並且以額外的硬體開銷提升了傳輸的速度.
使用一個 1D 的Foreach來修改在s中的每個單元,接著寫回到原本的DRAM. 在加速器之外, 能夠使用printArray來讓 Host 印出記憶體內容.
如同 SRAM, DRAM 能最高有著 5D.
import spatial.dsl._

@spatial object HelloSpatial extends SpatialApp {
  def main(args: Array[String]): Void = {
    // Create DRAM (malloc)
    val d = DRAM[Int](16)
    
    // Set DRAM (memcpy)
    val data = Array.fill[Int](16)(0)
    setMem(d, data)
    
    Accel {
      // Create 16-element SRAM
      val s = SRAM[Int](16)
      
      // Transfer data from d to s
      s load d(0::16)
    
      // Add number to each element
      Foreach(16 by 1){i => s(i) = s(i) + i}

      // Transfer data back to d
      d(0::16) store s
    }
    
    // Print contents in memory
    printArray(getMem(d), "Result: ")

  }
}

2018年11月16日 星期五

Spatial Tutorial - Control Flow

在這一節中, 將介紹撰寫良好 Spatial 應用所需要的概念, 精確地說, 內如涵蓋:


  • Control Level (inner vs outer)
  • Control Style (Pipe, Sequential, Parallel, and Stream annotations)
  • Retiming and Initiation Interval
  • Finite State Machine (FSM)

Overview

Spatial 應用是以層級控制結構與指令所組成. 在後續的教學中, 將會學習到此語言的特定的語法與結構體, 但在這一節中將摘要多數的細節來表達關鍵的概念. 專精這些概念是自此語言榨出最佳效能的主要因素, 以及後續的教學將會以具體的方式應用這裡所討論的. 此外並不會花太多時間在討論也相當重要的 banking, buffering 與 duplication 的細節.

Control Level (控制層級)


在 Spatial 中一個 controller 不是 "outer" controller 就是 "inner" controller. 一個 controller  在這個情況本質上為軟體世界所稱的一個迴圈. 在此語言中它們的實體化是藉由 counter chain 與/或 state machine, 以及包含此語言中其他節點的集合. 精確地說:
  • Inner controllers 僅包含指令 (i.e.- arithmetic, memory access, muxing, etc.)
  • Outer controllers 包含至少一個其他 controller (i.e. Foreach, Reduce, FSM etc.) 並且 "短暫的" 無需消耗 FPGA 資源的運算 (i.e.- bit slicing, struct concatenation, etc.) 
在下圖的中顯示一個用以後續反覆討論的 controller tree 範例. 而片段的部份只是一小段 Spatial 用以生成這個 tree 的示範程式.
    Foreach(M by 1){j =>
      Foreach(K by 1){k => ... }
      Foreach(Q by 1){q => ... }
      Foreach(R by 1){r => ... }
    }
https://static1.squarespace.com/static/5a8dbb09bff2006c33266320/t/5be1f7924d7a9ca42e726d13/1541535638177/Screenshot+from+2018-11-06+12-20-24.png?format=1000w

Control Schedule (控制排程)

一共有五種 schedule 能被 outer controller 所使用. 後續的數個動畫演示這些 controller 在先前的範例中如何運作. 這裡的一個例外為 FSM controller, 將在稍候的段落說明. 
https://static1.squarespace.com/static/5a8dbb09bff2006c33266320/t/5be20ead70a6add349e19062/1541541551287/vokoscreen-2018-11-06_13-52-00.gif?format=1000w
  • Pipelined - 若沒有指令, 此為所有 controller 的預設 schedule. 此 controller 的 children 將以 pipelined 型式執行. 此 controller 的 counter 只會在所有啟動的 children 都完成工作時才增加數值. 基於在 children 中序列的位置, 每個 child 各自觀察來自 parent 一個不同的 counter 數值. 第一個 child 接收最新的數值, 序列中第 N 個 child 將會觀察一個來自之前 N 個 interation 的 counter 數值. 也就是說 controller 的 initiation interval 時間相當於在指定 iteration 下狀態為 active 而有著最長的運作時間的 child. 在 steady-state 下, 最慢的 child  完全決定了 iteration 的執行時間.  請注意此 schedule 有著在後續效能教學中會討論到的 loop carry dependencies 以及 area usage 的影響.  強這使用這個 control schedule, 以  Pipe.<control>  來標明此 controller.
https://static1.squarespace.com/static/5a8dbb09bff2006c33266320/t/5be20cb18a922dd662bbb5b9/1541541044676/?format=1000w
  • Sequenced - 此 controller 的 children 在任一時間僅只有一個在運作. 只有在最後一個 child 完成 iteration i 後, parent 的 counter 才會增加並且發出 iteration i+1 給第一個 child.  也就是說此 controller 的 initiation interval 時間相當於所有 children 執行時間的總和. 儘管相較於 Pipe 較慢, 其消耗的資源較少並且能夠作為 loop-carry dependency 問題的解決方案. 每個 child 都對 controller 的 runtime 有相同的貢獻.
    以 Sequential.<control> 標明 controller 來強制使用此 control schedule.
https://static1.squarespace.com/static/5a8dbb09bff2006c33266320/t/5be20cbd89858378d8e52aba/1541541082303/vokoscreen-2018-11-06_13-44-06.gif?format=1000w
  • ForkJoin - 此 controller 的 children 以平行的方式運作. 每個 child 將自己維護來自 parent counter 的複製, 並且當完成時增加 counter 的數值作為自身本地的用途. 此 controller 的運作時間由執行時間最長的 child 所決定.
    以 Parallel.<control> 標明 controller 來強制使用此 control schedule. 請注意, 僅使用 Paralle{} 為縮寫來產生一個其 children 是以此 schedule 運作的迴圈.
https://static1.squarespace.com/static/5a8dbb09bff2006c33266320/t/5be20ce04d7a9c4539ac9f50/1541541091284/?format=1000w
  • Fork - 此 controller 在任一時間僅會只有一個 child 在運行. 目前唯一建立這樣的 controller 的方式是使用 if-then-else, 如此建立的 controller 只會執行單一 interation. 可以將其視為對 children 的 one-hot mux.
https://static1.squarespace.com/static/5a8dbb09bff2006c33266320/t/5be20c73575d1fe71fff2250/1541540983177/vokoscreen-2018-11-06_13-45-04.gif?format=1000w
  • Stream - 此排程指的是一個 controller 是在 "資料" 層級 pipelined. 它與 ForkJoin 類似, 除了並非允許 children 以任意的方式運作, 一個 child 只有在其所有輸出 steam 為 "ready" 狀態, 並且所有的輸入 stream 為 "valid" 才能夠執行. 此 "ready" 訊號代表著輸出 stream 準備好接收至少一筆的資料, 而 "valid" 訊號表示輸入 stream 至少有著一筆資料尚未被取出. 符合此介面的結點包含類似 FIFO 的記憶體, 與像是 DRAM 或其他類似 AXI 介面的匯流排.
    由於這是了解與使用上最為棘手的 schedule, 且為了自 Spatial 獲取最佳的效能這可能是最重要的一個.
    以 Stream.<control> 標明 controller 來強制使用此 control schedule.
    請注意在動畫中最後一個 child 並沒有輸入或輸出 streams, 因此當 parent 被啟動後它被允許任意地執行.

Latency and Interval

https://static1.squarespace.com/static/5a8dbb09bff2006c33266320/t/5be21f4ff950b7cb173b3731/1541545818613/8d3q6-ufpxq.gif?format=750w
先前的篇幅介紹了 outer controller 的 schedule 選項. 由於這些 schedule 並沒有真正地對應 inner controller(除了 Sequential 與 Pipelined 之類的, 將會在後續討論), 這裡討論 inner controller 的 latency 與 initiation interval.  Spatial compiler 包含了一個 retiming pass, 這能讓產生的設計符合較高的頻率. 每個節點有著與其關聯的一個 latency model, 而 compiler 會使用這些 model 來計算在 dataflow graph 上的 retiming, 並且自動地加入適當的 delay line. 
  • Latency - 對於 datapath 啟動訊號的 rising edge 到達 controller 主體的最後一個指令所需的 cycle 數目. 對一個僅運作單一 iteration 的 controller 而言, 這等同於此 controller 運作所需要的 cycle 數目.
  • Initiation Interval - 在增加 counter 並發出下一個 iteration 所必須等待的 cycle 數目. 對於主體沒有 cycle 延遲的部份, initiation interval 將會被設為 1. 這表示 controller 能夠每個 cycle 都增加其 counter 並發出此新的數值到第一個指令. 剩餘的主體部份為完全地 pipelined, 因此在 latency 之後主體將在每個 cycle 產生一個結果. 對於 controller 而言, 哪裡有著循環 (i.e. 在寫入記憶體後讀取該數值), compiler 以考量記憶體存取的模式來計算最小需要的 cycle 來處理解決此相依性.
若以 Sequential 標示一個 inner controller, 這會自動地強制該 controller 的 initiation interval 等同於 latency. 也能夠使用 Pipe(ii=desired_cycs) 標示來強制該 controller 的 initiation interval 為一些指定的 cycle 數目. 由於 latency 事由 latency model 與 controller 中的節點所計算出的, 因此 controller 的 latency 是無法被控制的.

Finite State Machine (FSM)

https://static1.squarespace.com/static/5a8dbb09bff2006c33266320/t/5be878f940ec9a97ddc12695/1541961990923/vokoscreen-2018-11-11_10-40-43.gif?format=750w
https://static1.squarespace.com/static/5a8dbb09bff2006c33266320/t/5be8790f03ce64619f84adb6/1541962008049/vokoscreen-2018-11-11_10-41-20.gif?format=750w
Spatial 也支援任意的 FSM controllers. 若想要產生一個如同軟體的 while loop 的迴圈, 或是基於數值的狀態採取特定的動作, 這將非常有用.  這些類型的 controller 能夠作為 inner controller (action logic 與 next state logic 僅由指令所組成) 或是 outer controller (有著至少一個 controller 為 action logic 或是 next state logic)

對於 outer controller, FSM 的行為類似於 Sequential. 對於 inner controller, FSM 的行為類似於 inner pipe 而其 initiation interval 設為其 latency. 下一狀態的計算直到 action 指令完成前都不會開始.

2018年11月15日 星期四

Spatial - 應用加速器的 DSL (Domain Specific Language)

Standford 在今年8月發表了一篇標題為 “Spatial: A Language and Compiler for Application Accelerators” 的論文, 在談論之前必須從 Moore’s Law 的結束談起, 在 CPU 的過程中數年前即開始陷入瓶頸, 儘管製程不斷精進, 然而單核效能增長放緩, 於是人們只能在處理器中放入更多的核心:



CPU 有著相當多的優點, 其中最重要者即是 Programmability. 然而缺點即在於 Power Efficiency. 而近年不斷提升效能與程式彈性的 GPU 儘管是部份問題的方案, 然而對於非俱備 data/task parallelism 的應用依然有著其侷限性. 在如此的情況下, 對於高度計算量的需求變開始著眼於其他計算裝置. 現今而言 FPGA, CGRA 等 Reconfigurable Device 提供了另一選擇.


而這類裝置的特性缺點即在於對於 Programmability 上缺乏的彈性. 對於應用上而言, 依照以往的分工透過 Digital Logic Designer 與 Software Engineer 的協同工作的模式已經不足以應付多元且變化快速的各類應用. 因此為了加速而衍生了 High Level Synthesis (HLS) 的 Programming Model, 然而使用 HLS 特定平台撰寫的硬體設計有著移植性差且因為 framework 與 language 設計上效能侷限問題. 若涵蓋諸多相似但迥異的平台關鍵的問題在於 “如何能更有生產力地對類似 FPGA 的 reconfigurable 架構提供方案”, 而這帶來三個面向的考量:
  • 性能(Performace) — 快速與有效的設計
  • 生產力(Productivity) — 快速與有效的程式設計師
  • 可移植性(Portability) — 通用性的方案


從程式語言的發展上, 可以觀察到近年在 CPU/GPU 等俱備 Programmablility 平台上, 針對解決特定面向範圍的問題提出了許多語言, 語言特性上逐漸往 Domain Specific 靠攏. 這裡列舉了自最靠近 CPU 的組語, C/C++, 網路應用 JavaScript, 簡單且便於流程控制的 Python, 較高抽象與數理方式的 Functional Language — Haskell. 最後是針對應用所設計的語言, Deep Learning 的 TensorFlow, Image Processing 的 Halide …等等, 然而 Digital Design 在發展上並沒有非常快速地往高度抽象的 “Domain Specific” 推移.



對於發展現況的了解後, 在切入 Spatial Language 定位前必須先了解目前主流的兩種 Programming Model (HDLs, HLS) 對於上面提出的三個面向的情況:

 Spatial 的開發者認為現有 HDL 儘管有著最好的效能, 然而其生產力與可移植性是相當不佳的, 而 HLS 的方式儘管在 HDL 的兩個弱處有所改進, 然而依然存在著幾個比較大的問題:
  • 缺乏 memory hierarchy 的架構考量
  • 無法輕易地 pipelining
  • 無法混合軟硬體設計
  • 難以優化
  • 單一平台
而 Spatial Language/Compiler 的提出最大的貢獻即在於相較以往的 HLS 方法提供了解決方案:


在類別上 Spatial 依然是屬於 HLS, 然而透過其設計與 compiler 分析與自動化, 提供了更為高效率的設計輸出, 語言設計上更為簡潔, 提供了撰寫難度降低的 programming model, 並且提供了多樣化的目標平台. 

Spatial 設計者認為 HLS 與 Spatial 在抽象程度上定位如下圖:


Spatial 的優勢在於其獨特針對硬體設計抽象化的語言設計. 首先記憶體特性上提供了六種儲存類型:
  • DRAM
  • SRAM
  • FIFO
  • LineBuffer
  • Reg
  • RegFile
程式上提供了六類的控制/排程/介面/參數的語法:



Spatial 即透過 memory 的類別使用與上述的語法等等設計組合, 在 Spatial Compiler 的內部會建構以 dataflow graph (DFG) 表示的 IR, 並針對這些語言特性所形成的 design space, 透過諸多的 compiler passes 中分析與尋找出較佳的設計, 並最終以 Chisel 的形式輸出.



對於目標平台的支援程度上, Spatial 也並非隨便說說, 一開始推出即支援了多種平台, 涵蓋了模擬平台, FPGA 甚至搭配 Synopsys 的工具能夠有機會製作 ASIC, 官網上列出的平台如下:



近日也提供了簡短的說明文件, 以 Arria10 為例, 講述如何實作一個 Spatial backend. 後續會嘗試翻譯官方的教程. 一方面自學, 一方面拋磚引玉吸引更多開發者投入.

相關資源

在 ARM 平台上使用 Function Multi-Versioning (FMV) - 以使用 Android NDK 為例

Function Multi-Versioning (FMV) 過往的 CPU 發展歷程中, x86 平台由於因應各種應用需求的提出, 而陸陸續續加入了不同的指令集, 此外也可能因為針對市場做等級區隔, 支援的數量與種類也不等. 在 Linux 平台上這些 CPU 資訊可以透過...