跳转至

Blog

这里收录技术相关的文章,包括 CPU、模拟器、系统建模和建站记录。

左侧会直接列出当前栏目下的文章标题,方便快速切换。

脉动阵列的推导

参考项目: * https://github.com/tiny-tpu-v2/tiny-tpu * https://zhuanlan.zhihu.com/p/650209037

从矩阵乘法到脉动阵列:一个 PE 是如何被推导出来的?

在学习 AI 加速器时,经常会遇到一个经典结构:Systolic Array(脉动阵列)

很多资料会直接给出一个 PE(Processing Element)的形式:

          x_in
            |
            v
        +-------+
w ----> |  MAC  | ----> x_out
        |       |
        |  acc  |
        +-------+
            |
            v
          y_out

并告诉我们:

\[ x_{out}=x_{in} \]
\[ y_{out}=y_{in}+w\times x_{in} \]

比如一个经典的一维脉动阵列运算就是 Weight Stationary(权重驻留):weight 在 PE 中不动,\(x\)\(y\) 向外流动。

但是,为什么 PE 要这样设计?为什么 \(x\) 不变,而 \(y\) 要累加?为什么输入数据需要继续向后传递?为什么有的计算结果需要保存在本地累加,有的计算结果又需要向后传递?

如果只看 TPU 或者 NPU 的结构图,很容易把脉动阵列理解成一个固定模板。实际上,它背后的设计逻辑来自一个更基础的问题:

如何高效地完成大规模矩阵乘法,同时减少数据搬运?

这也是 H. T. Kung 在 1982 年提出 Systolic Architecture 时关注的问题。

本文想回答的问题包括:

  • 脉动阵列是怎么从矩阵乘法推导出来的?
  • 每个 PE 为什么是一个 MAC(乘加模块)?
  • 为什么会有 \(x_{out}=x_{in}\)\(y_{out}=y_{in}+w\times x_{in}\)
  • 每一行、每一列分别是什么?数据如何流动?顺序和时序如何安排?
  • 谁横向移动,谁纵向移动?
  • 只堆 MAC 局限性是什么?

从矩阵乘法开始

AI 中最重要的计算之一就是矩阵乘:

\[ C=A\times B \]

其中:

\[ C_{ij}=\sum_k A_{ik}B_{kj} \]

也就是说,一个输出元素 \(C_{ij}\) 本质上是很多次 MAC:

\[ C_{ij}=A_{i0}B_{0j}+A_{i1}B_{1j}+A_{i2}B_{2j}+\cdots \]

因此,最基本的计算单元就是:

\[ acc=acc+a\times b \]

也就是乘加单元(MAC)。

最简单的硬件中,每个 MAC 每次根据输入 \(a\)\(b\) 计算一个 \(a\times b\),然后累加到内部的 sum 寄存器中。这样持续输入数据并累加,就可以算出一个 \(C_{out}\) 的结果;也就是一个 PE 对应一个 \(C\)

       a
       |
       v
    +-----+
b-->| MAC |
    +-----+
       |
       v
       C

如果矩阵规模变大,比如是一个 \(1000\times1000\) 的矩阵,需要不停地读取输入数据,一个 MAC 显然不够,于是自然会想到增加更多 MAC:

MAC MAC MAC
MAC MAC MAC
MAC MAC MAC

但是新的问题出现了:计算单元增加以后,如何给它们提供数据?如果所有 MAC 都从一个共享存储读取:

              Memory

          / / / / / /

       PE PE PE PE
       PE PE PE PE
       PE PE PE PE

那么,存储带宽和数据移动会成为主要瓶颈。

在 80 年代的 VLSI 设计中,一个重要事实是:长距离通信成本比计算成本高。因此 Kung 提出的思想是:不要让所有计算单元依赖远距离通信,而是让数据像流水一样流过计算单元,让 PE 只和邻居通信。


从数据复用推导二维阵列

前面我们已经知道了一个 PE 的构成,以及它如何计算出矩阵乘法中的一个最终结果。那么,如果需要计算出多个结果,数据应该怎么流动?

重新观察矩阵乘法:

\[ C_{ij}=\sum_k A_{ik}B_{kj} \]

先考虑只计算一个输出 \(C_{00}\)

\[ C_{00}=a_{00}b_{00}+a_{01}b_{10}+a_{02}b_{20} \]

其中:

  • \(A\) 的一行会参与多个输出。
  • \(B\) 的一列会参与多个输出。

所以最自然的数据流是:

  • \(A\) 横向移动。
  • \(B\) 纵向移动。
  • \(C\) 在当前位置累加,每个 PE 输出一个 \(C\) 的结果。

以结果是 \(3\times3\) 的矩阵为例,可以形成二维阵列

每个 PE 做三件事:

  1. 接收左侧传来的 \(A\)
  2. 接收上方传来的 \(B\)
  3. 计算并累加部分和。

由于 \(A\) 还需要给后面的 PE 使用,所以:

\[ x_{out}=x_{in} \]

而输出结果需要累加:

\[ psum_{out}=psum_{in}+x_{in}\times w \]

那么纵向的 \(y_{out}\) 到底是什么?这里会有几种做法,比如 Weight Stationary 和 Output Stationary;不同做法中流出的数据不同,也适用于不同的场景。

这就是最初的 PE 结构。它不是人为设计出来的,而是由矩阵乘法的数据依赖关系决定的。


PE 内部保存什么?

随着 AI 模型的发展,人们发现,计算 MAC 本身越来越便宜,真正昂贵的是:

  • 从 SRAM 读取数据。
  • 从 DRAM 搬运数据。
  • 在计算单元之间移动数据。

因此产生了不同的数据驻留方式。核心问题是:哪一种数据应该留在 PE 内部,以减少移动?

数据量比较大,最好就不要移动;数据复用性比较高,最好就不要移动。

主要有三种:

  • Output Stationary
  • Weight Stationary
  • Input Stationary

Output Stationary(输出驻留)

Output Stationary:让输出 partial sum 保存在 PE 中。

PE 内部保存 accumulator:

acc += A * B

优点:

  • partial sum 不需要移动。
  • accumulator 利用率高。
  • 非常适合大规模 GEMM。

缺点:

  • \(A\)\(B\) 需要持续流动。
  • 数据流固定,灵活性有限。

Google TPU v1 的矩阵计算结构接近这种思想。


Weight Stationary(权重驻留)

Weight Stationary:让 weight 保存在 PE 内。

        Activation

             |
             v

        +---------+
        |  MAC    | <--- Weight
        +---------+

             |
             v

           Output

这种方式在 CNN 中非常常见。

原因是卷积中的 kernel 会被重复使用,比如一个 \(3\times3\) filter 会滑过整个 feature map,同一个 weight 会参与大量计算。

优点:

  • weight reuse 高。
  • 减少权重读取。

缺点:

  • 输出累加需要更多通信。
  • 对动态矩阵计算不够友好。

Weight Stationary 时,纵向其实就没有从外部来的输入了;横向仍然有输入,因为纵向的输入现在直接通过固定在 PE 中的 weight 提供,不需要再流动。

不过,纵向还需要 partial sum 的流动,可以视作 \(C_{ij}\) 的流动;初始化时都是 0,并且 \(C\) 也需要各自错开。


Input Stationary(输入驻留)

Input Stationary:固定 activation。

这种方式适合某些卷积场景,因为一个输入像素可能参与多个输出。

优点:

  • 输入复用率高。

缺点:

  • weight 和 output 移动增加。
  • 适用范围较窄。

但是,数据在时序上怎么放、流动顺序是什么,仍然需要解决。数据输入顺序需要一定的 skew(错开),下面用一个例子来说明。


\(2\times2\) 脉动阵列示例

考虑:

\[ A= \begin{bmatrix} a_{00} & a_{01}\\ a_{10} & a_{11} \end{bmatrix} \]
\[ B= \begin{bmatrix} b_{00} & b_{01}\\ b_{10} & b_{11} \end{bmatrix} \]

结果为:

\[ C=A\times B \]

其中:

\[ C_{00}=a_{00}b_{00}+a_{01}b_{10} \]
\[ C_{01}=a_{00}b_{01}+a_{01}b_{11} \]

以下给出两种驻留的计算,需要重点观察数据流向、数据顺序(是一行还是一列放在一起流动,不同方式顺序也不同)、如何错开

输出驻留示例

映射关系如下:

             B

        b00        b01
         |          |

      +------+------+
      | PE00 | PE01 |
A --->+------+------+
      | PE10 | PE11 |
      +------+------+

每个 PE 保存一个输出:

  • PE00 -> \(C_{00}\)
  • PE01 -> \(C_{01}\)
  • PE10 -> \(C_{10}\)
  • PE11 -> \(C_{11}\)

如果数据这样流动,能正确计算吗?一个箭头线代表一个 cycle。假设 \(a_{00}\) 是 cycle 0 进入 PE00 的输入,\(a_{01}\) 是 cycle 1 进入 PE00 的输入,以此类推。

这样 PE00 的计算是对的,但是 PE01 和 PE10 的计算都是错的。为什么?

|589x429

cycle 0 时,\(b_{10}\) 进入 PE01 的输入,但是 \(a\) 的输入还没到,可以视作 0。而 \(C_{01}\) 正常应该是:

\[ C_{01}=a_{00}b_{01}+a_{01}b_{11} \]

在这里却错误地变成了:

\[ C_{01}=0\times b_{10}+a_{01}b_{11} \]

同理,PE10 也是错的,PE11 计算正确。

因此输入需要进行 skew(错位)。也就是说,\(b_{10}\) 需要等待一个 cycle,等到 \(a_{00}\) 流动过来以后,才能进入 PE01。

|542x422

Weight Stationary 计算示例

\(2\times2\) 矩阵为例:数据顺序也和之前的输出驻留不一样。横向输入顺序从 \(a_{00},a_{01}\) 变为 \(a_{00},a_{10}\),每个 PE 中驻留的是 weight,比如 PE01 中驻留的是 \(B_{01}\)

|573x515

  • cycle 0:PE00:\(C_{00}=0+a_{00}b_{00}\)
  • cycle 1:PE00:\(C_{10}=0+a_{10}b_{00}\);PE01:\(C_{01}=0+a_{00}b_{01}\)PE10:\(C_{00}=a_{00}b_{00}+a_{01}b_{10}\)
  • cycle 2:PE01:\(C_{11}=0+a_{10}b_{01}\)PE10\(C_{10}=a_{10}b_{00}+a_{11}b_{10}\)PE11\(C_{01}=a_{00}b_{01}+a_{01}b_{11}\)
  • cycle 3:PE11\(C_{11}=a_{10}b_{01}+a_{11}b_{11}\)

加粗的部分就是已经计算完成的结果。可见,4 个 cycle 后,4 个元素都已经计算完成。


为什么 Transformer 改变了 AI 加速器?

早期 CNN 具有这些特点:

固定卷积

固定 kernel

大量 weight reuse

因此非常适合 Weight Stationary。

但是 Transformer 的主要计算是:

\[ Attention(Q,K,V) \]

其中:

\[ QK^T \]

本质是大规模矩阵乘。

同时,Transformer 还具有这些特点:

  • sequence length 不固定。
  • token 数量变化大。
  • 有 softmax、layernorm 等非 GEMM 操作。

因此现代 AI 芯片逐渐从固定数据流 NPU,发展为:

Scalar Core
+
Vector Engine
+
Tensor/Systolic Engine
+
Large SRAM

其中,Tensor/Systolic Engine 负责大量矩阵乘;Scalar 和 Vector 单元负责控制、地址生成以及非矩阵计算。


总结

脉动阵列并不是一种“为了 AI 发明的特殊结构”,它本质上是:

从矩阵乘法的数据依赖关系出发,为了解决 VLSI 中数据搬运成本过高问题而产生的一种计算组织方式。

推导过程可以概括为:

矩阵乘法


大量 MAC


多个 PE 并行


数据搬运成为瓶颈


让 A、B 在 PE 之间流动


C 在 PE 内累加


Systolic Array

而不同的数据驻留方式,本质上是在回答同一个问题:

什么数据最值得留在计算单元附近?

  • Output Stationary:保留输出,适合 GEMM。
  • Weight Stationary:保留权重,适合 CNN。
  • Input Stationary:保留输入,适合部分卷积场景。

理解这个推导过程,比记住某一个 TPU 或 NPU 的结构图更重要。


思考:局限性

似乎有很大一部分 PE 在最开始时没有进行计算。比如一个结果为 \(n\times n\) 的矩阵,其对应地需要 \(n\times n\) 个 PE。实际上,PE 的利用是从左上角开始,沿着逆对角线往下逐步展开的。

\(3\times3\) PE 为例,PE 会依次沿着黄色方向、按逆对角线开始有输入并进行计算:

\[ (PE00),\ (PE01,PE10),\ (PE02,PE11,PE20),\ldots \]

这有点类似 CPU 中流水线的冷启动。但是 CPU 中流水线的 stage 比较少,而 PE 阵列往往比较大。对于一个 \(100\times100\) 的 PE 阵列来说,这会浪费多少个 cycle 的 PE?

  • 可以简单计算一下:第一个周期新启动 1 个,第二个周期新启动 2 个,直到第 100 个周期新启动 100 个,第 101 个周期新启动 99 个。 $$ (1002-1)+(1002-1-2)+(1002-1-2-3)+\cdots+(1002-1-2-\cdots-100)+(100^2-1-2-\cdots-100-99)+\cdots+0 $$
  • 可以看到浪费了很多周期:本来可以使用 PE,但是因为冷启动导致没有使用。不知道是否有相关研究进行了探讨。

https://mcyoung.xyz/2021/06/01/linker-script/#appendix

.S只是目标文件的文本表示

ar:本质上是一个古老的tar,将多个.o文件合并成一个库,可以看作是.o文件的集合。其后缀是 .a

工具链使用

objdump

  • objdump -x xxx.o:显示目标文件的所有header信息

  • Objdump -h xxx.o:显示目标文件的section列表

readelf

objcopy

  • 可以充当一个简单loader的作用,适用于那些没有任何load操作的小型控制器,后文会详细介绍

linker

linker的输入是多个.o对象文件或者.a,输出是可执行文件,具体分为多个步骤

  1. 解析所有对象和静态库,并将它们的符号存入数据库。符号是函数和全局变量的命名地址。

  2. .o 文件中搜索所有未解析的符号引用并进行匹配。 使用步骤1数据库中的符号,递归地对任何.a中被引用的代码执行此操作。这会在各个部分之间形成一种依赖关系图。这一步骤称为*符号解析 *。

  3. 通过追踪入口点符号(例如,Linux 上的 _start )的依赖关系图,丢弃所有未被输入文件引用的代码。这一步骤称为*垃圾回收 *。

  4. 执行链接器脚本link script以确定如何将最终二进制文件拼接在一起。包括确定所有文件的偏移量。

  5. 解决*重定位问题 *,即二进制文件中需要知道段最终运行时地址的“空洞”。重定位是放置在目标文件中供链接器执行的指令。

  6. 写出完整的二进制代码

Link script只关心第四步,如果想了解其他的步骤可以查看:https://lwn.net/Articles/276782/

section属性

Objdump -x xxx.o 可以查看所有的header信息

目标文件描述了程序如何加载到内存中。目标文件被划分为多个section,这些section被称为data blocks。section拥有类似文件的权限比如像是r,w,x。可以使用objdump -h显示section的列表。以下是一个例子,删去了前导0

$ objdump -h "$(which clang)"
/usr/bin/clang:     file format elf64-x86-64

Sections:
Idx Name    Size      VMA       LMA       File off  Algn
 11 .init   00000017  00691ab8  00691ab8  00291ab8  2**2
            CONTENTS, ALLOC, LOAD, READONLY, CODE
 12 .plt    00006bb0  00691ad0  00691ad0  00291ad0  2**4
            CONTENTS, ALLOC, LOAD, READONLY, CODE
 13 .text   0165e861  00698680  00698680  00298680  2**4
            CONTENTS, ALLOC, LOAD, READONLY, CODE
 14 .fini   00000009  01cf6ee4  01cf6ee4  018f6ee4  2**2
            CONTENTS, ALLOC, LOAD, READONLY, CODE
 15 .rodata 0018ec68  01cf6ef0  01cf6ef0  018f6ef0  2**4
            CONTENTS, ALLOC, LOAD, READONLY, DATA
 24 .data   000024e8  021cd5d0  021cd5d0  01dcc5d0  2**4
            CONTENTS, ALLOC, LOAD, DATA
 26 .bss    00009d21  021cfac0  021cfac0  01dceab8  2**4
            ALLOC
  • ALLOC:代表必须由操作系统分配空间,linker预留了这一片内存

  • LOAD:loadable代表OS必须在之后用section的内容填充这块空间,该填充过程被称为load,由加载器loader来做

loader有时被称为动态链接器,并且通常与程序链接器是一个东西,这也是为什么linker被称为ld的原因

loading需要大量内存,对于一些很小的控制器来说可以预先使用binary进行loading,objcopy可以在该过程使用以及其他转换.o文件的任务

对于每个section来说,如果不指定memory region,那么每个section会有**默认的属性**。一般来说都是alloc+load,但是bss只是alloc,不load,具体情况可通过objdump -h分析

一些常见的section

  1. .text:代码放在这里,通常是loadable,readonly,executeable

  2. .data:包含全局变量的初始值,通常是loadable

  3. .rodata:readonly data,是常量;loadable,readonly

  4. .bss:空的可分配段,一般存储未初始化的全局变量。因为全局变量如果未初始化默认为0,所以这可以避免存储大量无意义的0,节省空间。allocable

  5. 然后还有一些debug section,他们通常是不可load,不可分配。实际使用时不需要,调试时需要

linker决定了会保留.o和.a中的哪些部分(根据其需要),它会询问link script如何在输出中排布这些需要的部分

让我们来看第一个linker script吧!

SECTIONS {  
    */* Define an output section ".text". */*  
    .text : {    
        */* Pull in all symbols in input sections named .text */*    
        *(.text)    
        */* Do the same for sections starting with .text., such as .text.foo */*    
         *(.text.*)  
    }
    */* Do the same for ".bss", ".rodata", and ".data". */*  
    .bss : { *(.bss); *(.bss.*) }  
    .data : { *(.data); *(.data.*) }  
    .rodata : { *(.rodata); *(.rodata.*) }
  }

上面的脚本创建了一个.text段,其内部包含了所有输入文件中 .text 和 .text.*(比如.text.foo) 的段。所有的.text段位于.text.*之前,而具体到*\(\.text\)内部的顺序则没有保证

需要注意上述脚本中的两个.text是不同的,他们可以有不同的名称,linker只关心它的属性是什么

每个section都有一个对齐方式,比如下面的ALIGN\(16\)表示按照16字节对齐

SECTIONS {  
    .super_aligned : ALIGN(16) {    
    */* ... */*  
    }
}

可以通过/DISCARD/指示linker丢弃某些部分,可以用于丢弃gcc想保留的调试信息

也可以使用KEEP(*(.text.*)) 来确保不会有 .text 段被垃圾回收丢弃

LMA和VMA

每个section都关联了三个地址,分别是offset,VMA,LMA

offset:从文件开头到该节的距离

VMA:运行地址(或者叫链接地址),程序在运行时期望从这个位置找到内存段。程序中的指针和pc都使用这个地址

LMA:加载地址(或者叫存储地址),加载器(无论是运行时加载器还是 objcpy )必须放置代码的位置。几乎总是与 VMA 相同

一个是运行时地址,一个是存储地址,其含义在后文的rom和ram中会体现得很明显

当在link scipt中声明一个新的section时,VMA和LMA都会设置为当前 location counter的值,也就是.这个符号代表的值,它会自动递增,比如从输入的.o文件中复制了一些新的data进来,该符号会自动加对应的大小

我们可以通过在冒号前添加表达式来显式指定节的 VMA,通过 AT(lma) 说明符来显式指定 LMA。

```OpenGL Shading Language SECTIONS {
.text 0x10008000: AT(0x40008000) {
/ ... /
} }

上面这种写法会修改`.`的值,等价于

```C++
SECTIONS {  
    . = 0x10008000;  
    .text : AT(0x40008000) {    
        */* ... */*  
    }
}

在SECTIONS中,可以在任何位置设置location counter,但是很少直接操作它,它会根据section的添加自动递增

内存区域和section分配

一般来说,linker会从地址0开始分配内存段。可以使用MEMORY语句定义内存区域,以便更精细地控制 VMA 和 LMA 的分配方式,而无需显式地写入这些区域。

使用MEMORY的一个经典例子,把地址空间分为ROM和RAM

MEMORY {  
    rom (rx)   : ORIGIN = 0x8000,     LENGTH = 16K  
    ram (rw!x) : ORIGIN = 0x10000000, LENGTH = 256M
}

一块内存区域其实就是有一个name和一个具备rwx等属性的块

rw!x表示允许rw,不允许x,即感叹号之前的属性允许,之后的属性不允许

所有的属性:rwxal

  • rwx都很熟悉,a指的alloc已分配,l指的是load可加载

分配完内存区域后,还要对其对应的section做一个对应,以便section能够真正地位于这块memory region,主要是要有期望的属性。linker可以自动匹配,但是作者说他不太信任,最稳妥的做法是使用> region来把数据放入特定的区域

SECTION {  
    .data {    
        */* ... */*  
    } > ram AT> rom
}

上面表示data section,VMA = ram, LMA = rom。为什么要这样做?

  1. 烧录时:程序被烧录到 Flash \(ROM\) 中。.data 段的数据(例如变量的初始值 10)安静地躺在 Flash (AT> rom 指定的位置) 里。

  2. 上电时

    • CPU 开始执行代码。此时,RAM 里全是随机值或零,那个 10 还在 Flash 里。

    • 如果程序直接去读 RAM 里的变量地址(> ram 指定的位置),读不到正确的初始值。=

  3. 启动代码

    • 在进入 main() 函数之前,启动文件(通常是 startup.scrt0.s)会执行一段**数据搬运**代码。

    • 它会把 .data 段的内容,从 Flash \(LMA\) 复制到 RAM \(VMA\)

    • 复制完成后,程序才能在 RAM 中读写这些已初始化的变量。

简单来说AT> rom告知从哪里找到这段data,>ram告知运行时这段data暂存在哪里

其他可以放在section中的内容

  • 可以使用BYTE、 SHORT 、LONG 和 QUAD等将任意类型的数据放入section中

  • 还可以使用fill来填充section,这通常用于填充未使用的section,fill with junk value

比如下面这段脚本表示把4k的section都充满0xaa这个值

SECTIONS {  
    .scream_page : {    
        FILL(0xaa)    
        . += 4K;  
    }
}

其他示例

SECTIONS {  
    .scream_page : {    
        FILL(0x0a)    
        . += 2K;    
        FILL(0xa0)    
        . += 2K;  
    }
}

SECTIONS {  
    .scream_page : {    
    . += 4K;  
    } = 0xaa;
}

Linker symbols:链接符号

可以直接在脚本中声明符号,比如声明一个入口或者栈的地址

SECTIONS {  
    .text : {    
        __text_start = .;    
        */* stuff */*    
        __text_end = .;  
    }
}

这样一来,用于初始化的代码就能够找到该部分的地址和长度

建议在这样使用时,前面下划线,避免C声明的符号冲突

需要区分开符号和section的区别!!!只在前面差了一个点,我曾经在编写ld脚本时,错把section写成了符号,这会导致根本不会分配一个section,一旦访问到这个section地址的内容就会page fault,并且bug也不是很好找,需谨记。

PROVIDE()****弱符号

  • 可以将符号定义封装在 PROVIDE() 函数中,使其成为“弱符号”,类似于 Clang 中的“弱符号”特性。它的逻辑是:“如果你没有定义这个变量,那就用我给你的这个默认值;如果你定义了,那就听你的,我这个就当没看见。”像一个备胎或者default值

对比:强赋值 vs PROVIDE

写法 A:强赋值(Strong Assignment)

```Plain Text _stack_top = 0x20001000;

- 含义:强制将符号 `_stack_top` 定义为这个地址。

- 冲突:如果你的 C 代码里也写了 `int _stack_top = 0;`,链接器会报错:"multiple definition of `_stack_top`"(多重定义错误)。

写法 B:PROVIDE(弱赋值)

```Plain Text
PROVIDE(_stack_top = 0x20001000);

  • 含义:如果没人定义 _stack_top,那它就是 0x20001000

  • 冲突:如果你的 C 代码里写了 int _stack_top = 0;,链接器会优先使用你的 C 代码定义,忽略脚本里的 0x20001000,且不会报错。

使用符号和LMA

如前所述,LMA 和 VMA 不一致的情况极其罕见。最常见的情况是,在类似微控制器的系统中运行时,内存被分为两部分:ROM 和 RAM。ROM 中烧录了可执行文件,而 RAM 初始状态则包含随机垃圾数据

链接的可执行文件的大部分内容是只读的,因此它们的 VMA 可以放在 ROM 中。

  • .data.bss 段需要放在 RAM 中,因为它们是可写的。

    • .bss 来说,这很容易,因为它没有可加载的内容。

    • 对于 .data ,我们需要将 VMA 和 LMA 分开:VMA 必须放在 RAM 中,而 LMA 放在 ROM 中。

这种区别对于初始化 RAM 的代码至关重要:而对于 .bss 文件,它只需要将其清零;而对于 .data ,它需要从 ROM 复制到 RAM!LMA 使我们能够区分复制源和复制目标。

MEMORY {
  rom : /* ... */
  ram : /* ... */
}

SECTIONS {
  /* .text and .rodata just go straight into the ROM. We don't need
     to mutate them ever. */
  .text : { *(.text) } > rom
  .rodata : { *(.rodata) } > rom

  /* .bss doesn't have any "loadable" content, so it goes straight
     into RAM. We could include `AT> rom`, but because the sections
     have no content, it doesn't matter. */
  .bss : { *(.bss) } > ram

  /* As described above, we need to get a RAM VMA but a ROM LMA;
     the > and AT> operators achieve this. */
  .data : { *(.data) } > ram AT> rom
}

/* The initialization code will need some symbols to know how to
   zero the .bss and copy the initial .data values. We can use the
   functions from the previous section for this! */

bss_start = ADDR(.bss);
bss_end = bss_start + SIZEOF(.bss);

data_start = ADDR(.data);
data_end = data_start + SIZEOF(.data);

rom_data_start = LOADADDR(.data);

通常我们会用汇编编写初始化代码,因为C代码执行之前需要初始化.bss和.data,以及设置一些栈,但是为了便于说明,下面用C语言编写了一段初始化代码

#include <string.h>

extern char bss_start[];
extern char bss_end[];
extern char data_start[];
extern char data_end[];
extern char rom_data_start[];

void init_sections(void) {
  // Zero the .bss.
  memset(bss_start, 0, bss_end - bss_start);

  // Copy the .data values from ROM to RAM.
  memcpy(data_start, rom_data_start, data_end - data_start);
}

linker相关杂项

其他链接器脚本功能

  • ENTRY() 函数设置程序入口点,可以是符号或原始地址。一般设置为一个外部符号比如ENTRY(_start)

  • INPUT(foo.o) 会将 foo.o 添加为链接器输入,就像它是通过命令行传递的一样

  • UTPUT() 会覆盖通常的 a.out 默认输出名称

  • ASSERT() 提供静态断言

脚本可使用的一些函数

  • ADDRLOADADDRSIZEOFALIGNOF 分别用于生成先前定义的部分的 VMA、LMA、大小和对齐方式。

  • ORIGINLENGTH ,分别用于生成内存区域的起始地址和长度。

  • MAXMIN 是显而易见的; LOG2CEIL 计算以 2 为底的对数,向上取整。

  • ALIGN(expr, align)expr 四舍五入到 align 的下一个倍数。 ALIGN(align) 大致等价于 ALIGN(., align) 但在 PIC 方面有一些细微差别。 . = ALIGN(align); 会将location counter与 align 对齐。

example

  1. 说了那么多,我们终于有了所有的前置知识,可以来看一个真正的ld脚本示例了

https://github.com/tock/tock/blob/master/boards/build_scripts/tock_kernel_layout.ld

  1. 下面给出一个linker脚本的错误示例:

我在使用gem5官方脚本时出现了访问栈错误,debug了很久,才定位到是ld脚本的问题

ENTRY(_start)
SECTIONS
{
  .text : {
    */bootloader.o(.text)
    *(.text)
    *(.rodata)
    *(.data)
    *(COMMON)
  }
  .bss : { *(.bss) }
  heap_low = .;
  . = . + 0x1000000;
  heap_top = .;
  . = . + 0x1000000;
  stack_top = .;
}

对于上面这样一个脚本,在编译成可执行程序之后会在访问栈的时候爆pagefault错误,试分析为什么

如何查看elf中某个地址是否有分配或者有映射,可以通过objdump -h看这个section是否有ALLOC属性

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         000006ce  0000000000000000  0000000000000000  00001000  2**1
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .bss          02000e40  00000000000006d0  00000000000006d0  000016ce  2**3
                  ALLOC

改成这样,就没错了,为什么

ENTRY(_start)
SECTIONS
{
  .text : {
    */bootloader.o(.text)
    *(.text)
    *(.rodata)
    *(.data)
    *(COMMON)
  }
  .bss : { *(.bss) }
  .heap_low :{
    . = . + 0x1000000;
  }
  heap_top = .;
  .stack :{
    . = . + 0x1000000;
  }
  stack_top = .;
}

可以看到新增的section

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000300  0000000000000000  0000000000000000  00001000  2**1
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .text.startup 000003ce  0000000000000300  0000000000000300  00001300  2**1
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .bss          00000e40  00000000000006d0  00000000000006d0  000016ce  2**3
                  ALLOC
  3 .heap_low     01000000  0000000000001510  0000000000001510  000016ce  2**0
                  ALLOC
  4 .stack        01000000  0000000001001510  0000000001001510  000016ce  2**0
                  ALLOC

cache介绍

当我们谈论 CPU/GPU 时,我们脑子里想的是它是‘计算的大脑’,在做加减乘除。

但如果我们看它的物理版图(Floorplan),会发现一个惊人的事实:真正的计算单元(ALU/Core)只占了很小一部分面积(往往不到 30%)。

“剩下的大片‘昂贵的硅片地皮’都被什么占据了?是 SRAM,是 Cache。”

引出问题:为什么工程师宁愿牺牲塞入更多计算核心的机会,也要塞入这么多 Cache?

以AMD锐龙5000系列,用于游戏或者高生产力要求任务,8核16线程

image.png

  • 可以看到cache,几乎占据了⅔的芯片面积

为什么会这样呢,就是因为Computation is Bottlenecked by Memory

  • 内存的发展速度远比不上处理器核心的发展速度

  • 随着AI的发展,许多workload都是数据密集型

  • 系统60%以上的功耗都花在了数据移动,对于Ml模型来说,更是达到90%以上

image.png

现在的内存基本上如下:

image.png

  • 内存容量越大,速度越慢,这是由晶体管特性决定的

  • 内存速度越快就越贵

  • 带宽越大越贵

一般情况,我们可以认为离CPU越近,越小的存储器,其访问延迟越小。那么最理想的情况我们把所有的存储器都放到cpu旁边不就行了吗,但是由于物理限制,一方面存储器越大访问越慢,另一方面cpu旁边只能放那么多东西,再多也放不下了。所以最终我们只能保留一个**小而快、最重要的数据存储器**供CPU高速读写,这就是cache

有了cache之后,CPU的内存操作就可以简化,先访问cache,如果需要的数据在里面,那么就直接取出而不去访问内存;如果数据不在cache里面,那么还是需要访问内存,同时把这块数据搬到cache里面,下一次再访问这个数据就不用访问内存了

以此类推,如果这个cache还是不够用,那么我再添加一块稍大一点,稍慢一点的存储部件,作为cache的cache...

那么通过这种分层,就形成了现代处理器的内存层次

Memory Hierarchy(内存层次)

image.png

“现在我们有了这些 Cache(L1/L2/L3),接下来的工程挑战是:当 CPU 想要找一个地址的数据时,它怎么知道这个数据在不在 Cache 里?如果在,它在 Cache 的哪个位置?

那么如何确定cache里面存哪些数据,CPU会高频地访问哪些数据,这就是cache优化的一个方向了

  • 时间局部性 - 访问一个存储单元后, 短时间内可能再次访问它(比如循环)

  • 空间局部性 - 访问一个存储单元后, 短时间内可能访问它的相邻存储单元(如数组)

cache的内部结构:cache说到底还是一个存储部件,通过输入的地址索引到数据,不过其容量很小,不可能容纳所有的地址,因此部分地址会索引到同一个数据,引发cache冲突

  1. cache如何通过地址找到数据

  2. cache如何解决冲突

如何找到数据?

cache的组织形式分为直接映射(一个地址,cache中只有一个地方可以容纳他)、组相联(一个地址,cache中有一组地方可以容纳他)、全相联(一个地址,cache中任何地方都可以容纳他)

image.png

cache把一个地址分成三个部分:

  • Index:用于第一步查找,索引到一个cache set

  • tag:在cache set里比较tag,找出真正相等的那一个cacheline

  • offset:一个cacheline通常是64Bytes,offset找出具体需要哪些byte

只有index和tag都相同才说明找到了,这个也很显然,比如对于一个32位地址,假设offset是低4位,对应16进制最低位,如下几个都对应一个cache中的数据块

0x12345670
0x12345671
0x1234567a
  1. 直接映射:只比较一次tag

  2. 组相联:需要比较多次tag,具体多少次取决于分成多少组(相联度)

  3. 全相联:不需要index,逐个比较tag

如下是一个 4-way 组相联的cache

可以引出cache的一些概念

  1. Cache set:一组cache blk

  2. Cache way:set中的编号,way 0,way 1...

  3. Cache line:访问cache的数据块大小,一次访问都是一个cacheline,一般为64Bytes

image.png

对路的访问可以分为并行访问与串行访问

  • 并行访问:同时访问tag和data,一旦tag比对成功直接出数据

  • 串行访问:先访问tag,比对成功后再读出数据

以上就是cache的基本结构了,在此基础上,为了提升cache性能,从多个角度进行了优化

AMAT(Average Memory Access Time)公式:AMAT=HitTime + MissRate * MissPenalty,很显然为了让平均访存时间变短,可以从几个方面优化

  1. 降低miss率

    1. 更多相联度

    2. 替换策略:如LRU(利用时间局部性)、FIFO

      • 现代处理器往往采用很简单的替换策略甚至随机替换,而不是一些前沿的替换算法,即使是LRU也会引入巨大的开销,带来的收益不大,远远不如直接增大cache容量效果好
    3. 硬件预取:如stridePrefecther

    4. Victim cache、hasing、 pseudo-associativity、skewed associativity

  2. 降低missPenalty

    1. 非阻塞缓存:MSHR
  3. 降低hitTime

    1. 并行访问tag和data

    2. 路预测

  4. 提升带宽:允许在同一周期发射多条load/store

    1. Cache banking:可以思考一个问题,“对于cache banking来说,不同的地址去不同的bank,那么会不会出现一种情况,在你访问的这个bank没有命中,但是实际上在其他bank会命中呢?” 这样还不如不分块

写回策略和write_allocator

之前谈论的都是找数据,如果是读很简单,那么对于写cache来说,Cache 里的数据只是内存的一个**副本**。如果我们在 Cache 里把数据改了,内存里的原版数据就变成了**旧数据(Stale)**。这时候,我们要不要立马告诉内存?还是等会儿再说?这就引出了 Cache 设计中写策略的不同决策

写回xxx

写直达xxx

写是否分配,玄铁920二者都支持,它还支持一种自适应的策略,即监测到一大堆写入则不分配以免挤掉csche里面的重要内容

先简单介绍一下各种优化,后面就结合gem5来讲吧

gem5中TLB也是很大一部分,在这里我们略过

在配置文件中可以通过self.use_virtual_addresses = False来设置直接使用物理地址,跳过TLB

各种memcmd分类:

  • Read

  • Write

  • Conherence

可以看一下Conherence Req与一般read/write req的区别

image.png

Memory Banking:(提升的是带宽)

问题:对于只有一个读写端口的最普通的存储器,在发起上一个访存请求到接受访存回复的这个过程,是不能发起下一个访存请求的

为了支持一个周期发起多个访存,为什么不是简单地增加读写端口,而是进行这种复杂的分块、地址映射?

  • 物理设计限制:SRAM的面积不是随端口数线性增长,而是以O\(N方\)的形式增长,因此增加端口数会导致内存面积大幅增长、功耗爆炸

  • 不能变成多端口,那么就使用多个单端口cache,通过较少的代价实现大部分效果

image.png

问题:一个大的memory访问时间长,并且不允许同时多个请求

目标:减少访问延迟、并且允许同时多个请求

idea:把一个大的memory分成多个小的bank,每个bank都是独立的

  • 相比原来的memory更小,访问速度也就更快

  • 由于每个bank独立,因此允许多个请求

关键问题:如何把数据map到不同的bank中,越均匀分布,效率也就越高

banking引入的开销:

  • 电路倍增:每个bank需要独立的解码器、放大器

  • bus更加复杂

MSHR流程

https://deepwiki.com/gem5/gem5/5.1-packet-and-request-transaction-model#timing-request-packet-flow

MSHR Lifecycle

  1. Allocation - MSHR::allocate() creates MSHR for new miss

  2. Target Addition - MSHR::allocateTarget() coalesces subsequent requests to same block

  3. Service - MSHRQueue::markInService() when downstream request sent

  4. Completion - BaseCache::serviceMSHRTargets() handles response and satisfies all targets

  5. Deallocation - MSHRQueue::deallocate() frees MSHR for reuse

write_buffer(writeQueue)负责 writebacks,evictions,uncacheable writes

  • 在写到下级缓存或者memory之前作为一个buffer保存他们,减少因为等待写cache造成的阻塞,写到write_buffer之后CPU就可以去干别的事了

  • write合并:如果有多个相同目标的write,writebuffer可以将他们合并(比如都写到一个cacheline里面)

  • 如果write_buffer也满了,那么就会阻塞cache

Uncacheable writes一般来说是直接和总线交互,因此速度非常慢,可以将其直接写到write_buffer中,让CPU不需要一直等待这个结果

920的内存架构是L1私有、L2共享,不光920,现在很多CPU都是最后一级cache共享,可以想一下为什么L1不是共享、L2不是私有?当然这都是针对多核,如果是单核就没有私有和共享之分了

为什么L2不是private:Private vs shared(多个private放一起):

  • Private cache 更小,数据访问更快

  • 共享cache能够消除重复数据的存储,提升有效容量;便于数据共享,core A往里面写,写完core B直接读,核间通信更快

  • 为什么L1不是一个巨大的共享cache,但是通过多bank实现并行访存,原因就在“大”上面。越大越慢,CPU不能忍受这么慢的访存,但是这个设计其实是合理的,GPU就采用这样的设计,相比CPU来说,它对访存的要求没那么高,但是共享cache带来的容量提升是它需要的

image.png

一致性相关

在多核情况下,L1 cache私有,L2共享;一个数据副本可能同时存在于多个私有的L1 cache,那么如何维护这个副本的一致性,比如core A写了这个共享数据,如何通知core B等等

首先要介绍write-back和write-through,所有的一致性策略都是针对write-back,如果是write-through,内存中永远是最新的,就没有必要搞这么复杂的协议了

什么时候写回,如何写回?修改这个状态会发生什么,读这个状态会发生什么

MSI最简单的一致策略:

  • 各个cache观察彼此的读写操作,如果某个core需要修改某个块,通知其他所有core使这个块无效

  • 数据块状态分类:

    • Valid

      • M:Modified/dirty

      • S:clean/shared

    • I:invalid

  • 缺点:一直广播操作,即使其他core没有这个数据块,也会通知所有core,浪费带宽

MESI:

  • 数据块状态新加入E:表示独占,如果一个数据块是独占的,那么修改它就不用广播,不需要通知任何人

    • 也就是把clean细分了,其他不变

      • E:Clean Exclusive

      • S:Clean Shared

  • 缺点:考虑两个核心都想用脏数据的情况

    • Core A 修改了 X \(状态 **M**\)

    • Core B 想要读 X,由于其状态为M,需要先写回内存,Core B在从内存读(状态S)

    • 实际上可以直接让Core A从缓存中把数据给Core B,避免走慢速的内存

MOESI:为了解决上述情况,对M状态进行细分

  • modified:

    • M:独占脏数据

    • Owned:shared脏数据,但是以后写回这个数据块,还是归我管

snoop和基于目录的一致性传输:

  • 上述方法的优化总结来说是,MSI到MESI减少了广播次数、MESI到MOESI减少了访存次数

  • 但是无论怎么说,还是在广播,即使某一个核与这次snoop无关,其缓存控制器也会一直监听共享总线

  • 那么能否只通知需要参与进来的core呢,类似点对点传输?可以,这就是基于目录的一致性传输

引入目录,维护每个内存块的全局状态(对于每个数据块,哪些缓存持有该块副本以及处于什么状态);广播->单播

例子:

image.png

流程:

  1. 计算Home Node(寻找目录):对地址进行计算,可以得到该地址落在哪个目录中

  2. 发送单播请求

  3. 目录查询:查询缓存块状态

  4. 转发:别的core修改了该缓存块数据,转发请求

  5. 响应:发起者收到数据,响应方可能更新状态

Classic cache的MOESI:snoop based

  • M:exclusive dirty,modified

  • O:shared dirty,Owned

  • E:ExClusive clean

  • S:shared clean

  • I:invalid

任何时候M和O在系统所有的数据块中,只能有一个;而E指的是在同一层级只有一个,但是其上层或者下层也可以有一个,比如L1 有这个 cache line 的 E 副本,L2 也有 E 副本

gem5

*  state   writable    dirty   valid
 *  M       1           1       1
 *  O       0           1       1
 *  E       1           0       1
 *  S       0           0       1
 *  I       0           0       0

共享的情况下可读不可写,独占的情况下可读可写

Ruby Memory System简介

ruby与CPU是两个独立的模块,与classic集成在cpu中不同,ruby是单独的

sequencer负责把gem5的packets转换为rubyRequests

image.png

image.png

gem5与硬件仿真通用测试程序

gem5对于通用测试程序的要求:最终是一个elf形式,需要是裸机可运行程序,如果有编译工具链独特的csr,需要提前编译到gem5,不然gem5不认识会报错

Get started

获取源码:

git clone https://github.com/Ywinh/gem5-resources.git
git checkout develop
cd src/simple

编译工具链安装:

需要先安装riscv gnu toolchain或者xuantie toolchain

  1. riscv gnu toolchain:https://github.com/riscv-collab/riscv-gnu-toolchain

    • 需要指定安装32位且有-march=rv32imf -mabi=ilp32f的编译选项
  2. xunatie:官网下载编译好的二进制,开箱即用

    • https://www.xrvm.cn/community/download?id=4460156621967921152

Image

编译自己的程序

  1. 在 src/simple 下编写好 .c 文件

  2. 编译

make ISA=riscv BARE_INS=my_coremark bare CCFLAGS_ISA="-march=rv32imf -mabi=ilp32f"
  • 编译**32位**程序:make ISA=riscv BARE_INS=my_coremark bare CCFLAGS_ISA="-march=rv32imf -mabi=ilp32f"

  • 编译**64位**程序:make ISA=riscv BARE_INS=920_test_compact bare CCFLAGS_ISA="-march=rv64imf -mabi=lp64f"

-march 和 -mabi 可自己修改

需要关注

src/simple/Makefile

128行指定所用工具链前缀
*# PREFIX = riscv32-unknown-linux-gnu-*
PREFIX = /home/yinjianhui/Xuantie-900-gcc-elf-newlib-x86_64-V3.2.0/bin/riscv64-unknown-elf-

src/simple/bootloader/riscv.S:如下黄色部分是开启906的某些feature,需要使用xuantie工具链;去掉黄色部分则可以使用官方工具链编译

.global _start
.section .text

_start:
    *# enable write allocate*
    *# li x3, 0x4*
    *# csrs 0x7c1,x3*
    li x3, 0x11ff
    csrs mhcr,x3

    *# enable lbuf,way_pred,data_cache_prefetch, amr*
    *# li x3, 0x7e30c*
    *# csrs 0x7c5,x3*
    li x3, 0x6e30c
    csrs mhint,x3

    la sp, stack_top

    li a0, 0

    call main

exit:
    fence                                                       
    li a7, 93
    li a0, 0        
    ecall

920 riscv-coremark

from:https://github.com/riscv-boom/riscv-coremark

Usage

  • 可以打开浮点即DHAS_FLOAT=1,不然计时不准确,从而导致跑分不准确

  • 在build-coremark.sh修改BARE_ITERATIONS,实测至少500轮,才能跑出结果

```Plain Text git clone git@github.com:Ywinh/riscv-coremark.git enable_920={0/1} BAREMETAL_ENABLE_PRINTF={0/1} ./build-coremark.sh

> - baremetal 版本再参数化,兼顾 gem5 和玄铁 920 硬件。
> 
> - 新增了 enable\_920 开关;打开时,启动阶段会写一组 920 专有 CSR,默认关闭,所以 gem5 默认不会碰这些 CSR,riscv64\-baremetal/crt\.S:43 build\-coremark\.sh:11。
> 
> - 新增了 BAREMETAL\_ENABLE\_PRINTF,把 stdio/printf/stats 做成可选,适合“硬件上没法方便打印”的场景,
> 
> 

为了能够在gem5运行,做的修改:

- 把 baremetal 端口从原来偏 spike/pk 的运行方式,改成更适合 gem5 直接加载的裸机程序。

- 启动代码大幅简化成 \_start \-\> main \-\> ecall exit,去掉了原先复杂的 trap/TLS/tohost/fromhost 逻辑,riscv64\-baremetal/crt\.S:1。

- 把 syscall 实现改成直接走 RISC\-V Linux ABI 风格的 ecall write/exit,而不是 proxy\-kernel 的 magic memory/tohost 机制,riscv64\-baremetal/syscalls\.c:15。

- 重写了链接脚本,固定从 0x80000000 开始布局,与gem5\-resources差不多,显式给出 \.text/\.rodata/\.data/\.bss/heap/stack,这是裸机镜像在 gem5 里运行需要的内存模型,riscv64\-baremetal/link\.ld:1。

- 把 baremetal 编译切到玄铁的 riscv64\-unknown\-elf 工具链,配上 \-march=rv64imf \-mabi=lp64f \-DHAS\_FLOAT=0,并用 \-nostdlib \-nostartfiles \-T link\.ld \-lgcc 做纯裸机链接,build\-coremark\.sh:7 riscv64\-baremetal/core\_portme\.mak:42。

- 把 Linux 版 coremark\.riscv 的构建补齐了 ISA/ABI 参数,并加了 toolchain\-compat/bin 这套 wrapper 来兼容工具链子程序查找,build\-coremark\.sh:17



## 踩过的坑(均已解决)

1. **尝试自编译测试程序的错误总结**

    1. C程序:https://github\.com/gem5/gem5\-resources/tree/develop/src/simple

        1. 在simple目录下写一个C程序,\.s程序以及\.ld脚本,复用该目录下的makefile,需要更改下riscv工具链的prefix

            - 编译`make ISA=riscv BARE_INS=xxx bare`,成功编译

            - 运行时出错,gem5中会报一个pagefault的错误,报错信息与trace如下

            ```YAML
            88000: board.processor.cores.core: T0 : 0x80000000 @_start    : auipc sp, 8192             : IntAlu :  D=0x0000000082000000
              89000: board.processor.cores.core: T0 : 0x80000004 @_start+4    : addi sp, sp, 26            : IntAlu :  D=0x000000008200001a
              90000: board.processor.cores.core: T0 : 0x80000008 @_start+8    : c_li a0, 0                 : IntAlu :  D=0x0000000000000000
              91000: board.processor.cores.core: T0 : 0x8000000a @_start+10    : jal ra, 6                  : IntAlu :  D=0x000000008000000e
             154000: board.processor.cores.core: T0 : 0x80000010 @main    : c_addi sp, -16             : IntAlu :  D=0x000000008200000a
            src/sim/faults.cc:102: panic: panic condition !handled && !tc->getSystemPtr()->trapToGdb(GDBSignal::SEGV, tc->contextId()) occurred: Page table fault when accessing virtual address 0x82000016
            Memory Usage: 1190184 KBytes
            ```

            - 定位到是sw指令访问栈上的某个地址时,访问到了一个没有映射的虚拟地址。这个比较奇怪,起初以为是栈地址太大超出了内存范围,尝试减小栈起始地址还是不能解决。

            ```C++
            // fib.c
            int main(void) {
                volatile int a = 0;
                return a;
            }

            // riscv.S
            .global _start
            .section .text

            _start:
                la sp, stack_top

                li a0, 0

                call main

            loop:
                j loop

            // ld脚本
            ENTRY(_start)
            SECTIONS
            {
              .text : {
                */bootloader.o(.text)
                *(.text)
                *(.rodata)
                *(.data)
                *(COMMON)
              }
              .bss : { *(.bss) }
              heap_low = .;
              . = . + 0x1000000;
              heap_top = .;
              . = . + 0x1000000;
              stack_top = .;
            }
            ```

        - 最后编译出来长这样

        ```YAML
        out/riscv/bare/fib.out:     file format elf64-littleriscv


        Disassembly of section .text:

        0000000080000000 <_start>:
            80000000:   02000117                auipc   sp,0x2000
            80000004:   01a10113                addi    sp,sp,26 # 8200001a <stack_top>
            80000008:   4501                    li      a0,0
            8000000a:   006000ef                jal     80000010 <main>

        000000008000000e <loop>:
            8000000e:   a001                    j       8000000e <loop>

        Disassembly of section .text.startup:

        0000000080000010 <main>:
            80000010:   1141                    addi    sp,sp,-16
            80000012:   c602                    sw      zero,12(sp) # pagefault在这里出现
            80000014:   4532                    lw      a0,12(sp)
            80000016:   0141                    addi    sp,sp,16
            80000018:   8082                    ret
        ```

    2. 汇编:https://github\.com/gem5/gem5\-resources/tree/develop/src/asmtest

        1. 首先像benchmark这些用不了,他们的系统调用很多,不能简单地处理成一个nop,我猜测如果这样处理会造成结果误差很大,甚至都不能正常运行

        2. 尝试自己写一个汇编,遵守其macro格式,但是编译时报错`invalid -march= option: `rv32g'`,查了下是riscv工具链的问题,尝试重新编译,还是有报错,已解决

    3. 目前的方法:在https://resources\.gem5\.org/页面直接下载编译好的指令集test,像是https://resources\.gem5\.org/resources/rv64uf\-p\-move?version=1\.0\.0。它们能够满足测试程序的要求



2. C程序编译bug复现

    1. 编译:

    ```C++
    cd ~/gem5-resources/src/simple
    make clean
    make make ISA=riscv BARE_INS=fib bare
    ```

    2. 运行

    ```C++
    cd ~/gem5
    ./build/RISCV/gem5.opt configs/906/run.py 
    ```

在run\.py更改文件路径可以改变workload

```C++
board.set_se_binary_workload(
    binary=BinaryResource(
        # local_path="/home/yinjianhui/gem5-resources/src/simple/out/riscv/bare/fib.out"
        # local_path="/home/yinjianhui/gem5-resources/src/simple/out/riscv/user/hello.out"
        local_path="/home/yinjianhui/gem5/riscv-test/FloatMM"
    )
)

  1. 后续可能的解决方案(TODO):通过汇编来编译测试程序

    1. 首先解决编译问题

      1. 选项1:解决C程序运行问题,C程序是可以正常运行的,但是会pagefault以及不能正常退出,试着解决这个问题**(源流解决了)**

        • 把entry从0x80000000改到了0x0就解决了,这样使得访存时栈位于0-1G这个范围。原先的0x80000000这个地址超出了这个范围。但是目前的行为还是有些奇怪,取指令的时候有地址翻译,第一条0x80000000这个指令可以取出来并且运行,但是到了访存时就会pagefault

          • **目前推测是**gem5 load elf时是真正放在cpu上而不是配置的memory上面,这造成了取指令时这个访问mem的行为是可以的,但是访存时访问到了配置的memory,这会造成pagefault,有空时可以详细看看

          • 和ld脚本有很大关系,必须

      2. 选项2:解决汇编编译问题。从已有的汇编分析ld脚本、汇编入口和退出有什么要求,中间的地方就可以自定义了,还要利用已有的makefile脚本**(已解决)**

        • 编译asmtest要求,需要编译multilib
        ./configure --prefix=/opt/riscv \
                    --with-arch=rv64gc_zba_zbb_zbc_zbs_zfh_zicboz \
                    --with-abi=lp64d \
                    --enable-multilib
        make linux install
        
        • 程序入口没有要求,退出需要调用exit,即li a7, 93然后再ecall,这样就能从系统退出

性能建模分享

  1. 硬件与gem5通用测试程序的生成

  2. 负载程序要求:要求是ELF

    • 需要是支持的ISA+ABI组合,RISC-V 只接收 Riscv32/64 + Linux

    • elf内部布局没有特别要求,正常用户态elf,需要有加载段PT_LOAD,e_entry等,可选PT_INTERP(动态链接)

  3. 程序入口:ELF内部的e_entry,一般对应bootloader里面设置的_start

    • 程序加载:

    • python层根据isa和sys创建一个se workload,然后交给c++实例化一个对象。ELF相关类负责解析这个对象,从中获取entry,内存布局,把程序搬到内存中。激活线程,初始化页表等...

    • 接着初始化栈,初始pc值:通过 Process::getStartPC() 返回值:如果 ELF 有 PT_INTERP,就先跳到解释器入口,即动态链接程序先跑 ld.so;否则跳到主程序入口即_start。

  4. 程序退出:分为两种,exit syscall和m5_exit

    • Exit syscall:走系统调用,根据系统调用号找到对应的处理程序,halt当前线程,如果系统已经没有活动线程则退出模拟

    • m5_exit:执行gem5的特定op,直接退出模拟

例子

_start:

    # 控制寄存器初始化
    li x3, 0x70013
    csrs mcor,x3

    # enable write allocate
    # li x3, 0x4
    # csrs 0x7c1,x3
    li x3, 0x11ff
    csrs mhcr,x3

    # performence counter初始化
    li x3,0x1
    csrs 0x323, x3      # mhpmcnt3 L1 ICache Access
    ...

    # 设置栈指针
    la sp, stack_top

    # gpr初始化为0,避免仿真出现X
    li x1,0
    ...

    # 跳转到main函数,你自己的代码
    li a0, 0

    call main

exit:
    fence                                                       
    li a7, 93
    li a0, 0        
    ecall

踩过的坑

  • 链接错误:如果执意使用m5_exit,链接时需要gem5提前编好的一个.so,比较麻烦,后改用exit syscall解决

  • 执行时pagefault:一般是某个段没有分配地址,需要检查ld脚本,什么算是分配一段地址?下面二者的区别

    ```Plain Text

    ENTRY(_start) SECTIONS { .text : { */bootloader.o(.text) *(.text) *(.rodata) *(.data) *(COMMON) } .bss : { *(.bss) } heap_low = .; . = . + 0x1000000; heap_top = .; . = . + 0x1000000; stack_top = .; }

    ENTRY(_start) SECTIONS { .text : { */bootloader.o(.text) *(.text) *(.rodata) *(.data) *(COMMON) } .bss : { *(.bss) } .heap_low :{ . = . + 0x1000000; } heap_top = .; .stack :{ . = . + 0x1000000; } stack_top = .; } ```

  • gem5执行没问题,硬件仿真时出现X信号:gpr没有初始化,在硬件中会表现为X值,而不是0

gem5执行后有用的信息

  • 时间与总量:模拟时间、频率、提交了多少条、ipc(instructions per cycle)

  • 指令组成:

    • Load 占比 = numLoadInsts / numInsts

    • Store 占比 = numStoreInsts / numInsts

    • Int 占比 = numIntInsts / numInsts

    • FP 占比 = numFpInsts / numInsts

    • Vector 占比 = numVecInsts / numInsts

    • Branch 占比 = numBranches / numInsts

    • 条件分支占比 = committedControl::IsCondControl / numInsts

  • 分支行为:主要关注btb和预测准确度

    • BTB

    • 直接分支

    • 间接分支:指的是跳转目标地址需要从寄存器中读取,比如jalr

    • RAS

    • Loop predictor

  • cache行为:L1/L2

    • 总的access次数

    • 总的hit次数

    • 总的miss次数

  • DRAM行为

处理器前端和后端的概念

  • 前端:负责“把活接进来、看懂、分发出去”

  • 后端:负责“真正干活、算结果、访问数据、写回结果”

gem5仿真速度?

  • 90w insts/s

  • 后续可以通过simpoint、kvm等加速?

测试程序的选择?

理想情况下就是根据真实负载,比如别的组出的case,在gem5上面跑,跑完和硬件对比。但是目前gem5还没有接入仿真器,硬件环境也没有就绪。我们只能对单个CPU进行测试,衡量这个CPU建模是不是够准确。

测试程序的底层逻辑是,需要是一个可控的,参数化的行为发生器,通过这种测试程序能够区分开准确的建模和模糊的建模

  1. 用一小组很明确的计算/访存/控制流模式,持续重复执行足够长时间。

  2. 每种模式只突出一类主要硬件行为,尽量压低其他干扰因素。

  3. 所有关键行为都能用少量参数调节,从而扫出一条“响应曲线”,而不是只看一个点。

  4. 程序行为在每次运行中尽量一致,这样 gem5 和真实硬件的差异才更可能来自建模,而不是来自程序随机性或 OS 噪声。

性能建模:如何最大程度对齐到微架构,从手册里面获取各种需要的参数信息,总结为一个表。但是手册不全,一般只会给出大概的信息,然后建模需要一些很具体的参数,比如btb有多少个entry,物理寄存器有多少个,需要从RTL中获取。如果没有RTL,就需要通过测试程序,进行CPU逆向,试出来这些参数了

在进行性能建模时,需要关注这些问题

  • 性能差异较大时,如何找出怀疑的地方,性能差异出现在gem5的哪些指标

  • 不同指令的延时测量,计算指令延时很好测量,但是到了O3CPU时,load/store指令,retire时通常不代表真的完成了load和store

  • 如果模拟器架构和待建模的cpu架构有差异,是修改模拟器架构还是修改模拟器参数?

    • 迭代到后面,模拟器的规模也非常大,修改架构可能不比修改rtl简单,如何选择

gem5与硬件的校准体现在两个地方

  1. gem5与硬件都有的架构:参数高度保持一致

  2. gem5与硬件不共有的架构:先评估是否由于此处的误差,从而影响了整体程序的误差。如果是,能否在不修改gem5架构的情况下逼近

粗糙的对齐方法:

简单对比gem5与硬件的统计数据,一个个猜可能出现误差的地方,比如发现icache的访问次数不对,就要怀疑这一整条链路上,prefetcher、icache配置(way,大小,tag和data latency,lsq等),一个个改参数看是否能缩小差异

此过程需要先分析理论值、再看硬件和gem5

906例子:程序特征是lw多,sw少

  1. icache访问次数对不上,gem5差不多是906的⅓,首先怀疑每个周期取指令的宽度是否一样,是否有合并icache访问次数这一说法。后来发现是gem5每次默认取一个cacheline,而不是一条指令,将 fetch1LineSnapWidth 从0(这个值为0时代表使用cacheline size)改到了8,这样就能实现每次取两条指令,不过这种地缺点是没有模拟如果这两条指令是跨cacheline的,则只会取第一条指令,第二条下次再取,但是误差很小可以不考虑

修改之后icache次数从 413526 -> 1303136,十分接近硬件的1305959

  1. dcache访问次数对不上:906是gem5的½。首先还是怀疑访问粒度不一样,但是check了一下发现都是正确的,然后查看preftch,cache配置等都没有发现问题,只能怀疑是不是硬件有合并load或者store访存的机制,又或者是统计口径就不同

    1. 硬件有合并load/store机制?

      1. 有load-store forwarding(后续的load请求命中store buffer,直接返回数据,不会加一次cache访问请求),但是gem5这种情况会增加一次cache访问请求

      2. 有store同target合并,gem5没有,这也是误差之一

以上次数都能对上之后,发现时间差得还是比较多,于是写了脚本,记录gem5中每条指令执行时间,与硬件每条指令执行时间进行对比,发现大部分误差来源于lw

  1. dcache访问时间对不上

首先分析各个功能单元指令数量占比

然后分析时间瓶颈,分析每个pc的执行次数和执行时间,主要是在cache,从0.0048降到0.0026,一共有三次主要修改

image.png

  1. 将dcache的data latency和tag latency从2改为1,tag和data并行访问,修改之后从0.0048->0.0038

  2. 对icache的data latency和tag latency进行同样的修改,修改之后从0.0038->0.0033

  3. 允许outstanding的memory请求,即允许上一个lw还没有完全返回,下一个lw也发到lsq上,修改之后从0.0033->0.0026

发现有很多连续的lw,906一般一个cycle就处理了一条load,而gem5通常是需要3~4个cycle。

920例子;

  1. l1 icache访问次数:硬件值 2988328,gem5 1744278

  2. l1 dcache load miss:硬件值 79207,gem5 114743

    1. gem5比硬件高了45%,gem5 miss太多,可能原因

    2. 排查下load access是否对齐,是的

    3. prefetch对齐?是否是因为硬件的prefetch提前将数据load进来,导致miss减少?

    4. 推测:硬件对load miss有合并机制,write a case verify it?

      gem5也有合并机制,可能是gem5的load miss合并不够,有一个奇怪的点,miss次数更多,但是耗时反而更少,why?

      1. miss合并机制在gem5中表现为mshr,如何逼近到硬件的mshr? 当同一个 cache block 已经有一个未完成 miss 时,后续对同一 block 的请求会命中已有 MSHR,而不是再新开一个 miss

      2. mshr指标是什么:

      self.mshrs = 8  # max outstanding misses [required]
      self.tgts_per_mshr = 2  # targets per MSHR [required] 一个mshr可以合并多少个target
      

    通过把mshr不变,tgts_per_mshr变小,既达到了减小miss次数,也达到了时间延长,why?

  3. dram未对齐

    硬件没有建模,只是外挂了一个vip,不存在真实的DRAM

    • 但是内部DDR4有合法性检查,不能真的把延时设为0,那么把这个延时设置得很小,ps级别,基本可忽略

      • 为什么仅仅修改延时,也会导致访存次数这些的改变
    • 尝试更改ddr4为一个简单memory,但是这会导致访存次数这些也有很大的改变,why?

      • ddr内部有bank,以及对burst的拆分

这里的CPU逆向其实和测试程序的选取关系很大,首先介绍下CPU架构逆向的定义

  • 已知微架构采用某种设计,但是不知道其具体的设计参数

  • 不确定微架构采用的设计,给出一些可能的设计,逐个排除和确认

我们建模时遇到的问题主要还是第一种,遇到这种问题时,需要选用或者编写合适的benchmark

需要思考的问题

  1. 针对什么微架构部件

  2. 针对该部件的什么设计参数

  3. 反映出现差别的指标是什么

  4. 程序在什么情况下会导致瓶颈出现,程序的参数如何对应到设计参数

  5. 如何校准指标?

比如上面的 L1 DCache 容量的测试上,这几个要素的回答是:

  1. 针对什么微架构部件:L1 DCache

  2. 针对该部件的什么设计参数:L1 DCache 的容量

  3. 反映出现瓶颈的指标是什么:时间,周期数,缓存缺失次数

  4. 如何构造程序来导致瓶颈出现:在内存中开辟数组,然后不断地扫描访问

  5. 程序在什么情况下会导致瓶颈出现:数组大小超过 L1 DCache 容量

  6. 程序的参数如何对应到设计参数上:数组的大小对应到 L1 DCache 的容量

逆向的意义不仅仅在于逆向,还在于对齐微架构参数,以及暴露单一因素是否是造成性能差异的原因。当一个benchmark跑上去时,很可能某个地方跑的时间多了,但是因为另一个未对齐地方执行时间更少,从而掩盖了这部分差异,如果需要针对任何负载程序都做到非常精确,这种一个个小部件的对齐是很重要的。

举个例子:https://zhuanlan.zhihu.com/p/720136752

假如不知道CPU每个周期取多少条指令,怎么测量呢。我们是不是应该设计一个程序,我们需要找到一种方式精确控制取回的指令的数量,并让其不足以供给后端的指令执行从而产生瓶颈。

cache的平均访存时间,其实很难测量

性能建模分享

  1. 硬件与gem5通用测试程序的生成

  2. 负载程序要求:要求是ELF

    • 需要是支持的ISA+ABI组合,RISC-V 只接收 Riscv32/64 + Linux

    • elf内部布局没有特别要求,正常用户态elf,需要有加载段PT_LOAD,e_entry等,可选PT_INTERP(动态链接)

  3. 程序入口:ELF内部的e_entry,一般对应bootloader里面设置的_start

    • 程序加载:

    • python层根据isa和sys创建一个se workload,然后交给c++实例化一个对象。ELF相关类负责解析这个对象,从中获取entry,内存布局,把程序搬到内存中。激活线程,初始化页表等...

    • 接着初始化栈,初始pc值:通过 Process::getStartPC() 返回值:如果 ELF 有 PT_INTERP,就先跳到解释器入口,即动态链接程序先跑 ld.so;否则跳到主程序入口即_start。

  4. 程序退出:分为两种,exit syscall和m5_exit

    • Exit syscall:走系统调用,根据系统调用号找到对应的处理程序,halt当前线程,如果系统已经没有活动线程则退出模拟

    • m5_exit:执行gem5的特定op,直接退出模拟

例子

_start:

    # 控制寄存器初始化
    li x3, 0x70013
    csrs mcor,x3

    # enable write allocate
    # li x3, 0x4
    # csrs 0x7c1,x3
    li x3, 0x11ff
    csrs mhcr,x3

    # performence counter初始化
    li x3,0x1
    csrs 0x323, x3      # mhpmcnt3 L1 ICache Access
    ...

    # 设置栈指针
    la sp, stack_top

    # gpr初始化为0,避免仿真出现X
    li x1,0
    ...

    # 跳转到main函数,你自己的代码
    li a0, 0

    call main

exit:
    fence                                                       
    li a7, 93
    li a0, 0        
    ecall

踩过的坑

  • 链接错误:如果执意使用m5_exit,链接时需要gem5提前编好的一个.so,比较麻烦,后改用exit syscall解决

  • 执行时pagefault:一般是某个段没有分配地址,需要检查ld脚本,什么算是分配一段地址?下面二者的区别

    ```Plain Text

    ENTRY(_start) SECTIONS { .text : { */bootloader.o(.text) *(.text) *(.rodata) *(.data) *(COMMON) } .bss : { *(.bss) } heap_low = .; . = . + 0x1000000; heap_top = .; . = . + 0x1000000; stack_top = .; }

    ENTRY(_start) SECTIONS { .text : { */bootloader.o(.text) *(.text) *(.rodata) *(.data) *(COMMON) } .bss : { *(.bss) } .heap_low :{ . = . + 0x1000000; } heap_top = .; .stack :{ . = . + 0x1000000; } stack_top = .; } ```

  • gem5执行没问题,硬件仿真时出现X信号:gpr没有初始化,在硬件中会表现为X值,而不是0

gem5执行后有用的信息

  • 时间与总量:模拟时间、频率、提交了多少条、ipc(instructions per cycle)

  • 指令组成:

    • Load 占比 = numLoadInsts / numInsts

    • Store 占比 = numStoreInsts / numInsts

    • Int 占比 = numIntInsts / numInsts

    • FP 占比 = numFpInsts / numInsts

    • Vector 占比 = numVecInsts / numInsts

    • Branch 占比 = numBranches / numInsts

    • 条件分支占比 = committedControl::IsCondControl / numInsts

  • 分支行为:主要关注btb和预测准确度

    • BTB

    • 直接分支

    • 间接分支:指的是跳转目标地址需要从寄存器中读取,比如jalr

    • RAS

    • Loop predictor

  • cache行为:L1/L2

    • 总的access次数

    • 总的hit次数

    • 总的miss次数

  • DRAM行为

处理器前端和后端的概念

  • 前端:负责“把活接进来、看懂、分发出去”

  • 后端:负责“真正干活、算结果、访问数据、写回结果”

gem5仿真速度?

  • 90w insts/s

  • 后续可以通过simpoint、kvm等加速?

测试程序的选择?

理想情况下就是根据真实负载,比如别的组出的case,在gem5上面跑,跑完和硬件对比。但是目前gem5还没有接入仿真器,硬件环境也没有就绪。我们只能对单个CPU进行测试,衡量这个CPU建模是不是够准确。

测试程序的底层逻辑是,需要是一个可控的,参数化的行为发生器,通过这种测试程序能够区分开准确的建模和模糊的建模

  1. 用一小组很明确的计算/访存/控制流模式,持续重复执行足够长时间。

  2. 每种模式只突出一类主要硬件行为,尽量压低其他干扰因素。

  3. 所有关键行为都能用少量参数调节,从而扫出一条“响应曲线”,而不是只看一个点。

  4. 程序行为在每次运行中尽量一致,这样 gem5 和真实硬件的差异才更可能来自建模,而不是来自程序随机性或 OS 噪声。

性能建模:如何最大程度对齐到微架构,从手册里面获取各种需要的参数信息,总结为一个表。但是手册不全,一般只会给出大概的信息,然后建模需要一些很具体的参数,比如btb有多少个entry,物理寄存器有多少个,需要从RTL中获取。如果没有RTL,就需要通过测试程序,进行CPU逆向,试出来这些参数了

在进行性能建模时,需要关注这些问题

  • 性能差异较大时,如何找出怀疑的地方,性能差异出现在gem5的哪些指标

  • 不同指令的延时测量,计算指令延时很好测量,但是到了O3CPU时,load/store指令,retire时通常不代表真的完成了load和store

  • 如果模拟器架构和待建模的cpu架构有差异,是修改模拟器架构还是修改模拟器参数?

    • 迭代到后面,模拟器的规模也非常大,修改架构可能不比修改rtl简单,如何选择

gem5与硬件的校准体现在两个地方

  1. gem5与硬件都有的架构:参数高度保持一致

  2. gem5与硬件不共有的架构:先评估是否由于此处的误差,从而影响了整体程序的误差。如果是,能否在不修改gem5架构的情况下逼近

粗糙的对齐方法:

简单对比gem5与硬件的统计数据,一个个猜可能出现误差的地方,比如发现icache的访问次数不对,就要怀疑这一整条链路上,prefetcher、icache配置(way,大小,tag和data latency,lsq等),一个个改参数看是否能缩小差异

此过程需要先分析理论值、再看硬件和gem5

906例子:程序特征是lw多,sw少

  1. icache访问次数对不上,gem5差不多是906的⅓,首先怀疑每个周期取指令的宽度是否一样,是否有合并icache访问次数这一说法。后来发现是gem5每次默认取一个cacheline,而不是一条指令,将 fetch1LineSnapWidth 从0(这个值为0时代表使用cacheline size)改到了8,这样就能实现每次取两条指令,不过这种地缺点是没有模拟如果这两条指令是跨cacheline的,则只会取第一条指令,第二条下次再取,但是误差很小可以不考虑

修改之后icache次数从 413526 -> 1303136,十分接近硬件的1305959

  1. dcache访问次数对不上:906是gem5的½。首先还是怀疑访问粒度不一样,但是check了一下发现都是正确的,然后查看preftch,cache配置等都没有发现问题,只能怀疑是不是硬件有合并load或者store访存的机制,又或者是统计口径就不同

    1. 硬件有合并load/store机制?

      1. 有load-store forwarding(后续的load请求命中store buffer,直接返回数据,不会加一次cache访问请求),但是gem5这种情况会增加一次cache访问请求

      2. 有store同target合并,gem5没有,这也是误差之一

以上次数都能对上之后,发现时间差得还是比较多,于是写了脚本,记录gem5中每条指令执行时间,与硬件每条指令执行时间进行对比,发现大部分误差来源于lw

  1. dcache访问时间对不上

首先分析各个功能单元指令数量占比

然后分析时间瓶颈,分析每个pc的执行次数和执行时间,主要是在cache,从0.0048降到0.0026,一共有三次主要修改

image.png

  1. 将dcache的data latency和tag latency从2改为1,tag和data并行访问,修改之后从0.0048->0.0038

  2. 对icache的data latency和tag latency进行同样的修改,修改之后从0.0038->0.0033

  3. 允许outstanding的memory请求,即允许上一个lw还没有完全返回,下一个lw也发到lsq上,修改之后从0.0033->0.0026

发现有很多连续的lw,906一般一个cycle就处理了一条load,而gem5通常是需要3~4个cycle。

920例子;

  1. l1 icache访问次数:硬件值 2988328,gem5 1744278

  2. l1 dcache load miss:硬件值 79207,gem5 114743

    1. gem5比硬件高了45%,gem5 miss太多,可能原因

    2. 排查下load access是否对齐,是的

    3. prefetch对齐?是否是因为硬件的prefetch提前将数据load进来,导致miss减少?

    4. 推测:硬件对load miss有合并机制,write a case verify it?

      gem5也有合并机制,可能是gem5的load miss合并不够,有一个奇怪的点,miss次数更多,但是耗时反而更少,why?

      1. miss合并机制在gem5中表现为mshr,如何逼近到硬件的mshr? 当同一个 cache block 已经有一个未完成 miss 时,后续对同一 block 的请求会命中已有 MSHR,而不是再新开一个 miss

      2. mshr指标是什么:

      self.mshrs = 8  # max outstanding misses [required]
      self.tgts_per_mshr = 2  # targets per MSHR [required] 一个mshr可以合并多少个target
      

    通过把mshr不变,tgts_per_mshr变小,既达到了减小miss次数,也达到了时间延长,why?

  3. dram未对齐

    硬件没有建模,只是外挂了一个vip,不存在真实的DRAM

    • 但是内部DDR4有合法性检查,不能真的把延时设为0,那么把这个延时设置得很小,ps级别,基本可忽略

      • 为什么仅仅修改延时,也会导致访存次数这些的改变
    • 尝试更改ddr4为一个简单memory,但是这会导致访存次数这些也有很大的改变,why?

      • ddr内部有bank,以及对burst的拆分

这里的CPU逆向其实和测试程序的选取关系很大,首先介绍下CPU架构逆向的定义

  • 已知微架构采用某种设计,但是不知道其具体的设计参数

  • 不确定微架构采用的设计,给出一些可能的设计,逐个排除和确认

我们建模时遇到的问题主要还是第一种,遇到这种问题时,需要选用或者编写合适的benchmark

需要思考的问题

  1. 针对什么微架构部件

  2. 针对该部件的什么设计参数

  3. 反映出现差别的指标是什么

  4. 程序在什么情况下会导致瓶颈出现,程序的参数如何对应到设计参数

  5. 如何校准指标?

比如上面的 L1 DCache 容量的测试上,这几个要素的回答是:

  1. 针对什么微架构部件:L1 DCache

  2. 针对该部件的什么设计参数:L1 DCache 的容量

  3. 反映出现瓶颈的指标是什么:时间,周期数,缓存缺失次数

  4. 如何构造程序来导致瓶颈出现:在内存中开辟数组,然后不断地扫描访问

  5. 程序在什么情况下会导致瓶颈出现:数组大小超过 L1 DCache 容量

  6. 程序的参数如何对应到设计参数上:数组的大小对应到 L1 DCache 的容量

逆向的意义不仅仅在于逆向,还在于对齐微架构参数,以及暴露单一因素是否是造成性能差异的原因。当一个benchmark跑上去时,很可能某个地方跑的时间多了,但是因为另一个未对齐地方执行时间更少,从而掩盖了这部分差异,如果需要针对任何负载程序都做到非常精确,这种一个个小部件的对齐是很重要的。

举个例子:https://zhuanlan.zhihu.com/p/720136752

假如不知道CPU每个周期取多少条指令,怎么测量呢。我们是不是应该设计一个程序,我们需要找到一种方式精确控制取回的指令的数量,并让其不足以供给后端的指令执行从而产生瓶颈。

cache的平均访存时间,其实很难测量

性能建模分析 920与gem5

  1. 硬件与gem5通用测试程序的生成

  2. 负载程序要求:要求是ELF

    • 需要是支持的ISA+ABI组合,RISC-V 只接收 Riscv32/64 + Linux

    • elf内部布局没有特别要求,正常用户态elf,需要有加载段PT_LOAD,e_entry等,可选PT_INTERP(动态链接)

  3. 程序入口:ELF内部的e_entry,一般对应bootloader里面设置的_start

    • 程序加载:

    • python层根据isa和sys创建一个se workload,然后交给c++实例化一个对象。ELF相关类负责解析这个对象,从中获取entry,内存布局,把程序搬到内存中。激活线程,初始化页表等...

    • 接着初始化栈,初始pc值:通过 Process::getStartPC() 返回值:如果 ELF 有 PT_INTERP,就先跳到解释器入口,即动态链接程序先跑 ld.so;否则跳到主程序入口即_start。

  4. 程序退出:分为两种,exit syscall和m5_exit

    • Exit syscall:走系统调用,根据系统调用号找到对应的处理程序,halt当前线程,如果系统已经没有活动线程则退出模拟

    • m5_exit:执行gem5的特定op,直接退出模拟

例子

_start:

    # 控制寄存器初始化
    li x3, 0x70013
    csrs mcor,x3

    # enable write allocate
    # li x3, 0x4
    # csrs 0x7c1,x3
    li x3, 0x11ff
    csrs mhcr,x3

    # performence counter初始化
    li x3,0x1
    csrs 0x323, x3      # mhpmcnt3 L1 ICache Access
    ...

    # 设置栈指针
    la sp, stack_top

    # gpr初始化为0,避免仿真出现X
    li x1,0
    ...

    # 跳转到main函数,你自己的代码
    li a0, 0

    call main

exit:
    fence                                                       
    li a7, 93
    li a0, 0        
    ecall

踩过的坑

  • 链接错误:如果执意使用m5_exit,链接时需要gem5提前编好的一个.so,比较麻烦,后改用exit syscall解决

  • 执行时pagefault:一般是某个段没有分配地址,需要检查ld脚本,什么算是分配一段地址?下面二者的区别

    # link script 1
    ENTRY(_start)
    SECTIONS
    {
      .text : {
        */bootloader.o(.text)
        *(.text)
        *(.rodata)
        *(.data)
        *(COMMON)
      }
      .bss : { *(.bss) }
      heap_low = .;
      . = . + 0x1000000;
      heap_top = .;
      . = . + 0x1000000;
      stack_top = .;
    }
    
    # link script 2
    ENTRY(_start)
    SECTIONS
    {
      .text : {
        */bootloader.o(.text)
        *(.text)
        *(.rodata)
        *(.data)
        *(COMMON)
      }
      .bss : { *(.bss) }
      .heap_low :{
        . = . + 0x1000000;
      }
      heap_top = .;
      .stack :{
        . = . + 0x1000000;
      }
      stack_top = .;
    }
    
  • gem5执行没问题,硬件仿真时出现X信号:gpr没有初始化,在硬件中会表现为X值,而不是0

gem5执行后有用的信息

  • 时间与总量:模拟时间、频率、提交了多少条、ipc(instructions per cycle)

  • 指令组成:

    • Load 占比 = numLoadInsts / numInsts

    • Store 占比 = numStoreInsts / numInsts

    • Int 占比 = numIntInsts / numInsts

    • FP 占比 = numFpInsts / numInsts

    • Vector 占比 = numVecInsts / numInsts

    • Branch 占比 = numBranches / numInsts

    • 条件分支占比 = committedControl::IsCondControl / numInsts

  • 分支行为:主要关注btb和预测准确度

    • BTB

    • 直接分支

    • 间接分支:指的是跳转目标地址需要从寄存器中读取,比如jalr

    • RAS

    • Loop predictor

  • cache行为:L1/L2

    • 总的access次数

    • 总的hit次数

    • 总的miss次数

  • DRAM行为

处理器前端和后端的概念

  • 前端:负责“把活接进来、看懂、分发出去”

  • 后端:负责“真正干活、算结果、访问数据、写回结果”

gem5仿真速度?

  • 90w insts/s

  • 后续可以通过simpoint、kvm等加速?

测试程序的选择?

理想情况下就是根据真实负载,比如别的组出的case,在gem5上面跑,跑完和硬件对比。但是目前gem5还没有接入仿真器,硬件环境也没有就绪。我们只能对单个CPU进行测试,衡量这个CPU建模是不是够准确。

测试程序的底层逻辑是,需要是一个可控的,参数化的行为发生器,通过这种测试程序能够区分开准确的建模和模糊的建模

  1. 用一小组很明确的计算/访存/控制流模式,持续重复执行足够长时间。

  2. 每种模式只突出一类主要硬件行为,尽量压低其他干扰因素。

  3. 所有关键行为都能用少量参数调节,从而扫出一条“响应曲线”,而不是只看一个点。

  4. 程序行为在每次运行中尽量一致,这样 gem5 和真实硬件的差异才更可能来自建模,而不是来自程序随机性或 OS 噪声。

性能建模:如何最大程度对齐到微架构,从手册里面获取各种需要的参数信息,总结为一个表。但是手册不全,一般只会给出大概的信息,然后建模需要一些很具体的参数,比如btb有多少个entry,物理寄存器有多少个,需要从RTL中获取。如果没有RTL,就需要通过测试程序,进行CPU逆向,试出来这些参数了

在进行性能建模时,需要关注这些问题

  • 性能差异较大时,如何找出怀疑的地方,性能差异出现在gem5的哪些指标

  • 不同指令的延时测量,计算指令延时很好测量,但是到了O3CPU时,load/store指令,retire时通常不代表真的完成了load和store

  • 如果模拟器架构和待建模的cpu架构有差异,是修改模拟器架构还是修改模拟器参数?

    • 迭代到后面,模拟器的规模也非常大,修改架构可能不比修改rtl简单,如何选择

gem5与硬件的校准体现在两个地方

  1. gem5与硬件都有的架构:参数高度保持一致

  2. gem5与硬件不共有的架构:先评估是否由于此处的误差,从而影响了整体程序的误差。如果是,能否在不修改gem5架构的情况下逼近

粗糙的对齐方法:

简单对比gem5与硬件的统计数据,一个个猜可能出现误差的地方,比如发现icache的访问次数不对,就要怀疑这一整条链路上,prefetcher、icache配置(way,大小,tag和data latency,lsq等),一个个改参数看是否能缩小差异

此过程需要先分析理论值、再看硬件和gem5

906例子:程序特征是lw多,sw少

  1. icache访问次数对不上,gem5差不多是906的⅓,首先怀疑每个周期取指令的宽度是否一样,是否有合并icache访问次数这一说法。后来发现是gem5每次默认取一个cacheline,而不是一条指令,将 fetch1LineSnapWidth 从0(这个值为0时代表使用cacheline size)改到了8,这样就能实现每次取两条指令,不过这种地缺点是没有模拟如果这两条指令是跨cacheline的,则只会取第一条指令,第二条下次再取,但是误差很小可以不考虑

修改之后icache次数从 413526 -> 1303136,十分接近硬件的1305959

  1. dcache访问次数对不上:906是gem5的½。首先还是怀疑访问粒度不一样,但是check了一下发现都是正确的,然后查看preftch,cache配置等都没有发现问题,只能怀疑是不是硬件有合并load或者store访存的机制,又或者是统计口径就不同

    1. 硬件有合并load/store机制?

      1. 有load-store forwarding(后续的load请求命中store buffer,直接返回数据,不会加一次cache访问请求),但是gem5这种情况会增加一次cache访问请求

      2. 有store同target合并,gem5没有,这也是误差之一

以上次数都能对上之后,发现时间差得还是比较多,于是写了脚本,记录gem5中每条指令执行时间,与硬件每条指令执行时间进行对比,发现大部分误差来源于lw

  1. dcache访问时间对不上

首先分析各个功能单元指令数量占比

然后分析时间瓶颈,分析每个pc的执行次数和执行时间,主要是在cache,从0.0048降到0.0026,一共有三次主要修改

  1. 将dcache的data latency和tag latency从2改为1,tag和data并行访问,修改之后从0.0048->0.0038

  2. 对icache的data latency和tag latency进行同样的修改,修改之后从0.0038->0.0033

  3. 允许outstanding的memory请求,即允许上一个lw还没有完全返回,下一个lw也发到lsq上,修改之后从0.0033->0.0026

发现有很多连续的lw,906一般一个cycle就处理了一条load,而gem5通常是需要3~4个cycle。

920例子;

  1. l1 icache访问次数:硬件值 2988328,gem5 1744278

  2. l1 dcache load miss:硬件值 79207,gem5 114743

    1. gem5比硬件高了45%,gem5 miss太多,可能原因

    2. 排查下load access是否对齐,是的

    3. prefetch对齐?是否是因为硬件的prefetch提前将数据load进来,导致miss减少?

    4. 推测:硬件对load miss有合并机制,write a case verify it?

      gem5也有合并机制,可能是gem5的load miss合并不够,有一个奇怪的点,miss次数更多,但是耗时反而更少,why?

      1. miss合并机制在gem5中表现为mshr,如何逼近到硬件的mshr? 当同一个 cache block 已经有一个未完成 miss 时,后续对同一 block 的请求会命中已有 MSHR,而不是再新开一个 miss

      2. mshr指标是什么:

      self.mshrs = 8  # max outstanding misses [required]
      self.tgts_per_mshr = 2  # targets per MSHR [required] 一个mshr可以合并多少个target
      

    通过把mshr不变,tgts_per_mshr变小,既达到了减小miss次数,也达到了时间延长,why?

  3. dram未对齐

    硬件没有建模,只是外挂了一个vip,不存在真实的DRAM

    1. 但是内部DDR4有合法性检查,不能真的把延时设为0,那么把这个延时设置得很小,ps级别,基本可忽略

      • 为什么仅仅修改延时,也会导致访存次数这些的改变
    2. 尝试更改ddr4为一个简单memory,但是这会导致访存次数这些也有很大的改变,why?

      • ddr内部有bank,以及对burst的拆分

这里的CPU逆向其实和测试程序的选取关系很大,首先介绍下CPU架构逆向的定义

  • 已知微架构采用某种设计,但是不知道其具体的设计参数

  • 不确定微架构采用的设计,给出一些可能的设计,逐个排除和确认

我们建模时遇到的问题主要还是第一种,遇到这种问题时,需要选用或者编写合适的benchmark

需要思考的问题

  1. 针对什么微架构部件

  2. 针对该部件的什么设计参数

  3. 反映出现差别的指标是什么

  4. 程序在什么情况下会导致瓶颈出现,程序的参数如何对应到设计参数

  5. 如何校准指标?

比如上面的 L1 DCache 容量的测试上,这几个要素的回答是:

  1. 针对什么微架构部件:L1 DCache

  2. 针对该部件的什么设计参数:L1 DCache 的容量

  3. 反映出现瓶颈的指标是什么:时间,周期数,缓存缺失次数

  4. 如何构造程序来导致瓶颈出现:在内存中开辟数组,然后不断地扫描访问

  5. 程序在什么情况下会导致瓶颈出现:数组大小超过 L1 DCache 容量

  6. 程序的参数如何对应到设计参数上:数组的大小对应到 L1 DCache 的容量

逆向的意义不仅仅在于逆向,还在于对齐微架构参数,以及暴露单一因素是否是造成性能差异的原因。当一个benchmark跑上去时,很可能某个地方跑的时间多了,但是因为另一个未对齐地方执行时间更少,从而掩盖了这部分差异,如果需要针对任何负载程序都做到非常精确,这种一个个小部件的对齐是很重要的。

举个例子:https://zhuanlan.zhihu.com/p/720136752

假如不知道CPU每个周期取多少条指令,怎么测量呢。我们是不是应该设计一个程序,我们需要找到一种方式精确控制取回的指令的数量,并让其不足以供给后端的指令执行从而产生瓶颈。

cache的平均访存时间,其实很难测量

玄铁920 wmb

源码太多,基于 gpt 5.4 理解

LSU

一般 load/store:AG(Addr gen)->DC(Data Cache)->DA(Data Align,以及 store-load forwarding,来自 sq/wmb)->WB(Write back,写回 reg)

|559x459

可参考:https://zhuanlan.zhihu.com/p/492879610

  • lsu 以 16bytes 的粒度访问 cache,有 8 个 bank,每个 bank 4bytes,一个 cacheline 是 64bytes

wmb

store 所经过的层次:SQ->wmb_ce->wmb-> biu/L1D/VB

SQ commit 之后 pop 到 wmb_ce,wmb_ce 是用于 create wmb entry 的模块,VB 是 victim buffer

暂时无法在飞书文档外展示此内容

920 中所有的 store 种类:

  • Atomic:原子 store/Store conditional

  • Icc:icache、dcache、l2cache、tlb 等涉及缓存一致性的 store

  • sync/fence

  • 除以上几类之外的都是普通 store,每种普通 store 又分为 cacheable 和 uncacheable

    • Weak order

    • Strong order/device store

wmb 入口

wmb merge 针对的 store 种类:merge **只针对 weak order 开启,cacheable/uncacheable 都可能 merge,**源码是 wmb_ce_merge_en = wmb_ce_wo_st。

但是上述所有 store 都能且会进入 wmb,只是没有 merge 这一 feature。

只有一种特殊 store 不会进来,dcache all 这种“复用 store pipe 的维护操作”不应简单等同于普通 store,它会先经过缓存维护控制那条握手路径;先向 ICC 申请 sq_icc_req,等 icc_sq_grnt 且 icc_idle 后,再继续走 sq_wmb_pop_to_ce_req 这条路

wmb 内部

merge 指的是,把落在同一个 16B wmb data block 的 store 合并到同一个 entry,来减少写请求次数

merge 要求:weak order 以及 addr[PA-1:4] 相同

  • Wmb 一共有 8 个 entry,每个 entry 控制 16B,一共 8*16B

  • merge 窗口 是 16B

  • 覆盖粒度 是 byte

  • 允许字节重叠

  • 重叠时 后来的 store 覆盖前面的同字节数据(一般来说只有 weak order 可以,strong order 不能这样做)

Merge example:

假设两个 wo_st:

  • S0: 写 0x1000 ~ 0x1007,bytes_vld = 16'h00ff

  • S1: 写 0x1008 ~ 0x100f,bytes_vld = 16'hff00

注意掩码,是以 bytes 为单位,而不是 bits

合并:

  • 对每个 byte,若 wmb_ce_bytes_vld[i]=1,就用新 store 的 byte 覆盖旧 byte

  • 否则保留旧数据

  • 多个可以 merge 的 store,各自的 bytes_vld 做按位 OR

所以这个例子最后:

  • 原来 entry0.bytes_vld = 16'h00ff

  • merge 后变成 16'hffff

  • entry0.data[63:0] 来自 S0

  • entry0.data[127:64] 来自 S1

这个 bytes_vld 是如何生成的?bytes_vld 本质上是一个 16 bit 掩码,表示在当前这个 addr[PA-1:4] 对应的 16 字节窗口里,哪些 byte 被这条 store 写到。

  • 在 lsu 时,会根据写 byte 起始位置,store 写多少字节,算出结束位置,然后把[start,end)变为一个 16bits 的掩码,当然这个过程有可能会跨 16bytes,需要两次写,对应两个掩码

问题 1:store 的 merge 会不会导致 load 读到错误值

store1 addr1
load1 addr1
store2 addr1

是否会因为 wmb 把两个 store 把合并(因为其地址相同),导致 load 读到了合并后的结果

答:不会,由于 store 的顺序是先进入 SQ,然后从 SQ pop 时才进入 wmb。而又因为 store 指令只有 ROB commit 时才能出 SQ,这保证了 store2 指令是最新提交的,不可能有前面的 load1 在该条 store2 指令之后

问题 2:为什么 merge 时允许新的 store 直接覆盖同 byte 的数据,这不会造成内存状态的错误吗?

和上一个问题,其实是类似的。先想想,什么情况这种错误会有影响,那一定是在两个 store 中间穿插了一个 load,或者以某种别的方法要观测两个合并 store 的中间情况。但是由于进入 wmb 的 store,一定已经处在“对当前 load 来说足够老”的阶段,不可能会观测到这种中间情况。

所以说为什么允许合并,因为被覆盖的值还没成为**必须被外界观察到的状态,**这是最关键的,虽然错了一瞬间但是外界看不到

这也是为什么要求 weak order 才能合并,从内存状态更新的角度来看,确实少了一次旧值,如果真的 debug 时在这个地方有问题,那么软件开发者一定不知道是什么地方出错了。但是从程序执行的角度,执行结果还是正确的不会变。也就是 weak order 只保证程序结果正确,但是不保证中间每一个结果,内存都是正常更新

wmb 出口

出口:

  • Biu

    • Read(shareable cacheable store 的前置读/一部分 store conditinoal)

    • write

  • L1 Dcache(cacheable 命中或可直接更新的普通 store)

  • Victim buffer(部分 dcache 维护类操作,比如 clear/invalid)

为什么 write merge 会有 read?

  • 有些请求会先走“对外读地址通道”拿权限或做维护事务,然后才在另外三类里选一个真正落地的写出口。真正的“最终写落点”主要是后面三类:外部总线写、一级数据缓存、本地被替换缓存行缓冲区。

wmb 出口具体来说:

  1. 送到总线接口单元的“读地址通道”。

作用是“先读一下”或者“发缓存维护控制事务”,不是最终写数据。会走这里的类型有:可共享且可缓存的普通存储,前提是目标缓存行当前是共享状态或者本地无效;可共享且可缓存的存储条件指令;按地址操作的数据缓存维护;地址翻译缓冲区失效、指令缓存维护、二级缓存维护这类控制事务。

  1. 送到总线接口单元的“写地址通道和写数据通道”。

  2. 这是往核心外部真正发写请求的出口。会走这里的类型有:强序设备存储;不能在一级数据缓存内直接完成的普通存储;同步和栅栏;不能在一级数据缓存内直接完成的原子存储;不能在一级数据缓存内直接完成的存储条件指令。

  3. 直接更新一级数据缓存。

  4. 这是“本地完成写入”的出口。会走这里的类型有:可缓存而且目标缓存行当前在一级数据缓存中有效的普通存储;可缓存而且目标缓存行当前有效的原子存储;可缓存而且目标缓存行当前有效的存储条件指令;一部分单行“只做失效”的数据缓存维护操作。

  5. 送到“被替换缓存行缓冲区”。

  6. 这不是直接出核,而是先交给本地缓冲区,后面再由它决定是否要对外吐出、写回、清除或者失效。会走这里的类型主要是:可缓存、目标缓存行当前有效、并且属于“单行清除类”的数据缓存维护操作。

Wmb 什么时候往外发

每个entry会顺序经历五个阶段:biu read->biu write->wirte VB->write dcache->data(resp)

发出是看ptr落在哪个entry,并且满足一定条件,该entry就发出去,多个ptr功能独立,每周期都可以往外发

如果某一个entry不需要某一阶段,比如dcahce不需要经过biu,可以通过immediate skip跳过一些阶段

还有create_ptr

entry释放条件:

// ct_lsu_wmb_entry.v:2179
wmb_entry_pop_vld = vld                             // entry 有效
    && read_resp                                     // BIU R 响应已收到
    && (write_resp || write_resp_set                 // 写响应已收到
        || mem_set_req && !w_last)                    // 或 burst 中间 beat
    && (data_req_success || data_req_success_set)    // 数据已发送
    && wb_cmplt_success                               // 写回完成确认
    && wb_data_success;                              // 写回数据完成
没有仲裁  所有满足条件的 entry 同时 pop。 entry 自身把 vld 清零即可。

多个entry的ptr是否会同时valid:不会。ptr是独热码

资料

  1. 官方教程:https://learnsystemc.com/

    1. 上面learnsystemc的翻译和整理:https://zhuanlan.zhihu.com/c_1607417635079127040
  2. 台湾大学systemc slide:http://media.ee.ntu.edu.tw/courses/msoc/slide/02_SystemC_Tutorial.pdf

    1. 第一课的翻译整理:https://zhuanlan.zhihu.com/p/703265170

systemc

最基本的概念:

Below lists the most common terms used for SystemC.

Method A C++ method, i.e. a member function of a class.
Module A structural entity, which can contain processes, ports, channels, and other modules. Modules allow expressing structural hierarchy. Module is the principle structural building block of SystemC, used to repsent a component in real systems.
Process A special kind of member function of a sc_module class, registered to the SystemC simulation kernel and called only by the simulation Kernel.
Interface An interface provides a set of method declarations, but provides no method implementations and no data fields.
Channel A channel implements one or more interfaces, and serves as a container for communication functionality.
Port A port is an object through which a module can access a channel’s interface. But modules can also access a channel’s interface directly.
Event A process can suspend on, or be sensitive to, one or more events. Events allow for resuming and activating processes.
Sensitivity The sensitivity of a process defines when this process will be resumed or activated. A process can be sensitive to a set of events. Whenever one of the corresponding events is triggered, the process is resumed or activated.

sc执行的各种阶段示意

头文件:

需要引入SystemC头文件,一般有两种方法

  • 第一种如下
#include <systemc.h>

此法会将systenc的所有命名空间,如sc_core等加入当前文件,这种方法用于同老版本的SystemC兼容使用,在未来的版本中可能去掉

  • 第二种为
#include <systemc>
using namespace sc_core;

这种声明方式,本质上是第一种方法去掉命名空间,因此使用此法需要自行引入需要的命名空间,通常使用第二种方法,同时配合using

入口地址是sc_main而不是main

sc模块定义

定义一个systemc module有三种方法:简单来说就是定义一个继承自sc_module的class或者struct

  1. SC_MODULE(module_name) {}: this uses the systemC defined macro "SC_MODULE", which is equivement to #2. 一般使用这种

  2. struct module_name: public sc_module {}: a struct that inherits sc_module.

  3. class module_name : public sc_module { public: }: a class that inherits sc_module.

Note, class is identical to struct except for its default access control mode of "private", as compared to "public" of struct.

sc模块的构造函数

  • 每个module都需要一个不同的name,其构造函数至少需要一个参数(name)

  • SC_CTOR是一个构造函数的宏,只需要一个参数,就是name

  • 如果需要额外传递参数则不能使用这个宏,需要自己写构造函数,建议需要额外参数时使用SC_HAS_PROCESS宏

    SC_MODULE(MODULE_C) { // constructor taking more arguments
      const int i;
      SC_CTOR(MODULE_C); // SC_HAS_PROCESS is recommended, see next example for details
      MODULE_C(sc_module_name name, int i) : sc_module(name), i(i) { // explcit constructor
        SC_METHOD(func_c);
      }
      void func_c() {
        std::cout << name() << ", i = " << i << std::endl;
      }
    };
    

method代表这个模块的成员函数

SC_HAS_PROCESS(module)

  • 不会默认创建一个构建函数,需要手动创建

什么是method,什么是process

Simulation process:

  • 是sc_module的成员函数

  • 没有输入,没有返回值

thread:模拟硬件process

  • 并行跑

  • 有敏感列表

  • 不需要user来调用,始终激活

systemc支持3种threads

  • SC_METHOD():只执行一个周期

    • 串行执行

    • 类似 verilog的@always bllock

    • 用于组合逻辑或者简单的时序逻辑

  • SC_THREAD()

    • 类似verilog的@inital block

    • 只在模拟开始时run,然后就暂停;内部可以包含一个循环,固定时间执行一段code

    • 一般用于testbench

  • SC_CTHREAD():执行一个或多个周期

    • Clock thread,reference a clock edge

    • 串行执行

    • 最常用的方法

敏感列表:

#include <systemc.h>
SC_MODULE( and2 ) {
    sc_in<DT> a, b;
    sc_out<DT> f;

    void func() {
    f.write( a.read() & b.read() );
    }

    SC_CTOR( and2 ) {
        SC_METHOD( func );
        sensitive << a << b; 
    }
}

类似verilog中的

always @(a,b) begin
    f = a & b;
end

如果要表示时钟上升沿可以通过 sensitive << clk.pos();

sc可以通过指定变量的bit数

sc_uinit<3> x; // unsigned
sc_init<3> x; // signed

// 与port_in和port_out一起使用
sc_in< sc_unit<1> > a,b;

ports:

  • sc_in; — input port

  • sc_out; — output port

  • sc_inout; — input/output port

如果是自定义bit宽度,需要在末尾的括号前有一个空格sc_in<sc_uint<10> >

Fast Port Binding

上面的port绑定不是很直观并且比较慢,从sc2.1开始采用SC_EXPORT来快速绑定端口,可代替SC_PORT

sc_signal:

  • 是一个wire,没有指定方向input or output

    • Current Value (**cur_val****)**: read() 方法返回的值,代表当前仿真时间(或当前 Delta Cycle)下的信号状态。

    • New Value (**new_val****)**: write() 方法更新的值,代表下一个 Delta Cycle 将要呈现的状态。

  • 其数据流由连接的port指定

  • sc_signal >;

两种使用示例

  1. Name mapping
  2. Positional mapping

SC_CTHREAD:

可以通过reset_signal_is(rst,true)这样来复位

SC_CTHREAD( func, clk.pos());
reset_signal_is(rst,true)

void func(void){
 wait();

 while(true){
     // read inputs
     // do something
     // write outputs
     wait()
 }
}

Systemc stage:

  1. Elaboration:sc_start之前的准备工作,准备一部分数据结构(modules, ports, primitive channels, and processes),绑定ports

  2. Execution

    1. Initialization:kernel识别所有待模拟的进程,放置到runable或者waiting set。除了no initialization的进程在waiting里,其余的都在runable。简单来说就是把进程插到调度队列中

    2. simulation:调度进程,advance模拟时间

      1. evaluate:一次次性run所有的runable process;对于每个process来说,会一直跑直到遇见wait()或者return。如果没有剩余的runable process,evaluate阶段就结束了

      2. advance-time:runable process清空时,开始这个阶段。主要完成三件事:使用事件机制将仿真事件转变为closest time;将等待特定时机的processes移动到runnable set;返回evaluate阶段

      Execution结束:1.所有process yielded;一个process执行了sc_stop;模拟时间到达设定值

  3. cleanup:销毁对象、memory等

systemc的并发性建模并不是真的并发:实际上是串行执行(串行顺序不清楚如何安排),只是在所有进程执行完之后再增加一个统一的时间,使得外部看上去是并发

Event:event属于sc_event类,用于进程间同步,有如下方法

  • void notify():创建一个即时通知

  • void notify(const sc_time&),void notify(double, sc_time_unit)

  • cancel():删除本event的任意挂起的notify

    • 不能删除即时通知

    • 任意event只能有最多一个挂起的notify

一个event只能有两种动作:wait或者使其发生

waiti:

  • wait():等待敏感列表中的事件

  • wait(e1):等待event e1

  • wait(e1|e2|e3):等待事件e1,e2 or e3

  • wait(e1&e2&e3):等待事件e1,e2 and e3

  • wait(200, SC_NS):等待200ns

  • wait(200, SC_NS, e1):等待200ns之后的event e1

  • wait(200, SC_NS, e1|e2|e3):等待200ns之后的e1,e2 or e3

  • wait(200, SC_NS, e1&e2&e3):等待200ns之后的e1,e2 and e3

  • wait(sc_time(200, SC_NS)):等待200ns

  • wait(sc_time(200, SC_NS), e1):等待200ns之后的event e1

  • wait(sc_time(200, SC_NS), e1|e2|e3):等待200ns之后的e1,e2 or e3

  • wait(sc_time(200, SC_NS), e1&e2&e3):等待200ns之后的e1,e2 and e3

  • wait(200):等待200个时钟周期,仅限于SC_CTHREAD(SystemC 1.0)

  • wait(0, SC_NS):等待一个delta 周期

  • wait(SC_ZERO_TIME):等待一个delta周期

静态wait与动态wait

Delta cycle理解:

https://caslab.ee.ncku.edu.tw/dokuwiki/techdoc:esl:systemc_delta_cycle

在 Verilog 中,你在时钟上升沿(Posedge)写入数据,接收端必须等到**下一个****时钟周期**才能读到数据。

在 SystemC 中,这个“时钟周期”就是 Delta Cycle

但是delta cycle又不等价于一个时钟周期,它不会推进模拟时间

systemc(二)

datatype

sc_bit只有两个值 0 和 1

sc_logic增加了X和Z值,有4个值

sc_int和sc_uint

  • 支持按位操作,支持选择某个bit,支持选择某个范围的bit,支持拼接;和verilog的操作很像
mybit = myint[7]
myrange = myint.range(7,4)
intc = inta, intb)

sc_bitint和sc_biguint

sc_bv:二值,额外允许reduction操作,比如and_reduce(),or_reduce(),xor_reduce()

  • or_reduce returns the result of ORing all bits in databus,然后返回一个bool值

sc_lv:4值,额外允许reduction操作,与上面的sc_bv类似

各种数据类型的模拟速度:

从上到下依次变慢,原生的c++数据类型最快

还有定点数fixed与浮点数ufixed,暂时不太关注,略过

Basic channels and interfaces

interface是communication暴露出的接口,channel是具体实现communication,可能会包括多个interface,不同的channel对同一种interface的实现可能不一样

interface示例:

  • sc_fifo_in_if

  • sc_fifo_out_if

  • sc_mutex_if

  • sc_semaphore_if

  • sc_signal_in_if

  • sc_signal_inout_if

channel示例:

  • sc_buffer

  • sc_fifo

  • sc_mutex

  • sc_semaphore

  • sc_signal

  • sc_signal_resolved

  • sc_signal_rv

下面介绍基本的channel:sc_mutex,sc_semaphore,sc_fifo,sc_signal,sc_buffer

mutex:独占一个对象,对共享对象做一个保证

  • lock(一致阻塞直到获取锁)和trylock(非阻塞,通过返回值判断是否锁)
class bus{
    sc_mutex bus_access;
    ...
    void write(int addr, int data){
        bus_access.lock();
        // perform write
        bus_access_unlock();
    }
};

sc_semaphore:Have more than one copy of resources,信号量也是锁,但是会有多个可用的资源,只有资源不够用时才会阻塞(PV操作)

sc_fifo:可能是用得最多的建模channel

sc_signal:类似verilog里面的reg

说它是reg是因为其存在这样一种特性,同一计时下先写后读,读出的是旧值,写操作会在当前时间上加一个delta cycle,而读的还是没加delta cycle的值,因此是旧值

Evaluate-update channels

Evaluate stage:从runable的process中选一个出来执行,选谁这个顺序没有规定

计算完成之后可以调用request_update(),可以视作向update阶段发了一个请求,暂时挂起,等到所有process的evaluation结束之后进入update阶段,就会来处理这些挂起的update请求更新值;因此在update完成之前,变量中存的都还是旧值

注意只:The request_update() function may only be called inside member functions of a primitive channel

Update stage:

遍历evaluate阶段所有挂起的update请求,更新值。

在更新值时可能触发某些敏感列表,会唤醒某些进程加入到runable process,此时会+1 delta cycle,回到evaluate阶段继续计算这些process,直到某一时刻,所有值稳定,也没有唤醒新的process,此阶段结束。这里类似组合逻辑的信号传播过程。一级门电路翻转(Update),驱动下一级门电路运算(Evaluation),直到整个电路稳定。

接下来会有两个选择:

  1. 查看未来的时间轴事件队列(Event Queue)。如果未来没有任何安排(没有 wait(time),也没有 next_trigger(time)),说明仿真任务结束,调用 sc_stop()

  2. 如果还有time event notify:内核将当前仿真时间(\(T_{now}\))更新为事件队列中最近的下一个时刻(\(T_{next}\))。Verilog 类比:比如从 #10 跳到了 #20

Advance time:仿真时间的推进可能会触发某些process,需要把他们加入

写systemc的多种视角:

  • Direct top-level

  • Indirect top-level

  • Direct sub-module header-only

  • Indirect sub-module header-only

  • Direct sub-module

  • Indirect sub-module

communication

port就是指向channel的一个指针

sc的interface是继承自sc_interface的虚类

sc的channel是实现了一个或者多个interface的类,继承自sc_channel或者sc_prim_channel

互联的多种方式:可以看看选用什么类型的port

Port connection可以by name或者by position,这点类似verilog的模块初始化。说是connection,其实就是指针的赋值

更多有关ports的信息:

  1. sc_fifo_out_if (if是interface的缩写)

    1. write()

    2. nb_write()

    3. num_free()

    4. data_read_event()

  2. sc_fifo_in_if

    1. read()

    2. nb_read()

    3. num_available()

    4. data_written_event()

sc_fifo、sc_fifo_in_if、sc_fifo_in的区别是什么?

  • sc_fifo是一个primitive channel

  • sc_fifo_in_if:是一个interface,由sc_fifo实现

    • 等价于 sc_port > in;
  • sc_fifo_in:是一个port:A specialized port class for use when reading from a fifo. (sc_port)

interface与port的区别,interface会有一些实现好的函数,而port只是一个指针的包装

sc_port<> Array可以通过如下进行初始化

sc_port<interface [,N]> portname;
sc_port<sc_fifo_in_if<int>, 4> p1;

sc_export:把内部的服务暴露给外面,让外部可以调用;类似一个函数指针;对比sc_port,它像一个**指针**。它定义了“我想调用 IF 接口里的函数(比如 read / write)”,但它自己没有实现这些函数。它必须连接到一个实现了该接口的对象上。例子:https://learnsystemc.com/basic/export

// Learn with Examples, 2020, MIT license
#include <systemc>
using namespace sc_core;

SC_MODULE(MODULE1) { // defines one module
  sc_export<sc_signal<int>> p; // an export for other modules to connect
  sc_signal<int> s; // a signal (channel) inside the module. If not using export, the channel need to be defined outside module1.
  SC_CTOR(MODULE1) {
    p(s); // bind an export to an internal channel
    SC_THREAD(writer); // a process to write to an internal channel
  }
  void writer() {
    int val = 1; // init value
    while (true) {
      s.write(val++); // write to an internal channel
      wait(1, SC_SEC);
    }
  }
};
SC_MODULE(MODULE2) { // a module that reads from an export
  sc_port<sc_signal_in_if<int>> p; // a port used to read from an export of another module
  SC_CTOR(MODULE2) {
    SC_THREAD(reader); // a process to read from an outside channel
    sensitive << p; // triggered by value change on the channel
    dont_initialize();
  }
  void reader() {
    while (true) {
      std::cout << sc_time_stamp() << ": reads from outside channel, val=" << p->read() << std::endl; // use port to read from the channel, like a pointer.
      wait(); // receives from port
    }
  }
};

int sc_main(int, char*[]) {
  MODULE1 module1("module1"); // instantiate module1
  MODULE2 module2("module2"); // instantiate module2
  module2.p(module1.p); // connect module2's port to module1's export. No need to declare a channel outside module1 and module2.
  sc_start(2, SC_SEC);
  return 0;
}

Gem5接入nbu

目录

  1. 术语解释
  2. 背景
  3. 原理
  4. build & run
  5. build
  6. run
  7. 演进、开发历程、替换步骤
  8. 具体
  9. bridge warpper
    1. 背景
    2. 核心功能
  10. FIFO建模
    1. 背景
  11. decoder设计
  12. Sc main的骨架,接入sc sim top
  13. host修改/ddr接入
    1. 背景
    2. 核心实现
  14. 多CCU
  15. 不足之处

术语解释:

  • sc:systemc
  • emu/calcore:都指 nbu model

背景

补充架构图

为什么要用920替换906?

对于整体的 emu 架构来说,可分为 scalar core 和 npu 模块。scalar core 主要做的事情是进行标量指令的计算,以及分发 nscl(non scalar:非标量指令)给 npu。

原来的负载主要是 CNN,即使有大模型(Transformer),参数量也比较小,几百兆几B。后来随着超大大模型的出现,原本的906 core计算能力不够了,跟不上npu的能力,出现npu等待906这一状况。

两种负载的大致区别:

  • cnn:输入一张图 -> 输出分类 / 检测结果
  • 不管输入是什么,都会经过相同的处理层(固定 shape),其计算量可以提前算出来,可以在运行前大致知道 latency。
  • transformer:输入 prompt -> prefill -> 逐 token decode -> 直到生成结束
  • 虽然不同输入也会经过同样的架构,但是计算量不一样,会随着输入 token 长度变化而变化,并且输出 token 也不固定。

一个关键区别是:CNN对于不同shape,最终到硬件上都会变成一个统一的shape(不够的地方padding),这给了很多在运行之前就能确定的信息,比如把地址提前在编译阶段就算好,这样scalar core 就不用再算地址了,计算压力也比较小。但是Transformer,shape不固定,这意味着你的地址需要scalar core在运行时动态计算,模型大的时候 scalar core 的计算压力比以前cnn/小模型时期重很多。需要处理变长输入、KV cache、token-by-token decode、动态 batch、mask、采样等更多控制逻辑。这是906需要用920替换的一个主要原因。

经过内部 prof 发现,906 作为标量核时,会出现 NPU 算完了,但下一条 NSCL 指令还没发出来,906 一直转,npu 却经常歇着。这说明 906 的计算、控制能力已经不够了,需要一颗更加强大的标量核心,所以需要替换为 920。

C906 是顺序单发射小核,控制能力够,但遇到复杂 runtime、频繁分支、cache miss、地址计算、队列管理时,容易成为瓶颈。C920 是乱序多发射大核,能更好隐藏访存延迟、提高 IPC,更适合跑复杂软件栈。

ks2 的 nscl 指令形式变了

ks2 的指令不再是直接从 ddr 里面获取,而是需要用 4 个标准riscv sd(store double word)来拼接为 256 bits

ks1 的指令集其实是混合的,不仅有标准riscv指令,还有nscl指令集(通过riscv自定义扩展指令实现),虽然nscl指令不会在scalar core里面执行,但是scalar core认识它

程序指令平铺 vs 正常有if else的程序: * 指令平铺是指把所有if else都展开,整个程序只需要顺着pc一路+1,+1,执行到底。这样的好处是程序少了分支指令,不用判断了,执行的总指令条数相比有分支指令的会少一些,但是程序很大(pc一直递增,不会重用),局部性较差,cache基本不命中 * 正常有 if else的程序本身比较小,很多重复的地方都用跳转指令来重用,局部性比平铺的好。但是常常需要实时计算,判断是否跳转等。计算量比较大,但是内存压力小,cache命中多。

ks2 指令集就只有标准的riscv指令集+玄铁扩展指令集,所有的nscl指令不再显式出现,而是通过store来做。scalar core计算更加密集

原理

gem5 会对访存指令计算出的 addr 进行判断。如果某个 addr 在 bridge 指定的范围内,则会往外部发。在当前的实现中,把这个范围设置为 ALL,也就是所有的访存请求都会往外发,gem5 只负责计算、执行标量指令。

gem5 -> bridge -> bridge warpper -> nbu model 大 bus。这个 bridge warpper 模块的作用其实类似一个小 bus,但是由于功能很简单,没有做成 bus:

  • 一个 slave 接收 bridge,两个 master(fifo 和 non-fifo)接到 ddr/sram 等大 bus,由大 bus 路由。
  • 由于大 bus 原本这个地方的 socket 被 scalar core 占用,需要断开。

简单介绍gem5接入 原理概述

gem5 能和 SystemC 一起仿真,本质原因是:两者都是离散事件驱动模拟器

  • gem5 用 EventQueue 管理事件,时间单位是 Tick
  • SystemC 用内核调度 SC_THREADSC_METHODsc_event,时间单位是 sc_time

它们都遵循同一个基本思想: 在某个仿真时间点处理事件,再推进到下一个事件时间点。

所以,gem5 与 SystemC 协同仿真的核心,不是“让两边都运行”,而是解决下面三件事:

  1. 谁来做总调度器
  2. 两边的时间如何保持一致
  3. gem5 的请求/响应如何转换成 SystemC/TLM 事务(这个简单,查看源码 gem5totlmbridge 例子即可)

gem5 提供了两种路径,在此插入两种路径具体介绍与讨论。 我们选用将 gem5 作为一个 lib 供 sc 使用,其基本原理是:

  • sc 作为总调度器,gem5 作为一个 systemc 程序嵌入,gem5 的 eventq 属于 sc kernal 管辖,由 gem5 自己调度、push 和 pop,只是这个 eventq 数据结构属于 sc。

ks2与ks1仿真器的区别:

  • 整个 scalar core 弃用,换为 gem5,原有的与其他模块的连接断开。
  • 增加了 inst fifo:【todo文档】
  • decode 模式变了:【todo文档】
  • host 没有 csr 启动,调用 gem5 启动函数传入 pc 来手动启动。

架构图 todo

build & run

build

查看build_new.md

run

首先需要 gem5 跑一遍生成 config.ini,然后使用 calcore 接收该 config.ini

cd gem5/util/tlm
../../build/RISCV/gem5.opt ../../configs/c920/run.py [options] --binary /path/to/elf

cd calemu/nbu_model
./bin/calcore [calcore options] /path/to/gem5_config_ini [gem5 options]

此处涉及到参数解析。calcore 有一套参数解析,gem5 也有一套参数解析,以 config.ini 作为分界线,分别传入不同的 parser。

演进、开发历程、替换步骤

  1. 建模 sc fifo,然后测试,此时还是在 gem5 环境。
  2. 将 gem5、sc fifo 与 calcore 一起编译,连接 bridge 到 nbu core,需要修改 cmake。
  3. 修改 decode,从 64 bits 改到 256 bits。
  4. 进行初步集成并测试,ddr/sram 采用 gem5 自带的,只有写到 fifo 的访存请求会通过 bridge 路由出去。并且此时的 nbu host 也没有启动(注释掉了 host 的 sc thread),load elf、启动 cpu 都是 gem5 自己干的。
  5. 将 gem5 的 load elf、启动 cpu 转移到 nbu host,同时 gem5 把所有的访存请求都路由到 bridge,再由 bridge 发往下一 systemc 模块。

具体

以下说明一些具体模块的实现、连接关系,以及开发遇到的一些 bug。

bridge warpper

背景

在最开始的步骤中,gem5 是这样集成的:ddr 使用 gem5,只把写到 fifo 的内存请求通过 bridge 路由到 sc,此时 bridge 的出口只有一条,就是连接到 inst fifo 上面,不用做区分。

到了后续,需要将 ddr 也从 gem5 转向 emu 内部的 ddr,也就是接入 sc ddr。此时对于 gem5 的内存请求来说,就有区别了。按照地址范围,访存可能分为 sram/ddr/fifo,其中 sram/ddr 的区分在内部 mem bus,fifo 还是依旧。

此时 bridge 出来,其实应该有两条路径。如果发现地址位于 fifo 的范围,则依旧传给 fifo;除此之外的所有 addr 都应该交给内部的 mem bus 处理。那么此处其实需要一个小 bus,或者说小模块,来区分 addr 位于 fifo 还是 nonFifo,进行不同的连接与 tlm 调用。这个模块就是 bridge warpper

在实际实现时还会有一个问题:bridge 发过来的包都是 tlm2.0 协议,fifo 接收也是 tlm2.0 协议,但是 mem bus 却是 tlm1.0(mem bus 向外通过 Initiator 连接到 Initiator nbu_core::DataIsocket,此处又是 tlm2.0),nonFifo 如何连接出去呢?在此处讨论过两种方案:

  1. 方案一:bridge warpper 的 nonFifo 出口直接连接到 nbu core 的 DataIsocket,都是 tlm2.0,也就不用转换了。但是要注意这个 socket 得是新增的一个,同时也得在外部大 bus 增加一个 master。因为如果使用原本的 dataIsocket,就得断开 mem bus 的连接,而 dm0 和 dm2 又是通过这个 mem bus 连接到外部大 bus 的,因此断开会造成问题。
  2. 方案二:nonFifo 出口连接到 mem bus 上面,在 mem bus 上增加一个 master 口和一个用于 tlm1.0 通信的 sc fifo。此改动兼容性较好,但是略微复杂。因为 mem bus 的接口是 tlm1.0,在此需要先把 tlm2.0 转为 1.0,把包发出去。等到包回来时,如果是 read,把读到的数据写回到 2.0 的 payload 中,再 return;如果是 write,则设置 response 状态再 return。

todo 画个图

核心功能

所处位置:bridge -> bridge warpper -> fifo/mem bus

入口:接收来自 gem5 bridge 的 tlm2.0 blocking transport 请求。当前只实现了 blocking。

地址区分:check payload 的 addr,如果是 fifo,直接调用 fifo 的 tlm2.0 transport 接口。如果是 nonFifo,则调用 putTrans。该函数首先把 b transport 的 tlm2.0 packet 转换为 1.0,然后通过 tlm1.0 port put 出去。在此函数中,read 和 write 的处理都是一样的,都是把 payload 的 data copy 到 transnoc 中。接着在此处调用 get,阻塞住等待回复。

bridge warpper put 后,mem bus 中的 port 接收,然后往外发请求。等到 response 回来时,mem bus 将 response put,bridge warpper 解除阻塞。根据 read 和 write 的不同处理,完善 tlm2.0 所需信息,然后 return。

这样一次请求就完成了,路径是 bridgewarpper -> mem bus -> external -> mem bus -> bridge warpper -> gem5 bridge...

FIFO建模

背景

ks2 中,nscl 指令不再是从 ddr 中取出,elf 会变为全是 riscv+玄铁指令,不再有 nscl 指令。所有的 ks2 指令会有 4 个 sd 来拼接,每个 sd 写 64 bits,一共 64*4 bits,写到 inst fifo 中,该模块设计为 4 个 fifo,每个 fifo 深度是 4,entry 大小为 64 bits。一旦拼接好 256 bits 之后,就进行 decode,然后发给 inst queue。

【插入文档】

FIFO 出口的 256 bits 兼容已有的高级数据结构,发给 inst_queue。 fifo 实例化放在 ccu 内部,每个 ccu 都得有一个 fifo,而不是在 sim top。 放在哪里实例化呢?它们需要解析 argc 和 argv。

作为参数传递?

  • fifo 依赖 transactor,transactor 在哪里创建都没关系。
  • simcontro 需要解析 argc 和 argv。
  • 上述两个 obj 的唯一性?

decoder设计

  • Decoder bin 的输入从 32/64 统一改为 256 拼接的。
  • 256decode,提取寄存器值,没有寄存器index信息,不提取寄存器index,直接填写0?

输入的 256 需要新建一个 256 bits 的数据结构。

输出还是 instrptr,但是需要 makeinstr 时再加一个 val,这个 val 从 256 提取。

其他的字段也需要重新解析。

scalar 的 256 decode 不需要做,gem5 自己完成即可。

Sc main的骨架,接入sc sim top

#include <systemc>
#include <tlm>

#include "cli_parser.hh"
#include "report_handler.hh"
#include "sim_control.hh"
#include "stats.hh"

int
sc_main(int argc, char **argv)
{
    CliParser parser;
    parser.parse(argc, argv);

    sc_core::sc_report_handler::set_handler(reportHandler);

    Gem5SystemC::Gem5SimControl sim_control("gem5",
                                           parser.getConfigFile(),
                                           parser.getSimulationEnd(),
                                           parser.getDebugFlags());


    Gem5SystemC::Gem5SlaveTransactor transactor("transactor", "transactor");

    instantiate..
    socket bind..
    transactor.sim_control.bind(sim_control);

    SC_REPORT_INFO("sc_main", "Start of Simulation");

    sc_core::sc_start();

    SC_REPORT_INFO("sc_main", "End of Simulation");

    CxxConfig::statsDump();

    return EXIT_SUCCESS;
}

简单说明: 1. 这个parser是解析gem5参数的,比如-d Exec等,但是由于运行calcore时,前面是calcore参数解析,以config.ini为分界线,之后是gem5参数,需要对argc,argv进行额外处理 2. simcontrol是一个gem5的顶层控制模块,他会解析config,进行所有gem5 obj的初始化等 3. 端口bind:每个transactor作为slave需要bind到sim control上面,然后transactor作为master连接到nbu这端的inst fifo‘ 4. statsdump是在gem5仿真结束后,dump出统计信息,生成stats.txt

host修改/ddr接入

背景

在最初的集成中,先是 gem5 来 load elf,自己启动 cpu 相关线程,然后取指执行,发给 fifo,驱动 nscl core,此时 host 完全没有启动。

完全集成好的话,需要遵循 host load elf。其实也讨论过 gem5 继续 load elf,只是写到 sc 的 ddr 内,但是考虑到会有一些比较特殊的操作,比如不根据 elf 的 entry point 加载,而是加载到其他地方,这是 gem5 做不到的。为了满足这种要求,还是从 host 来 load elf。原本的 host elf 是 32 bits,而 920 的 elf 是 64 bits,需要修改 elf load 与解析的数据结构。

除此之外还需要支持 host 启动 gem5。原本的 scalar core 启动是由 csr ctrl 来控制,host 会写入 start bit,scalar core 识别到该 start bit 之后启动相关 sc thread。而 gem5 是由一个 resetThreadactivate 来控制 cpu ready 启动的。

  • resetThread 会设置线程的初始化状态、机器态寄存器等,最重要的是根据 gem5 配置的 workload 来设置 pc。
  • activate 则是把 thread 状态设置为 ready,不然 sc_start 之后不会动。

核心实现
  1. load elf修改:
  2. 将 nbu model 的 load bin 从 32 位解析修改为 64 位解析。
  3. 后门加载:需要在 sc_start 之前,programinit,所以需要后门加载,因为此时 ddr/sram 还没有创建。
  4. 为 gem5 增加 skip load elf 选项,需要在配置文件中修改,默认是跳过。该选项打开时不会触发把 elf 写入 ddr。

  5. host start:

  6. gem5 增加了一个 host start 选项,开启之后不会主动 activate thread。然后暴露出一个接口供 calcore host 调用。

需要确认gem5 config host start和skip elf****都是true 问题:如果 tc->activate 放在 sc_start 之后,gem5 其实根本没动;如果 tc->activate 放在 sc_start 之前,会发现 gem5 发过来了 fetch。

为什么? start from host 是谁做的?每个 ccu 一个?每个system各需要一个start from host

ddr: 需要 host 加一个后门加载,在 nbu mem access backdoor 以不消耗时间的动作加载 elf,替换掉原有 programinit 里面的 memblockrwddr。

保证 fetch 过来之前,这个 memory 已经写入 elf。

需要 64 位的 load bin,以及 program init 里相关 32 bit 的数据结构和掩码都改为 64 位 1。

确认load bin正确,

多CCU

由于每个chip里面实际上有两个ccu,这两个ccu的地址空间是独立的。那么在gem5中也需要类似的配置,如果是两个ccu,则需要两颗独立的gem5 CPU,在后期他们可能运行不同的elf。在gem5具体实现中,需要多个board

从上到下结构是 root -board -processor - cache-hierarchy - memory

因此,我们需要在root下面创建两个board,每个board地址空间独立,都有一个transactor向外转发访存请求

  • host start:每个ccu都需要来启动自己的gem5,可以利用gem5接口来找到每个ccu各自的board,然后调用start host函数
  • elf指定:elf在board.workload中指定,如果后期需要多个不同elf,需要在gem5中配置
  • transactor bind:

不足之处

  • delay 没有建模。
  • wmb【TODO】
  • 原有的仿真器独有指令 gem5 暂未支持,后期可以考虑在 gem5 实现识别这些指令。

Gem5:

  • cacheable 和 uncacheable
  • Gem5 是没有 store buffer 的,store queue 充当了这一角色。
  • uncacheable 会走 cache 层次,但是只会加上 tag lookup + read/write resp 延迟。