跳转至

博客

RISC-V Vector 1.0 工具链构建

不久前 RVV 1.0 标准终于是出来了,但是工具链的支持目前基本还处于刚 upstream 还没有 release 的状态。而目前 RVV 1.0 的支持主要在 LLVM 上比较活跃,因此也是采用 LLVM Clang + GCC Newlib Toolchain 的方式进行配合,前者做 RVV 1.0 的编译,后者提供 libc 等基础库。

UPDATE: LLVM 14 已经发布,这个版本已经支持 RVV 1.0,直接从 https://apt.llvm.org 等地安装 LLVM 14 即可。

LLVM Clang 直接采用 upstream 即可。编译选项:

$ cmake -G Ninja ../llvm -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_INSTALL_PREFIX=/prefix/llvm -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS="clang" -DLLVM_TARGETS_TO_BUILD="RISCV"
$ ninja
$ ninja install
$ /prefix/llvm/bin/clang --version
clang version 14.0.0 (https://github.com/llvm/llvm-project.git 8d298355ca3778a47fd6b3110aeee03ea5e8e02b)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /data/llvm/bin

还需要配合一个 GCC 工具链才可以完整地工作。可以直接采用 riscv-gnu-toolchain nightly 版本,比如 riscv64-elf-ubuntu-20.04-nightly-2022.01.17-nightly.tar.gz。下载以后解压,得到 riscv 目录,GCC 版本是比较新的:

$ ~/riscv/bin/riscv64-unknown-elf-gcc --version
riscv64-unknown-elf-gcc (GCC) 11.1.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

但是如果编译 C++ 程序,链接的时候就会报错:prefixed ISA extension must separate with _,这是因为 riscv-gnu-toolchain 仓库的 binutils 版本不够新,在 upstream 的 binutils 里面已经修复了这个问题。所以 clone 下来,然后编译,覆盖掉 riscv-gnu-toolchain 里面的 binutils:

UPDATE: binutils 2.38 已经发布,用这个版本即可。

$ ../configure --target=riscv64-unknown-elf --prefix=$HOME/riscv --disable-gdb --disable-sim --disable-libdecnumber --disable-readline
$ make
$ make install
$ ~/riscv/bin/riscv64-unknown-elf-ld --version
GNU ld (GNU Binutils) 2.38.50.20220125
Copyright (C) 2022 Free Software Foundation, Inc.
This program is free software; you may redistribute it under the terms of
the GNU General Public License version 3 or (at your option) a later version.
This program has absolutely no warranty.

然后编译程序的时候,使用 clang,配置参数 --gcc-toolchain=~/riscv 即可让 clang 找到 GNU 工具链。这样就可以编译出来 RVV 1.0 的程序了:

$ /llvm/bin/clang --target=riscv64-unknown-elf -O2 -march=rv64gcv1p0 -menable-experimental-extensions -mllvm --riscv-v-vector-bits-min=256 --gcc-toolchain=$HOME/riscv add.cpp -o add
$ /data/llvm/bin/llvm-objdump --mattr=+v -S add
   1020e: 57 70 04 c5   vsetivli        zero, 8, e32, m1, ta, mu
   10212: 07 64 03 02   vle32.v v8, (t1)
   10216: 87 e4 03 02   vle32.v v9, (t2)
   1021a: 93 07 07 fe   addi    a5, a4, -32
   1021e: 07 e5 07 02   vle32.v v10, (a5)
   10222: 87 65 07 02   vle32.v v11, (a4)
   10226: 57 14 85 02   vfadd.vv        v8, v8, v10
   1022a: d7 94 95 02   vfadd.vv        v9, v9, v11
   1022e: 93 07 05 fe   addi    a5, a0, -32
   10232: 27 e4 07 02   vse32.v v8, (a5)
   10236: a7 64 05 02   vse32.v v9, (a0)

可以看到 llvm 的自动向量化是工作的。此外,也可以编写 rvv intrinsic。

jiege.ch 停用

jiege.ch 域名不再续费,之后一直继续用 jia.je 这个域名。

分析 Rocket Chip 中 Diplomacy 系统

概念

Diplomacy 主要实现了两个功能:

  1. 把整个总线结构在代码中表现出来
  2. 自动配置总线中各个端口的参数

具体来说,第一点实现了类似 Vivado Board Design 中连线的功能,第二点则是保证总线两端的参数一致,可以连接起来。

Diplomacy 为了表示总线的结构,每个模块可以对应一个 Node,Node 和 Node 之间连接形成一个图。Node 的类型主要有以下几个:

  1. Client:对应 AXI 里面的 Master,发起请求
  2. Manager:对应 AXI 里面的 Slave,处理请求
  3. Adapter:对应 AXI Width Converter/Clock Converter/AXI4 to AXI3/AXI4 to AHB bridge 等,会修改 AXI 的参数,然后每个输入对应一个输出
  4. Nexus:对应 AXI Crossbar,多个输入和多个输出

每个 Node 可能作为 Manager 连接上游的 Client,这个叫做入边(Inward Edge);同样地,也可以作为 Client 连接下游的 Manager,这个是出边(Outward Edge)。想象成一个 DAG,从若干个 Client 流向 Manager。

连接方式采用的是 := :=* :*= :*=* 操作符,左侧是 Client,右侧是 Manager。

Rocket Chip 总线结构

Rocket Chip 主要有以下几个总线:

  1. sbus: System Bus
  2. mbus: Memory Bus
  3. cbus: Control Bus
  4. pbus: Periphery Bus
  5. fbus: Frontend Bus

图示可以见参考文档中的链接,不过链接中的结构和实际的有一些区别。目前的 Rocket Chip 内存结构大致是这样:

fbus -> sbus -> mbus
tile --/    \-> cbus -> pbus

主要是 pbus 的位置从连接 sbus 移动到了连接 cbus。

相关代码:

/** Parameterization of a topology containing three additional, optional buses for attaching MMIO devices. */
case class HierarchicalBusTopologyParams(
  pbus: PeripheryBusParams,
  fbus: FrontBusParams,
  cbus: PeripheryBusParams,
  xTypes: SubsystemCrossingParams,
  driveClocksFromSBus: Boolean = true
) extends TLBusWrapperTopology(
  instantiations = List(
    (PBUS, pbus),
    (FBUS, fbus),
    (CBUS, cbus)),
  connections = List(
    (SBUS, CBUS, TLBusWrapperConnection  .crossTo(xTypes.sbusToCbusXType, if (driveClocksFromSBus) Some(true) else None)),
    (CBUS, PBUS, TLBusWrapperConnection  .crossTo(xTypes.cbusToPbusXType, if (driveClocksFromSBus) Some(true) else None)),
    (FBUS, SBUS, TLBusWrapperConnection.crossFrom(xTypes.fbusToSbusXType, if (driveClocksFromSBus) Some(false) else None)))
)

当然了,也有简化版的 JustOneBusTopology,那就只有 SystemBus 了。如果再配置了 CoherentBusTopology,那么 SBUS 和 MBUS 之间还有一层 L2:

/** Parameterization of a topology containing a banked coherence manager and a bus for attaching memory devices. */
case class CoherentBusTopologyParams(
  sbus: SystemBusParams, // TODO remove this after better width propagation
  mbus: MemoryBusParams,
  l2: BankedL2Params,
  sbusToMbusXType: ClockCrossingType = NoCrossing,
  driveMBusClockFromSBus: Boolean = true
) extends TLBusWrapperTopology(
  instantiations = (if (l2.nBanks == 0) Nil else List(
    (MBUS, mbus),
    (L2, CoherenceManagerWrapperParams(mbus.blockBytes, mbus.beatBytes, l2.nBanks, L2.name, sbus.dtsFrequency)(l2.coherenceManager)))),
  connections = if (l2.nBanks == 0) Nil else List(
    (SBUS, L2,   TLBusWrapperConnection(driveClockFromMaster = Some(true), nodeBinding = BIND_STAR)()),
    (L2,  MBUS,  TLBusWrapperConnection.crossTo(
      xType = sbusToMbusXType,
      driveClockFromMaster = if (driveMBusClockFromSBus) Some(true) else None,
      nodeBinding = BIND_QUERY))
  )
)

参考文档

Chisel3 Cookbook

Chisel 版本选择

尽量选择较新版本的 Chisel。Chisel v3.5 完善了编译器插件,使得生成的代码中会包括更多变量名信息。

去掉输出 Verilog 文件中的寄存器随机初始化

版本:FIRRTL >= 1.5.0-RC2

代码:

new ChiselStage().execute(
  Array("-X", "verilog", "-o", s"${name}.v"),
  Seq(
    ChiselGeneratorAnnotation(genModule),
    CustomDefaultRegisterEmission(
      useInitAsPreset = false,
      disableRandomization = true
    )
  )
)

设置 disableRandomization=true 即可。useInitAsPreset 不建议开启。

关闭 FIRRTL 优化,输出尽可能与源代码一致的 Verilog

设置 Chisel 生成 MinimumVerilog:

new ChiselStage().execute(
  Array("-X", "mverilog", "-o", s"${name}.v"),
  Seq(
    ChiselGeneratorAnnotation(genModule)
  )
)

此时代码中会保留更多原始 Chisel 代码的元素。

重命名 AXI4 为标准命名

Rocket Chip 中 AXI4Bundle 直接生成的名字和标准写法不同,可以利用 Chisel3 3.5.0 的 DataView 功能进行重命名:

// https://www.chisel-lang.org/chisel3/docs/explanations/dataview.html
// use standard names
class StandardAXI4BundleBundle(val addrBits: Int, val dataBits: Int, val idBits: Int)
    extends Bundle {
  val AWREADY = Input(Bool())
  val AWVALID = Output(Bool())
  val AWID = Output(UInt(idBits.W))
  val AWADDR = Output(UInt(addrBits.W))
  val AWLEN = Output(UInt(8.W))
  val AWSIZE = Output(UInt(3.W))
  val AWBURST = Output(UInt(2.W))
  val AWLOCK = Output(UInt(1.W))
  val AWCACHE = Output(UInt(4.W))
  val AWPROT = Output(UInt(3.W))
  val AWQOS = Output(UInt(4.W))

  val WREADY = Input(Bool())
  val WVALID = Output(Bool())
  val WDATA = Output(UInt(dataBits.W))
  val WSTRB = Output(UInt((dataBits / 8).W))
  val WLAST = Output(Bool())

  val BREADY = Output(Bool())
  val BVALID = Input(Bool())
  val BID = Input(UInt(idBits.W))
  val BRESP = Input(UInt(2.W))

  val ARREADY = Input(Bool())
  val ARVALID = Output(Bool())
  val ARID = Output(UInt(idBits.W))
  val ARADDR = Output(UInt(addrBits.W))
  val ARLEN = Output(UInt(8.W))
  val ARSIZE = Output(UInt(3.W))
  val ARBURST = Output(UInt(2.W))
  val ARLOCK = Output(UInt(1.W))
  val ARCACHE = Output(UInt(4.W))
  val ARPROT = Output(UInt(3.W))
  val ARQOS = Output(UInt(4.W))

  val RREADY = Output(Bool())
  val RVALID = Input(Bool())
  val RID = Input(UInt(idBits.W))
  val RDATA = Input(UInt(dataBits.W))
  val RRESP = Input(UInt(2.W))
  val RLAST = Input(Bool())
}

object StandardAXI4BundleBundle {
  implicit val axiView = DataView[StandardAXI4BundleBundle, AXI4Bundle](
    vab =>
      new AXI4Bundle(
        AXI4BundleParameters(vab.addrBits, vab.dataBits, vab.idBits)
      ),
    // AW
    _.AWREADY -> _.aw.ready,
    _.AWVALID -> _.aw.valid,
    _.AWID -> _.aw.bits.id,
    _.AWADDR -> _.aw.bits.addr,
    _.AWLEN -> _.aw.bits.len,
    _.AWSIZE -> _.aw.bits.size,
    _.AWBURST -> _.aw.bits.burst,
    _.AWLOCK -> _.aw.bits.lock,
    _.AWCACHE -> _.aw.bits.cache,
    _.AWPROT -> _.aw.bits.prot,
    _.AWQOS -> _.aw.bits.qos,
    // W
    _.WREADY -> _.w.ready,
    _.WVALID -> _.w.valid,
    _.WDATA -> _.w.bits.data,
    _.WSTRB -> _.w.bits.strb,
    _.WLAST -> _.w.bits.last,
    // B
    _.BREADY -> _.b.ready,
    _.BVALID -> _.b.valid,
    _.BID -> _.b.bits.id,
    _.BRESP -> _.b.bits.resp,
    // AR
    _.ARREADY -> _.ar.ready,
    _.ARVALID -> _.ar.valid,
    _.ARID -> _.ar.bits.id,
    _.ARADDR -> _.ar.bits.addr,
    _.ARLEN -> _.ar.bits.len,
    _.ARSIZE -> _.ar.bits.size,
    _.ARBURST -> _.ar.bits.burst,
    _.ARLOCK -> _.ar.bits.lock,
    _.ARCACHE -> _.ar.bits.cache,
    _.ARPROT -> _.ar.bits.prot,
    _.ARQOS -> _.ar.bits.qos,
    // R
    _.RREADY -> _.r.ready,
    _.RVALID -> _.r.valid,
    _.RID -> _.r.bits.id,
    _.RDATA -> _.r.bits.data,
    _.RRESP -> _.r.bits.resp,
    _.RLAST -> _.r.bits.last
  )
  implicit val axiView2 = StandardAXI4BundleBundle.axiView.invert(ab =>
    new StandardAXI4BundleBundle(
      ab.params.addrBits,
      ab.params.dataBits,
      ab.params.idBits
    )
  )
}

// usage
val MEM = IO(new StandardAXI4BundleBundle(32, 64, 4))
MEM <> target.mem_axi4.head.viewAs[StandardAXI4BundleBundle]

给所有模块添加名称前缀

有些时候,我们希望给所有模块添加一个名称前缀,防止可能出现的冲突。

在 Chisel 3 中,可以使用自定义 FIRRTL Transform 来实现这个功能。这一部分的实现参考了 chisel issue #1059

import firrtl._
import firrtl.annotations.NoTargetAnnotation
import firrtl.options.Dependency
import firrtl.passes.PassException
import firrtl.transforms.DedupModules

// adapted from https://github.com/chipsalliance/chisel3/issues/1059#issuecomment-814353578

/** Specifies a global prefix for all module names. */
case class ModulePrefix(prefix: String) extends NoTargetAnnotation

/** FIRRTL pass to add prefix to module names
  */
object PrefixModulesPass extends Transform with DependencyAPIMigration {
  // we run after deduplication to save some work
  override def prerequisites = Seq(Dependency[DedupModules])

  // we do not invalidate the results of any prior passes
  override def invalidates(a: Transform) = false

  override protected def execute(state: CircuitState): CircuitState = {
    val prefixes = state.annotations.collect { case a: ModulePrefix =>
      a.prefix
    }.distinct
    prefixes match {
      case Seq() =>
        logger.info("[PrefixModulesPass] No ModulePrefix annotation found.")
        state
      case Seq("") => state
      case Seq(prefix) =>
        val c = state.circuit.mapModule(onModule(_, prefix))
        state.copy(circuit = c.copy(main = prefix + c.main))
      case other =>
        throw new PassException(
          s"[PrefixModulesPass] found more than one prefix annotation: $other"
        )
    }
  }

  private def onModule(m: ir.DefModule, prefix: String): ir.DefModule =
    m match {
      case e: ir.ExtModule => e.copy(name = prefix + e.name)
      case mod: ir.Module =>
        val name = prefix + mod.name
        val body = onStmt(mod.body, prefix)
        mod.copy(name = name, body = body)
    }

  private def onStmt(s: ir.Statement, prefix: String): ir.Statement = s match {
    case i: ir.DefInstance => i.copy(module = prefix + i.module)
    case other             => other.mapStmt(onStmt(_, prefix))
  }
}

实现思路就是遍历 IR,找到所有的 Module 并改名,再把所有模块例化也做一次替换。最后在生成 Verilog 的时候添加 Annotation 即可:

new ChiselStage().execute(
  Array("-o", s"${name}.v"),
  Seq(
    ChiselGeneratorAnnotation(genModule),
    RunFirrtlTransformAnnotation(Dependency(PrefixModulesPass)),
    ModulePrefix(prefix)
  )

如果使用新的 MLIR FIRRTL Compiler,则可以利用 sifive.enterprise.firrtl.NestedPrefixModulesAnnotation annotation,让 firtool 来进行 prefix 操作

package sifive {
  package enterprise {
    package firrtl {
      import _root_.firrtl.annotations._

      case class NestedPrefixModulesAnnotation(
          val target: Target,
          prefix: String,
          inclusive: Boolean
      ) extends SingleTargetAnnotation[Target] {

        def duplicate(n: Target): Annotation =
          NestedPrefixModulesAnnotation(target, prefix, inclusive)
      }
    }
  }
}

object AddPrefix {
  def apply(module: Module, prefix: String, inclusive: Boolean = true) = {
    annotate(new ChiselAnnotation {
      def toFirrtl =
        new NestedPrefixModulesAnnotation(module.toTarget, prefix, inclusive)
    })
  }
}

这个方法的灵感来自 @sequencer。唯一的缺点就是比较 Hack,建议 SiFive 把相关的类也开源出来用。

关闭 RTL 级别的优化

Chisel3 生成 Verilog/System Verilog 的时候会进行一些优化。如果想要关闭这些优化,可以使用:

  1. dontTouch annotation
  2. 添加命令行参数:--preserve-values=[none/named/all],见 FIRRTL Dialect Rationale

升级 Linksys E8450 的 OpenWRT 系统到 UBI

背景

OpenWRT Linksys E8450 页面 中,如果要用新版的固件,需要转换到 UBI 格式的文件系统。之前用的是 non-UBI 格式的文件系统,直接在官方的分区下,覆盖掉其中一个启动分区。但是经常会报告 flash 出错,然后系统也不稳定,决定要按照文档更新到 UBI。

步骤

请注意:更换文件系统操作比较危险,请先备份好数据,并做好变砖的心理准备。本文仅记录了作者编写时可行的更新操作,不代表读者在阅读时,依然可以按照这个顺序进行,请按照 https://github.com/dangowrt/owrt-ubi-installer 的文档进行操作。

基本按照文档一步一步走。初始状态是一个 non-UBI 版本的 OpenWRT 固件:

  1. 下载官方的 1.0 固件:https://downloads.linksys.com/support/assets/firmware/FW_E8450_1.0.01.101415_prod.img
  2. 在 luci 中,刷入官方 1.0 固件,这时候进入了官方固件的系统
  3. 登录官方固件网页,恢复出厂设置
  4. 下载 openwrt ubi recovery 固件 然后在官方固件里刷入
  5. 这时候进入了 recovery 固件,下载 ubi 固件,继续在网页里刷入
  6. 这时候固件就更新完成了。ssh root@192.168.1.1,然后进去安装 luci 等软件,恢复配置即可。

XRDP 和 NVIDIA 显卡兼容性问题

背景

最近在尝试配置 XRDP,发现它在有 NVIDIA 的机器上启动远程桌面后会黑屏,查看错误信息可以看到:

xf86OpenConsole: Cannot open virtual console 1 (Permission denied)

解决方法

XRDP 作者在 issue #2010 中提到了解决方法:

修改 /etc/xrdp/sesman.ini,在 [Xorg] 部分里加上下面的配置:

param=-configdir
param=/

实际上就是不让 Xorg 加载 nvidia xorg 驱动,这样就绕过了问题。

NVIDIA 驱动和 CUDA 版本信息速查

背景

之前和 NVIDIA 驱动和 CUDA 搏斗比较多,因此记录一下一些常用信息,方便查询。

常用地址

CUDA 版本与 NVIDIA 驱动兼容性

可以通过 apt show cuda-runtime-x-x 找到:

  • cuda 12.3 >= 545 (Release Notes: 525)
  • cuda 12.2 >= 535 (Release Notes: 525)
  • cuda 12.1 >= 530 (Release Notes: 525)
  • cuda 12.0 >= 525 (Release Notes: 525)
  • cuda 11.8 >= 520 (Release Notes: 450)
  • cuda 11.7 >= 515 (Release Notes: 450)
  • cuda 11.6 >= 510 (Release Notes: 450)
  • cuda 11.5 >= 495 (Release Notes: 450)
  • cuda 11.4 >= 470 (Release Notes: 450)
  • cuda 11.3 >= 465 (Release Notes: 450)
  • cuda 11.2 >= 460 (Release Notes: 450)
  • cuda 11.1 >= 455 (Release Notes: 450)
  • cuda 11.0 >= 450 (Release Notes: 450)
  • cuda 10.2 >= 440
  • cuda 10.1 >= 418
  • cuda 10.0 >= 410
  • cuda 9.2 >= 396
  • cuda 9.1 >= 390
  • cuda 9.0 >= 384

使用 nvidia-smi 看到的 CUDA 版本,通常就是这个驱动在上表里对应的 CUDA 版本,例如内核驱动版本是 470 的话,看到的 CUDA 版本就是 11.4。

实际上兼容的驱动版本会比 APT 宣称的更多一些:官方文档 里面写了 CUDA 11.x 可以兼容 NVIDIA >= 450。

CUDA 版本和 GCC/Clang 版本兼容性

可以在 cuda/include/crt/host_config.h 文件里找到:

  • cuda 12.1: gcc <= 12, 3.2 < clang < 16
  • cuda 12.0: gcc <= 12, 3.2 < clang < 15
  • cuda 11.8: gcc <= 11, 3.2 < clang < 15
  • cuda 11.5: gcc <= 11
  • cuda 11.4: gcc <= 10
  • cuda 11.3: gcc <= 10, 3.2 < clang < 12
  • cuda 11.1: gcc <= 10, 3.2 < clang < 11
  • cuda 11.0: gcc <= 9, 3.2 < clang < 10
  • cuda 10.2: gcc <= 8, 3.2 < clang < 9
  • cuda 10.1: gcc <= 8, 3.2 < clang < 9
  • cuda 10.0: gcc <= 7
  • cuda 9.1: gcc <= 6

CUDA 版本与显卡兼容性

编译选项与显卡对应关系 https://arnon.dk/matching-sm-architectures-arch-and-gencode-for-various-nvidia-cards/

可以在 nvcc --help 搜索 gpu-architecture 找到:

  • cuda 12.0 sm_50 to sm_90a
  • cuda 11.8 sm_35 to sm_90
  • cuda 11.4 sm_35 to sm_87
  • cuda 11.3 sm_35 to sm_86
  • cuda 11.1 sm_35 to sm_86
  • cuda 11.0 sm_35 to sm_80
  • cuda 10.2 sm_30 to sm_75
  • cuda 10.0 sm_30 to sm_75
  • cuda 9.1 sm_30 to sm_72
  • cuda 9.0 sm_30 to sm_70

显卡的 Compute Capability 可以在 https://developer.nvidia.com/cuda-gpus 找到:

  • H100: 90
  • A100: 80
  • V100: 70
  • P100: 60

升级 NVIDIA 驱动

升级后,需要 rmmod 已有的,再 modprobe 新的:

sudo rmmod nvidia_uvm nvidia_drm nvidia_modeset nvidia && sudo modprobe nvidia

如果发现 rmmod 失败,可以 lsof /dev/nvidiactl 查看谁在占用。DGX OS 上需要停止:

sudo systemctl stop nvsm.service
sudo systemctl stop nvidia-dcgm.service 

除了 /dev/nvidia* 可能被占用以外,还需要用 lsof 检查 /dev/dri/render*

「教学」缓存一致性协议分析

本文的内容已经整合到知识库中。

背景

最近在《高等计算机系统结构》课程中学习缓存一致性协议算法,这里用自己的语言来组织一下相关知识的讲解。

Write-invalidate 和 Write-update

最基础的缓存一致性思想有两种:

  1. Write-invalidate:写入数据的时候,将其他 Cache 中这条 Cache Line 设为 Invalid
  2. Write-update:写入数据的时候,把新的结果写入到有这条 Cache Line 的其他 Cache

Write-once 协议

Write-once 协议定义了四个状态:

  1. Invalid:表示这个块不合法
  2. Valid:表示这个块合法,并可能是共享的,同时数据没有修改
  3. Reserved:表示这个块合法,不是共享的,同时数据没有更改
  4. Dirty:表示这个块合法,不是共享的,数据做了修改,和内存不同。

可见,当一个缓存状态在 R 或者 D,其他缓存只能是 I;而缓存状态是 V 的时候,可以有多个缓存在 V 状态。

Write-once 协议的特点是,第一次写的时候,会写入到内存(类似 Write-through),连续写入则只写到缓存中,类似 Write-back。

当 Read hit 的时候,状态不变。

Read hit: The information is supplied by the current cache. No state change.

当 Read miss 的时候,会查看所有缓存,如果有其他缓存处于 Valid/Reserved/Dirty 状态,就从其他缓存处读取数据,然后设为 Valid,其他缓存也设为 Valid。如果其他缓存处于 Dirty 状态,还要把数据写入内存。

Read miss: The data is read from main memory. The read is snooped by other caches; if any of them have the line in the Dirty state, the read is interrupted long enough to write the data back to memory before it is allowed to continue. Any copies in the Dirty or Reserved states are set to the Valid state.

当 Write hit 的时候,如果是 Valid 状态,首先写入内存,把其他 Cache 都设为 Invalid,进入 Reserved 状态,这意味着第一次写是 Write-through。如果是 Reserved/Dirty 状态,则不修改内存,进入 Dirty 状态,这表示后续的写入都是 Write-back。

Write hit: If the information in the cache is in Dirty or Reserved state, the cache line is updated in place and its state is set to Dirty without updating memory. If the information is in Valid state, a write-through operation is executed updating the block and the memory and the block state is changed to Reserved. Other caches snoop the write and set their copies to Invalid.

当 Write miss 的时候,这个行为 Wikipedia 上和上课讲的不一样。按照 Wikipedia 的说法,首先按照 Read miss 处理,再按照 Write hit 处理,类似于 Write Allocate 的思路。如果是这样的话,那么首先从其他缓存或者内存读取数据,然后把其他缓存都设为 Invalid,把更新后的数据写入内存,进入 Reserved 状态。相当于 Write miss 的时候,也是按照 Write-through 实现。

Write miss: A partial cache line write is handled as a read miss (if necessary to fetch the unwritten portion of the cache line) followed by a write hit. This leaves all other caches in the Invalid state, and the current cache in the Reserved state.

教材上则是 Write miss 的时候按照 Write-back 处理。如果其他缓存都是 Invalid 时,从内存里读取数据,然后写入到缓存中,进入 Dirty 状态。如果其他缓存是 Valid/Reserved/Dirty 状态,就从其他缓存里读取数据,让其他缓存都进入 Invalid 状态,然后更新自己的数据,进入 Dirty 状态。

MSI 协议

MSI 协议比较简单,它定义了三个状态:

  1. Modified:表示数据已经修改,和内存里不一致
  2. Shared:数据和内存一致,可以有一到多个缓存同时处在 Shared 状态
  3. Invalid:不在缓存中

当 Read hit 的时候,状态不变。

当 Read miss 的时候,检查其他缓存的状态,如果都是 Invalid,就从内存里读取,然后进入 Shared 状态。如果有 Shared,就从其他缓存处读取。如果有 Dirty,那就要把其他缓存的数据写入内存和本地缓存,然后进入 Shared 状态。

当 Write hit 的时候,如果现在是 Shared 状态,则要让其他的 Shared 缓存进入 Invalid 状态,然后更新数据,进入 Modified 状态。如果是 Modified 状态,那就修改数据,状态保持不变。

当 Write miss 的时候,如果有其他缓存处于 Modified/Shared 状态,那就从其他缓存处读取数据,并让其他缓存进入 Invalid 状态,然后修改本地数据,进入 Modified 状态。如果所有缓存都是 Invalid 状态,那就从内存读入,然后修改缓存数据,进入 Modified 状态。

MESI 协议

MESI 协议定义了四种状态:

  1. Modified:数据与内存不一致,并且只有一个缓存有数据
  2. Exclusive:数据与内存一致,并且只有一个缓存有数据
  3. Shared:数据与内存一致,可以有多个缓存同时有数据
  4. Invalid:不在缓存中

当 Read hit 的时候,状态不变。

当 Read miss 的时候,首先会检查其他缓存的状态,如果有数据,就从其他缓存读取数据,并且都进入 Shared 状态,如果其他缓存处于 Modified 状态,还需要把数据写入内存;如果其他缓存都没有数据,就从内存里读取,然后进入 Exclusive 状态。

当 Write hit 的时候,进入 Modified 状态,同时让其他缓存进入 Invalid 状态。

当 Write miss 的时候,检查其他缓存的状态,如果有数据,就从其他缓存读取,否则从内存读取。然后,其他缓存都进入 Invalid 状态,本地缓存更新数据,进入 Modified 状态。

值得一提的是,Shared 状态不一定表示只有一个缓存有数据:比如本来有两个缓存都是 Shared 状态,然后其中一个因为缓存替换变成了 Invalid,那么另一个是不会受到通知变成 Exclusive 的。Exclusive 的设置是为了减少一些总线请求,比如当数据只有一个核心访问的时候,只有第一次 Read miss 会发送总线请求,之后一直在 Exclusive/Modified 状态中,不需要发送总线请求。

MOESI 协议

MOESI 定义了五个状态:

  1. Modified:数据经过修改,并且只有一个缓存有这个数据
  2. Owned:同时有多个缓存有这个数据,但是只有这个缓存可以修改数据
  3. Exclusive:数据没有修改,并且只有一个缓存有这个数据
  4. Shared:同时有多个缓存有这个数据,但是不能修改数据
  5. Invalid:不在缓存中

状态中,M 和 E 是独占的,所有缓存里只能有一个。此外,可以同时有多个 S,或者多个 S 加一个 O,但是不能同时有多个 O。

它的状态转移与 MESI 类似,区别在于:当核心写入 Owned 状态的缓存时,有两种方式:1)通知其他 Shared 的缓存更新数据;2)把其他 Shared 缓存设为 Invalid,然后本地缓存进入 Modified 状态。在 Read miss 的时候,则可以从 Owned 缓存读取数据,进入 Shared 状态,而不用写入内存。它相比 MESI 的好处是,减少了写回内存的次数。

AMD64 文档里采用的就是 MOESI 协议。AMBA ACE 协议其实也是 MOESI 协议,只不过换了一些名称,表示可以兼容 MEI/MESI/MOESI 中的一个协议。ACE 对应关系如下:

  1. UniqueDirty: Modified
  2. SharedDirty: Owned
  3. UniqueClean: Exclusive
  4. SharedClean: Shared
  5. Invalid: Invalid

需要注意的是,SharedClean 并不代表它的数据和内存一致,比如说和 SharedDirty 缓存一致,它只是说缓存替换的时候,不需要写回内存。

Dragon 协议

Dragon 协议是一个基于更新的协议,意味着写入缓存的时候,会把更新的数据同步到拥有这个缓存行的其他核心。它定义了四个状态:

  1. Exclusive clean(E):独占,并且数据和内存一致
  2. Shared clean(Sc):数据同时存在多个缓存中,并且自己不是最后一个写入该缓存数据的
  3. Shared modified(Sm):数据同时存在多个缓存中,并且自己设最后一个写入该缓存数据的,类似于前面 MOESI 协议的 Owner 状态
  4. Modify(M):独占,并且数据和内存不一致

可以看到,E 和 M 都是独占的,如果出现了多个缓存有同一个缓存行,那就是若干个 Sc 和一个 Sm。

当 Read miss 的时候,在总线上检查是否有缓存已经有这个缓存行的数据,如果没有,则从内存读取并转到 Exclusive clean 状态;如果已经在其他缓存中,则从其他缓存读取,将其他缓存转移到 Shared clean/Shared modified 状态,然后该缓存转移到 Shared clean 状态。

当 Write miss 的时候,同样检查其他缓存的状态,如果是第一个访问的,就从内存读取,更新数据,然后转到 Modify 状态;如果不是第一个访问的,就进入 Shared modified 状态,并且让原来 Shared modified 的缓存进入 Shared clean 状态。

当 Write hit 的时候,如果状态是 Shared modified,这时候需要通知其他缓存更新数据;如果状态是 Shared clean,则要通知其他缓存更新数据的同时,让原来 Shared modified 的缓存进入 Shared clean 状态;如果状态是 Exclusive clean,则进入 Modify 状态。

在这里,Shared modified 的缓存负责在换出的时候,写入数据到内存中。

ACE 协议

ACE 协议在 AXI 的基础上,添加了三个 channel:

  1. AC: Coherent address channel, Input to master: ACADDR, ACSNOOP, ACPROT
  2. CR: Coherent response channel, Output from master: CRRESP
  3. CD: Coherent data channel, Output from master: CDDATA, CDLAST

此外,已有的 Channel 也添加了信号:

  1. ARSNOOP[3:0]/ARBAR[1:0]/ARDOMAIN[1:0]
  2. AWSNOOP[3:0]/AWBAR[1:0]/AWDOMAIN[1:0]/AWUNIQUE
  3. RRESP[3:2]
  4. RACK/WACK

ACE-lite 只在已有 Channel 上添加了新信号,没有添加新的 Channel。因此它内部不能有 Cache,但是可以访问一致的缓存内容。

当 Read miss 的时候,首先 AXI master 发送 read transaction 给 Interconnect,Interconnect 向保存了这个缓存行的缓存发送 AC 请求,如果有其他 master 提供了数据,就向请求的 master 返回数据;如果没有其他 master 提供数据,则向内存发起读请求,并把结果返回给 master,最后 master 提供 RACK 信号。

当 Write miss 的时候,也是类似地,AXI master 发送 MakeUnique 请求给 Interconnect,Interconnect 向保存了该缓存行的缓存发送请求,要求其他 master 状态改为 Invalid;当所有 master 都已经 invalidate 成功,就向原 AXI master 返回结果。

基于目录的缓存一致性

上面的缓存一致性协议中,经常有这么一个操作:向所有有这个缓存行的缓存发送/接受消息。简单的方法是直接广播,然后接受端自己判断是否处理。但是这个方法在核心很多的时候会导致广播流量太大,因此需要先保存下来哪些缓存会有这个缓存的信息,然后对这些缓存点对点地发送。这样就可以节省一些网络流量。

那么,怎么记录这个信息呢?一个简单的办法(Full bit vector format)是,有一个全局的表,对每个缓存行,都记录一个大小为 N(N 为核心数)的位向量,1 表示对应的核心中有这个缓存行。但这个方法保存数据量太大:缓存行数正比于 N,还要再乘以一次 N,总容量是 O(N^2) 的。

一个稍微好一些的方法(Coarse bit vector format)是,我把核心分组,比如按照 NUMA 节点进行划分,此时每个缓存行都保存一个大小为 M(M 为 NUMA 数量)的位向量,只要这个 NUMA 节点里有这个缓存行,对应位就取 1。这样相当于是以牺牲一部分流量为代价(NUMA 节点内部广播),来节省一些目录的存储空间。

但实际上,通常情况下,一个缓存行通常只会在很少的核心中保存,所以这里有很大的优化空间。比如说,可以设置一个缓存行同时出现的缓存数量上限 (Limited pointer format),然后保存核心的下标而不是位向量,这样的存储空间就是 O(Nlog2N)。但是呢,这样限制了缓存行同时出现的次数,如果超过了上限,需要替换掉已有的缓存,可能在一些场景下性能会降低。

还有一种方式,就是链表 (Chained directory format)。目录中保存最后一次访问的核心编号,然后每个核心的缓存里,保存了下一个保存了这个缓存行的核心编号,或者表示链表终止。这样存储空间也是 O(Nlog2N),不过发送消息的延迟更长,因为要串行遍历一遍,而不能同时发送。类似地,可以用二叉树 (Number-balanced binary tree format) 来组织:每个缓存保存两个指针,指向左子树和右子树,然后分别遍历,目的还是加快遍历的速度,可以同时发送消息给多个核心。

参考文档

DRAM 在 Kintex 7 FPGA 上内部 Vref 的性能问题

背景

最近我们设计的 Kintex 7 FPGA 开发板在测试 DDR SDRAM 的时候遇到了一个问题,因为采用了 Internel VREF,MIG 在配置的时候限制了频率只能是 400 MHz,对应 800 MT/s,这样无法达到 DDR 的最好性能。

原理

首先,VREF 在 DDR 中是用来区分低电平和高电平的。在 JESD79-4B 标准中,可以看到,对于直流信号,电压不小于 VREF+0.075V 时表示高电平,而电压不高于 VREF-0.075V 时表示低电平。VREF 本身应该介于 VDD 的 0.49 倍到 0.51 倍之间。

在连接 FPGA 的时候,有两种选择:

  • Internal VREF: 从 FPGA 输出 VREF 信号到 DRAM
  • External VREF:接入 FPGA 以外的 VREF

对于 7 Series 的 FPGA,Xilinx 要求如下:

For DDR3 SDRAM interfaces running at or below 800 Mb/s (400 MHz),
users have the option of selecting Internal VREF to save two I/O
pins or using external VREF. VREF is required for banks containing
DDR3 interface input pins (DQ/DQS).

进一步,Xilinx 在 UltraScale 文档下解释了背后的原因:

The UltraScale internal VREF circuit includes enhancements compared
to the 7 Series internal VREF circuit. Whereas 7 Series MIG had datarate
limitations on internal VREF usage (see (Xilinx Answer 42036)), internal
VREF is recommended in UltraScale. The VREF for 7 Series had coarse steps
of VREF value that were based on VCCAUX. This saved pins but limited the
performance because VCCAUX did not track with VCCO as voltage went up and
down. Not being able to track with VCCO enforced the performance
limitations of internal VREF in MIG 7 Series. UltraScale includes several
changes to internal VREF including a much finer resolution of VREF for DDR4
read VREF training. Additionally, internal VREF is based on the VCCO supply
enabling it to track with VCCO. Internal VREF is not subject to PCB and
Package inductance and capacitance. These changes in design now give internal
VREF the highest performance.

用中文简单来说:

  1. 7 Series FPGA 中,Internal VREF 可以节省引脚,代价是 VREF 不会随着 VCCO 变化而变化(而是随着 VCCAUX 变化而变化),当 DRAM 频率提高的时候,可能无法满足 VREF 约等于 VDD 一半的要求
  2. UltraScale FPGA 中,Internal VREF 是随着 VCCO 变化而变化的,并且会比 External VREF 性能更好;因此 UltraScale FPGA 的 DDR4 只支持 Internal VREF。

以 MA703FA-35T 开发板为例,它使用的 FPGA 是 Artix7 35T,内存是 DDR3,采用的是 External VREF。它采用了 TPS51200 Sink and Source DDR Termination Regulator 芯片,将芯片的 REFOUT 芯片接到 DRAM 的 VREFDQ 和 VREFCA 引脚上。

参考文档

「教学」DRAM 结构和特性

本文的内容已经整合到知识库中。

DRAM 是如何组织的

DRAM 分成很多层次:Bank Group,Bank,Row,Column,从大到小,容量也是各级别的乘积。

举例子:

  • 4 Bank Group
  • 4 Bank per Bank Group
  • 32,768 Row per Bank
  • 1024 Column per Row
  • 4 Bits per Column

那么总大小就是 4*4*32768*1024*4=2 Gb

访问模式

DRAM 的访问模式决定了访问内存的实际带宽。对于每次访问,需要这样的操作:

  1. 用 ACT(Bank Activate) 命令打开某个 Bank Group 下面的某个 Bank 的某个 Row,此时整个 Row 的数据都会复制到 Sense Amplifier 中。这一步叫做 RAS(Row Address Strobe)
  2. 用 RD(Read)/WR(Write) 命令按照 Column 访问数据。这一步叫做 CAS(Column Address Strobe)。
  3. 在访问其他 Row 之前,需要用 PRE(Single Bank Precharge) 命令将 Sense Amplifier 中整个 Row 的数据写回 Row 中。

可以看到,如果访问连续的地址,就可以省下 ACT 命令的时间,可以连续的进行 RD/WR 命令操作。

除了显式 PRE 以外,还可以在某次读写之后自动进行 PRE:WRA(Write with Auto-Precharge) 和 RDA(Read with Auto-Precharge)。

总结一下上面提到的六种命令:

  1. ACT: Bank Activate,激活一个 Row,用于接下来的访问
  2. PRE: Single Bank Precharge,与 ACT 是逆操作,解除 Row 的激活状态
  3. RD: Read,读取当前 Row 的某个 Column 数据
  4. RDA: Read with Auto-Precharge,读取后执行 Precharge
  5. WR: Write,写入当前 Row 的某个 Column 数据
  6. WRA: Write with Auto-Precharge,写入后执行 Precharge

除此之外,还有一些常用命令:

  1. REF: Refresh,需要定期执行,保证 DRAM 数据不会丢失。

参数

DRAM 有很多参数,以服务器上的内存 MTA36ASF2G72PZ-2G3A3 为例子:

  • 16GB 容量,DDR4 SDRAM RDIMM
  • PC4-2400
  • Row address: 64K A[15:0]
  • Column address: 1K A[9:0]
  • Device bank group address: 4 BG[1:0]
  • Device bank address per group: 4 BA[1:0]
  • Device configuration: 4Gb (1Gig x 4), 16 banks
  • Module rank address: 2 CS_n[1:0]
  • Configuration: 2Gig x 72
  • Module Bandwidth: 19.2 GB/s=2400 MT/s * 8B/T
  • Memory Clock: 0.83ns(1200 MHz)
  • Data Rate: 2400 MT/s
  • Clock Cycles: CL-nRCD-nRP = 17-17-17

容量:每个 DRAM 颗粒 64K*1K*4*4*4=4Gb,不考虑 ECC,一共有 16*2=32 个这样的颗粒,实际容量是 16 GB。32 个颗粒分为两组,每组 16 个颗粒,两组之间通过 CS_n 片选信号区分。每组 16 个颗粒,每个颗粒 4 位 DQ 数据信号,合并起来就是 64 位,如果考虑 ECC 就是 72 位。

再举一个 FPGA 开发板上内存的例子:MT40A512M16LY-075E,参数如下:

  1. Data Rate: 2666 MT/s, Clock Frequency: 1333 MHz, tCK=0.750ns=750ps
  2. Target CL-nRCD-nRP: 18-18-18
  3. tAA(Internal READ command to first data)=13.50ns(=18*0.750)
  4. tRCD(ACTIVATE to internal READ or WRITE delay time)=13.50ns(=18*0.750)
  5. tRP(PRECHARGE command period)=13.50ns(=18*0.750)
  6. tRAS(ACTIVATE-to-PRECHARGE command period)=32ns
  7. 512 Meg x 16
  8. Number of bank groups: 2
  9. Bank count per group: 4
  10. Row addressing: 64K
  11. Column addressing: 1K
  12. Page size: 2KB=2K*16b

总大小:2*4*64K*1K*16=1GB。这个开发板用了 5 个 DRAM 芯片,只采用了其中的 4.5 个芯片:最后一个芯片只用了 8 位数据,这样就是 4.5*16=72 位的数据线,对应 64 位+ECC。

时序

可以看到,上面的 DRAM Datasheet 里提到了三个时序参数:

  1. CL=17: CAS Latency,从发送读请求到第一个数据的延迟周期数
  2. RCD=17: ACT to internal read or write delay time,表示从 ACT 到读/写需要的延迟周期数
  3. RP=17: Row Precharge Time,表示 Precharge 后需要延迟周期数

如果第一次访问一个 Row 中的数据,并且之前没有已经打开的 Row,那么要执行 ACT 和 RD 命令,需要的周期数是 RCD+CL;如果之前已经有打开了的 Row,那么要执行 PRE,ACT 和 RD 命令,需要的周期数是 RP+RCD+CL。但如果是连续访问,虽然还需要 CL 的延迟,但是可以流水线起来,充分利用 DDR 的带宽。

如果把这个换算到 CPU 角度的内存访问延迟的话,如果每次访问都是最坏情况,那么需要 17+17+17=51 个 DRAM 时钟周期,考虑 DRAM 时钟是 1200MHz,那就是 42.5ns,这个相当于是 DRAM 内部的延迟,实际上测得的是 100ns 左右。

更严格来说,读延迟 READ Latency = AL + CL + PL,其中 AL 和 PL 是可以配置的,CL 是固有的,所以简单可以认为 READ Latency = CL。同理 WRITE Latency = AL + CWL + PL,可以简单认为 WRITE Latency = CWL。CWL 也是可以配置的,不同的 DDR 速率对应不同的 CWL,范围从 1600 MT/s 的 CWL=9 到 3200 MT/s 的 CWL=20,具体见 JESD79-4B 标准的 Table 7 CWL (CAS Write Latency)。

波形

用 Micron 提供的 Verilog Model 进行仿真,可以看到如下的波形图:

首先看第一个命令,ACT_n=0, ADDR=0x009C, CAS_n_A15=0, CKE=1->1, CS_n=0, RAS_n_A16=0, WE_n_A14=1,查阅标准可知这是 ACT(Bank Activate) 命令。接着第二个命令,ACT_n=1, ADDR=0x0400, CAS_n_A15=0, CKE=1->1, CS_n=0, RAS_n_A16=1, WE_n_A14=1, A10=1, 这是 RDA(Read with Auto Precharge) 命令。若干个周期后,读取的数据从 DQ 上输出,一共 8 个字节的数据。

刷新

DRAM 的一个特点是需要定期刷新。有一个参数 tREFI,表示刷新的时间周期,这个值通常是 7.8us,在温度大于 85 摄氏度时是 3.9 us(见 JESD79-4B Table 131)。在刷新之前,所有的 bank 都需要 Precharge 完成并等待 RP 的时间,这时候所有的 Bank 都是空闲的,再执行 REF(Refresh) 命令。等待 tRFC(Refresh Cycle) 时间后,可以继续正常使用。

为了更好的性能,DDR4 标准允许推迟一定次数的刷新,但是要在之后补充,保证平均下来依然满足每过 tREFI 时间至少一次刷新。

地址映射

如果研究 DRAM 内存控制器,比如 FPGA 上的 MIG,可以发现它可以配置不同的地址映射方式,例如:

  • ROW_COLUMN_BANK
  • ROW_BANK_COLUMN
  • BANK_ROW_COLUMN
  • ROW_COLUMN_LRANK_BANK
  • ROW_LRANK_COLUMN_BANK
  • ROW_COLUMN_BANK_INTLV

就是将地址的不同部分映射到 DRAM 的几个地址:Row,Column,Bank。可以想象,不同的地址映射方式针对不同的访存模式会有不同的性能。对于连续的内存访问,ROW_COLUMN_BANK 方式是比较适合的,因为连续的访问会分布到不同的 Bank 上,这样性能就会更好。

此外,如果访问会连续命中同一个 Page,那么直接读写即可;反之如果每次读写几乎都不会命中同一个 Page,那么可以设置 Auto Precharge,即读写以后自动 Precharge,减少了下一次访问前因为 Row 不同导致的 PRE 命令。一个思路是在对每个 Page 的最后一次访问采用 Auto Precharge。

传输速率

DDR SDRAM 的传输速率计算方式如下:

Memory Speed (MT/s) * 64 (bits/transfer)

例如一个 DDR4-3200 的内存,带宽就是 3200 * 64 = 204.8 Gb/s = 25.6 GB/s。但前面已经看到,除了传输数据,还需要进行很多命令,实际上很难达到 100% 的带宽。然后 CPU 可以连接多个 channel 的 DRAM,再考虑多个 CPU Socket,系统的总带宽就是

Memory Speed (MT/s) * 64 (bits/transfer) * Channels * Sockets

使用 MLC 等工具进行测试,计算实际与理论的比值,我测试得到的大概在 70%-90% 之间。

HBM

HBM 相比前面的 DDR SDRAM,它堆叠了多个 DRAM,提供多个 channel 并且提高了位宽。例如 Micron HBM with ECC,堆叠了 4/8 层 DRAM,提供 8 个 channel,每个 channel 的数据宽度是 128 位,以 3200 MT/s 计算,一个 HBM 芯片的传输速率最大是:

3200 (MT/s) * 128 (bits/transfer) * 8 (Channels) = 3276.8 Gb/s = 409.6 GB/s

所以一片 HBM 的传输速率就相当于 16 个传统的 DDR SDRAM:8 个 Channel 加双倍的位宽。128 位实际上就是把两片 64-bit DDR SDRAM 并起来了,可以当成一个 128 位的用,也可以在 Pseudo Channel 模式下,当成共享地址和命令信号的两个 DDR SDRAM 用。

Xilinx 的 Virtex Ultrascale Plus HBM FPGA 提供了 1800 (MT/s) * 128 (bits/transfer) * 8 (Channels) = 230.4 GB/s 的带宽,如果用了两片 HBM 就是 460.8 GB/s。暴露给 FPGA 逻辑的是 16 个 256 位的 AXI3 端口,AXI 频率 450 MHz,内存频率 900 MHz。可以看到,每个 AXI3 就对应了一个 HBM 的 pseudo channel。每个 pseudo channel 是 64 位,但是 AXI 端口是 256 位:在速率不变的情况下,从 450MHz 到 900MHz,再加上 DDR,相当于频率翻了四倍,所以位宽要从 64 位翻四倍到 256 位。

当然了,HBM 的高带宽的代价就是引脚数量很多。根据 HBM3 JESD238A,每个 Channel 要 120 个 pin,一共 16 个 channel(HBM2 是 8 channel,每个 channel 128 位;HBM3 是 16 channel,每个 channel 64 位),然后还有其他的 52 个 pin,这些加起来就 1972 个 pin 了。所以一般在 Silicon Interposer 上连接,而不是传统的在 PCB 上走线(图源 A 1.2V 20nm 307GB/s HBM DRAM with At-Speed Wafer-Level I/O Test Scheme and Adaptive Refresh Considering Temperature Distribution):

所以在 HBM3 标准里,用 Microbump 来描述 HBM 的 pin。

可以理解为把原来插在主板上的内存条,通过堆叠,变成一个 HBM Die,然后紧密地连接到 CPU 中。但是另一方面,密度上去了,价格也更贵了。

A100 显卡 40GB PCIe 版本提供了 1555 GB/s 的内存带宽。根据倍数关系,可以猜测是 5 个 8GB 的 HBM,每个提供 1555 / 5 = 311 GB/s 的带宽,那么时钟频率就是 311 (GB/s) * 8 (bits/byte) / 128 (bits/transfer) / 8 (channels) / 2 (DDR) = 1215 MHz,这与 nvidia-smi -q 看到的结果是一致的。

进一步,A100 80GB PCIe 版本提供了 1935 GB/s 的带宽,按照同样的方法计算,可得时钟频率是 1935 (GB/s) / 5 * 8 (bits/byte) / 128 (bits/transfer) / 8 (channels) / 2(DDR) = 1512 MHz,与 Product Brief 一致。频率的提高是因为从 HBM2 升级到了 HBM2e。

A100 文档中的 Memory bus width 5120 的计算方式也就清楚了:128 (bits/transfer) * 8 (channels) * 5 (stacks) = 5120 (bits)

H100 SXM5 升级到了 HBM3,内存容量依然是 80GB,但是时钟频率提高,内存带宽是 2619 (MHz) * 2 (DDR) * 128 (bits/transfer) * 8 (channels) * 5 (stacks) / 8 (bits/byte) = 3352 GB/s

参考文档

「教学」RISC-V Debug 协议

背景

之前用过一些 RISC-V 核心,但是遇到调试相关的内容的时候就两眼一抹黑,不知道原理,出了问题也不知道如何排查,趁此机会研究一下工作原理。

架构

为了调试 RISC-V 核心,需要很多部件一起工作。按 RISC-V Debug Spec 所述,有这么几部分:

  1. Debugger: GDB,连接到 OpenOCD 启动的 GDB Server
  2. Debug Translator: OpenOCD,向 GDB 提供 Server 实现,同时会通过 FTDI 等芯片控制 JTAG
  3. Debug Transport Hardware: 比如 FTDI 的芯片,可以提供 USB 接口,让 OpenOCD 控制 JTAG 信号 TMS/TDI/TCK 的变化,并读取 TDO
  4. Debug Transport Module: 在芯片内部的 JTAG 控制器(TAP),符合 JTAG 标准
  5. Debug Module Interface:RISC-V 自定义的一系列寄存器,通过这些寄存器来控制 Debug Module 的行为
  6. Debug Module:调试器,控制 RISC-V 核心,同时也支持直接访问总线,也有内部的 Program Buffer

可以看到,DMI 是实际的调试接口,而 JTAG 可以认为是一个传输协议。

JTAG

首先什么是 JTAG?简单来说,它工作流程是这样的:

  1. JTAG TAP 维护了一个状态机,由 TMS 信号控制
  2. 当状态机进入 CaptureDR/CaptureIR 状态的时候,加载数据到 DR/IR 中
  3. 在 ShiftDR/ShiftIR 状态下,寄存器从 TDI 移入,从 TDO 移出
  4. 当进入 UpdateDR/UpdateIR 状态的时候,把 DR/IR 的结果输出到其他单元

具体来说,JTAG 定义了两类寄存器:IR 和 DR。可以把 JTAG 理解成一个小的总线,我通过 IR 选择总线上的设备,通过 DR 向指定的设备上进行数据传输。比如在 RISC-V Debug Spec 里面,规定了以下的 5 位 IR 地址定义:

  1. 0x00/0x1f: BYPASS
  2. 0x01: IDCODE
  3. 0x10: dtmcs
  4. 0x11: dmi

可以类比为有四个设备:BYPASS,IDCODE,dtmcs,dmi,对应了一些地址。如果要选择 dtmcs 这个设备,就在 ShiftIR 阶段向 TDI 输入二进制的 00001 即可。选择地址以后,再向 DR 写入时,操作的就是 dtmcs 设备。

那么,每个设备是怎么操作的呢?假如我已经通过 IR 设置了当前设备是 dtmcs,然后进入 ShiftDR 模式时,JTAG 会同时输入和输出。输入的就是当前要输入的数据,输出的就是原来寄存器里的结果,这个结果可能是固定的,也可能是表示上一次输入对应的结果。

举个例子:IDCODE 设备,在 CaptureDR 阶段的时候,DR 总会被设为一个固定的 IDCODE,表示设备的 ID;在 Shift 的时候,这个 IDCODE 就会一位一位从 TDO 中输出,而 TDI 输入的数据都会被忽略掉。BYPASS 设备则是一个 1 位的寄存器,直接从 TDI 到寄存器,寄存器到 TDO,数据就这么流过去了。

那么,在 RISC-V Debug 里面,JTAG 是怎么用的呢?我们可以这么类比一下:CaptureDR 相当于读取寄存器到缓冲区,然后 ShiftDR 在读取缓冲区的同时写入缓冲区,最后 UpdateDR 则是把缓冲区中的数据写入到寄存器中。这和 MMIO 有点类似,只不过每次操作不是单独的写和读,而是一次操作等于先读后写。

还是来看例子。dtmcs 这个设备表示的是 DTM 当前的状态,它有 32 位,读取的时候可以得到 DMI 的状态和配置,写入的时候可以 reset DMI。以 OpenOCD 代码 dtmcontrol_scan 为例子,它做了这么几个事情:

  1. 首先设置 IR 为 0x10,对应 dtmcs。
  2. 向 DR 中写入数据,同时读取数据。
  3. 设置 IR 为 0x11,对应 dmi,因为 dmi 操作是比较多的,所以它默认恢复到 dmi。

如果我只想读取 dtmcs 寄存器,那么只要设置写入数据为 0 即可,因为寄存器的设计里考虑到,如果写入全 0 是没有副作用的。同理,如果只想写入 dtmcs 寄存器,直接写入即可,因为设计的时候也保证读入寄存器的值是没有副作用的。这样,就在一个一读一写的操作中,实现了读或者写的功能。

那么,dmi 寄存器的用途是什么呢?我们前面提到过,JTAG 其实是一个传输层,而 DMI 又定义了一系列的寄存器,这会让人有点混乱,为啥到处都是寄存器?又是 JTAG 的 IR/DR,又是 dmi,dmi 又有一堆寄存器,这是什么关系?

首先我们来看 dmi 寄存器的定义。它由三部分组成:地址、数据和操作。由于 JTAG 每次操作是一读一写,虽然寄存器定义差不多,但是读和写的含义是不同的。

比如读的时候,它表示的是上一次 dmi 请求的结果。地址还是上一次请求的地址,数据则是上一次请求的结果,操作字段 0 表示成功,2 表示失败,3 表示还没执行完。而写的时候,地址和数据表示了对哪个寄存器写入什么数据,操作字段 0 表示无操作,1 表示读,2 表示写。

可以看到,如果想操作 dmi 定义的寄存器,需要如下几个步骤,这也是 OpenOCD dmi_op_timeout 要做的事情:

  1. 设置 IR 为 0x11,对应 DMI。
  2. 向 DR 写入请求的地址 + 数据 + 操作,丢弃读取的结果。
  3. 等待若干个周期。
  4. 向 DR 写入全 0,对应无操作,同时读取结果,这个结果就对应上面的请求。

可以预期,如果首先写入了一个写操作,那么第二次 DR scan 得到的结果就是是否成功写入;如果首先写入了一个读操作,那么第二次 DR scan 得到的结果就是目标寄存器的值。

可能看起来还是很绕,确实很绕,因为这里有一个封装的过程。首先,DMI 本身定义了一些寄存器,这些寄存器读/写都有一定的含义,比如控制某一个 RISC-V 核心暂停等等。接着,JTAG 需要传输 DMI 的读取和写入操作,同时还要考虑读写尚未完成的情况,怎么办?结论就是通过 DR 来实现,写入 DR 时,按照 DR 中的操作数,对应到 DMI 的写入/读取;然后读取 DR 的时候,按照 DMI 的状态,告诉 OpenOCD 目前是否已经完成了上一次 DMI 操作,和操作的结果。

DMI

讲完 JTAG 以后,终于来到了 DMI。其实 DMI 就是一系列的寄存器,类似于 MMIO 设备,只不过访问方式不是我们通常的内存读写,而是通过 JTAG 的方式进行。它有很多个寄存器,摘录如下:

  1. dmcontrol 0x10: Debug Module Control
  2. dmstatus 0x11: Debug Module Status
  3. hartinfo 0x12: Hart Info
  4. hartsum 0x13: Hart Summary
  5. command 0x16: Abstract Control and Status
  6. data0 0x04: Abstract Data 0
  7. progbuf0 0x20: Program Buffer 0
  8. sbcs 0x38: System Bus Access Control and Status
  9. sbaddress0 0x39: System Bus Address 31:0
  10. sbdata0 0x3c: System Bus Data 31:0

OpenOCD 的 examine 函数对 DMI 初始化并进行一些参数的获取。它的操作如下:

  1. 调用 dtmcontrol_scan,读取 JTAG 里的 dtmcs,可以得到 JTAG-DMI 的配置信息
  2. 向 dmcontrol 写入,进行复位
  3. 向 dmcontrol 写入,启用调试模块
  4. 从 hartinfo 读取 hart 信息
  5. 检查各个 hart 的状态

类似地,其他各种调试操作都是对这些 DMI 寄存器的读和写进行。RISC-V Debug Spec 附录里还提到了如何实现调试器的一些功能。

比如要读取 CPU 的寄存器(比如通用寄存器,CSR 等等)的话,有如下的方式:

第一种是 Abstract Command,直接向 DMI 写入要寄存器编号,就可以实现读/写。

第二种是 Program Buffer。它是一块小的代码存储,可以通过 DMI 向其中写入指令,比如 csrw s0, mstatus; ebreak,然后设置 s0 寄存器的值,再执行 Program Buffer 里的代码。

以 OpenOCD 代码为例,register_read_abstract 做了以下操作:

  1. 找到要读取的寄存器对应的 Abstract Register Number
  2. 进行 transfer 命令,DM 会读取对应寄存器到 data0 中
  3. 从 data0 中读取寄存器内容

如果要读取内存的话,也有两种方法。一种是直接向 DMI 写入要读取的总线地址,然后再向指定的寄存器中读取数据。第二种还是利用 Program Buffer,写入一条 lw s0, 0(s0) 指令,然后先向 s0 写入地址,执行 Program Buffer 后,再把 s0 寄存器的值读出来。

Abstract Command 实现

那么,如何实现上面提到的 Abstract Command(比如读写寄存器,读写内存等)呢?Debug Spec 里面提到一种 Execution-Based 的方式,即在 Debug mode 下,核心依然在执行代码,只不过执行的是调试用的特殊代码。它做的就是轮询 Debug Module 等待命令,接受到命令以后,就去读写寄存器/内存,然后通过 data0-12 来传输数据。

这里还有一个比较特别的点,就是读取寄存器的时候,寄存器的编号是直接记录在指令中的,所以可以让 Debug Module 动态生成指令,然后让核心刷新 ICache 然后跳转过去。另外,还可以利用 dscratch0/dscratch1 寄存器来保存 gpr,然后用 dret 退出的时候再恢复,这样就有两个 gpr 可以用来实现功能了,实际上这已经够用了(一个技巧是,把地址设为 0 附近,然后直接用 zero 寄存器加偏移来寻址)。

单步调试实现

在 dcsr 中,有一个值 step 表示是否在单步调试状态。设 step 为 1 的时候,如果不在 debug mode 中,只需要记录以及执行的指令数,当执行了一条指令后,视为下一个指令发生了进入 debug mode 的异常,这样就实现了单步调试。

软件断点实现

调试器为了打断点,一种简单的方式是,往断点处写入 ebreak 指令,然后设置 dcsr 的 ebreakm/s/u,表示在这些特权集里,ebreak 是进入 debug mode,而不是原来的处理过程。然后,程序运行到 ebreak 指令的时候,进入 debug mode,openocd 发现核心进入 halted 状态后,让 gdb 继续进行调试。

硬件方面的实现方法就是,在遇到 ebreak 的时候,判断一下当前的特权集,结合 ebreakm/s/u 判断跳转到什么状态。此外,由于它会写入指令到内存,所以还需要执行 fence.i 指令,而 OpenOCD 需要依赖 progbuf 来执行 fence.i 指令,所以为了让这个方案工作,还得实现 Program Buffer。

当然了,软件断点也有局限性,比如内存不可写,比如 ROM,不能覆盖里面的指令,这样就有可能出问题。而且硬件断点性能也更好,不需要来回这样写指令。

Semihosting

ARM 有一种 semihosting 机制,就是处理器执行一种特定的指令序列,然后调试器看到整个序列的时候,不是进入 GDB 调试状态,而是去进行一些操作,比如输出信息,读写文件等等,然后结果通过 JTAG 写回去。OpenOCD 给 RISC-V 也做了类似的 semihosting 机制,只不过触发的指令序列不大一样,但是机制是类似的。

如果用过 Rocket Chip 仿真的或者以前的 ucb-bar/fpga-zynq 项目的话,会知道还有一个目的有些类似的东西:HTIF + fesvr,它是通过 fromhost/tohost 两组地址来进行通信,但是这个方法缺点是需要 poll tohost/fromhost 地址的内容,相对来说比较麻烦。

Program Buffer

此外,debug spec 还有一个可选的功能,就是 Program Buffer,调试器可以往里面插入自定义的指令,然后结合 abstract command 进行操作。这样就可以做一些比较高效的操作,比如 OpenOCD 实现的批量写入内存:

sw s1, 0(a0)
addi a0, a0, 4
ebreak

并且设置 abstractauto,然后重复的操作是往 s1 里面写入新的数据,然后跳转到 program buffer,进行上面的 sw 操作,这样就可以一次 dmi 请求完成一次内存的写入,比较高效。

参考文档

  1. RISC-V Debug Spec 0.13
  2. IEEE Standard for JTAG 1149.1-2013
  3. OpenOCD 相关代码

教学随想

背景

最近关于课程改革的讨论比较多,我也来谈谈我的看法。

动机

一位高中毕业的同学,选择计算机系的动机是什么?我想了想,可能有下面几种:

  1. 计算机行业就业好,我选了计算机系,毕业以后可以赚到很多钱
  2. 喜欢计算机,希望从事计算机方面的工作
  3. 计算机系分高,大家都说好,那我就选择去这里吧
  4. 我是竞赛保送的,所以来到了这里

学校希望计算机系培养出来的学生:

  1. 有很好的能力
  2. 有很好的毕业去向(保研/工作/留校等等)

学校为希望计算机系:

  1. 有更多重大科研成果
  2. 能够培养多且精的计算机人才

计算机系为了实现上面的目标:

  1. 教学的专业课程要全面,并且能够支撑后续的科研
  2. 吸引更多学生进入实验室科研

同时还要与其他院系(软件学院,交叉信息学院,集成电路学院等)有区分(分工)。

路径

可以看到,上面的这一系列诉求是有矛盾的,可以假想这么几条路径:

  1. 想赚钱->读研毕业薪资更高->读研需要高 GPA->每门课都要 4.0->每个课程的可选部分都要做->工作量太大
  2. 想科研->找好老师->需要高 GPA 和或论文->每门课都要 4.0 同时还要在实验室科研->工作量特别大
  3. 不想科研直接工作->工作不需要高的 GPA->放弃一些课程的可选部分->空余时间学习实用技术->面试轻松过关
  4. 对未来没有想法->从众心理卷 GPA->每个课程都做可选部分->花费很多时间->没有时间做自己喜欢的事情

这对于七字班(2017)或者更早同学来说,这可能是难以理解的。当时,保研不需要很高的 GPA,老师会看重科研潜力,想科研的同学可能选择在实验室科研的同时,放弃一些课程。

但是从八字班(2018)开始,多重因素下,问题就凸显了。一是总人数更多,保研难度本身就更大,竞争激烈;二是保研名额严格按照 GPA 排序,导致保研的同学必须科研学习两手抓;三是 GPA 改革以后,4.0 难度变低,以前会想 A B C 课程比较难,大部分人都拿不到 4.0,我 A 课程 3.7,B 课程 3.3 和你 A 课程 3.3,B 课程 3.7 是一样的,精力有限,只做三个里面最简单的一个,但现在会发现,比 GPA 实际上就是比谁 4.0 更多,虽然 A B C 课程也比较难,但是此时只能把三个都做了,不然就会排名下降明显。

这还会带来其他的问题:为了拿到更多 4.0,但精力有限,按照自己的时间,三个课里只能拿到一个 4.0,比不过拿到三个 4.0 的同学,咋办?抄袭。让老师放水,人人 4.0。

解决

那么,怎么解决这个问题?

从个人的角度出发:尽早想好自己要什么。我要做什么,就在我要做的方向上做好,其他方向可以选择性放弃,不要随大流。

从院系的角度出发:难。

Manycore 处理器架构分析

参考文档

Xeon Phi - Intel MIC

MIC: Many Integrated Core Architecture

Knights Corner:

4 路 SMT,AVX512 指令,32 KB L1I,32 KB L1D,每核心 512KB L2,乱序执行,一条 512 位计算流水线,每个周期双精度性能 512 / 64 * 2 = 16 FLOP/cycle。61 核 1.053GHz 双精度性能是 16 * 61 * 1.053 = 1028 GFLOPS

向量寄存器分为四组,每组 128 位,两个 DP/四个 SP。SP 和 DP 计算共享乘法器,来优化面积。

Knights Landing:

核心:4 路 SMT,AVX512 指令,乱序执行,两条 512 位计算流水线,每个周期双精度性能 512 / 64 * 2 * 2 = 32 FLOP/cycle,如果是 64 核 1.3 GHz,总双精度性能是 32 * 64 * 1.3 = 2662 GFLOPS。一共 36 个 Tile,每个 Tile 有 2 Core + 2 VPU/core + 1MB 16-way L2,最大 72 个核心。

内存:6-channel 384GB DDR4 2400 RAM(理论 2400 * 6 * 8 = 115.2 GB/s),8-16GB 3D MCDRAM(400+ GB/s)。

Fujitsu A64FX

内存:4 组,每组 8GB HBM2,带宽 256 GB/s(1024 bit * 2G),总共 32GB HBM2,带宽 1TB/s。Cache Line 大小 256 B。

核心:4 个 NUMA Node(Core Memory Group),每个 NUMA Node 包括 12 计算核,有 8MB 16 路的 L2 Cache。总共 48 计算核,4 辅助核。

指令集:ARMv8.2+SVE,512 位向量宽度,乱序执行,两个浮点流水线和两个整数流水线,每个周期双精度性能 512 / 64 * 2 * 2 = 32 FLOP/cycle,主频 2.2 GHz,按主频算理论双精度浮点性能 32 * 2.2 * 48 = 3.4 TFLOPS。文档里写的是双精度浮点性能 2.7 TFLOPS,单精度 5.4 TFLOPS,半精度 10.8 TFLOPS,8 位整数 21.6 TOPS,应该是按照实际测出来的算。TOP 500 配置是 7630848 核,对应 7630848 / 48 = 158976 个节点,Rpeak 是 537212 TFLOPS,那么每个节点是 537212 / 158976 = 3.38 TFLOPS,和上面的 3.4 接近。Linpack 跑出来的 Rmax 是 442010 TFLOPS,每个节点是 442010 / 158976 = 2.78 TFLOPS,和文档里说的比较接近。

部分主要特性:

  • Four-operand FMA: ARM FMA 指令只能是 R0=R0+R1*R2,A64FX 可以合并 R0=R3,R0=R0+R1*R2 两条为一条 R0=R3+R1*R2 指令
  • Gather/Scatter: 非连续访存,同一个 128B 内连续的 lane 可以合并访问,如果数据有局部性的话,可以得到两倍带宽

NVIDIA GPU

型号 工艺 Peak DP(TFLOPS) 功耗 (W) 性能功耗比 (TFLOPS/W)
P100 16 nm FinFET+ 4.7 250 0.019
V100 12 nm FFN 7 250-300 0.023-0.028
A100 7 nm N7 9.7 250-400 0.024-0.039
型号 内存容量 (GB) 内存带宽 (GB/s) 内存类型 L2 缓存大小 寄存器堆大小
P100 12-16 549-732 4096 bit HBM2 4096 KB 14336 KB
V100 16-32 900 4096 bit HBM2 6144 KB 20480 KB
A100 40-80 1555-2039 5120 bit HBM2 40960 KB 27648 KB
型号 SM 数量 CUDA 核心数 FP64 核心数 SM 频率 (MHz)
P100 56 3584 1792 1328
V100 80 5120 2560 1380
A100 108 6912 3456 1410
  • CUDA 核心数 = SM 数量 * 64
  • FP64 核心数 = SM 数量 * 32
  • Peak DP = FP64 核心数 * SM 频率 * 2
  • 寄存器堆大小 = SM 数量 * 256 KB

Sunway 处理器架构分析

参考文档

SW26010

Sunway TaihuLight 的层次:

  1. 1 Sunway TaihuLight = 40 Cabinet
  2. 1 Cabinet = 4 Super nodes
  3. 1 Super node = 256 nodes
  4. 1 node = 4 core groups
  5. 1 core group = 1 MPE(management processing element) + 8*8 CPE(computer processing element)

MPE 双精度性能:16 FLOP/cycle * 1.45 GHz = 23.2 GFlops CPE 双精度性能:8 FLOP/cycle * 1.45 GHz = 11.6 GFlops CPE 单精度性能:8 FLOP/cycle * 1.45 GHz = 11.6 GFlops 单节点双精度性能:4 * 8 * 8 * 11.6 + 4 * 23.2 = 3.0624 TFlops Sunway TaihuLight 双精度性能:40 * 4 * 256 * 3.0624 = 125.435904 PFlops

MPE: 32KB L1I, 32 KB L1D, 256 KB L2(中文文献里写的是 512 KB)。乱序执行,4 译码,7 发射(5 整数 2 浮点)。指令预取,分支预测,寄存器重命名,预测执行。5 条整数流水线,2 条 256 位 SIMD 浮点流水线。

CPE:16KB L1I,无 DCache,有 64KB 可重构局部数据存储器(SPM scratch pad memory/LDM local data memory)。2 译码 2 发射,乱序执行,1 条 256 位 SIMD 流水线,1 条整数流水线。不同精度的 SIMD 宽度不同,单精度浮点运算 128 位(4 个单精度),双精度浮点运算 256 位(4 个双精度)。从 SPM 每个周期可以读取 32 字节的数据(正好一个 SIMD 寄存器)。

每个 core group 中还有一个 MC(Memory Controller),连接 8GB DDR3 memory,每个 MC 内存带宽 128 bit * 2133 MT/s = 34.128 GB/s,单节点内存带宽 4 * 34.128 = 136.512 GB/s。在 Stream Triad 测试,每个 core group 用 DMA 从内存到 SPM 传输数据带宽为 22.6 GB/s,而全局读写 gload/gstore 带宽只有 1.5 GB/s。访问全局内存需要 120+ 个周期。

8x8 矩阵中的从核可以在同行和同列方向上进行低延迟和高带宽的数据传递:2 个从核点对点通信延迟不超过 11 个周期,单个 core group 寄存器通信集合带宽达到 637 GB/s。

28nm 工艺流片,芯片 die 面积超过 500 mm^2,峰值功耗 292.7W,峰值能效比达 10.559 GFLOPS∕W(HPL 6.05 GFLOPS/W)。

SW26010-Pro

SW26010-Pro 是升级版 SW26010,升级的内容在于:

  1. 每个 node 从 4 个 core group 升级到 6 个,一共有 6 * (8 * 8 + 1) = 390 个核心。频率也提高了,MPE 频率 2.1GHz,CPE 频率 2.25 GHz。SIMD 宽度扩展到 512 位。
  2. 每个 MC 连接了 16 GB DDR4 内存,带宽是 128 bit * 3200 MT/s = 51.2 GB/s;单节点总内存 96 GB,总内存带宽 51.2 * 6 = 307.2 GB/s
  3. 每个 CPE 的局部存储(LDM)从 64KB 升级到 256KB。
  4. CPE 之间的通信可以通过 RMA 进行,而之前的 SW26010 只能在同一行/列之间进行寄存器通信。

SW52020

在新闻稿和 Sunway supercomputer architecture towards exascale computing: analysis and practice 文章中出现,没有在今年发出来的论文里实际采用,名称可能是新闻稿自己编的,我猜可能没有实际采用,而是做了 SW26010P。和 SW26010 区别:

  1. Core Group 从 4 个提升到了 8 个,所以每个 node 有 8 * (8 * 8 + 1) = 520 个核心。
  2. MPE 和 CPE 向量宽度从 256 位扩展到了 512 位。添加了 16 位半精度浮点支持。
  3. 每个 node 提供超过 12 TFlops 的双精度浮点性能。应该是靠两倍的 Core Group,乘上两倍的向量计算宽度,达到四倍的性能。

移植系统到 Rocket Chip on VCU128

背景

最近需要在 VCU128 上搭建一个 SOC,然后想到可以把 OpenSBI、U-Boot 和 Linux 移植到这个平台上方便测试,于是又开始折腾这些东西。代码仓库都已经开源:

Rocket Chip on VCU128

第一部分是基于之前 rocket2thinpad 在 Thinpad 上移植 Rocket Chip 的经验,做了一些更新,主要是因为 VCU128 的外设不大一样,同时我也要运行更复杂的程序,主要做了这些事情:

  1. 添加了 VCU128 的内存和外设:HBM、SPI、I2C、UART、ETH
  2. 打开了更多核心选项:S-mode 和 U-mode

主要踩过的坑:

  1. BSCAN 不工作,估计是因为一些参数不对,@jsteward 之前在 zcu 平台上做了一些测试,估计要用类似的办法进行修改;我最后直接去掉了这部分逻辑
  2. 这个板子的 PHY RESET 信号要通过 I2C 接口访问 TI 的 Port Expander,所以没法直接连,要通过 gpio 输出来手动 reset
  3. SPI Startup Flash 的时序配置,见我之前的博客
  4. Xilinx PCS/PMA IP 也会自己挂一个设备到 MDIO bus 上,应该有自己的 PHY 地址,而不要和物理的 PHY 冲突

U-Boot

在 U-Boot 上花了比较多的时间,用它的目的主要是:

  1. BootROM 中的代码只支持从串口加载程序,如果后续要加载 Linux 内核等软件,性能太差。
  2. U-Boot 驱动比较完善,而且 dts 也可以很容易地迁移到 Linux 中
  3. 有一些可以参考的资料

移植的时候,首先新建一个自定义的 board,然后自己写 defconfig 和 dts,其中 dts 可以参考 rocket chip 生成的 dts 文件。然后,按照各个外设的 device tree binding 去写,然后打开/关闭各个 CONFIG 开关。

对代码主要的改动是,实现了 DCache 的 flush 功能,因为以太网部分用了 DMA,所以要让外设看到内存的更改,这里采用的是 SiFive 的扩展指令 cflush.d.l1。由于编译器还不支持这个指令,就按照网上的方式去构造了汇编指令。实现完成以后,就可以用网络了。

一开始的时候,为了简单,直接在 M-mode 中运行 U-Boot,这样不需要 OpenSBI,同时 DTB 也是内置的。但后续为了运行 Linux,还是需要一个 SBI 实现:OpenSBI,然后在 S-mode 中运行 U-Boot,再引导到 Linux。

此外还花了很多努力来缩小 binary 大小,首先可以用 nm --size -r u-boot | head -20 来找到比较大的一些符号,不考虑其中 BSS 的部分(type=b),主要看哪些代码/数据比较占空间。

UPDATE: U-Boot 在 v2022.01 版本修复了一个 BUG,之前的版本在 riscv 架构下没有 reserve lmb region,使得加载 initrd 的时候,会覆盖掉自己的栈空间,这解释了之前的诸多玄学内存问题,升级到 v2022.01 后就好了。

OpenSBI

OpenSBI 移植比较简单,直接参考 template 修改即可,主要就是串口的配置,其他基本不用改。然后,我把 U-Boot 作为 OpenSBI 的 Payload 放到 OpenSBI 的后面,此时要把 U-Boot 配置为 S-mode 模式。接着,遇到了新的问题:cflush.d.l1 指令只能在 M-mode 用,因此我在 OpenSBI 代码中处理了 trap,转而在 M-mode 里面运行这条指令。这样,就可以在 S-mode 里刷新 Cache 了。

Linux

Linux 目前可以 boot 到寻找 init,还没有碰文件系统,之后计划用 buildroot 打一个 initramfs 出来。为了在 U-Boot 中启动 Linux,用 U-Boot 的 mkimage 工具生成了 FIT 格式的 uImage,里面打包了 kernel image 和 dtb,就可以用 bootm 命令启动了,注意地址不要和加载地址重复。

此外还遇到一个坑:RV64 里面 Linux dts 的 address cell 得是 2(对应 64 位),否则会有错误。但 U-Boot 对这个没有做要求。

缓存一致性

一开始的时候,AXI DMA 直接接到内存上,所以与 CPU 缓存是不一致的,网卡驱动需要经常地刷缓存。在 Rocket Chip 上,可以用 sifive 自己的 cflush 指令来刷缓存,但是它只能在 M 态执行,同时又支持虚拟地址,这种奇怪的设计就使得要在 OpenSBI,U-Boot 和 Linux 三处都添加逻辑:OpenSBI 处理 illegal instruction,如果发现是 cflush 指令,就再次 cflush;U-Boot 和 Linux 修改驱动,在合适的地方添加 cflush 指令。U-Boot 驱动比较简单,工作得比较好,但是 Linux 的网卡驱动怎么都改不好。

最后决定,打开 Rocket Chip 的 Frontend Bus,添加一个 AXI Slave 接口,然后让 AXI DMA 通过 AXI Slave 接入到 Rocket Chip 中,然后通过 TLBroadcast 实现缓存一致性。这样软件实现会比较简单,但是硬件就更复杂了。

ESXi 常用信息

常用链接

vsish -e cat /hardware/cpu/cpuList/0 | grep -i -E 'family|model|stepping|microcode|revision'
$ esxcli software vib install -d $PWD/Net-Community-Driver_1.2.0.0-1vmw.700.1.0.15843807_18028830.zip

离线升级方法

  1. 下载 Offline Bundle 文件
  2. 上传到 ESXi datastore 中
  3. /vmfs/volumes/ 里找到更新文件
  4. 查询 profile 列表 esxcli software sources profile list -d <zip>
  5. 更新到 profile esxcli software profile update -p <profile> -d <zip>

ref: Upgrade or Update a Host with Image Profiles

如果 CPU 比较旧,可能会有警告:Updated Plan for CPU Support Discontinuation In Future Major vSphere Releases,按照信息添加参数忽略即可,ESXi 7.0 系列都是支持的,如果之后出了新的版本可能不支持。

在线升级方法

$ esxcli network firewall ruleset set -e true -r httpClient
# find profile name
$ esxcli software sources profile list -d https://hostupdate.vmware.com/software/VUM/PRODUCTION/main/vmw-depot-index.xml
# upgrade to 7.0u3 for example
$ esxcli software profile update -p ESXi-7.0U3-18644231-standard -d https://hostupdate.vmware.com/software/VUM/PRODUCTION/main/vmw-depot-index.xml

ref: Update Standalone ESXi Host

目前 OEM 版本还没找到在线升级方法,需要下载 zip 然后按照离线升级方法安装。

防火墙

列出所有防火墙规则:

$ esxcli network firewall ruleset list

允许出站 SSH:

$ esxcli network firewall ruleset set --enabled=true --ruleset-id=sshClient

关闭出站 SSH:

$ esxcli network firewall ruleset set --enabled=false --ruleset-id=sshClient

NUC11i5 ESXi 7.0 安装过程

  1. 下载 ESXi ISO 文件,用 UNetbootin 制作安装盘
  2. 插入 U 盘,在 NUC 上安装 ESXi,在 81% 的时候卡住了,不管直接重启
  3. 用 root 无密码登录进去,然后重置网络设置
  4. 配置 usb 网卡,然后通过网页访问 ESXi,打开 SSH
  5. 下载 Fling 上面的社区网卡支持,用 esxcli 安装
  6. 重启以后,就可以看到 vmnic0 网卡了

参考:Solution: ESXi Installation with USB NIC only fails at 81%

推荐博客

发现以下博客有很多关于 ESXi 的内容:

  • https://williamlam.com/
  • https://www.virten.net/

重要版本更新

参考 ESXi/vCSA Release Notes:

  • Performance improvements for AMD Zen CPUs: With ESXi 7.0 Update 2, out-of-the-box optimizations can increase AMD Zen CPU performance by up to 30% in various benchmarks. The updated ESXi scheduler takes full advantage of the AMD NUMA architecture to make the most appropriate placement decisions for virtual machines and containers. AMD Zen CPU optimizations allow a higher number of VMs or container deployments with better performance.
  • Reduced compute and I/O latency, and jitter for latency sensitive workloads: Latency sensitive workloads, such as in financial and telecom applications, can see significant performance benefit from I/O latency and jitter optimizations in ESXi 7.0 Update 2. The optimizations reduce interference and jitter sources to provide a consistent runtime environment. With ESXi 7.0 Update 2, you can also see higher speed in interrupt delivery for passthrough devices.
  • vSphere Lifecycle Manager fast upgrades: Starting with vSphere 7.0 Update 2, you can configure vSphere Lifecycle Manager to suspend virtual machines to memory instead of migrating them, powering them off, or suspending them to disk. For more information, see Configuring vSphere Lifecycle Manager for Fast Upgrades.
  • Zero downtime, zero data loss for mission critical VMs in case of Machine Check Exception (MCE) hardware failure: With vSphere 7.0 Update 3, mission critical VMs protected by VMware vSphere Fault Tolerance can achieve zero downtime, zero data loss in case of Machine Check Exception (MCE) hardware failure, because VMs fallback to the secondary VM, instead of failing. For more information, see How Fault Tolerance Works.

vCSA 相关常见错误

  • https://kb.vmware.com/s/article/85468 vCSA 日志分区 /storage/log 满,原因是访问 vmware 网站失败打印的日志太大:/storage/log/vmware/analytics/analytics-runtime.log*;解决方法:vmon-cli -r analytics 重启服务,然后删掉旧的日志。
  • https://kb.vmware.com/s/article/83070 vCSA 日志分区 /storage/log 满,原因是 tomcat 日志太大。
  • XXX Service Health Alarm:尝试重启对应服务,比如 vmon-cli -r perfcharts 对应 Performance Chartsvmon-cli -r vapi-endpoint 对应 VMWare vAPI Endpoint

查看更新状态:cat /storage/core/software-update/stage_operation;更新文件下载路径:/storage/updatemgr/software-update*/stage。有一个包特别大:wcpovf 需要两个多 G。

CLI 更新方法:https://earlruby.org/2021/01/upgrading-vcenter-7-via-the-command-line/

迁移虚拟机到不同 VM

首先,unregister 原来的 VM,然后把文件移动到新的路径下。对于 Thin Provisioned Disk,需要特殊处理,否则直接复制的话,会变成 Thick Provisioned Disk,正确方法是采用 vmkfstool

vmkfstool -i "old.vmdk" -d thin "new.vmdk"

需要注意的是,这里的路径用的是不带 -flat 的 vmdk,因为这个文件记录了 metadata,而 -flat.vmdk 保存了实际的数据。可以用 du 命令看实际的硬盘占用,从而确认它确实是 Thin Provisioned。

如果已经在 Web UI 上复制了,你会发现无法停止复制,解决办法是:

/etc/init.d/hostd restart

这样就会重启 Web UI,不过等它恢复需要很长的时间,还要删掉 cookie。

「教学」AXI Quad SPI 时序分析

本文的内容已经整合到知识库中。

背景

之前一直没搞懂 Vivado 中 xdc 需要怎么编写,遇到一些必须要写 xdc 的时候就很头疼,不知道怎么写才可以得到正确的结果。今天分析了一下 AXI Quad SPI 的时序 xdc,终于理解了其中的含义。

AXI Quad SPI

AXI Quad SPI 是一个 SPI 的控制器,它支持 XIP(eXecute In Place)模式,即可以暴露一个只读 AXI Slave 接口,当接收到读请求的时候,就按照标准的 SPI Flash 命令去对应的地址进行读取,然后返回结果。由于不同厂家的 SPI Flash 支持有所不同,所以 IP 上的设置可以看到厂家的选择。

特别地,一个常见的需求是希望访问 Cfg(Configuration)Flash,亦即用来保存 Bitstream 的 Flash。当 FPGA 上电的时候,如果启动模式设置为 SPI Flash,FPGA 就会向 Cfg Flash 读取 Bitstream,Cfg Flash 需要连接到 FPGA 的指定引脚上,当 FPGA 初始化的时候由内部逻辑驱动,初始化完成后又要转交给用户逻辑。转交的方式就是通过 STARTUP 系列的 primitive。

通常,如果要连接外部的 SPI Flash,需要连接几条信号线到顶层,然后通过 xdc 把信号绑定到引脚上,然后引脚连接了一个外部的 SPI Flash。但由于 Cfg Flash 比较特殊,所以信号从 AXI Quad SPI 直接连到 STARTUP 系列的 primitive 上。如果是采用 STARTUPE2 原语的 7 系列的 FPGA,那么只有时钟会通过 STARTUPE2 pritimive 连接到 SPI Flash 上,其他数据信号还是正常通过顶层绑定;如果是采用 STARTUPE3 原语的 UltraScale 系列的 FPGA,那么时钟和数据都通过 STARTUPE3 primitive 连接到 SPI Flash。

Virtex UltraScale+ 时序

把信号连好了只是第一步,因为外设对时序要求比较复杂,如果用一个比较高直接跑,很大可能就读取到错误的数据了。很贴心的是,AXI Quad SPI 已经在生成的文件里提供了一个样例的 xdc,在文档里也有体现。在这里,我使用的设备是 Virtex Ultrascale+ 的 FPGA,其他系列的 FPGA 会有所不一样。它内容如下:

#### All the delay numbers have to be provided by the user

#### Following are the SPI device parameters
#### Max Tco
set tco_max 7
#### Min Tco
set tco_min 1
#### Setup time requirement
set tsu 2
#### Hold time requirement
set th 3
#####################################################################################################
# STARTUPE3 primitive included inside IP for US+                                                             #
#####################################################################################################
set tdata_trace_delay_max 0.25
set tdata_trace_delay_min 0.25
set tclk_trace_delay_max 0.2
set tclk_trace_delay_min 0.2

create_generated_clock -name clk_sck -source [get_pins -hierarchical *axi_quad_spi_0/ext_spi_clk] [get_pins -hierarchical */CCLK] -edges {3 5 7}
set_input_delay -clock clk_sck -max [expr $tco_max + $tdata_trace_delay_max + $tclk_trace_delay_max] [get_pins -hierarchical *STARTUP*/DATA_IN[*]] -clock_fall;
set_input_delay -clock clk_sck -min [expr $tco_min + $tdata_trace_delay_min + $tclk_trace_delay_min] [get_pins -hierarchical *STARTUP*/DATA_IN[*]] -clock_fall;
set_multicycle_path 2 -setup -from clk_sck -to [get_clocks -of_objects [get_pins -hierarchical */ext_spi_clk]]
set_multicycle_path 1 -hold -end -from clk_sck -to [get_clocks -of_objects [get_pins -hierarchical */ext_spi_clk]]
set_output_delay -clock clk_sck -max [expr $tsu + $tdata_trace_delay_max - $tclk_trace_delay_min] [get_pins -hierarchical *STARTUP*/DATA_OUT[*]];
set_output_delay -clock clk_sck -min [expr $tdata_trace_delay_min - $th - $tclk_trace_delay_max] [get_pins -hierarchical *STARTUP*/DATA_OUT[*]];
set_multicycle_path 2 -setup -start -from [get_clocks -of_objects [get_pins -hierarchical */ext_spi_clk]] -to clk_sck
set_multicycle_path 1 -hold -from [get_clocks -of_objects [get_pins -hierarchical */ext_spi_clk]] -to clk_sck

我们分段来看这个 xdc 都做了什么:

create_generated_clock -name clk_sck -source [get_pins -hierarchical *axi_quad_spi_0/ext_spi_clk] [get_pins -hierarchical */CCLK] -edges {3 5 7}

首先,它创建了一个时钟 clk_sck。CCLK 是 STARTUP 输出的实际时钟,会连接到 Cfg Flash 的时钟信号上。而 AXI Quad SPI 的 ext_spi_clk 会输出到 CCLK 上,因此这里是一个生成的时钟,并且指定上下边沿的位置。edges 参数有三个,分别表示上升、下降和上升沿分别的位置。1 表示源时钟的第一个上升沿,2 表示源时钟的第一个下降沿,以此类推,所以 {3, 5, 7} 的意思就是频率减半,相位差半个周期。

接着,最主要的就是,怎么设置延迟。可以看到,代码中首先定义了一些参数:

#### Max Tco
set tco_max 7
#### Min Tco
set tco_min 1
#### Setup time requirement
set tsu 2
#### Hold time requirement
set th 3

#### Trace delay
set tdata_trace_delay_max 0.25
set tdata_trace_delay_min 0.25
set tclk_trace_delay_max 0.2
set tclk_trace_delay_min 0.2

首先是 \(t_{co}\),应该表示的是 SPI Flash 的时钟到输出的延迟。本文用的 SPI Flash 型号是 Micron MT25QU02GCBB8E12-0SIT,可以从它的 Datasheet 看到,时钟到输出的延迟应该是 Max 7ns:

Clock LOW to output valid under 30pF Max 7ns
Clock LOW to output valid under 10pF Max 6ns

因此 tco_max 设为 7,tco_min 默认即可,因为 Datasheet 中没有做要求。

然后 \(t_{su}\)\(t_h\) 则是输入的 setup 和 hold time。类似的,可以查到 SPI Flash 的参数:

Data in setup time Min 2.5ns
Data in hold time Min 2ns

所以 tsu 设为 2.5,th 设为 2。

接下来则是 tdata 和 tclk 的 trace delay。这指的是从 FPGA 引脚到 SPI Flash 引脚的信号传输延迟。从严谨的角度来说,可以从板子的布线上测量长度来计算出来,不过这里就先用默认值了。一个简单的估算方法:光速 \(3*10^8 \text{m/s}\),考虑电信号传播速度是光速的一半,可以得到延迟和长度的比值: \(0.06 \text{ns/cm} = 0.15 \text{ns/inch}\)

那么,这些变量怎么参与到 input/output delay 的计算呢?

首先考虑 input delay。它指的是,从 SPI Flash 到 FPGA 的数据,相对于时钟的延迟。这个延迟由三部分组成:

  1. 从 FPGA 输出的时钟 CCLK 到 SPI Flash 的时钟有延迟 \(t_{clk}\),下图 a -> b
  2. 从 SPI Flash 的时钟到数据输出有延迟 \(t_{co}\),下图 b -> c
  3. 从 SPI Flash 的数据到 FPGA 的数据输入有延迟 \(t_{data}\),下图 c -> d
clk_fpgaclk_flashdata_flashdata_fpgaabcd

因此总延迟就是 \(t_{clk}+t_{co}+t_{data}\),就可以得到对应的设置:

set_input_delay -clock clk_sck -max [expr $tco_max + $tdata_trace_delay_max + $tclk_trace_delay_max] [get_pins -hierarchical *STARTUP*/DATA_IN[*]] -clock_fall;
set_input_delay -clock clk_sck -min [expr $tco_min + $tdata_trace_delay_min + $tclk_trace_delay_min] [get_pins -hierarchical *STARTUP*/DATA_IN[*]] -clock_fall;

接下来要考虑 output delay。虽然 output delay 也有 min 和 max,但其含义有所区别,需要分别考虑。

首先是 max,它对应的是 setup time。如果定义时间 0 为时钟的上升沿,沿更早的时间为正的时间轴,沿更晚的时间为负的时间轴。那么,我们希望的是,数据到达寄存器输入的时间大于 setup time,此时可以满足 setup 条件。那么,具体怎么算呢?注意,我们要考虑的是从 FPGA 数据输出到 SPI Flash 上时钟的延迟。

假设 FPGA CCLK 时钟上升沿在 \(0\) 时刻(下图的 a),那么 SPI Flash 时钟上升沿在 \(-t_{clk}\) 时刻(下图的 b)。假设 FPGA 数据输出时刻为 \(t_0\)(通常为正,下图的 c),那么 FPGA 数据输出到达 SPI Flash 在 \(t_0-t_{data}\) 时刻(下图的 d),我们期望 \(t_0-t_{data}\)\(-t_{clk}\) 时刻之前(下图的 d -> b)至少 \(t_{su}\) 时间到达,可以得到表达式:

clk_fpgaclk_flashdata_fpgadata_flashabcd
\[ t_0 - t_{data} > -t_{clk} + t_{su} \]

化简一下,就可以得到 \(t_0 > t_{data} + t_{su} - t_{clk}\),如果考虑极端情况,右侧 \(t_{data}\) 取最大值,\(t_{clk}\) 取最小值,我们就可以得到约束:

set_output_delay -clock clk_sck -max [expr $tsu + $tdata_trace_delay_max - $tclk_trace_delay_min] [get_pins -hierarchical *STARTUP*/DATA_OUT[*]];

接下来考虑 output delay 的 min,这对应的是 hold time。我们希望数据到达 SPI Flash 寄存器的时候,距离上升沿时间超过了 \(t_h\)。还是一样的假设,如果 FPGA CCLK 时钟上升沿在 0 时刻(下图的 a),那么 SPI Flash 时钟上升沿在 \(-t_{clk}\) 时刻(下图的 b)。假设 FPGA 数据输出时刻为 \(t_0\)(下图的 c),那么 FPGA 数据输出到达 SPI Flash 在 \(t_0-t_{data}\) 时刻(下图的 d),要求满足 hold 条件,可以得到:

clk_fpgadata_fpgaclk_flashdata_flashacbd
\[ t_0 - t_{data} < -t_{clk} - t_h \]

化简以后,可以得到 \(t_0 < t_{data} - t_{clk} - t_h\),按照极限来取,\(t_{data}\) 取最小值,\(t_{clk}\) 取最大值,可以得到最终的时序约束:

set_output_delay -clock clk_sck -min [expr $tdata_trace_delay_min - $th - $tclk_trace_delay_max] [get_pins -hierarchical *STARTUP*/DATA_OUT[*]];

这样就可以实现 FPGA 和 SPI Flash 之间的正常通讯了。我觉得,这里比较绕的就是时间轴的定义,和我们平常思考的是反过来的。而且,这里的 min 和 max 并不是指 \([\min, \max]\),而是 \((-\inf, \min] \cup [\max, \inf)\)。代入上面的数据,可以得到 \(\max=2.05, \min=-2.95, t_0 \in (\inf, -2.95] \cup [2.05, \inf)\)。如果变化的时刻距离时钟上升沿太接近,就会导致在 SPI Flash 侧出现不满足 setup 或者 hold 约束的情况。

也可以换个角度来理解 min 和 max:对于同一个周期的时钟和数据来说,数据相对时钟有一个延迟,这个延迟不能太小,至少要满足 hold,所以这是一个最小的延迟;同时这个延迟不能太大,最多需要满足下一个时钟上升沿的 setup,所以这是一个最大的延迟。如果从这个角度来看,那就是延迟在一个 \([\min, \max]\) 的范围内。但是,这样在计算的时候就需要把时钟周期纳入到 \(\max\) 的计算中,比如 \(\max=t_c-t_{su}\)。如果我们把坐标轴修改一下,原点变成原来的下一个时钟周期的上升沿,x 的正方向变成反向,就可以得到上面的形式了。

Artix 7 时序

那么,更常见的 FPGA 是 7 系列的,比如 Artix 7,它采用的是 STARTUPE2 原语,只有时钟是通过 STARTUPE2 原语的 USRCCLKO 信号传递到 CCLK 引脚上的,其他数据信号都是需要在顶层信号绑定对应的引脚。在 AXI Quad SPI 文档中,描述了 STARTUPE2 所需要的时序约束,我们分段来分析一下。

# You must provide all the delay numbers
# CCLK delay is 0.5, 6.7 ns min/max for K7-2; refer Data sheet
# Consider the max delay for worst case analysis
set cclk_delay 6.7
# Following are the SPI device parameters
# Max Tco
set tco_max 7
# Min Tco
set tco_min 1
# Setup time requirement
set tsu 2
# Hold time requirement
set th 3
# Following are the board/trace delay numbers
# Assumption is that all Data lines are matched
set tdata_trace_delay_max 0.25
set tdata_trace_delay_min 0.25
set tclk_trace_delay_max 0.2
set tclk_trace_delay_min 0.2
### End of user provided delay numbers

可以看到,这一部分和上面 UltraScale+ 部分差不多,只是多一个 cclk_delay 变量,这是因为 Artix 7 中,时钟只能创建到 USRCCLKO 引脚上,但是实际 SPI Flash 接收到的时钟等于 USRCCLKO 到 CCLK 引脚,然后再通过 PCB 上的线传播到 SPI Flash,所以需要手动添加一个偏移,这个偏移就是 USRCCLKO 到 CCLK 的延迟,可以在 Artix 7 Data Sheet 里面看到:对于 1.0V,-2 速度的 FPGA,这个延迟最小值为 0.50ns,最大值为 6.70ns,这里采用了最大值。

所以,下面的约束,除了时钟部分以外,和上面分析的 UltraScale+ 时序约束计算方法是相同的。不同点在于,首先约束了从 AXI Quad SPI 到 STARTUPE2 的路由时延,从 0.1ns 到 1.5ns,然后又从 USRCCLKO 创建了一个分频 + 延迟 cclk_delay 纳秒的时钟,作为 SPI Flash 上 SCK 引脚的时钟。

# this is to ensure min routing delay from SCK generation to STARTUP input
# User should change this value based on the results
# having more delay on this net reduces the Fmax
set_max_delay 1.5 -from [get_pins -hier *SCK_O_reg_reg/C] -to [get_pins -hier
*USRCCLKO] -datapath_only
set_min_delay 0.1 -from [get_pins -hier *SCK_O_reg_reg/C] -to [get_pins -hier
*USRCCLKO]
# Following command creates a divide by 2 clock
# It also takes into account the delay added by STARTUP block to route the CCLK
create_generated_clock -name clk_sck -source [get_pins -hierarchical
*axi_quad_spi_1/ext_spi_clk] [get_pins -hierarchical *USRCCLKO] -edges {3 5 7}
-edge_shift [list $cclk_delay $cclk_delay $cclk_delay]
# Data is captured into FPGA on the second rising edge of ext_spi_clk after the SCK
falling edge

# Data is driven by the FPGA on every alternate rising_edge of ext_spi_clk
set_input_delay -clock clk_sck -max [expr $tco_max + $tdata_trace_delay_max +
$tclk_trace_delay_max] [get_ports IO*_IO] -clock_fall;
set_input_delay -clock clk_sck -min [expr $tco_min + $tdata_trace_delay_min +
$tclk_trace_delay_min] [get_ports IO*_IO] -clock_fall;
set_multicycle_path 2 -setup -from clk_sck -to [get_clocks -of_objects [get_pins
-hierarchical */ext_spi_clk]]
set_multicycle_path 1 -hold -end -from clk_sck -to [get_clocks -of_objects [get_pins
-hierarchical */ext_spi_clk]]
# Data is captured into SPI on the following rising edge of SCK
# Data is driven by the IP on alternate rising_edge of the ext_spi_clk
set_output_delay -clock clk_sck -max [expr $tsu + $tdata_trace_delay_max -
$tclk_trace_delay_min] [get_ports IO*_IO];
set_output_delay -clock clk_sck -min [expr $tdata_trace_delay_min - $th -
$tclk_trace_delay_max] [get_ports IO*_IO];
set_multicycle_path 2 -setup -start -from [get_clocks -of_objects [get_pins
-hierarchical */ext_spi_clk]] -to clk_sck
set_multicycle_path 1 -hold -from [get_clocks -of_objects [get_pins -hierarchical */
ext_spi_clk]] -to clk_sck

一个 Artix 7 上配置 STARTUP SPI Flash 的例子 io_timings.xdc 可供参考。

研究 k8s 网络工作原理

背景

用 k8s 也有一段时间了,之前遇到过 iptables 等出现问题,导致 k8s 节点间网络出现问题,于是想研究一下 k8s 的网络工作原理。

Docker 网络

首先研究一下 Docker 网络连接是如何实现的。Docker 首先会创建一个 bridge,名为 bridge0:

$ ip a show docker0
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:c4:87:73:bf brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:c4ff:fe87:73bf/64 scope link
       valid_lft forever preferred_lft forever

默认情况下,每个容器都会有单独的一个 netns,然后创建一对 veth pair,一端留在 global netns,另一端放到容器中。在 global netns 中的 veth 端口会加入到 docker0 中:

$ ip a show dev veth3db9316
21: veth3db9316@if20: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default
    link/ether e2:49:a6:2d:5a:bd brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::e049:a6ff:fe2d:5abd/64 scope link
       valid_lft forever preferred_lft forever
$ brctl show docker0
bridge name     bridge id               STP enabled     interfaces
docker0         8000.0242c48773bf       no              veth3db9316

容器中的网络,在 veth 上 docker 会分配并配置一个地址(比如 172.17.0.2),然后设置默认路由 via 172.17.0.1。一方面,可以通过默认路由到 172.17.0.1 再通过 iptables NAT 访问外面的网络:

$ iptables-save -t nat
# Generated by xtables-save v1.8.2 on Sat Sep 18 10:44:49 2021
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A DOCKER -i docker0 -j RETURN
COMMIT
# Completed on Sat Sep 18 10:44:49 2021

另一方面,因为连接不同容器的 veth 在同一个 bridge 下面,所以不同容器的可以认为在同一个二层网络中,自然可以互相访问。

K8s 网络

在 k8s 中,所有的 pod 都希望可以通过 IP 地址互联。一个思路是把各个节点上的 pod 通过类似 docker 的方法实现,即每个 netns 通过 veth 连接到一个 bridge 上,然后再想办法去路由在其它节点上的 pod。

因为我用 k3s 搭建 k8s 集群,它用的 cni 是 flannel。flannel 采用的是 vxlan 的方式来实现节点间的网络通信。

首先还是看看节点内的 pod 如何组网。

5: cni0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UP group default qlen 1000
    link/ether 6a:4f:ff:8b:b1:b3 brd ff:ff:ff:ff:ff:ff
    inet 10.42.0.1/24 brd 10.42.0.255 scope global cni0
       valid_lft forever preferred_lft forever
    inet6 fe80::7cf6:57ff:fed7:c49b/64 scope link
       valid_lft forever preferred_lft forever
6: vethc47d6140@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue master cni0 state UP group default
    link/ether da:19:f8:48:f6:49 brd ff:ff:ff:ff:ff:ff link-netns cni-9d2a5120-16a3-453e-bf64-c4006c06c93b
    inet6 fe80::d819:f8ff:fe48:f649/64 scope link
       valid_lft forever preferred_lft forever

首先,flannel 给每个节点分配了一个 /24 的网段,比如第一个节点是 10.42.0.0/24,第二个是 10.42.1.0/24,依次类推。然后,节点内的 pod 就从这个网段里分配地址,比如 10.42.0.50/24,它的默认网关是 10.42.0.1。这些 veth 都会加入到 cni0 的 bridge 中。这一部分原理和 docker 是一样的,只不过名字不同了。也有相应的 iptables 规则:

$ iptables-save | grep MASQUERADE
-A POSTROUTING -s 10.42.0.0/16 ! -d 224.0.0.0/4 -j MASQUERADE --random-fully
-A POSTROUTING ! -s 10.42.0.0/16 -d 10.42.0.0/16 -j MASQUERADE --random-fully

那么,节点间网络如何实现呢?假如,我们要从第一个节点 pod 10.42.0.50/24 访问第二个节点的 pod 10.42.1.51/24,首先,pod 根据默认路由会发给 10.42.0.1/24,到达第一个节点的 cni0,然后查路由表:

$ ip r
10.42.0.0/24 dev cni0 proto kernel scope link src 10.42.0.1
10.42.1.0/24 via 10.42.1.0 dev flannel.1 onlink

可以看到,它会匹配 10.42.1.0/24 via 10.42.1.0 dev flannel.1 的路由。flannel.1 是一个 vxlan 的 interface:

$ ip a show flannel.1
4: flannel.1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UNKNOWN group default
    link/ether b6:2f:39:4a:02:c0 brd ff:ff:ff:ff:ff:ff
    inet 10.42.0.0/32 scope global flannel.1
       valid_lft forever preferred_lft forever
    inet6 fe80::b42f:39ff:fe4a:2c0/64 scope link
       valid_lft forever preferred_lft forever

当这个 interface 接收到一个 packet 的时候,会查询 fdb:

$ bridge fdb show brport flannel.1
...

这个 fdb 中包括了 (MAC 地址,IP 地址) 的 tuple。当 flannel.1 收到一个 Ethernet Frame 的时候,如果目的地址匹配这里的 MAC 地址,就会直接把 Eth Frame 封装到 UDP 里面发给目的 IP 地址;否则,就会在这个表里面 broadcast。这样,第二个节点就会收到 packet 并且转给实际的 pod。

总结

总结一下 k8s 的网络互联的实现方法:节点内通过 bridge 实现,把链接各个 netns 的 veth 桥接起来;节点间划分为多个子网,子网间通过 flannel 的网关进行路由,flannel 网关间通过 vxlan 进行互联。

参考文档

技术干货 | 深入理解 flannel

一文看懂 k8s 的 Flannel 网络

浅谈乱序执行 CPU

本文的内容已经整合到知识库中。

背景

最早学习乱序执行 CPU 的时候,是在 Wikipedia 上自学的,后来在计算机系统结构课上又学了一遍,但发现学的和现在实际采用的乱序执行 CPU 又有很大区别,后来又仔细研究了一下,觉得理解更多了,就想总结一下。

经典 Tomasulo

参考 Stanford 教材

经典 Tomasulo,也是 Wikipedia 上描述的 Tomasulo 算法,它的核心是保留站。指令在 Decode 之后,会被分配到一个保留站中。保留站有以下的这些属性:

  1. Op:需要执行的操作
  2. Qj,Qk:操作数依赖的指令目前所在的保留站 ID
  3. Vj,Qk:操作数的值
  4. Rj,Rk:操作数是否 ready(或者用特殊的 Qj,Qk 值表示是否 ready)
  5. Busy:这个保留站被占用

此外还有一个 mapping(Wikipedia 上叫做 RegisterStat),记录了寄存器是否会被某个保留站中的指令写入。

指令分配到保留站的时候,会查询 RegisterStat,得知操作数寄存器是否 ready,如果不 ready,说明有一个先前的指令要写入这个寄存器,那就记录下对应的保留站 ID。当操作数都 ready 了,就可以进入计算单元计算。当一条指令在执行单元中完成的时候,未出现 WAW 时会把结果写入寄存器堆,并且通过 Common Data Bus 进行广播,目前在保留站中的指令如果发现,它所需要的操作数刚好计算出来了,就会把取值保存下来。

这里有一些细节:因为保留站中的指令可能要等待其他指令的完成,为了保证计算单元利用率更高,对于同一个计算类型(比如 ALU),需要有若干个同类的保留站(比如 Add1,Add2,Add3)。在 Wikipedia 的表述中,每个保留站对应了一个计算单元,这样性能比较好,但自然面积也就更大。如果节省面积,也可以减少计算单元的数量,然后每个计算单元从多个保留站中获取计算的指令。

可以思考一下,这种方法的瓶颈在什么地方。首先,每条指令都放在一个保留站中,当保留站满的时候就不能发射新的指令。其次,如果计算单元的吞吐跟不上保留站的填充速度,也会导致阻塞。

这种方法的一个比较麻烦的点在于难以实现精确异常。精确异常的关键在于,异常之前的指令都生效,异常和异常之后的指令不生效,但这种方法无法进行区分。

从寄存器重命名的角度来看,可以认为这种方法属于 Implicit Register Renaming,也就是说,把 Register 重命名为保留站的 ID。

再分析一下寄存器堆需要哪些读写口。有一条规律是,寄存器堆的面积与读写口个数的平方成正比。对于每条发射的指令,都需要从寄存器堆读操作数,所以读口是操作数 x 指令发射数。当执行单元完成计算的时候,需要写入寄存器堆,所以每个执行单元都对应一个寄存器堆的写口。

硬件实现的时候,为了性能,希望保留站可以做的比较多,这样可以容纳更多的指令。但是,保留站里面至少要保存操作数的值,会比较占用面积,并且时延也比较大。

ROB (ReOrder Buffer)

参考教材

为了实现精确异常,我们需要引入 ROB。在上面的 Tomasulo 算法中,计算单元计算完成的时候,就会把结果写入到寄存器堆中,因此精确异常时难以得到正确的寄存器堆取值。既然我们希望寄存器堆的状态与顺序执行的结果一致,我们需要引入 ROB。

ROB 实际上就是一个循环队列,队列头尾指针之间就是正在执行的指令,每个 ROB 表项记录了指令的状态、目的寄存器和目的寄存器将要写入的值。ROB 会检查队列头的指令,如果已经执行完成,并且没有异常,就可以让结果生效,并把指令从队列头中删去,继续检查后面的。Decode 出来的指令则会插入到 ROB 的尾部,并且随着指令的执行过程更新状态。遇到异常的指令,就把队列中的指令、保留站和执行单元清空,从异常处理地址开始重新执行。

为了保证寄存器堆的正确性,运算单元的运算结果会写入 ROB 项中,当这一项在 ROB 队列头部被删去时,就会写入寄存器堆。在经典 Tomasulo 中,寄存器重命名为保留站的 ID,但在这种设计中,应该重命名为 ROB 的 ID,也就是说,需要维护一个寄存器到 ROB ID 的映射,当指令进入保留站的时候,需要从寄存器堆或者 ROB 中去读取操作数的值。在 CDB 上广播的也是 ROB 的 ID,而不是保留站的 ID。

这种方法中,ROB 的大小成为了一个新的瓶颈,因为每条在正在执行的指令都需要在 ROB 中记录一份。不过好处是实现了精确异常。

Explicit Register Renaming

上面两种设计都是采用的 Implicit Register Renaming 的方法,第一种方法重命名到了保留站,第二种方法重命名到了 ROB。还有一种设计,把寄存器编号映射到物理的寄存器。把 ISA 中的寄存器称为架构寄存器(比如 32 个通用寄存器),CPU 中实际的寄存器称为物理寄存器,物理寄存器一般会比架构寄存器多很多(一两百个甚至更多)。

Explicit Register Renaming 和 ReOrder Buffer 这两个设计方向可以同时使用,也可以单独使用。

映射的方法和前面的类似,也是维护一个 mapping,从架构寄存器到物理寄存器。当一条指令 Dispatch 的时候,操作数在 mapping 中找到实际的物理寄存器编号。如果这条指令要写入新的架构寄存器,则从未分配的物理寄存器中分配一个新的物理寄存器,并且更新 mapping,即把写入的架构寄存器映射到新的物理寄存器上。然后,放到 Issue Queue 中。

Issue Queue 可以理解为保留站的简化版,它不再保存操作数的取值,而仅仅维护操作数是否 ready。后面解释为什么不需要保存操作数的取值。在 Issue Queue 中的指令在所有操作数都 ready 的时候,则会 Issue 到不同的端口中。每个端口对应着一个执行单元,比如两个 ALU 端口分别对应两个 ALU 执行单元。指令首先通过寄存器堆,以物理寄存器为编号去读取数据,然后这个值直接传给执行单元,不会存下来。当执行单元执行完毕的时候,结果也是写到物理寄存器堆中,ROB 不保存数据。

可以发现,这种设计中,值仅保存在寄存器堆中,Issue Queue 和 ROB 都只保存一些状态位,因此它们可以做的很大,典型的 Issue Queue 有几十项,ROB 则有几百项。

接下来讨论一些细节。首先是,物理寄存器何时释放。当一条指令写入一个架构寄存器的时候,在下一次这个架构寄存器被写入之前,这个寄存器的值都有可能被读取,因此这个架构寄存器到物理寄存器的映射要保留。如果我们能保证读取这个值的指令都已经完成,我们就可以释放这个物理寄存器了。一个方法是,我在覆盖架构寄存器到物理寄存器的映射时,我还要记录原来的物理寄存器,当该指令在 ROB 中提交了(从队头出去了),说明之前可能依赖这个物理寄存器的所有指令都完成了,这时候就可以把原来的物理寄存器放到未映射的列表中。

还有一个问题,就是在遇到异常的时候,如何恢复在异常指令处的架构寄存器到物理寄存器的映射呢?一个办法是,利用我在 ROB 中记录的被覆盖的物理寄存器编号,从 ROB 队尾往前回滚,当发现一条指令覆盖了一个架构寄存器映射的时候,就恢复为覆盖之前的值。这样,当回滚到异常指令的时候,就会得到正确的映射。MIPS R10K 的论文中是这么描述的:

The active list contains the logical-destination register number and its
old physical-register number for each instruction. An instruction's
graduation commits its new mapping, so the old physical register can
return to the free list for reuse. When an exception occurs, however,
subsequent instructions never graduate. Instead, the processor restores
old mappings from the active list. The R1OOOO unmaps four instructions
per cycle--in reverse order, in case it renamed the same logical
register twice. Although this is slower than restoring a branch,
exceptions are much rarer than mispredicted branches. The processor
returns new physical registers to the free lists by restoring their read
pointers.

和 @CircuitCoder 讨论并参考 BOOM 文档 后发现,另一种办法是记录一个 Committed Map Table,也就是,只有当 ROB Head 的指令被 Commit 的时候,才更新 Committed Map Table,可以认为是顺序执行的寄存器映射表。当发生异常的时候,把 Committed Map Table 覆盖到 Register Map Table 上。这样需要的周期比较少,但是时序可能比较差。

Implicit Renaming(ROB) 和 Explicit Renaming 的比较

这两种方法主要区别:

  1. Implicit Renaming 在分发的时候,就会从寄存器堆读取数据,保存到保留站中;而 Explicit Renaming 是指令从 Issue Queue 到执行单元时候从寄存器堆读取数据
  2. Implicit Renaming 的寄存器堆读取口较少,只需要考虑发射数乘以操作数个数,但所有类型的寄存器堆(整数、浮点)都需要读取;Explicit Renaming 的寄存器堆读取口更多,对于每个 Issue Queue,都需要操作数个数个读取口,但好处是可以屏蔽掉不需要访问的读取口,比如浮点 FMA 流水不需要读取整数寄存器堆。写和读是类似的:Implicit Renaming 中,寄存器堆的写入是从 ROB 上提交;而 Explicit Renaming 则是执行单元计算完后写入寄存器堆。

其他优化的手段

在第一和第二种设计中,当一条指令计算完成时,结果会直接通过 CDB 转发到其他指令的输入,这样可以提高运行效率。在第三种设计中,为了提高效率,也可以做类似的事情,但因为中间多了一级寄存器堆读取的流水线级,处理会更加复杂一点。比如,有一条 ALU 指令,可以确定它在一个周期后一定会得到计算结果,那么我就可以提前把依赖这条指令的其他指令 Dispatch 出去,然后在 ALU 之间连接一个 bypass 网络,这样就可以减少一些周期。

此外,为了提高吞吐率,一般计算单元都被设计为每个周期可以接受一条指令,在内部实现流水线执行,每个周期完成一条指令的计算。当然了,很多时候由于数据依赖问题,可能并不能达到每个周期每个计算单元都满载的情况。

有些时候,一些指令不方便后端实现,就会加一层转换,从指令转换为 uop,再由后端执行。这在 x86 处理器上很普遍,因为指令集太复杂了。像 AArch64 指令集,它在 Load/Store 的时候可以对地址进行计算,这种时候也是拆分为两条 uop 来执行。一些实用的指令需要三个操作数,比如 D = A ? B : C,在 Alpha 21264 中,这条指令会转换为两条 uop,这两条 uop 的操作数就只有两个,便于后端实现,否则在各个地方都允许三个操作数会导致一定的浪费。

Locale 影响排序的特殊副作用

背景

最近在答疑的时候,发现同一条命令在不同系统上行为不同,一开始以为是 bash 版本问题,排查后最后发现是 locale 的问题。一个例子如下:

$ cat poc.txt | tr '\\n' ' '
1 + - * / \ a b A B     α
$ LANG="" sort poc.txt | tr '\\n' ' '
* + - / 1 A B \ a b α    $ LANG="zh_CN.UTF-8" sort poc.txt | tr '\\n' ' '
* + - / \ 1     a A b B α
$ LANG="en_US.UTF-8" sort poc.txt | tr '\\n' ' '
* + - / \ 1 a A b B α    

注意 1 a A 的顺序,在不同的 locale 下结果不同。

网上也有关于这个问题的讨论:

  1. https://unix.stackexchange.com/questions/75341/specify-the-sort-order-with-lc-collate-so-lowercase-is-before-uppercase
  2. https://stackoverflow.com/questions/43448655/weird-behavior-of-bash-glob-regex-ranges

一次从 RHEL 6 到 CentOS 7 的更新

背景

有一台 RHEL 6 的服务器,各种软件版本太老了,用起来很难受,因此想升级。一开始想升级到 RHEL 7,但是发现必须要从 RedHat 下载 ISO,比较慢,所以我就先切换到 CentOS 6,再升级到 CentOS 7

过程

RHEL 6 Pre upgrade

一开始还是打算升级到 RHEL 7,所以跟随 RedHat 的文档去做 pre upgrade check,发现有一步要跑好久,网上搜了一下,发现这个步骤会扫描已有的各种程序,检查升级以后会不会出现不能运行的问题。但是如果有很多小文件,这一个过程就会进行很久,好在可以设置 exclusion 目录。最后检查出来的结果就是 GNOME 没法升级,建议卸载。

倒腾了一下升级工具,发现需要离线安装,比较麻烦,我就干脆换 CentOS 了。

RHEL 6 -> CentOS 6

首先,把软件源都切换到 CentOS,这一步很简单,因为包都是一样的。只不过,因为 CentOS 6 在 centos-vault 里面,所以用起来比较麻烦。

CentOS 6 -> CentOS 7

由于 CentOS 6 到 CentOS 7 升级涉及的改动比较多,官方提供了一个升级工具。一开始,我想直接升级到 CentOS 7 最新版本,但是报错,看到网上说可以升级到 CentOS 7 的早期版本,试了一下,确实没问题。

一通升级以后,重启,进入更新过程,发现很多包都安装失败了。重启以后,因为找不到 rootfs,挂在了 dracut 的 initramfs 里面。

漫长的修复过程

简单试了一下,发现 dracut 的 initramfs 里程序太少了,调试起来很痛苦。所以,我在 BMC 里通过 Virtual Media 挂了一个 Arch Linux 的 Live CD。因为通过 Web 访问延迟太高,我设了一个 root 密码,然后直接 ssh 到 live cd 系统中。

接着,我发现,可以正常找到盘和里面的各个分区,所以怀疑是之前 initramfs 里缺了什么东西,导致找不到硬盘。我 arch-chroot 到 root 分区里,然后手动更新各个包,特别麻烦:我首先升级了 yum repos 到最新的 CentOS 7,然后手动删掉/升级 el6 的各个软件包。最后好不容易把 kernel 终于升级好了,又重新生成 grub2 的配置,因为 CentOS 6 是 grub1。这时候,重启进入系统,发现可以找到 rootfs 了,但是经过 selinux relabel 以后,仍然会遇到 systemd-logind 起不来的问题,伴随着一系列的 audit 报警。

最后,使出了暴力的解决办法:在 cmdline 中设置 selinux=0 audit=0,然后终于进入系统了。再继续删掉一些 el6 的包,然后升级各种包,最后终于是恢复了正常。

轶事一则

7.31 号周六的时候,发现 Intel Intrinsics Guide(https://software.intel.com/sites/landingpage/IntrinsicsGuide/) 出现错误,加载数据失败,于是在 Intel 的网站上提交了一个 bug。

8.2 号的时候,Intel 发邮件过来,说已经复现了问题,已经汇报给了后端团队。邮件原文:

Thank you for bringing this to our attention. We have verified and
encountered the same issue. Please know that we have escalated this
issue to our backend technical team.

We will get back to you as soon as we have an update. Have a nice day
ahead!

8.4 号的时候,Intel 再次发邮件过来,说后端团队正在处理这个问题,会尽快完成修复,请我耐心等待。这个时候我去网站上看,还是有问题。邮件原文:

Our backend team is still working on this issue. We are trying our level
best to get back to you with an update soon.

Have a nice day ahead!

8.6 号 19:27 的时候,Intel 又发了一次邮件,说后端团队依然在处理这个问题,并且正在进行一个永久性的修复(言下之意是现在提供了一个临时性的修复)。这个时候去网站上看,终于是修好了。邮件原文:

We have received an update from our backend team is that they are
working on this issue and, a more permanent fix is in the works.
Hopefully, it will resolve soon.

We appreciate your patience and understanding on this matter. Have a
nice day!

我回复了一下邮件,告诉 Intel 我这边看到已经是修复好的版本,紧接着又收到了一封邮件,告诉我可以从网站上下载离线版的 Intrinsics Guide:

Thank you for your prompt response. We are glad that your issue has been
resolved and we would like to thank you for your co operation.  Please
be informed that the offline version of the Intrinsic Guide is now
available for download from the site. The offline version of the guide
has the same content as the site, but is viewable offline by the user. A
link to the download is now added in the left column of the site:
https://software.intel.com/sites/landingpage/IntrinsicsGuide/

That said, we are closing this ticket and if you have further issues
please open another ticket and we will be happy to help you.

After case closure, you will receive a survey email. We appreciate it if
you can complete this survey regarding the support you received.  Your
feedback will help us improve our support.

For any concerns related to Intel® Developer Zone account, login or
website, please feel free to open a new ticket:
https://software.intel.com/en-us/support

这次 Intel Support 的反应挺快的,给个好评。就是希望 Intel 能够不挤牙膏,能拿出和 AMD 相当水平的 CPU。

背景

最近发现空调遥控器电池有点不足,有时候会自动关机,于是拿出以前买的 Broadlink RM mini 3 充当远程的空调遥控器使用。为了方便手机上配置,分别采用了官方的 App 智慧星和 homebridge 进行配置。

步骤

首先用官方的智慧星配置好 Broadlink RM mini 3 的网络,然后配置 homebridge-broadlink-rm-pro。最早的插件作者不怎么更新了,这个版本是目前用的比较多的一个 fork。

安装好以后,在 Home 里面可以看到 Scan Code 的开关。打开以后,用遥控器在 Broadlink RM mini 3 附近按按键,就可以在 Homebridge 日志里看到 hex code 了。然后,就按照插件教程里的方法写配置,例子如下:

{
        "platform": "BroadlinkRM",
        "name": "Broadlink RM",
        "accessories": [
        {
                "name": "Air Conditioner",
                "type": "air-conditioner",
                "noHumidity": true,
                "minTemperature": 26,
                "maxTemperature": 28,
                "defaultCoolTemperature": 27,
                "data": {
                        "off": "2600...",
                        "cool28": {
                                "data": "2600..."
                        },
                        "cool27": {
                                "data": "2600..."
                        },
                        "cool26": {
                                "data": "2600..."
                        }
                }
        }]
}

这样就可以在手机上方便地控制空调温度了。测试了一下,可以用 Siri 说“设置空调为 XX 度”,也是完全可以工作的。

P.S. 小米的空气净化器现在可以用插件 https://github.com/torifat/xiaomi-mi-air-purifier#readme,之前博客里写的那一个已经不更新了。

轶事一则

7.17 号周六的时候,一位朋友 @elenacliu 发现深研院网站的一个文档链接,文档中标题写的是 2021,但是网页的标题显示的是 2006 年。于是我发邮件给深研院的招生办,抄送本部的研招办,提交了这个 issue。

7.19 号周一上午的时候,本部的研招办回复了一封邮件,没有理解我想表达的意思,可能以为我是要报考的学生,让我关注明年发布的文档。

7.19 号周一下午的时候,深研院招生办回复了邮件,说“谢谢你的反馈”,不过没有提到是否进行了修复。我晚上再查看页面的时候,发现新的文档链接已经修复了问题。

Nginx 处理 POST 请求出现 Internal Server Error 排查一则

前言

最近一个服务忽然出现问题,用户反馈,HTTP POST 一个小的 body 不会出错,POST 一个大的 body 就会 500 Internal Server Error。

排查

观察后端日志,发现没有出错的那一个请求。观察 Nginx 日志,发现最后一次日志是几个小时前。最后几条 Nginx 日志写的是 a client request body is buffered to a temporary file

结论

继续研究后,发现是硬盘满了。Nginx 在处理 POST body 的时候,如果 body 超过阈值,会写入到临时文件中:

Syntax: client_body_buffer_size size;
Default: client_body_buffer_size 8k|16k;
Context: http, server, location
Sets buffer size for reading client request body. In case the request body is larger than the buffer, the whole body or only its part is written to a temporary file. By default, buffer size is equal to two memory pages. This is 8K on x86, other 32-bit platforms, and x86-64. It is usually 16K on other 64-bit platforms.

详见 https://nginx.org/en/docs/http/ngx_http_core_module.html#client_body_buffer_size

这就可以解释为什么 Nginx 返回 500 而且没有转发到后端,也可以解释为什么 Nginx 没有输出新的错误日志。