跳转至

Architecture

llvm Pass

简介

  • Pass就是“遍历一遍IR,可以同时对它做一些操作”的意思。翻译成中文应该叫“传递”。
  • 在实现上,LLVM的核心库中会给你一些 Pass类 去继承。你需要实现它的一些方法。
  • ModulePass , CallGraphSCCPass, FunctionPass , or LoopPass, or RegionPass
  • 最后使用LLVM的编译器会把它翻译得到的IR传入Pass里,给你遍历和修改。

作用

  1. 插桩: 在Pass遍历LLVM IR的同时,自然就可以往里面插入新的代码。
  2. 机器无关的代码优化:编译原理一课说:IR在被翻译成机器码前会做一些机器无关的优化。 但是不同的优化方法之间需要解耦,所以自然要各自遍历一遍IR,实现成了一个个LLVM Pass。 最终,基于LLVM的编译器会在前端生成LLVM IR后调用一些LLVM Pass做机器无关优化, 然后再调用LLVM后端生成目标平台代码。
  3. 静态分析: 像VSCode的C/C++插件就会用LLVM Pass来分析代码,提示可能的错误 (无用的变量、无法到达的代码等等)。

理解 llvm Pass

理解Pass API

Pass类是实现优化的主要资源。然而,我们从不直接使用它,而是通过清楚的子类使用它。当实现一个Pass时,你应该选择适合你的Pass的最佳粒度,适合此粒度的最佳子类,例如基于函数、模块、循环、强联通区域,等等。常见的这些子类如下:

  • ModulePass:这是最通用的Pass;它一次分析整个模块,函数的次序不确定。它不限定使用者的行为,允许删除函数和其它修改。为了使用它,你需要写一个类继承ModulePass,并重载runOnModule()方法。
  • FunctionPass:这个子类允许一次处理一个函数,处理函数的次序不确定。这是应用最多的Pass类型。它禁止修改外部函数、删除函数、删除全局变量。为了使用它,需要写一个它的子类,重载runOnFunction()方法。
  • BasicBlockPass:这个类的粒度是基本块。FunctionPass类禁止的修改在这里也是禁止的。它还禁止修改或者删除外部基本块。使用者需要写一个类继承BasicBlockPass,并重载它的runOnBasicBlock()方法。

被重载的入口函数runOnModule()、runOnFunction()、runOnBasicBlock()返回布尔值false,如果被分析的单元(模块、函数和基本块)保持不变,否则返回布尔值true。

Pass的执行顺序/依赖

  • ChatGPT说默认顺序是:FunctionPass -> Module Pass -> LoopPass ?
  • 当然我们是可以修改插入Pass的执行顺序的。
char PIMProf::AnnotationInjection::ID = 0;
// 注册 llvm pass
static RegisterPass<PIMProf::AnnotationInjection> RegisterMyPass(
    "AnnotationInjection", "Inject annotators to uniquely identify each basic block.");

static void loadPass(const PassManagerBuilder &,
                           legacy::PassManagerBase &PM) {
    PM.add(new PIMProf::AnnotationInjection());
}

// Ox 的代码 llvm pass 在EP_OptimizerLast 位置load
static RegisterStandardPasses clangtoolLoader_Ox(PassManagerBuilder::EP_OptimizerLast, loadPass);
// O0 的代码 llvm pass EP_EnabledOnOptLevel0 位置load
static RegisterStandardPasses clangtoolLoader_O0(PassManagerBuilder::EP_EnabledOnOptLevel0, loadPass);

流程

  1. 编写LLVM pass代码
  2. 配置编译环境(cmake or make)
  3. 运行(opt or clang)

1 代码框架

最简单框架hello.cpp如下,注意Important一定需要:

#include "llvm/Pass.h"
#include "llvm/IR/Function.h"
#include "llvm/Support/raw_ostream.h"
#include "llvm/IR/LegacyPassManager.h"
#include "llvm/Transforms/IPO/PassManagerBuilder.h"

using namespace llvm;

namespace {
 // Important
  struct Hello : public FunctionPass {
    static char ID;
    Hello() : FunctionPass(ID) {}
 // Important
    bool runOnFunction(Function &F) override {
      errs() << "Hello: ";
      errs().write_escaped(F.getName()) << '\n';
      return false;
    }
  };
}

char Hello::ID = 0;

// Important:Register for opt
static RegisterPass<Hello> X("hello", "Hello World Pass");

// Important:Register for clang
static RegisterStandardPasses Y(PassManagerBuilder::EP_EarlyAsPossible,
  [](const PassManagerBuilder &Builder, legacy::PassManagerBase &PM) {
    PM.add(new Hello());
  });

2 编译动态库

使用cmake

参考官方文档

An example of a project layout is provided below.

<project dir>/
    |
    CMakeLists.txt
    <pass name>/
        |
        CMakeLists.txt
        Pass.cpp
        ...

Contents of <project dir>/CMakeLists.txt:

find_package(LLVM REQUIRED CONFIG)

separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS})
add_definitions(${LLVM_DEFINITIONS_LIST})
include_directories(${LLVM_INCLUDE_DIRS})

add_subdirectory(<pass name>)

Contents of <project dir>/<pass name>/CMakeLists.txt:

add_library(LLVMPassname MODULE Pass.cpp)

运行cmake编译。产生LLVMPassname.so文件

mkdir build && cd build
cmake .. && make

使用命令行

请阅读知乎的文章

3 使用

opt加载Pass

clang -c -emit-llvm main.c -o main.bc # 随意写一个C代码并编译到bc格式
opt -load path/to/LLVMHello.so -hello main.bc -o /dev/null

把源代码编译成IR代码,然后用opt运行Pass实在麻烦且无趣。

clang加载Pass

clang -Xclang -load -Xclang path/to/LLVMHello.so main.c -o main
# or
clang++ -Xclang -load -Xclang ./build/hello/libLLVMPassname.so test.cpp -o main

实践

插入代码

void InjectSimMagic2(Module &M, Instruction *insertPt, uint64_t arg0, uint64_t arg1, uint64_t arg2)
{
    LLVMContext &ctx = M.getContext();
    std::vector<Type *> argtype {
        Type::getInt64Ty(ctx), Type::getInt64Ty(ctx), Type::getInt64Ty(ctx)
    };
    FunctionType *ty = FunctionType::get(
        Type::getVoidTy(ctx), argtype, false
    );
    // template of Sniper's SimMagic0
    InlineAsm *ia = InlineAsm::get(
        ty,
        "\tmov $0, %rax \n"
        "\tmov $1, %rbx \n"
        "\tmov $2, %rcx \n"
        "\txchg %bx, %bx\n",
        "imr,imr,imr,~{rax},~{rbx},~{rcx},~{dirflag},~{fpsr},~{flags}",
        true
    );
    Value *val0 = ConstantInt::get(IntegerType::get(ctx, 64), arg0);
    Value *val1 = ConstantInt::get(IntegerType::get(ctx, 64), arg1);
    Value *val2 = ConstantInt::get(IntegerType::get(ctx, 64), arg2);
    std::vector<Value *> arglist {val0, val1, val2};
    CallInst::Create(
            ia, arglist, "", insertPt);
}

这段代码使用内联汇编嵌入到 LLVM IR 中,指令如下:

mov $0, %rax
mov $1, %rbx
mov $2, %rcx
xchg %bx, %bx

其中:

  • mov $0, %rax 将立即数 arg0 装载到通用寄存器 %rax 中。
  • mov $1, %rbx 将立即数 arg1 装载到通用寄存器 %rbx 中。
  • mov $2, %rcx 将立即数 arg2 装载到通用寄存器 %rcx 中。
  • xchg %bx, %bx 是一条无操作指令,用于保证该汇编代码的原子性。

打印每个BBL内的汇编指令

由于直接打印的是llvm IR的表示,想要打印特定架构比如x86的汇编代码,其实需要进行llvm后端的转换。(取巧,可执行文件反汇编,然后根据插入的汇编桩划分)


需要进一步的研究学习

暂无

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

复现PIMProf论文时,用到了使用 llvm pass来插入特殊汇编

参考文献

https://www.llvm.org/docs/WritingAnLLVMPass.html

https://zhuanlan.zhihu.com/p/122522485

GNU Assembly File

GNU汇编语法

伪指令

  • 指示(Directives): 以点号开始,用来指示对编译器,连接器,调试器有用的结构信息。指示本身不是汇编指令。
伪指令 描述
.file 指定由哪个源文件生成的汇编代码。
.data 表示数据段(section)的开始地址
.text 指定下面的指令属于代码段。
.string 表示数据段中的字符串常量。
.globl main 指明标签main是一个可以在其它模块的代码中被访问的全局符号 。
.align 数据对齐指令
.section 段标记
.type 设置一个符号的属性值
  • 语法:.type name , description
  • description取值如下:
    • %function 表示该符号用来表示一个函数名
    • %object 表示该符号用来表示一个数据对象

至于其它的指示你可以忽略。

实践:阅读汇编文件

从最简单的C文件入手

int main(){
 return 0;
}

运行gcc -S -O3 main.c -o main.s,得到main.s文件

 .file "simple.cpp"
 .text
 .section .text.startup,"ax",@progbits
 .p2align 4
 .globl main
 .type main, @function
main:
.LFB0:
 .cfi_startproc
 endbr64
 xorl %eax, %eax
 ret
 .cfi_endproc
.LFE0:
 .size main, .-main
 .ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
 .section .note.GNU-stack,"",@progbits
 .section .note.gnu.property,"a"
 .align 8
 .long  1f - 0f
 .long  4f - 1f
 .long  5
0:
 .string  "GNU"
1:
 .align 8
 .long  0xc0000002
 .long  3f - 2f
2:
 .long  0x3
3:
 .align 8
4:
  • 下面回答来自ChatGPT-3.5,暂时没有校验其可靠性(看上去貌似说得通)。

section

  • .section .rodata.str1.1,"aMS",@progbits,1
  • rodata.str1.1是一个标号(label), 意思是只读数据段的字符串常量
  • aMS是一个属性值:
    • 可分配的(allocatable),即程序运行时需要动态分配空间才能分配该代码段,
    • 不可执行 (M),
    • 数据的类型为串(S)
    • 其余属性值:对齐方式的通常为 b(byte对齐),w(word对齐),或者其他更大的对齐单位,例如 d(double word对齐)。
  • @progbits: 表示该段的类型是程序数据段(PROGBITS),这种类型的段包含程序的代码和数据。
  • 1: 表示该段的对齐方式是2^1 = 2个字节(按字节对齐)。如果不写这个数字,默认对齐到当前机器的字长。
  • .section .text.startup,"ax",@progbits 其中ax表示该段是可分配的(allocatable)和可执行的(executable)。
  • ".section .note.GNU-stack"指令用于告诉链接器是否允许在堆栈上执行代码。
  • ".section .note.gnu.property"指令用于指定一些属性,这里是一个GNU特性标记。

汇编的入口

  • 汇编的执行流程:入口函数在哪里
  • 入口函数在该文件中的名称为“main”,定义于“.text.startup” section,其首地址为“.globl main”。
 .section .text.startup,"ax",@progbits
 .p2align 4
 .globl main
 .type main, @function

构造函数

  • 为了确保这些初始化操作可以在程序启动时正确执行,编译器将把这些构造函数和析构函数的调用代码打包成若干个函数,统一放在名字为“_GLOBAL__sub_I_xxx”的section中。
  • 因为在C++程序编译后的二进制文件中,全局变量、静态变量和全局对象等信息都需要进行初始化操作,包括构造函数(初始化对象)和析构函数(清理对象)。
  • 在这段汇编代码中,也就是那个"_GLOBAL__sub_I_main"函数,它是C++全局变量和静态变量的构造函数,它调用了预初始化函数 "ios_base::Init()",并注册了一个在程序退出时调用的析构函数 "__cxa_atexit"。
  • 在".init_array" section中,定义了一个"_GLOBAL__sub_I_main"的地址,这是在程序启动时需要调用的所有C++全局和静态对象的初始化函数列表,编译器链接这个列表并在程序启动时依次调用这些初始化函数。
  • 总之,这两个section的存在是为了保证C++全局变量和静态变量的正确初始化。

其中四条指令都定义了一些符号或变量,并分配了一些内存空间,这些在程序里的意义如下:

  1. ".quad _GLOBAL__sub_I_main":

在程序启动时,将调用所有全局静态对象的构造函数。这些构造函数被放在一个名为"_GLOBAL__sub_I_xxx"的section中,而每个section都是由一个指向该section所有对象的地址列表所引用。这里的".quad _GLOBAL__sub_I_main"是为了将"_GLOBAL__sub_I_main"函数的地址添加到该列表中。

  1. ".local _ZStL8__ioinit":

这条指令定义了一个本地符号"_ZStL8__ioinit",它表示C++标准输入输出的初始化过程。由于该符号是一个本地符号,所以只能在编辑该文件的当前单元中使用该符号。

  1. ".comm _ZStL8__ioinit,1,1":

这条指令定义了一个名为"_ZStL8__ioinit"的未初始化的弱符号,并为该符号分配了1个大小的字节空间。这个弱符号定义了一个C++标准输入输出部分的全局状态对象。在全用动态库时,不同的动态库可能有自己的IO状态,所以为了确保C++输入输出的状态正确,需要为其指定一个单独的段来存储这些状态数据。在这里,".comm _ZStL8__ioinit,1,1"将会为"_ZStL8__ioinit"符号分配一个字节大小的空间。

  1. ".hidden __dso_handle":

这条指令定义了一个隐藏的符号 "__dso_handle"。这个符号是一个链接器生成的隐式变量,其定义了一个指向被当前动态库使用的全局数据对象的一个指针。该符号在被链接进来的库中是隐藏的,不会被其他库或者main函数本身调用,但是在main返回后,可以用来检查库是否已经被卸载。

末尾的元数据

这段代码是一些特殊的指令和数据,主要是用于向可执行文件添加一些元数据(metadata)。这些元数据可能包含各种信息,如调试信息、特定平台的指令集支持等等。

具体来说:

  • ".long"指令用于定义一个长整型数值,这里用来计算地址之间的差值。
  • 例如,第一行".long 1f - 0f"建立了一个长整型数值,表示"1:"标签相对于当前指令地址(即0f)的偏移量。偏移量可以用来计算标签对应的指令地址,从而可用于跳转或计算指针偏移量。
  • "4f - 1f",即"4:"标签相对于"1:"标签的偏移量;
  • ".long 0xc0000002"表示这是一个特殊的属性标记,标识这个文件可以在Linux平台上执行。它是用来告诉操作系统这个程序是用特定指令集编译的。
  • ".long 0x3"表示另一个属性标记,表示这个文件可以加载到任意地址。

总之,这些元数据可能对程序运行起到关键作用,但在大多数情况下可能都没有明显的作用,因此看起来没有用。

比较汇编的debugging symbols

执行gcc -S -g testBigExe.cpp -o testDebug.s,对比之前的汇编文件,由72行变成9760行。

  • -g前后

.loc

.LBE32:
 .file 3 "/usr/include/c++/9/bits/char_traits.h"
 .loc 3 342 2 is_stmt 1 view .LVU4
 .loc 1 5 11 is_stmt 0 view .LVU5
  • 第一行:.loc 3 342 2 表示当前指令对应的源代码文件ID为3,在第342行,第2列(其中第1列是行号,第2列是第几个字符),同时is_stmt为1表示这条指令是语句的起始位置。
  • 第二行:.loc 1 5 11 表示当前指令对应的源代码文件ID为1,在第5行,第11列,同时is_stmt为0表示这条指令不是语句的起始位置。
  • view .LVU4 表示当前指令所处的作用域(scope)是.LVU4。作用域是指该指令所在的函数、代码块等一段范围内的所有变量和对象的可见性。在这个例子中,.LVU4 是一个局部变量作用域,因为它是位于一个C++标准库头文件中的一个函数的起始位置。

debug section

新增的这些 section 存储了 DWARF 调试信息。DWARF(Debugging With Attributed Record Formats)是一种调试信息的标准格式,包括代码中的变量、类型、函数、源文件的映射关系,以及代码的编译相关信息等等。

具体来说,这些 section 存储的内容如下:

  • .debug_info:包含程序的调试信息,包括编译单元、类型信息、函数和变量信息等。
  • .debug_abbrev:包含了 .debug_info 中使用到的所有缩写名称及其对应的含义,用于压缩格式和提高效率。
  • .debug_loc:存储每个程序变量或表达式的地址范围及其地址寄存器、表达式规则等信息。在调试时用来确定变量或表达式的值和范围。
  • .debug_aranges:存储简化版本的地址范围描述,允许调试器加速地定位代码和数据的位置。
  • .debug_ranges:存储每个编译单元(CU)的地址范围,每个范围都是一个有限开区间。
  • .debug_line:存储源代码行号信息,包括每行的文件、行号、是否为语句起始位置等信息。
  • .debug_str:包含了所有字符串,如文件名、函数名等,由于每个调试信息的数据都是字符串,因此这是所有调试信息的基础。

需要注意的是,这些 section 中的信息是根据编译器的配置和选项生成的,因此不同编译器可能会生成略有不同的调试信息。

需要进一步的研究学习

  • 在编译的过程中,哪个阶段 label会变成真实执行地址

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

https://www.cnblogs.com/zuofaqi/articles/12853734.html

https://www.cnblogs.com/zuofaqi/articles/12853734.html

PIM Simulator

PIM 模拟器的基本分类

技术路线 代表
全系统模拟 gem5
基于平台无关的PIM的trace代码的模拟 Sinuca (HPCC'15)
Host端为真实机器,只模拟PIM端 \(Sim^2PIM\) (DATE'21)
PIMSim( IEEE Computer Architecture Letters'19)
## memory operations采集
  1. Intel's Pin Software 采集 user-mode memory operations
  2. Bochs full system emulator / ZSim / gem5

各种PIM论文里的模拟器环境

文献 环境 特点
CoNDA(ISCA ’19) gem5(X86 full-system) + DRAMSim2 魔改了gem5的内存模型
Accelerating Neural Network Inference with Processing-in-DRAM: From the Edge to the Cloud(IEEE Micro) 讨论了三种PIM架构1. UPMEM(真实系统) 2. Mensa(Google’s Edge TPU in-house simulator) 3. SIMDRAM(gem5)
Ambit: In-Memory Accelerator for Bulk Bitwise Operations Using Commodity DRAM Technology(Micro 17) gem5
GraphPIM: Enabling Instruction-Level PIM Offloading in Graph Computing Frameworks Structural Simulation Toolkit (SST) [28] with MacSim [29], a cycle-level architecture simulator. HMC is simulated by VaultSim, a 3D-stacked memory simulator. We extend VaultSim with extra timing models based on DRAMSim2
ProPRAM: Exploiting the Transparent Logic Resources in Non-Volatile Memory for Near Data Computing Multi2Sim + DRAMSim2 + NVSim
Operand Size Reconfiguration for Big Data Processing in Memory(RVU 架构 DATE 17 B会) SiNUCA(类似gem5)

越来越多的工作在real PIM system上开展,基于专门的PIM模拟器的貌似很少???为什么无法满足定制的要求吗?

PIM 编译器

A compiler for automatic selection of suitable processing-in-memory instructions,

PIM cache coherence实现

Providing plug n’ play for processing-in-memory accelerators,

LazyPIM: An Efficient Cache Coherence Mechanism for Processing-in-Memory,

各种的PIM模拟器

比较,优点和局限性

模拟器名称 文献 代码 特点
ZSim + Ramulator Processing-in-memory: A workload-driven perspective https://github.com/CMU-SAFARI/ramulator-pim/ ZSim(类似gem5)+Ramulator(HMC logic layer add PIM core) 了解实现原理后,其memory端的拓展性值得期待
Sim2PIM 暂无 可以将任意PIM架构和任意host端结合,多线程very fast as perf(通过利用Host系统OS的pthread和硬件计数器来实现)缺点:Host端的cache策略等不能任意定制
gem5 SiNUCA文章指出gem5的DRAM模拟误差可以达到36%
Sinuca(HPCC 15) Sinuca: A validated micro-architecture simulator use real trace-based simulator(但是不能采OS和多线程的)
PinTools Pin: Building customized program analysis tools with dynamic instrumentation, 类似上面的,JIT执行
MultiPIM Multipim: A detailed and configurable multistack processing-in-memory simulator
Pimsim Pimsim: A flexible and detailed processing-in-memory simulator 太慢
Hmc-sim-2.0: A simulation platform for exploring custom memory cube operations 特定架构
Cycle Accurate Parallel PIM Simulator (CLAPPS) A generic processing in memory cycle accurate simulator under hybrid memory cube architecture 依赖system模拟器(SystemC HMC simulation)
Mnsim: Simulation platform for memristor-based neuromorphic computing system 不是全系统的模拟(忆阻器PIM 模拟器)
Cim-sim Non-Volatile Memory(忆阻器PIM 模拟器)

ZSim + Ramulator 功能

host CPU cores and general-purpose PIM cores.

The PIM cores are placed in the logic layer of a 3D-stacked memory (Ramulator's HMC model).

The simulation framework does not currently support concurrent execution on host and PIM cores.

主机CPU核和通用PIM核的计算系统。PIM核心被放置在一个3d堆叠存储器(Ramulator的HMC模型)的逻辑层中。通过这个模拟框架,我们可以模拟主机CPU核和通用PIM核,目的是比较两者对于一个应用程序或其部分的性能。该仿真框架目前不支持主机和PIM核心上的并发执行。

use ZSim to generate memory traces that are fed to Ramulator.

Zim跟踪内存的使用,还可以模拟主机的缓存层次结构(包括coherence协议)。ZSim还可以模拟硬件预取器。

Ramulator simulates the memory accesses of the host cores and the PIM cores

Ramulator contains simple models of out-of-order and in-order cores that can be used for simulation of host and PIM.

需要进一步的研究学习

暂无

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

RAM

RAM

RAM (random access memory), 中文名叫随机存储器, 随机是什么意思呢? 意思是, 给定一个地址, 可以立即访问到数据(访问时间和位置无关)

而不像咱们熟悉的磁带, 知道最后一首歌在最后的位置, 却没法直接一下子跳到磁带的最后部门, 所以磁带不是随机存储器, 而是顺序存储器。

SRAM vs DRAM

SRAM (Static Random Access Memory) and DRAM (Dynamic Random Access Memory)

BASIS FOR COMPARISON SRAM DRAM
Speed Faster Slower
Size Small Large
Cost Expensive Cheap
Used in Cache memory Main memory
Density Less dense Highly dense
Construction Complex and uses transistors and latches. Simple and uses capacitors and very few transistors.
Single block of memory requires 6 transistors Only one transistor.
Charge leakage property Not present Present hence require power refresh circuitry
Power consumption Low High

基本电路实现

左边的是静态的,右边的是dynamic的。

SRAM,保存一个bit需要6个晶体管。

DRAM 存储一个bit的DRAM只需要一个电容和一个晶体管。 DRAM的数据实际上是存在于电容里面的, 电容会有电的泄露, 损失状态, 故需要对电容状态进行保持和刷新处理, 以维持持久状态, 而这是需要时间的, 所以就慢了。而且很耗电。

DRAM内存实现的存储是通过晶体管实现的一个电路 门控D锁存器,其更简化的形式是 SR锁存器,电路结构如下图:

但是bank矩阵的一个点(基本存储单元, 寻址能力, 内存颗粒(Chip)的位宽)一般是8bit.

8个 门控D锁存器 组成内存的基本(最小)存储单元,他们共用一个行/列 地址线。在一次寻址中每个内存颗粒返回 8 bit的数据 8个内存可以同时寻址 最终得到的是 8 * 8(8个chip) = 64 bit 的连续数据 也就是说 内存一次寻址可以读取 8 Byte 的数据,这里也能说明在C语言中的内存不齐的原因(减少寻址次数)。

SDRAM

现在的DRAM一般都是SDRAM,即Synchronous Dynamic Random Access Memory,同步且能自由指定地址进行数据读写。其结构一般由许多个bank组成并利用以达到自由寻址。

chip的多 Bank 的设计允许向每个Bank 发出不同的命令。同一时刻,不同的bank可以处理不同的行地址。当然,不可能同时读取或者写入多个 Bank,因为读写通道只有 1 个,当时可以在 1 个 Bank 读写时,向另一个 Bank 发出 Precharge 或者 Active 命令。

DRAM基本术语

名词 解释
dual inline memory modules (DIMMs). 每个channel可以连接多个DIMM,每个DIMM与多个DRAM chip相联
Cell: 颗粒中的一个数据存储单元叫做一个Cell,由一个电容和一个N沟道MOSFET组成。
chip: 一个颗粒叫做一个chip。一根内存的内存带宽是64bit,如果是单面就是8个8bit颗粒,如果是双面,那就是16个4bit的颗粒分别在两面,不算ECC颗粒(Error Checking and Correcting错误校验芯片)。
Bank 每个chip有4~8个bank,每个bank可以看作一个行列矩阵,每个点存储4~16bit的信息。
Rank: 内存PCB的一面所有颗粒叫做一个rank,目前在Unbuffered台式机内存上,通常一面是8个颗粒,所以单面内存就是1个rank,8个chip
寻址空间 是指内存总共可以存储多少个地址,比如一个2G DDR3内存 ,每个Rank是2/1=1G ,每个内存颗粒是1/8=128M 每个Bank是 128/8=16M 16M = 2^4 * 2^10 = 2^14 也就是地址线需要14根 正对应地址线的 A0-A13

Overview

CRC Error Detection

DDR4 chip 内bank & bank group设计

每個DRAM裏有4個bank選取位元可用來選取多達16個bank單元:兩個bank位址位元(BA0、BA1),和兩個bank群組位元(BG0、BG1)。當在同一個bank群組中存取不同的bank單元時會有另外的時間限制;在不同的bank群組中,存取一個bank比以往的更快。

另外,3個晶片層選取信號(C0、C1、C2),允許最多8個堆疊式晶片層封裝於一塊DRAM封裝上。這可以更有效地充當3個以上的bank單元選取位元,使選取總數達到7(可以定位128個bank單元)。

内存控制器(Memory Controller)

我们知道cache的存在导致访存是按照cache line(32或者64字节)来进行的,但是内存一般只会处理连续64bits数据,导致需要控制器和总线分多周期(memory burst概念)来实现cache的更新。

SNB CPU的内存控制器可以实现和处理:

  • 对读写操作命令进行有效地重新分配,以使得行地址激活命中率最大化(如果重复激活一个已经处于激活状态的行地址,那就是RAS激活命令未命中)
  • 比如说open page policy情况下,row hit就不用发activate命令,直接发column就可以了,
  • 比如说两个地址连续mem_read命令,中间插有其他命令的时候是不是要乱序执行

reduction in DRAM row buffer conflicts

[^1]

CPU集成内存控制器技术

AMD公司提高CPU与内存性能的一项技术,将北桥的内存控制器集成到CPU,使得原来CPU-北桥-内存三方传输数据的过程简化成CPU与内存之间的单向传输技术,降低了延迟。

DRAM 寻址模式

  • 列数一般是1024,主要是因为功耗的原因

以2GB DDR3为例子,编码如上,

  1. 确定好rank面后
  2. 对该rank面的所有内存颗粒(chip),使用相同的Bank层 、行地址 、列地址 这些选址信息后,各自产生8bits数据,总共64bits
  3. 单个 Bank 只有一个 Sense Amps,只能缓存单个行的内容。因此在激活某行后,访问同一 Bank 不同行之前,需要使用 PRECHARGE 命令关闭(de-activate)当前激活行。PRECHARGE 命令好比关上当前打开的文件柜抽屉,命令发出后当前 Sense Amps 中缓存的行会被写回原地址。

Burst

DDR中的Burst(突发长度)指的是,当收到了一个读请求和地址后,会连续取出这个地址周围几个连续地址上的数据,具体取几个就叫BL(Burst Length),是可以随地址信号配置的。(原因是:次次等待Address和Enable信号再读写有些浪费时间)

Burst的实现是通过Prefetch完成的,Prefetch就是一次从Array上取出多bit的过程,而Burst则是根据规则发送这些预取的数据的过程。

Burst Length(BL)是可以配置的,比如8Bit预取可以支持BL8的Burst或者BC4(Burst Length Chopped)的Burst。

Prefetch (Request Pipelining)

Prefetch数量也是前几代DDR的主要区别。

红框标出的DRAM的核心频率基本不变,传输速度的提高是通过增加prefetch的位数(黄框)来做到的。

DDR 有两项主要的技术 2n-prefetch (2 倍预取),和 DLL (延迟锁相环)。这在之后历代 DDR 协议中都是一脉相承的。所谓 2 倍预取,即在一个时钟的上升边沿读取当前地址单元的数据,并同时读取下一个地址单元的数据。

例如同样是100MHz的核心频率
  1. SDRAM一周期取一次,它和内存控制器的速度是100MT/s(这里的T是传输的意思);
  2. DDR上升沿下降沿各取一次,相当于2次prefetch,Bus速度变成200;
  3. DDR2变成4n prefetch,Bus speed变成400;
  4. DDR3,照此办理,8n带来了800。DDR3/4 采用的是 8 倍预取,8n-prefetch,同时也设计有 DLL。

DDR3

DDRx的核心频率一直维持在100Mhz到266MHz的水平上,每代速度的提升都是靠倍增Prefetch的个数来达到的。

DDR4

DDR4和DDR3一样,只有8n的prefetch,但为了提升前端Front End的总线速度,不得不在核心频率上动起了手脚:

核心频率不在徘徊在100~266HMz,直接200起跳,到400Mhz。因为核心频率提高,8bit的prefetch不变,总线速度才得以提升。

除此之外,引入了Bank Group。DDR4 新增了4 個Bank Group 資料組的設計,各個Bank Group具備獨立啟動操作讀、寫等動作特性,Bank Group 資料組可套用多工的觀念來想像,亦可解釋為DDR4 在同一時脈工作周期內,至多可以處理4 筆資料,效率明顯好過於DDR3。

为什么DDR4不能进一步提高prefetch到16n的问题

我们都知道memory控制器实际上很大程度受cache操纵。X86 cache line 64B,而每次操作是64bit。所以一个cache line刷新是通过联系8个读操作实现的,这8个操作不是分别完成,而是一次burst操作,所以BL(burst line)是8。BL8的64B cache line只需要64个Bytes,如果prefetch是16,DIMM那边所有chip会准备

64 X 16 = 128 Byte

的数据。多出来的数据就变成了垃圾数据,空耗能而对速度帮助不大,所以DDR4到16 prefetch。

DDR5 为啥变成16n prefetch呢?

是不是CPU的cache line变长了呢?并不是,CPU的cache line还是64B,变化的是DIMM端增加了个新东西:Sub Channel。

Sub Channel,顾名思义,就是子通道,它是把DDR5 DIMM的72bit位宽(包括64bit数据+8bit ECC码)拆分成两个40bit的sub Channel。包括32bit的数据,+8bit的ECC:

这两个sub channel是相互独立的,既可以独立使用,也可以如前面合并使用。所以prefetch就可以提高到16n,当然也支持8n。

聪明的设计让DDR5在同样3200MT/s的传输率上,可以提高带宽1.36倍。再加上可以支持更高的频率,才能保证DDR5的传输速度。

DDR5的prefetch是16,那么怎么解决我们前面提到的cache line大小的问题呢?DDR5采取的方式是减少DIMM data lane的数量,从64个data lane降低到32个data lane,从而继续保持64 Byte的cache line大小。

访存时序知识

CL-tRCD-tRP-tRAS-CR

名词 解释
CL(CAS Latency) 列信号延迟: 在读取命令发出后到数据读出到IO接口的间隔时间(时钟周期数)
tCAS(tCL?) 实际延迟时间tCAS(ns)=(CAS*2000)/内存等效频率
tRAS(Row Active Time) 行地址激活的时间。从一个行地址预充电之后,从激活到寻址再到读取完成所经过的整个时间 tRCD+tCL
tRCD(Read-to-Column Delay) 行地址激活(Active)命令发出之后,内存对行地址的操作所需要的时间。内存中某一行地址被激活时,我们称它为“open page”
tRCDR(Read-to-Column Command Delay) 行地址激活(Active)命令发出之后,内存对行地址的读操作所需要的时间。
tRCDW(Write-to-Column Command Delay) 行地址激活(Active)命令发出之后,内存对行地址的写操作所需要的时间。
nWR (Write Recovery Time) time delay between successive write commands to the same row.
tRP(RAS Precharge Time) 前一个行地址操作完成并在行地址关闭(page close)命令发出之后,准备对同一个bank中下一个行地址进行Active操作需要的时间(在对同一个bank的多个不同的行地址进行操作时影响才大)
CR(Command Rate) 首命令延迟。是指从选定bank之后到可以发出行地址激活命令所经过的时间。(如果CPU所需要的数据都在内存的一个行地址上,就不需要进行重复多次的bank选择,CR的影响就很小)
Tccd is the minimum amount of time between column operations
tRPRE The minimum pulse width of READ preamble
tRPST The minimum pulse width of READ postamble

XMP时序都没有介绍

不同的DRAM。随着频率提升,CL周期也同步提升,但是最后算出来的CL延迟时间却差不多(5~15ns)。其实当下memory的频率宽度过剩,integrated memory controller (IMC)才是瓶颈

在列信号之前还有行信号

如何连续两次访问同一行的不同列,则之间不需要额外的切换行信号。

参考文献

https://zhuanlan.zhihu.com/p/52272990

https://fantiq.github.io/2019/03/14/%E5%86%85%E5%AD%98-%E7%9A%84%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86/

https://people.inf.ethz.ch/omutlu/pub/stfm_micro07.pdf

https://www.micron.com/-/media/client/global/documents/products/data-sheet/dram/ddr4/4gb_ddr4_dram_2e0d.pdf

https://zhuanlan.zhihu.com/p/420994258

Inline Assembly

GCC内联汇编

__asm__ __volatile__("Instruction List" : Output : Input : Clobber/Modify);
  1. __asm__或asm 用来声明一个内联汇编表达式,所以任何一个内联汇编表达式都是以它开头的,是必不可少的。
  2. __volatile__或volatile 是可选的。如果用了它,则是向GCC 声明不允许对该内联汇编优化,否则当 使用了优化选项(-O)进行编译时,GCC 将会根据自己的判断决定是否将这个内联汇编表达式中的指令优化掉。
  3. Instruction List 是汇编指令序列。它可以是空的,比如:__asm__ __volatile__(""); 或 __asm__ ("");都是完全合法的内联汇编表达式,只不过这两条语句没有什么意义。
  4. 但并非所有Instruction List 为空的内联汇编表达式都是没有意义的,比如:__asm__ ("":::"memory");就非常有意义,它向GCC 声明:“内存作了改动”,GCC 在编译的时候,会将此因素考虑进去。
  5. 当在"Instruction List"中有多条指令的时候,需要用分号(;)或换行符(\n)将它们分开。
  6. 指令中的操作数可以使用占位符引用C语言变量,操作数占位符最多10个,名称如下:%0,%1,…,%9。指令中使用占位符表示的操作数,总被视为long型(4个字节),
    1. 但对其施加的操作根据指令可以是字或者字节,当把操作数当作字或者字节使用时,默认为低字或者低字节
    2. 对字节操作可以显式的指明是低字节还是次字节。方法是在%和序号之间插入一个字母,"b"代表低字节,"h"代表高字节,例如:%h1
  7. Output/Input
    1. 格式为形如"constraint"(variable)的列表(逗号分隔)。按照出现的顺序分别与指令操作数"%0","%1"对应
    2. 每个输出操作数的限定字符串必须包含"="表示他是一个输出操作数。例子"=r" (value)
  8. Clobber/Modify(由逗号格开的字符串组成)
    1. 在Input/Output操作表达式所指定的寄存器,或当你为一些Input/Output操作表达式使用"r"约束,让GCC为你选择一个寄存器时,GCC知道这些寄存器是被修改的,你根本不需要在Clobber/Modify域再声明它们。
    2. 但是对于"Instruction List"中的临时寄存器,需要在Clobber/Modify域声明这些寄存器或内存,让GCC知道修改了他们
      1. 例子:__asm__ ("mov R0, #0x34" : : : "R0");寄存器R0出现在"Instruction List中",并且被mov指令修改,但却未被任何Input/Output操作表达式指定,所以你需要在Clobber/Modify域指定"R0",以让GCC知道这一点。
    3. Clobber/Modify域存在"memory",那么GCC会保证在此内联汇编之前,如果某个内存的内容被装入了寄存器,那么在这个内联汇编之后,如果需要使用这个内存处的内容,就会直接到这个内存处重新读取,而不是使用被存放在寄存器中的拷贝。因为这个 时候寄存器中的拷贝已经很可能和内存处的内容不一致了。

输入输出与指令的对应关系

寄存器约束符Operation Constraint

每一个Input和Output表达式都必须指定自己的操作约束Operation Constraint,这里将讨论在80386平台上所可能使用的操作约束。

当前的输入或输出需要借助一个寄存器时,需要为其指定一个寄存器约束,可以直接指定一个寄存器的名字。

常用的寄存器约束的缩写 约束 | 意义| |--------- | ----| r |表示使用一个通用寄存器,由 GCC 在%eax/%ax/%al,%ebx/%bx/%bl,%ecx/%cx/%cl,%edx/%dx/%dl中选取一个GCC认为合适的。 g |表示使用任意一个寄存器,由GCC在所有的可以使用的寄存器中选取一个GCC认为合适的。 q |表示使用一个通用寄存器,和约束r的意义相同。 a |表示使用%eax/%ax/%al b |表示使用%ebx/%bx/%bl c |表示使用%ecx/%cx/%cl d |表示使用%edx/%dx/%dl D |表示使用%edi/%di S |表示使用%esi/%si f |表示使用浮点寄存器 t |表示使用第一个浮点寄存器 u |表示使用第二个浮点寄存器

分类 |限定符 |描述 |--------- | ----|----------------| 通用寄存器 |“a”| 将输入变量放入eax 这里有一个问题:假设eax已经被使用,那怎么办?其实很简单:因为GCC 知道eax 已经被使用,它在这段汇编代码的起始处插入一条语句pushl %eax,将eax 内容保存到堆栈,然 后在这段代码结束处再增加一条语句popl %eax,恢复eax的内容 ||“b” |将输入变量放入ebx ||“c” |将输入变量放入ecx ||“d” |将输入变量放入edx ||“s” |将输入变量放入esi ||“d” |将输入变量放入edi ||“q” |将输入变量放入eax,ebx,ecx,edx中的一个 ||“r” |将输入变量放入通用寄存器,也就是eax,ebx,ecx,edx,esi,edi中的一个 ||"A"|把eax和edx合成一个64 位的寄存器(use long longs) 内存| “m” |内存变量 ||“o” |操作数为内存变量,但是其寻址方式是偏移量类型, 也即是基址寻址,或者是基址加变址寻址 ||“V”| 操作数为内存变量,但寻址方式不是偏移量类型 ||" "| 操作数为内存变量,但寻址方式为自动增量 ||“p” |操作数是一个合法的内存地址(指针) 寄存器或内存| “g” |将输入变量放入eax,ebx,ecx,edx中的一个 或者作为内存变量 ||“X” |操作数可以是任何类型 立即数 |“I” |0-31之间的立即数(用于32位移位指令) ||“J” |0-63之间的立即数(用于64位移位指令) ||“N” |0-255之间的立即数(用于out指令) ||“i” |立即数 ||“n” |立即数,有些系统不支持除字以外的立即数, 这些系统应该使用"n"而不是"i" 匹配 |" 0 ",“1” …“9” |, 表示用它限制的操作数与某个指定的操作数匹配,也即该操作数就是指定的那个操作数,例如"0"去描述"%1"操作数,那么"%1"引用的其实就是"%0"操作数,注意作为限定符字母的0-9 与 指令中的"%0"-"%9"的区别,前者描述操作数,后者代表操作数。 ||&; |该输出操作数不能使用过和输入操作数相同的寄存器 |操作数类型 |“=” |操作数在指令中是只写的(输出操作数) ||“+” |操作数在指令中是读写类型的(输入输出操作数) 浮点数| “f” |浮点寄存器 ||“t” |第一个浮点寄存器 ||“u” |第二个浮点寄存器| ||“G” |标准的80387浮点常数 ||% |该操作数可以和下一个操作数交换位置.例如addl的两个操作数可以交换顺序 (当然两个操作数都不能是立即数) ||# |部分注释,从该字符到其后的逗号之间所有字母被忽略 ||* |表示如果选用寄存器,则其后的字母被忽略

内存约束

如果一个Input/Output 操作表达式的C/C++表达式表现为一个内存地址,不想借助于任何寄存器,则可以使用内存约束。比如:

__asm__("lidt%0":"=m"(__idt_addr));
__asm__("lidt%0"::"m"(__idt_addr));

修饰符 |输入/输出 |意义 |---- | --- |--- = | O |表示此Output操作表达式是Write-Only的。 + | O |表示此Output操作表达式是Read-Write的。 & | O |表示此Output操作表达式独占为其指定的寄存器。 % | I |表示此Input 操作表达式中的C/C++表达式可以和下一 个Input操作表达式中的C/C++表达式互换

例子

Static __inline__ void __set_bit(int nr, volatile void * addr)
{
         __asm__(
                         "btsl %1,%0"
                         :"=m" (ADDR)
                         :"Ir" (nr));
}

第一个占位符%0与C 语言变量ADDR对应,第二个占位符%1与C语言变量nr对应。因此上面的汇编语句代码与下面的伪代码等价:btsl nr, ADDR

Clobber/Modify域存在"memory"的其他影响

使用"memory"是向GCC声明内存发生了变化,而内存发生变化带来的影响并不止这一点。

例如:

int main(int __argc, char* __argv[]) 
{ 
    int* __p = (int*)__argc; 
    (*__p) = 9999; 
    __asm__("":::"memory"); 
    if((*__p) == 9999) 
        return 5; 
    return (*__p); 
}
本例中,如果没有那条内联汇编语句,那个if语句的判断条件就完全是一句废话。GCC在优化时会意识到这一点,而直接只生成return 5的汇编代码,而不会再生成if语句的相关代码,而不会生成return (*__p)的相关代码。

但你加上了这条内联汇编语句,它除了声明内存变化之外,什么都没有做。

但GCC此时就不能简单的认为它不需要判断都知道 (*__p)一定与9999相等,它只有老老实实生成这条if语句的汇编代码,一起相关的两个return语句相关代码。

另外在linux内核中内存屏障也是基于它实现的include/asm/system.h中

# define barrier() _asm__volatile_("": : :"memory")
主要是保证程序的执行遵循顺序一致性。呵呵,有的时候你写代码的顺序,不一定是终执行的顺序,这个是处理器有关的。

Linux 源码例子

static inline char * strcpy(char * dest, const char *src)
{
    char *xdest = dest;
    __asm__ __volatile__
    ("1: \tmoeb %1@+, %0@+\n\t"   "jne 1b"  //这个冒号不是分隔符
    : "=a" (dest) , "=a" (stc)
    : "0"(dest), "1" (src)
     : "memory");
    return xdest;
}

需要进一步的研究学习

暂无

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

https://blog.csdn.net/yi412/article/details/80846083

https://www.cnblogs.com/elnino/p/4313340.html

Vtune Assembly Analysis

超算机器用vtune的命令行文件分析

  1. 首先找到vtune程序
> module load intel/2022.1                                    
> which icc                                                        
/public1/soft/oneAPI/2022.1/compiler/latest/linux/bin/intel64/icc                          
> cd /public1/soft/oneAPI/2022.1  
> find . -executable -type f -name "*vtune*"
./vtune/2022.0.0/bin64/vtune-worker-crash-reporter
./vtune/2022.0.0/bin64/vtune-gui.desktop
./vtune/2022.0.0/bin64/vtune-gui
./vtune/2022.0.0/bin64/vtune-agent
./vtune/2022.0.0/bin64/vtune-self-checker.sh
./vtune/2022.0.0/bin64/vtune-backend
./vtune/2022.0.0/bin64/vtune-worker
./vtune/2022.0.0/bin64/vtune
./vtune/2022.0.0/bin64/vtune-set-perf-caps.sh
  1. vtune-gui获取可执行命令
/opt/intel/oneapi/vtune/2021.1.1/bin64/vtune -collect hotspots -knob enable-stack-collection=true -knob stack-size=4096 -data-limit=1024000 -app-working-dir /home/shaojiemike/github/IPCC2022first/build/bin -- /home/shaojiemike/github/IPCC2022first/build/bin/pivot /home/shaojiemike/github/IPCC2022first/src/uniformvector-2dim-5h.txt
  1. 编写sbatch_vtune.sh
#!/bin/bash
#SBATCH -o ./slurmlog/job_%j_rank%t_%N_%n.out
#SBATCH -p IPCC
#SBATCH -t 15:00
#SBATCH --nodes=1
#SBATCH --exclude=
#SBATCH --cpus-per-task=64
#SBATCH --mail-type=FAIL
#SBATCH [email protected]

source /public1/soft/modules/module.sh
module purge

module load intel/2022.1

logname=vtune
export OMP_PROC_BIND=close; export OMP_PLACES=cores
# ./pivot |tee ./log/$logname
/public1/soft/oneAPI/2022.1/vtune/2022.0.0/bin64/vtune -collect hotspots -knob enable-stack-collection=true -knob stack-size=4096 -data-limit=1024000 -app-working-dir /public1/home/ipcc22_0029/shaojiemike/slurm -- /public1/home/ipcc22_0029/shaojiemike/slurm/pivot /public1/home/ipcc22_0029/shaojiemike/slurm/uniformvector-2dim-5h.txt |tee ./log/$logname
  1. log文件如下,但是将生成的trace文件r000hs导入识别不了AMD

> cat log/vtune
dim = 2, n = 500, k = 2
Using time : 452.232000 ms
max : 143 351 58880.823709
min : 83 226 21884.924801
Elapsed Time: 0.486s
   CPU Time: 3.540s
      Effective Time: 3.540s
      Spin Time: 0s
      Overhead Time: 0s
   Total Thread Count: 8
   Paused Time: 0s

Top Hotspots
Function         Module  CPU Time  % of CPU Time(%)
---------------  ------  --------  ----------------
SumDistance      pivot     0.940s             26.6%
_mm256_add_pd    pivot     0.540s             15.3%
_mm256_and_pd    pivot     0.320s              9.0%
_mm256_loadu_pd  pivot     0.300s              8.5%
Combination      pivot     0.250s              7.1%
[Others]         N/A       1.190s             33.6%

汇编

objdump -Sd ../build/bin/pivot > pivot1.s
gcc -S -O3 -fverbose-asm ../src/pivot.c -o pivot_O1.s

汇编分析技巧

https://blog.csdn.net/thisinnocence/article/details/80767776

如何设置GNU和Intel汇编语法

vtune汇编实例

(没有开O3,默认值)

偏移 -64 是k

-50 是ki

CDQE复制EAX寄存器双字的符号位(bit 31)到RAX的高32位。

这里的movsdq的q在intel里的64位,相当于使用了128位的寄存器,做了64位的事情,并没有自动向量化。

生成带代码注释的O3汇编代码

如果想把 C 语言变量的名称作为汇编语言语句中的注释,可以加上 -fverbose-asm 选项:

gcc -S -O3 -fverbose-asm ../src/pivot.c -o pivot_O1.s
.L15:
# ../src/pivot.c:38:                 double dis = fabs(rebuiltCoordFirst - rebuiltCoordSecond);
   movsd (%rax), %xmm0 # MEM[base: _15, offset: 0B], MEM[base: _15, offset: 0B]
   subsd (%rax,%rdx,8), %xmm0 # MEM[base: _15, index: _21, step: 8, offset: 0B], tmp226
   addq $8, %rax #, ivtmp.66
# ../src/pivot.c:38:                 double dis = fabs(rebuiltCoordFirst - rebuiltCoordSecond);
   andpd %xmm2, %xmm0 # tmp235, dis
   maxsd %xmm1, %xmm0 # chebyshev, dis
   movapd %xmm0, %xmm1 # dis, chebyshev
# ../src/pivot.c:35:             for(ki=0; ki<k; ki++){
   cmpq %rax, %rcx # ivtmp.66, _115
   jne .L15 #,
.L19:
# ../src/pivot.c:32:         for(j=i+1; j<n; j++){
   addl $1, %esi #, j
# ../src/pivot.c:41:             chebyshevSum += chebyshev;
   addsd %xmm1, %xmm4 # chebyshev, <retval>
   addl %r14d, %edi # k, ivtmp.75
# ../src/pivot.c:32:         for(j=i+1; j<n; j++){
   cmpl %esi, %r15d # j, n
   jg .L13 #,
# ../src/pivot.c:32:         for(j=i+1; j<n; j++){
   addl $1, %r10d #, j
# ../src/pivot.c:32:         for(j=i+1; j<n; j++){
   cmpl %r10d, %r15d # j, n
   jne .L16 #,

vtune O3汇编分析

原本以为O3是看不了原代码与汇编的对应关系的,但实际可以-g -O3 是不冲突的。

指令的精简合并

  1. 访存指令的合并
  2. r9 mov到 rax里,
    1. leaq (%r12,%r8,8), %r9。其中r12rebuiltCoord,所以r8原本存储的是[i*k]的值
    2. raxrebuiltCoord+[i*k]的地址,由于和i有关,index的计算在外层就计算好了。
  3. rdx的值减去r8存储在rdx
    1. rdx原本存储的是[j*k]的地址
    2. r8原本存储的是[i*k]的值
    3. rdx之后存储的是[(j-i)*k]的地址
  4. data16 nop是为了对齐插入的nop
  5. 值得注意的是取最大值操作,这里变成了maxsd
  6. xmm0缓存值
  7. xmm1chebyshev
  8. xmm2fabs的掩码
  9. xmm4chebyshevSum

自动循环展开形成流水

  1. r14d存储k的值,所以edi存储j*k
  2. Block22后的指令验证了rdx原本存储的是[j*k]的地址
  3. 最外层循环
  4. 因为r14d存储k的值,r8r11d存储了i*k的值

从汇编看不出有该操作,需要开启编译选项

自动向量化

从汇编看不出有该操作,需要开启编译选项

自动数据预取

从汇编看不出有该操作,需要开启编译选项

问题

为什么求和耗时这么多

添加向量化选项

gcc

Baseline

-mavx2 -march=core-avx2

  1. 阅读文档, 虽然全部变成了vmov,vadd的操作,但是实际还是64位的工作。
  2. 这点add rax, 0x8没有变成add rax, 0x16可以体现
  3. 但是avx2不是256位的向量化吗?用的还是xmm0这类的寄存器。
VADDSD (VEX.128 encoded version)
DEST[63:0] := SRC1[63:0] + SRC2[63:0]
DEST[127:64] := SRC1[127:64]
DEST[MAXVL-1:128] := 0

ADDSD (128-bit Legacy SSE version)
DEST[63:0] := DEST[63:0] + SRC[63:0]
DEST[MAXVL-1:64] (Unmodified)

-march=skylake-avx512

汇编代码表面没变,但是快了10s(49s - 39s)

下图是avx2的 下图是avx512的

猜测注意原因是

  1. nop指令导致代码没对齐
  2. 不太可能和红框里的代码顺序有关

添加数据预取选项

判断机器是否支持

lscpu|grep pref
3dnowprefetch //3DNow prefetch instructions

应该是支持的

汇编分析

虽然时间基本没变,主要是对主体循环没有进行预取操作,对其余循环(热点占比少的)有重新调整。如下图增加了预取指令

添加循环展开选项

变慢很多(39s -> 55s)

-funroll-loops

汇编实现,在最内层循环根据k的值直接跳转到对应的展开块,这里k是2。 默认是展开了8层,这应该和xmm寄存器总数有关

分析原因

  1. 循环展开的核心是形成计算和访存的流水
  2. 不是简单的少几个跳转指令
  3. 这种简单堆叠循环核心的循环展开,并不能形成流水。所以时间不会减少
  4. 但是完全无法解释循环控制的时间增加
  5. 比如图中cmp的次数应该减半了,时间反而翻倍了

手动分块

由于数据L1能全部存储下,没有提升

手动数据预取

并没有形成想象中预取的流水。每512位取,还有重复。

每次预取一个Cache Line,后面两条指令预取的数据还有重复部分(导致时间增加 39s->61s)

想预取全部,循环每次预取了512位=64字节

手动向量化

avx2

(能便于编译器自动展开来使用所有的向量寄存器,avx2

39s -> 10s -> 8.4s 编译器

for(i=0; i<n-blockSize; i+=blockSize){
   for(j=i+blockSize; j<n-blockSize; j+=blockSize){
      for(ii=i; ii<i+blockSize; ii++){
            __m256d vi1 = _mm256_broadcast_sd(&rebuiltCoord[0*n+ii]);
            __m256d vi2 = _mm256_broadcast_sd(&rebuiltCoord[1*n+ii]);

            __m256d vj11 = _mm256_loadu_pd(&rebuiltCoord[0*n+j]); //读取4个点
            __m256d vj12 = _mm256_loadu_pd(&rebuiltCoord[1*n+j]);

            __m256d vj21 = _mm256_loadu_pd(&rebuiltCoord[0*n+j+4]); //读取4个点
            __m256d vj22 = _mm256_loadu_pd(&rebuiltCoord[1*n+j+4]);

            vj11 = _mm256_and_pd(_mm256_sub_pd(vi1,vj11), vDP_SIGN_Mask);
            vj12 = _mm256_and_pd(_mm256_sub_pd(vi2,vj12), vDP_SIGN_Mask);

            vj21 = _mm256_and_pd(_mm256_sub_pd(vi1,vj21), vDP_SIGN_Mask);
            vj22 = _mm256_and_pd(_mm256_sub_pd(vi2,vj22), vDP_SIGN_Mask);

            __m256d tmp = _mm256_add_pd(_mm256_max_pd(vj11,vj12), _mm256_max_pd(vj21,vj22));
            _mm256_storeu_pd(vchebyshev1, tmp);

            chebyshevSum += vchebyshev1[0] + vchebyshev1[1] + vchebyshev1[2] + vchebyshev1[3];

            // for(jj=j; jj<j+blockSize; jj++){
            //     double chebyshev = 0;
            //     int ki;
            //     for(ki=0; ki<k; ki++){
            //         double dis = fabs(rebuiltCoord[ki*n + ii] - rebuiltCoord[ki*n + jj]);
            //         chebyshev = dis>chebyshev ? dis : chebyshev;
            //     }
            //     chebyshevSum += chebyshev;
            // }
      }
   }
}

明明展开了一次,但是编译器继续展开了,总共8次。用满了YMM 16个向量寄存器。

下图是avx512,都出现寄存器ymm26了。

vhaddpd是水平的向量内加法指令

avx512

当在avx512的情况下展开4次,形成了相当工整的代码。

  1. 向量用到了寄存器ymm18,估计只能展开到6次了。
  2. avx2 应该寄存器不够

最后求和的处理,编译器首先识别出了,不需要实际store。还是在寄存器层面完成了计算。并且通过三次add和两次数据 移动指令自动实现了二叉树型求和。

avx2 寄存器不够会出现下面的情况。

avx求和的更快速归约

假如硬件存在四个一起归约的就好了,但是对于底层元件可能过于复杂了。

__m256d _mm256_hadd_pd (__m256d a, __m256d b);
VEXTRACTF128 __m128d _mm256_extractf128_pd (__m256d a, int offset);

如果可以实现会节约一次数据移动和一次数据add。没有分析两种情况的寄存器依赖。可能依赖长度是一样的,导致优化后时间反而增加一点。

对于int还有这种实现

将横向归约全部提取到外面

并且将j的循环展开变成i的循环展开

手动向量化+手动循环展开?

支持的理由:打破了循环间的壁垒,编译器会识别出无效中间变量,在for的jump指令划出的基本块内指令会乱序执行,并通过寄存器重命名来形成最密集的计算访存流水。

不支持的理由:如果编译器为了形成某一指令的流水,占用了太多资源。导致需要缓存其他结果(比如,向量寄存器不够,反而需要额外的指令来写回,和产生延迟。

理想的平衡: 在不会达到资源瓶颈的情况下展开。

支持的分析例子

手动展开后,识别出来了连续的访存应该在一起进行,并自动调度。将+1的偏移编译器提前计算了。

如果写成macro define,可以发现编译器自动重排了汇编。

不支持的分析例子

avx2可以看出有写回的操作,把值从内存读出来压入栈中。

寄存器足够时没有这种问题

寻找理想的展开次数

由于不同代码对向量寄存器的使用次数不同,不同机器的向量寄存器个数和其他资源数不同。汇编也难以分析。在写好单次循环之后,最佳的展开次数需要手动测量。如下图,6次应该是在不会达到资源瓶颈的情况下展开来获得最大流水。

for(j=beginJ; j<n-jBlockSize; j+=jBlockSize){  /
//展开jBlockSize次
}
for(jj=j; jj<n; jj++){  //j初始值继承自上面的循环
//正常单次
}

由于基本块内乱序执行,代码的顺序也不重要。 加上寄存器重命名来形成流水的存在,寄存器名也不重要。当然数据依赖还是要正确。

对于两层循环的双层手动展开

思路: 外层多load数据到寄存器,但是运行的任何时候也不要超过寄存器数量的上限(特别注意在内层循环运行一遍到末尾时)。 左图外层load了8个寄存器,但是右边只有2个。

特别注意在内层循环运行一遍到末尾时: 如图,黄框就有16个了。

注意load的速度也有区别

所以内层调用次数多,尽量用快的

_mm256_loadu_ps >> _mm256_broadcast_ss > _mm256_set_epi16
0.04 >> 0.5
vsub  vmax    ps 0.02      Latency 4
vand                       Latency 1

vadd              ps 0.80              Throughput 0.5
vhadd                      Latency 7
vcvtps2pd            2.00  Latency 7
vextractf128         0.50  Latency 3

|指令|精度|时间(吞吐延迟和实际依赖导致)|Latency|Throughput |-|-|-|-|-|-| |_mm256_loadu_ps /_mm256_broadcast_ss|||7|0.5 |vsub vmax | ps| 0.02 | 4|0.5 vand ||0.02| 1|0.33 vadd |ps |0.80 |4| 0.5 vhadd ||0.8| 7|2 vcvtps2pd || 2.00 | 7|1 vextractf128 || 0.50 | 3|1

向量化double变单精度没有提升

17条avx计算 5load 2cvt 2extract

单位时间 | avx计算|load|cvt |extract |-|-|-|-|-| ||2.33|3.68|12.875|4.1|

可见类型转换相当耗费时间,最好在循环外,精度不够,每几次循环做一次转换。

GCC编译器优化

-march=skylake-avx512是一条指令

-mavx2 是两条指令

vmovupd xmm7, xmmword ptr [rdx+rsi*8]
vinsertf128 ymm1, ymm7, xmmword ptr [rdx+rsi*8+0x10], 0x1

原因是不对齐的访存在老架构上可能更快

O3对于核心已经向量化的代码还有加速吗?

将IPCC初赛的代码去掉O3发现还是慢了10倍。

为什么连汇编函数调用也慢这么多呢?

这个不开O3的编译器所属有点弱智了,一条指令的两个操作数竟然在rbp的栈里存来存去的。

需要进一步的研究学习

暂无

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

Processing In/near Memory

缘由

  1. 指令为中心,数据移动带来的功耗墙,性能墙
  2. 内存计算的经典模式
  3. 3D的内存技术
    1. Through silicon vias
  4. ReRAM 新型结构

PIM分类

  1. 按照PIM core和memory的距离分类

  2. 新的内存工艺使得内存的最小电路单元具有计算能力(忆阻器)

  3. 基于现有的商业DRAM和处理器的设计(加速的上限低一些,但是落地推广应用的阻力也越小, 应用范围更广,编程困难低)
  4. 基于3D堆叠memory(HMC)的设计(Starting from HMC 2.0, it supports the execution of 18 atomic operations in its logic layer.)
  5. 在每个最小存储单元融入计算能力(可以结合忆阻器)
  6. 完整的处理器核,有cache hierarchy
  7. 简单一点的应用相关的硬件计算单元
  8. 或者更简单的Functional Units (FUs)

关键技术

  1. 传统器件
  2. 地址翻译
    1. 三种不同解决思路
      1. 全部由CPU负责指令的发射和翻译
      2. 使能PIM侧页表管理,翻译机制
      3. 物理地址空间隔离(交互时需要拷贝),PIM独立管理地址空间
  3. 数据映射
    1. 物理内存地址排列的冲突(比如 GPUbank)
      1. CPU高带宽访存(会把数据分散来实现高带宽) vs PIM空间局部性(连续数据会跨多个颗粒)
    2. 纯软件方案或者软硬件结合大方案
  4. 安全性
    1. 物理内存被暴露在PIM core下,需要新的机制来确保内存安全。
  5. 数据一致性
    1. 现有一致性协议拓展差
    2. 核数量超级多,成千上万
    3. 解决方法
      1. 内存空间隔离,避免共享
      2. 弱化一致性问题,只处理特殊条件下一致性(eg.任务迁移)
      3. 批量处理一致性请求
  6. 新型器件
  7. 计算误差
  8. 外围电路大
  9. 异构编程模型
  10. 应用场景和编程模型
  11. 高能效比
  12. 高并行和NUMA访问
  13. 识别PIM函数的条件(什么函数适合用PIM做)
    1. 在所有函数中能耗最高
    2. 数据移动占据应用大比例,或者说是唯一的
    3. 访存密集型(通过LLC miss rate来判断)

根据PIM距离Memory的距离分成三类 1. NDP GPU 2. ? 3. ?

论文1

https://arxiv.org/pdf/2110.01709.pdf

论文2

hardware architecture and software stack for pim based on commercial dram technology

论文3

pim-enabled instructions a low-overhead locality-aware processing-in-memory architecture

论文4

展望

问题

  1. 由于核很小,不支持OS
  2. 但是可以支持message pass(reduce等)
  3. HPC应用经过数学变化后有些变成稀疏计算的,这时候变成memory-bound。所以PIM减少了数据移动,这时提升比较大。
  4. PIM的优势在于能效比,功耗的降低。而不是绝对性能。
  5. 单chip多核怎么通过PIM的思想,软件调度来实现?(不就是减少数据移动,和更近)

需要进一步的研究学习

暂无

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

Microarchitecture: Micro-Fusion & Macro-Fusion

Micro-Fusion

历史原因

  1. 有很多对内存进行操作的指令都会被分成两个或以上的μops,如 add eax, [mem] 在解码时就会分成 mov tmp, [mem]; add eax, tmp。这类型的指令在前端只需要fetch与decode一条指令,相比原来的两条指令占用更少资源(带宽、解码资源、功耗),不过由于在解码后分成多个μops,占用资源(μop entries)增多,但是throughput相对较小,使得RAT以及RRF阶段显得更为拥堵
  2. 随着技术的发展,CPU内部指令处理单元(execution unit)以及端口(port)增多。相对,流水线中的瓶颈会出现在register renaming(RAT)以及retirement(RRF)

为了突破RAT以及RRF阶段的瓶颈,Intel从Pentium M处理器开始引入了micro-fusion技术。

解决办法

在RAT以及RRF阶段,把同一条指令的几个μops混合成一个复杂的μop,使得其只占用一项(比如在ROB里,但是Unlaminated μops会占用2 slots);

而在EU阶段,该复杂μop会被多次发送到EU中进行处理,表现得像是有多个已被分解的μops一样。(每个uops还是要各自运行)

可以micro-fused的指令

其中一条uops是load或者store

  1. 所有的store指令,写回内存的store指令分为两个步骤:store-address、store-data。
  2. 所有读内存与运算的混合指令(load+op),如:
    • ADDPS XMM9, OWORD PTR [RSP+40]
    • FADD DOUBLE PTR [RDI+RSI*8]
    • XOR RAX, QWORD PTR [RBP+32]
  3. 所有读内存与跳转的混合指令(load+jmp),如:
    • JMP [RDI+200]
    • RET
  4. CMP与TEST对比内存操作数并与立即数的指令(cmp mem-imm)。

例外的指令

不能采用RIP寄存器进行内存寻址:

CMP [RIP+400], 27
MOV [RIP+3000], 142
JMP [RIP+5000000]
采用了RIP寄存器进行内存寻址的指令是不能被micro-fused的,并且这些指令只能由decoder0进行解码。

Macro-Fusion

历史原因

为了占用更少的资源,Intel在酷睿处理器引入macro-fusion(Macro-Op Fusion, MOP Fusion or Macrofusion)

解决办法

在IQ时读取指令流,把两条指令组合成一个复杂的μop,并且在之后decode等流水线各个阶段都是认为是一项uops。

macro-fused后的指令可以被任意decoder进行解码

可以macro-fused的指令

其他架构ARM,RISC-V见wikiChip

Intel的要求如下: 1. 两条指令要相互紧邻 2. 如果第一条指令在缓存行的第63个字节处结束,而第二条指令在下一行的第0个字节处开始,则无法进行fusion。 3. 两条指令要满足下表,更新的架构可能会拓展 4.

需要进一步的研究学习

暂无

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

https://www.cnblogs.com/TaigaCon/p/7702920.html

https://blog.csdn.net/hit_shaoqi/article/details/106630483

Microarchitecture: Out-Of-Order execution(OoOE/OOE) & Register Renaming

乱序执行的步骤

简单来说每个阶段执行的操作如下:1

1)获取指令,解码后存放到执行缓冲区Reservations Stations 2)乱序执行指令,结果保存在一个结果序列中 3)退休期Retired Circle,重新排列结果序列及安全检查(如地址访问的权限检查),提交结果到寄存器

  1. 取指令/uops
  2. 指令(uops)dispatch 到instruction queue (/instruction buffer / reservation stations).
  3. 指令等待操作数指令可用,然后可以在前后指令前离开等待队列
  4. issue到对应port单元执行,并且在 scheduler(reservation station)里跟踪uops依赖。
  5. 结果缓存在(re-order buffer, ROB)
  6. 在Tomasulo算法中,重排序缓冲区(英语:re-order buffer, ROB))可以使指令在乱序执行,之后按照原有顺序提交。
  7. 按照程序序结束(只有前面的指令都完成写回寄存器的操作),该指令才能retire
  8. 在retire的时候,重新排序运算结果来实现指令的顺序执行中的运行结果

why out-of-order execution retire/commit in program order

  1. 对于程序员外部视角来看,程序还是按序执行的。
  2. 如果指令出错,可以精确定位exceptions 位置,并且执行回滚来复原。
  3. ???寄存器数据依赖(重命名打破?)

乱序执行的实现

scoreboard

只有当一条指令与之前已发射(issue)的指令之间的冲突消失之后,这条指令才会被发射、执行。

如果某条指令由于数据冲突而停顿,计分板会监视正在执行的指令流,在所有数据相关性造成的冲突化解之后通知停顿的指令开始执行。

Tomasulo 托马苏洛算法

通过寄存器重命名机制,来解决后两种数据依赖。

使用了共享数据总线(common data bus, CDB)将已计算出的值广播给所有需要这个值作为指令源操作数的保留站

在指令的发射(issue)阶段,如果操作数和保留站都准备就绪,那么指令就可以直接发射并执行。

如果操作数未就绪,则进入保留站的指令会跟踪即将产生这个所需操作数的那个功能单元。

乱序执行的发展

随着流水线pipeline的加深和主存(或者缓存)和处理器间的速度差的变大。在顺序执行处理器等待数据的过程中,乱序执行处理器能够执行大量的指令。使得乱序执行更加重要。

Register Renaming

来由

已知可以通过乱序执行来实现,硬件资源的高效利用(避免计算指令等待访存指令的完成)。为了实现乱序执行,需要通过寄存器重命名来打破寄存器的之间的读写依赖。

例子1

对于原始代码

1. R1=M[1024]
2. R1=R1+2
3. M[1032]=R1
4. R1=M[2048]
5. R1=R1+4
6. M[2056]=R1

原本代码前后3条是没有关系的,可以并行的。需要使用寄存器重命名来解决R1的读后写依赖。

1. R1=M[1024] 4. R2=M[2048]
2. R1=R1+2     5. R2=R2+4
3. M[1032]=R1 6. M[2056]=R2

数据冲突

如果多条指令使用了同一个存储位置,这些指令如果不按程序地址顺序执行可能会导致3种数据冲突(data hazard):

  • 先写后Read-after-write,RAW):从寄存器或者内存中读取的数据,必然是之前的指令存入此处的。直接数据相关(true data dependency)

  • 先写后Write-after-write,WAW):连续写入特定的寄存器或内存,那么该存储位置最终只包含第二次写的数据。这可以取消或者废除第一次写入操作。WAW相关也被说成是“输出相关”(output dependencies)。

  • 先读后Write-after-read,WAR):读操作获得的数据是此前写入的,而不是此后写操作的结果。因此并行和乱序时无法改善的资源冲突(antidependency)。

后面两个WAW和WAR可以通过寄存器重命名解决(register renaming),不必等待前面的读写操作完成后再执行写操作,可以保持这个存储位置的两份副本:老值与新值。

前一条指令的读老值的操作可以继续进行,无需考虑那些后一条指令的写新值甚至该写新值指令之后的读新值的操作。产生了额外的乱序执行机会。当所有读老值操作被满足后,老值所使用的寄存器既可以释放。这是寄存器重命名的实质

重命名存储对象

任何被读或写的存储都是可以被重名。

  1. 最常考虑的是通用整数寄存器与浮点寄存器。
  2. 标志寄存器、状态寄存器甚至单个状态位也是常见的重命名的对象。
  3. 内存位置也可以被重命名,虽然这么做不太常见。

通用(逻辑)寄存器和物理寄存器

对于某种ISA,有固定的供编译器/汇编器访问使用的寄存器。例如,Alpha ISA使用32个64位宽整数寄存器,32个64位宽浮点寄存器。

但是一款特定的处理器,实现了这种处理器体系结构。例如Alpha 21264有80个整数寄存器、72个浮点寄存器,作为处理器内物理实现的寄存器。

寄存器个数设计考虑

如果寄存器个数很多,就不需要寄存器重命名机制。比如IA-64指令集体系结构提供了128个通用寄存器。但是这会导致一些问题:

  1. 编译器如果需要重用寄存器会很容易导致程序尺寸大增
  2. 程序的循环连续迭代执行就需要复制循环体的代码以使用不同的寄存器,这种技术叫做循环展开。
  3. 代码尺寸增加,会导致指令高速缓存的未命中(cache miss)增加,处理器执行停顿等待从低级存储中读入代码。这对运算性能的影响是致命的。
  4. 大量的寄存器,需要在指令的操作数中需要很多位表示,导致程序尺寸变大。
  5. 很多指令集在历史上就使用了很少的寄存器,出于兼容原因现在也很难改变。

实现方法简述

  1. tag索引的寄存器堆(tag-indexed register file)
  2. 保留站(reservation station)方法
  3. 通常是每个执行单元的输入口都有一个物理寄存器堆

相关寄存器部件

  1. 远期寄存器堆(Future File):
  2. 处理器对分支做投机执行的寄存器的状态保存于此。
  3. 使用逻辑寄存器号来索引访问。
  4. 历史缓冲区(History Buffer):
  5. 用于保存分支时的逻辑寄存器状态。
  6. 如果分支预测失败,将使用历史缓冲区的数据来恢复执行状态。
  7. 排缓冲区(Reorder Buffer,ROB):
  8. 为了实现指令的顺序提交,处理器内部使用了一个Buffer。如果在该缓冲区中排在一条指令之前的所有都已经提交,没有处于未提交状态的(称作in flight),则该指令也被提交(即确认执行完毕)。
  9. 因此重排缓冲区是在远期寄存器堆之后,体系结构寄存器堆之前。提交的指令的结果写入体系寄存器堆。
  10. 体系结构寄存器堆(Architectural Register File)或者引退寄存器堆(Retirement Register File,RRF):
  11. 存储了被提交的体系寄存器的状态。通过逻辑寄存器的号来查询这个寄存器堆。
  12. 重排序缓冲区(reorder buffer)中的引退(retired)或者说提交(committed)指令,把结果写入这个寄存器堆。

所属部件

  1. 编译器
  2. 会尽力检测出类似这样的问题,并把不同的寄存器分配给不同的指令使用。但是,受指令集体系结构的限制,汇编程序可以使用的寄存器名字的数量是有限的。
  3. 硬件实现
  4. 在处理器指令流水线执行时把这些指令集体系结构寄存器映射为不同的物理寄存器。
  5. 比如下图的Renamer / Allocator(也称为Resource Allocation Table (RAT))将架构寄存器映射到物理寄存器。 它还为loads and stores分配资源,并将uops分到不同端口。

需要进一步的研究学习

暂无

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

https://zh.wikipedia.org/zh-cn/%E4%B9%B1%E5%BA%8F%E6%89%A7%E8%A1%8C

https://easyperf.net/blog/2018/04/22/What-optimizations-you-can-expect-from-CPU