模板

面对创建一个 eBPF 项目,您是否对如何开始搭建环境以及选择编程语言感到困惑?别担心,我们为您准备了一系列 GitHub 模板,以便您快速启动一个全新的eBPF项目。只需在GitHub上点击 Use this template 按钮,即可开始使用。

搭建BPF程序运行环境

下载内核源码

下载的内核版本应与你系统的版本一致,查看当前内核版本 uname -r

然后在源码镜像站点(http://ftp.sjtu.edu.cn/sites/ftp.kernel.org/pub/linux/kernel)下载对应版本的内核源码

也可以通过Ubuntu apt仓库下载。Ubuntu官方自己维护了每个操作系统版本的背后的Linux内核代码,可以通过以下两种apt命令方式获取相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 第一种方式
# 先搜索
> apt-cache search linux-source
linux-source - Linux kernel source with Ubuntu patches
linux-source-4.15.0 - Linux kernel source for version 4.15.0 with Ubuntu patches
linux-source-4.18.0 - Linux kernel source for version 4.18.0 with Ubuntu patches
linux-source-5.0.0 - Linux kernel source for version 5.0.0 with Ubuntu patches
linux-source-5.3.0 - Linux kernel source for version 5.3.0 with Ubuntu patches
# 再安装
> apt install linux-source-4.15.0

# 第二种方式
> apt-get source linux
Reading package lists... Done
NOTICE: 'linux' packaging is maintained in the 'Git' version control system at:
git://git.launchpad.net/~ubuntu-kernel/ubuntu/+source/linux/+git/bionic
Please use:
git clone git://git.launchpad.net/~ubuntu-kernel/ubuntu/+source/linux/+git/bionic
to retrieve the latest (possibly unreleased) updates to the package.
Need to get 167 MB of source archives.
Get:2 https://mirrors.ustc.edu.cn/ubuntu bionic-updates/main linux 4.15.0-99.100 (tar) [158 MB]
...

# 以上两种方式,内核源代码均下载至/usr/src/目录下

安装依赖项

1
apt install libncurses5-dev flex bison libelf-dev binutils-dev libssl-dev

安装Clang和LLVM

然后使用以下两条命令分别安装 clang 和 llvm

1
2
apt install clang`
`apt install llvm

配置内核

在源码根目录下使用make defconfig生成.config<c/ode>文件

解决modpost: not found错误

因为直接make M=samples/bpf时,会报错缺少modules的错误。修复modpost的错误,以下两种解决方案二选一

1
2
make modules_prepare
make script

关联内核头文件

1
make headers_install

编译内核程序样例

在源码根目录下执行make M=samples/bpf,

此时进入linux-source-4.15.0/smaples/bpf中,会看到生成了BPF字节码文件*_kern.o和用户态的可执行文件

img

你可以运行几个试试,例如sockex1

img

使用BPF C编写hello world程序

先了解一下原理吧

img

BPF程序经过Clang/LLVM编译成BPF字节码,然后通过BPF系统调用的方式加载进内核,然后交给BPF虚拟机来执行,也是JIT的方式动态转成机器码

内核有很多hook点,我们在写BPF程序时也会做事件源配置。当hook点上的事件发生时,就会执行我们的BPF程序。

我们还可以在BPF程序中创建一个Map,把我们想拿到的数据保存在Map中,然后用户态程序就可以拿到。

总之,就是我们可以通过BPF程序拿到内核的一些数据

hello world程序

img

进入samples/bpf目录,可以利用自带的Makefile编译,

编写hello_kern.c:

1
2
3
4
5
6
7
8
9
10
11
12
#include <linux/bpf.h>
#include "bpf_helpers.h"
#define SEC(NAME) __attribute__((section(NAME), used))

SEC("tracepoint/syscalls/sys_enter_execve")
int bpf_prog(void *ctx){
char msg[] = "Hello World\n";
bpf_trace_printk(msg, sizeof(msg));
return 0;
}

char _license[] SEC("license") = "GPL";

这个程序的作用就是当发生系统调用(sys_enter_execve)时在终端输出”Hello World”,其实bpf_trace_printk只是将msg写到一个管道文件中

编写hello_user.c:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include "bpf_load.h"

int main(int argc, char **argv){
if(load_bpf_file("hello_kern.o")!=0){
printf("The kernel didn't load BPF program\n");
return -1;
}

read_trace_pipe();
return 0;
}

这个程序的作用是将包含BPF的文件hello_kern.o通过系统调用的方式加载进内核,read_trace_pipe()读取管道文件并打印到终端

修改Makefile

模仿原有的,有四处需要修改:

1
2
3
4
5
6
7
8
9
10
# List of programs to build
hostprogs-y += hello

# Libbpf dependencies
hello-objs := bpf_load.o $(LIBBPF) hello_user.o

# Tell kbuild to always build the programs
always += hello_kern.o

HOSTLOADLIBES_hello += -lelf

编译

可以返回源码根目录用 make M=samples/bpfmake samples/bpf/ 编译

或者直接在当前目录(samples/bpf) 执行make 编译

可以查看编译后的结果,生成了hello可执行文件

img

运行

img

进一步

进一步学习BPF程序是如何转换成字节码的

BPF程序中的节(section)

img

img

SEC宏会将宏里面的内容(kprobe/sys_write)作为节的名字放到elf文件中,也就是目标文件,可以用readelf工具查看

还用宏生成了一个名字为license的section

BPF程序中的字节码(bytecode)

可以用objdump工具查看

img

可见是将我们的bpf程序编译到elf文件的某个节中,右边黄框内就是常说的bpf字节码,对应左边灰色内容

接下来讲一下,bpf程序是如何转成字节码的

BPF内核辅助函数调用转换为BPF字节码的过程

img

我们用到的BPF内核辅助函数是bpf_trace_printk

bpg_prog是我们的elf函数名字,分析下call 6是怎么得到的?

img

BPF_FUNC_map_lookup_elem(BPF_FUNC_trace_printk类似)是在bpf.h中定义的,只不过是宏的形式,我们将其展开:

img

可见BPF_FUNC_trace_printk的相对位置是6,

一般BPF内核辅助函数转汇编是这样的:

img

就是BPF_call id,id就是bpf_func_id中的id;

进一步就是BPF_EMIT_CALL(func name)

例如,在内核中的某一处代码,调用bpf_map_lookup_elem,在BPF指令集编程中,就是使用BPF_EMIT_CALL来调用的

img

不难想象,我们调用bpf_trace_printk也是采用同样的调用方式

BPF_EMIT_CALL(func name)是如何转化成字节码的呢?

img

_bpf_call_base啥也没做,直接返回0,可见只是需要其地址,而差值就是在enum中的位置

进一步分析

img img

所以,call 6对应的字节码就是85 00 00 00 06 00 00 00

我们还可以进一步查看JIT前后字节码的变化:

img

首先执行objdump -s hello_kern.o 得到JIT之前的字节码:

img

在一直运行hello

进入linux-source-4.15.0/tools/bpf/bpftool目录,make,生成bpftool工具,

通过 ./bpftool prog show 显示加载了哪些BPF程序:

img

可见我们的hello程序对应的id为86,钩子类型为tracepoint

再使用./bpftool prog dump xlated id 86 opcodes 即可查看JIT之后的字节码:

img

对比起来看:

img

其他的没变,可以看到这个变化,这是因为JIT前call使用的id,JIT后成了调用函数到这个指令的距离

BPF程序到BPF字节码的编译过程:Clang与LLVM

img

img

LLVM支持很多后端,通过命令llc -version

img

bpf target有三种,不指定就根据系统的大小端法

有两种方式编译BPF程序:

img

gcc缺少BPF backend,幸运的是clang支持BPF. 之前的Makefile就是使用clang将hello_kern.c编译成hello_kern.o

右边的图表示一步到位和分布编译的结果是一样的,而且是之前用Makefile编译的也一样

img

分步编译是生成中间IR文件,默认是.ll格式

教程

Hello World,基本框架和开发流程

eBPF开发环境准备与基本开发流程

在开始编写eBPF程序之前,我们需要准备一个合适的开发环境,并了解eBPF程序的基本开发流程。本部分将详细介绍这些内容。

安装必要的软件和工具

要开发eBPF程序,您需要安装以下软件和工具:

  • Linux 内核:由于eBPF是内核技术,因此您需要具备较新版本的Linux内核(推荐4.8及以上版本),以支持eBPF功能。
  • LLVM 和 Clang:这些工具用于编译eBPF程序。安装最新版本的LLVM和Clang可以确保您获得最佳的eBPF支持。

eBPF 程序主要由两部分构成:内核态部分和用户态部分。内核态部分包含 eBPF 程序的实际逻辑,用户态部分负责加载、运行和监控内核态程序。当您选择了合适的开发框架后,如 BCC(BPF Compiler Collection)、libbpf、cilium/ebpf或eunomia-bpf等,您可以开始进行用户态和内核态程序的开发。以 BCC 工具为例,我们将介绍 eBPF 程序的基本开发流程:

当您选择了合适的开发框架后,如BCC(BPF Compiler Collection)、libbpf、cilium/ebpf或eunomia-bpf等,您可以开始进行用户态和内核态程序的开发。以BCC工具为例,我们将介绍eBPF程序的基本开发流程:

  • 安装BCC工具:根据您的Linux发行版,按照BCC官方文档的指南安装BCC工具和相关依赖。
    编写eBPF程序(C语言):使用C语言编写一个简单的eBPF程序,例如Hello World程序。该程序可以在内核空间执行并完成特定任务,如统计网络数据包数量。
  • 编写用户态程序(Python或C等):使用Python、C等语言编写用户态程序,用于加载、运行eBPF程序以及与之交互。在这个程序中,您需要使用BCC提供的API来加载和操作内核态的eBPF程序。
  • 编译eBPF程序:使用BCC工具,将C语言编写的eBPF程序编译成内核可以执行的字节码。BCC会在运行时动态从源码编译eBPF程序。
  • 加载并运行eBPF程序:在用户态程序中,使用BCC提供的API加载编译好的eBPF程序到内核空间,然后运行该程序。
  • 与eBPF程序交互:用户态程序通过BCC提供的API与eBPF程序交互,实现数据收集、分析和展示等功能。例如,您可以使用BCC API读取eBPF程序中的map数据,以获取网络数据包统计信息。
  • 卸载eBPF程序:当不再需要eBPF程序时,用户态程序应使用BCC API将其从内核空间卸载。
  • 调试与优化:使用 bpftool 等工具进行eBPF程序的调试和优化,提高程序性能和稳定性。

通过以上流程,您可以使用BCC工具开发、编译、运行和调试eBPF程序。请注意,其他框架(如libbpf、cilium/ebpf和eunomia-bpf)的开发流程大致相似但略有不同,因此在选择框架时,请参考相应的官方文档和示例。

通过这个过程,你可以开发出一个能够在内核中运行的 eBPF 程序。eunomia-bpf 是一个开源的 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。它基于 libbpf 的 CO-RE 轻量级开发框架,支持通过用户态 WASM 虚拟机控制 eBPF 程序的加载和执行,并将预编译的 eBPF 程序打包为通用的 JSON 或 WASM 模块进行分发。我们会使用 eunomia-bpf 进行演示。

下载安装 eunomia-bpf 开发工具

可以通过以下步骤下载和安装 eunomia-bpf:

下载 ecli 工具,用于运行 eBPF 程序:

1
2
3
$ wget https://aka.pw/bpf-ecli -O ecli && chmod +x ./ecli
$ ./ecli -h
Usage: ecli [--help] [--version] [--json] [--no-cache] url-and-args

下载编译器工具链,用于将 eBPF 内核代码编译为 config 文件或 WASM 模块:

1
2
3
4
5
$ wget https://github.com/eunomia-bpf/eunomia-bpf/releases/latest/download/ecc && chmod +x ./ecc
$ ./ecc -h
eunomia-bpf compiler
Usage: ecc [OPTIONS] <SOURCE_PATH> [EXPORT_EVENT_HEADER]
....

也可以使用 docker 镜像进行编译:

1
2
3
4
$ docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest # 使用 docker 进行编译。`pwd` 应该包含 *.bpf.c 文件和 *.h 文件。
export PATH=PATH:~/.eunomia/bin
Compiling bpf object...
Packing ebpf object and config into /src/package.json...

Hello World - minimal eBPF program

我们会先从一个简单的 eBPF 程序开始,它会在内核中打印一条消息。我们会使用 eunomia-bpf 的编译器工具链将其编译为 bpf 字节码文件,然后使用 ecli 工具加载并运行该程序。作为示例,我们可以暂时省略用户态程序的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
#define BPF_NO_GLOBAL_DATA
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

typedef unsigned int u32;
typedef int pid_t;
const pid_t pid_filter = 0;

char LICENSE[] SEC("license") = "Dual BSD/GPL";

SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{
pid_t pid = bpf_get_current_pid_tgid() >> 32;
if (pid_filter && pid != pid_filter)
return 0;
bpf_printk("BPF triggered from PID %d.\n", pid);
return 0;
}

这段程序通过定义一个 handle_tp 函数并使用 SEC 宏把它附加到sys_enter_write tracepoint(即在进入 write 系统调用时执行)。该函数通过使用bpf_get_current_pid_tgidbpf_printk函数获取调用 write 系统调用的进程 ID,并在内核日志中打印出来。

bpf_trace_printk(): 一种将信息输出到trace_pipe(/sys/kernel/debug/tracing/trace_pipe)简单机制。 在一些简单用例中这样使用没有问题, but它也有一些限制:最多3 参数; 第一个参数必须是%s(即字符串);同时trace_pipe在内核中全局共享,其他并行使用trace_pipe的程序有可能会将 trace_pipe 的输出扰乱。 一个更好的方式是通过BPF_PERF_OUTPUT(), 稍后将会讲到。

void *ctx:ctx本来是具体类型的参数, 但是由于我们这里没有使用这个参数,因此就将其写成void *类型。
return 0;必须这样,返回0。

要编译和运行这段程序,可以使用 ecc 工具和 ecli 命令。首先在 Ubuntu/Debian 上,执行以下命令:

1
sudo apt install clang llvm

ecc 编译程序:

1
2
3
$ ./ecc minimal.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...

或使用 docker 镜像进行编译:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

然后使用 ecli 运行编译后的程序:

1
2
$ sudo ecli run package.json
Runing eBPF program...

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
$ sudo cat /sys/kernel/debug/tracing/trace_pipe | grep "BPF triggered sys_enter_write"
<...>-3840345 [010] d... 3220701.101143: bpf_trace_printk: write system call from PID 3840345.
<...>-3840345 [010] d... 3220701.101143: bpf_trace_printk: write system call from PID 3840345.

按 Ctrl+C 停止 ecli 进程之后,可以看到对应的输出也停止。

eBPF 程序的基本框架

如上所述, eBPF 程序的基本框架包括:

  • 包含头文件:需要包含 <linux/bpf.h> 和 <bpf/bpf_helpers.h> 等头文件。
  • 定义许可证:需要定义许可证,通常使用 “Dual BSD/GPL”。
  • 定义 BPF 函数:需要定义一个 BPF 函数,例如其名称为 handle_tp,其参数为void *ctx,返回值为 int。通常用 C 语言编写。
  • 使用 BPF 助手函数:在例如 BPF 函数中,可以使用 BPF 助手函数bpf_get_current_pid_tgid() bpf_printk()
  • 返回值

tracepoints

跟踪点(tracepoints)是内核静态插桩技术,跟踪点在技术上只是放置在内核源代码中的跟踪函数,实际上就是在源码中插入的一些带有控制条件的探测点,这些探测点允许事后再添加处理函数。比如在内核中,最常见的静态跟踪方法就是 printk,即输出日志。又比如:在系统调用、调度程序事件、文件系统操作和磁盘 I/O 的开始和结束时都有跟踪点。 于 2009 年在 Linux 2.6.32 版本中首次提供。跟踪点是一种稳定的 API,数量有限。

总结

eBPF 程序的开发和使用流程可以概括为如下几个步骤:

  • 定义 eBPF 程序的接口和类型:这包括定义 eBPF 程序的接口函数,定义和实现 eBPF 内核映射(maps)和共享内存(perf events),以及定义和使用 eBPF 内核帮助函数(helpers)。
  • 编写 eBPF 程序的代码:这包括编写 eBPF 程序的主要逻辑,实现 eBPF 内核映射的读写操作,以及使用 eBPF 内核帮助函数。
  • 编译 eBPF 程序:这包括使用 eBPF 编译器(例如 clang)将 eBPF 程序代码编译为 eBPF 字节码,并生成可执行的 eBPF 内核模块。ecc 本质上也是调用 clang 编译器来编译 eBPF 程序。
  • 加载 eBPF 程序到内核:这包括将编译好的 eBPF 内核模块加载到 Linux 内核中,并将 eBPF 程序附加到指定的内核事件上。
  • 使用 eBPF 程序:这包括监测 eBPF 程序的运行情况,并使用 eBPF 内核映射和共享内存进行数据交换和共享。
  • 在实际开发中,还可能需要进行其他的步骤,例如配置编译和加载参数,管理 eBPF 内核模块和内核映射,以及使用其他高级功能等。
  • 需要注意的是,BPF 程序的执行是在内核空间进行的,因此需要使用特殊的工具和技术来编写、编译和调试 BPF 程序。eunomia-bpf 是一个开源的 BPF 编译器和工具包,它可以帮助开发者快速和简单地编写和运行 BPF 程序。

kprobes 技术背景

开发人员在内核或者模块的调试过程中,往往会需要要知道其中的一些函数有无被调用、何时被调用、执行是否正确以及函数的入参和返回值是什么等等。比较简单的做法是在内核代码对应的函数中添加日志打印信息,但这种方式往往需要重新编译内核或模块,重新启动设备之类的,操作较为复杂甚至可能会破坏原有的代码执行过程。

而利用kprobes技术,用户可以定义自己的回调函数,然后在内核或者模块中几乎所有的函数中动态的插入探测点,当内核执行流程执行到指定的探测函数时,会调用该回调函数,用户即可收集所需的信息了,同时内核最后还会回到原本的正常执行流程。如果用户已经收集足够的信息,不再需要继续探测,则同样可以动态地移除探测点。因此kprobes技术具有对内核执行流程影响小和操作方便的优点。

kprobes技术包括的3种探测手段分别时kprobe、jprobe和kretprobe。首先kprobe是最基本的探测方式,是实现后两种的基础,它可以在任意的位置放置探测点(就连函数内部的某条指令处也可以),它提供了探测点的调用前、调用后和内存访问出错3种回调方式,分别是pre_handler、post_handler和fault_handler,其中pre_handler函数将在被探测指令被执行前回调,post_handler会在被探测指令执行完毕后回调(注意不是被探测函数),fault_handler会在内存访问出错时被调用;jprobe基于kprobe实现,它用于获取被探测函数的入参值;最后kretprobe从名字中就可以看出其用途了,它同样基于kprobe实现,用于获取被探测函数的返回值。

kprobes的技术原理并不仅仅包含存软件的实现方案,它也需要硬件架构提供支持。其中涉及硬件架构相关的是CPU的异常处理和单步调试技术,前者用于让程序的执行流程陷入到用户注册的回调函数中去,而后者则用于单步执行被探测点指令,因此并不是所有的架构均支持,目前kprobes技术已经支持多种架构,包括i386、x86_64、ppc64、ia64、sparc64、arm、ppc和mips(有些架构实现可能并不完全,具体可参考内核的Documentation/kprobes.txt)。

kprobes的特点与使用限制:

  1. kprobes允许在同一个被被探测位置注册多个kprobe,但是目前jprobe却不可以;同时也不允许以其他的jprobe回调函数和kprobe的post_handler回调函数作为被探测点。
  2. 一般情况下,可以探测内核中的任何函数,包括中断处理函数。不过在kernel/kprobes.carch/*/kernel/kprobes.c程序中用于实现kprobes自身的函数是不允许被探测的,另外还有do_page_faultnotifier_call_chain
  3. 如果以一个内联函数为探测点,则kprobes可能无法保证对该函数的所有实例都注册探测点。由于gcc可能会自动将某些函数优化为内联函数,因此可能无法达到用户预期的探测效果;
  4. 一个探测点的回调函数可能会修改被探测函数运行的上下文,例如通过修改内核的数据结构或者保存与struct pt_regs结构体中的触发探测器之前寄存器信息。因此kprobes可以被用来安装bug修复代码或者注入故障测试代码;
  5. kprobes会避免在处理探测点函数时再次调用另一个探测点的回调函数,例如在printk()函数上注册了探测点,则在它的回调函数中可能再次调用printk函数,此时将不再触发printk探测点的回调,仅仅时增加了kprobe结构体中nmissed字段的数值;
  6. 在kprobes的注册和注销过程中不会使用mutex锁和动态的申请内存;
  7. kprobes回调函数的运行期间是关闭内核抢占的,同时也可能在关闭中断的情况下执行,具体要视CPU架构而定。因此不论在何种情况下,在回调函数中不要调用会放弃CPU的函数(如信号量、mutex锁等);
  8. kretprobe通过替换返回地址为预定义的trampoline的地址来实现,因此栈回溯和gcc内嵌函数__builtin_return_address()调用将返回trampoline的地址而不是真正的被探测函数的返回地址;
  9. 如果一个函数的调用次数和返回次数不相等,则在类似这样的函数上注册kretprobe将可能不会达到预期的效果,例如do_exit()函数会存在问题,而do_execve()函数和do_fork()函数不会;
  10. 如果当在进入和退出一个函数时,CPU运行在非当前任务所有的栈上,那么往该函数上注册kretprobe可能会导致不可预料的后果,因此,kprobes不支持在X86_64的结构下为__switch_to()函数注册kretprobe,将直接返回-EINVAL。

kprobe 示例

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
pid_t pid;
const char *filename;

pid = bpf_get_current_pid_tgid() >> 32;
filename = BPF_CORE_READ(name, name);
bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
return 0;
}

SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret)
{
pid_t pid;

pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("KPROBE EXIT: pid = %d, ret = %ld\n", pid, ret);
return 0;
}

这段代码是一个简单的 eBPF 程序,用于监测和捕获在 Linux 内核中执行的 unlink 系统调用。unlink 系统调用的功能是删除一个文件,这个 eBPF 程序通过使用 kprobe(内核探针)在do_unlinkat函数的入口和退出处放置钩子,实现对该系统调用的跟踪。

首先,我们导入必要的头文件,如 vmlinux.h,bpf_helpers.h,bpf_tracing.h 和 bpf_core_read.h。接着,我们定义许可证,以允许程序在内核中运行。

1
2
3
4
5
6
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

接下来,我们定义一个名为BPF_KPROBE(do_unlinkat)的 kprobe,当进入 do_unlinkat 函数时,它会被触发。该函数接受两个参数:dfd(文件描述符)和 name(文件名结构体指针)。在这个 kprobe 中,我们获取当前进程的 PID(进程标识符),然后读取文件名。最后,我们使用 bpf_printk 函数在内核日志中打印 PID 和文件名。

1
2
3
4
5
6
7
8
9
10
11
SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
pid_t pid;
const char *filename;

pid = bpf_get_current_pid_tgid() >> 32;
filename = BPF_CORE_READ(name, name);
bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
return 0;
}

接下来,我们定义一个名为BPF_KRETPROBE(do_unlinkat_exit)的 kretprobe,当从 do_unlinkat 函数退出时,它会被触发。这个 kretprobe 的目的是捕获函数的返回值(ret)。我们再次获取当前进程的 PID,并使用 bpf_printk 函数在内核日志中打印 PID 和返回值。

1
2
3
4
5
6
7
8
9
SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret)
{
pid_t pid;

pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("KPROBE EXIT: pid = %d, ret = %ld\n", pid, ret);
return 0;
}

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。

要编译这个程序,请使用 ecc 工具:

1
2
3
$ ecc kprobe-link.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...

然后运行:

1
sudo ecli run package.json

在另外一个窗口中:

1
2
3
4
touch test1
rm test1
touch test2
rm test2

在 /sys/kernel/debug/tracing/trace_pipe 文件中,应该能看到类似下面的 kprobe 演示输出:

1
2
3
4
5
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
rm-9346 [005] d..3 4710.951696: bpf_trace_printk: KPROBE ENTRY pid = 9346, filename = test1
rm-9346 [005] d..4 4710.951819: bpf_trace_printk: KPROBE EXIT: ret = 0
rm-9346 [005] d..3 4710.951852: bpf_trace_printk: KPROBE ENTRY pid = 9346, filename = test2
rm-9346 [005] d..4 4710.951895: bpf_trace_printk: KPROBE EXIT: ret = 0

Fentry

fentry(function entry)和fexit(function exit)是eBPF(扩展的伯克利包过滤器)中的两种探针类型,用于在Linux内核函数的入口和退出处进行跟踪。它们允许开发者在内核函数执行的特定阶段收集信息、修改参数或观察返回值。这种跟踪和监控功能在性能分析、故障排查和安全分析等场景中非常有用。

与 kprobes 相比,fentry 和 fexit 程序有更高的性能和可用性。在这个例子中,我们可以直接访问函数的指针参数,就像在普通的 C 代码中一样,而不需要使用各种读取帮助程序。fexit 和 kretprobe 程序最大的区别在于,fexit 程序可以访问函数的输入参数和返回值,而 kretprobe 只能访问返回值。从 5.5 内核开始,fentry 和 fexit 对 eBPF 程序可用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

SEC("fentry/do_unlinkat")
int BPF_PROG(do_unlinkat, int dfd, struct filename *name)
{
pid_t pid;

pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("fentry: pid = %d, filename = %s\n", pid, name->name);
return 0;
}

SEC("fexit/do_unlinkat")
int BPF_PROG(do_unlinkat_exit, int dfd, struct filename *name, long ret)
{
pid_t pid;

pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("fexit: pid = %d, filename = %s, ret = %ld\n", pid, name->name, ret);
return 0;
}

这段程序是用C语言编写的eBPF(扩展的伯克利包过滤器)程序,它使用BPF的fentry和fexit探针来跟踪Linux内核函数do_unlinkat。在这个教程中,我们将以这段程序作为示例,让您学会如何在eBPF中使用fentry监测捕获unlink系统调用。

程序包含以下部分:

  • 包含头文件:包括vmlinux.h(用于访问内核数据结构)、bpf/bpf_helpers.h(包含eBPF帮助函数)、bpf/bpf_tracing.h(用于eBPF跟踪相关功能)。
  • 定义许可证:这里定义了一个名为LICENSE的字符数组,包含许可证信息”Dual BSD/GPL”。
  • 定义fentry探针:我们定义了一个名为BPF_PROG(do_unlinkat)的fentry探针,该探针在do_unlinkat函数的入口处被触发。这个探针获取当前进程的PID(进程ID)并将其与文件名一起打印到内核日志。
  • 定义fexit探针:我们还定义了一个名为BPF_PROG(do_unlinkat_exit)的fexit探针,该探针在do_unlinkat函数的退出处被触发。与fentry探针类似,这个探针也会获取当前进程的PID并将其与文件名和返回值一起打印到内核日志。

通过这个示例,您可以学习如何在eBPF中使用fentry和fexit探针来监控和捕获内核函数调用,例如在本教程中的unlink系统调用。

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。我们使用 eunomia-bpf 编译运行这个例子。

编译运行上述代码:

1
2
3
4
5
$ ecc fentry-link.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...
$ sudo ecli run package.json
Runing eBPF program...

在另外一个窗口中:

1
2
3
4
touch test_file
rm test_file
touch test_file2
rm test_file2

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
4
5
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
rm-9290 [004] d..2 4637.798698: bpf_trace_printk: fentry: pid = 9290, filename = test_file
rm-9290 [004] d..2 4637.798843: bpf_trace_printk: fexit: pid = 9290, filename = test_file, ret = 0
rm-9290 [004] d..2 4637.798698: bpf_trace_printk: fentry: pid = 9290, filename = test_file2
rm-9290 [004] d..2 4637.798843: bpf_trace_printk: fexit: pid = 9290, filename = test_file2, ret = 0

总结

这段程序是一个 eBPF 程序,通过使用 fentry 和 fexit 捕获 do_unlinkat 和 do_unlinkat_exit 函数,并通过使用 bpf_get_current_pid_tgid 和 bpf_printk 函数获取调用 do_unlinkat 的进程 ID、文件名和返回值,并在内核日志中打印出来。

在 eBPF 中捕获进程打开文件的系统调用集合,使用全局变量过滤进程 pid

eBPF(Extended Berkeley Packet Filter)是一种内核执行环境,它可以让用户在内核中运行一些安全的、高效的程序。它通常用于网络过滤、性能分析、安全监控等场景。eBPF 之所以强大,是因为它能够在内核运行时捕获和修改数据包或者系统调用,从而实现对操作系统行为的监控和调整。

本文是 eBPF 入门开发实践教程的第四篇,主要介绍如何捕获进程打开文件的系统调用集合,并使用全局变量在 eBPF 中过滤进程 pid。

在 Linux 系统中,进程与文件之间的交互是通过系统调用来实现的。系统调用是用户态程序与内核态程序之间的接口,它们允许用户态程序请求内核执行特定操作。在本教程中,我们关注的是 sys_openat 系统调用,它是用于打开文件的。

当进程打开一个文件时,它会向内核发出sys_openat系统调用,并传递相关参数(例如文件路径、打开模式等)。内核会处理这个请求,并返回一个文件描述符(file descriptor),这个描述符将在后续的文件操作中用作引用。通过捕获 sys_openat 系统调用,我们可以了解进程在什么时候以及如何打开文件。

在 eBPF 中捕获进程打开文件的系统调用集合

首先,我们需要编写一段 eBPF 程序来捕获进程打开文件的系统调用,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>

/// @description "Process ID to trace"
const volatile int pid_target = 0;

SEC("tracepoint/syscalls/sys_enter_openat")
int tracepoint__syscalls__sys_enter_openat(struct trace_event_raw_sys_enter* ctx)
{
u64 id = bpf_get_current_pid_tgid();
u32 pid = id;

if (pid_target && pid_target != pid)
return false;
// Use bpf_printk to print the process information
bpf_printk("Process ID: %d enter sys openat\n", pid);
return 0;
}

/// "Trace open family syscalls."
char LICENSE[] SEC("license") = "GPL";

这段 eBPF 程序实现了:

  • 引入头文件:<vmlinux.h> 包含了内核数据结构的定义,<bpf/bpf_helpers.h> 包含了 eBPF 程序所需的辅助函数。
  • 定义全局变量 pid_target,用于过滤指定进程 ID。这里设为 0 表示捕获所有进程的 sys_openat 调用。
  • 使用 SEC 宏定义一个 eBPF 程序,关联到 tracepoint “tracepoint/syscalls/sys_enter_openat”。这个 tracepoint 会在进程发起 sys_openat 系统调用时触发。
  • 实现 eBPF 程序tracepoint__syscalls__sys_enter_openat,它接收一个类型为struct trace_event_raw_sys_enter的参数 ctx。这个结构体包含了关于系统调用的信息。
  • 使用bpf_get_current_pid_tgid()函数获取当前进程的 PID 和 TGID(线程组 ID)。由于我们只关心 PID,所以将其赋值给 u32 类型的变量 pid。
  • 检查pid_target变量是否与当前进程的 pid 相等。如果 pid_target 不为 0 且与当前进程的 pid 不相等,则返回 false,不对该进程的sys_openat调用进行捕获。
  • 使用bpf_printk()函数打印捕获到的进程 ID 和 sys_openat 调用的相关信息。这些信息将在用户空间通过 BPF 工具查看。
  • 将程序许可证设置为 “GPL”,这是运行 eBPF 程序的必要条件。

这个 eBPF 程序可以通过 libbpf 或 eunomia-bpf 等工具加载到内核并执行。它将捕获指定进程(或所有进程)的 sys_openat 系统调用,并在用户空间输出相关信息。

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。我们使用 eunomia-bpf 编译运行这个例子。

编译运行上述代码:

1
2
3
4
5
$ ecc opensnoop.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...
$ sudo ecli run package.json
Runing eBPF program...

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
<...>-3840345 [010] d... 3220701.101179: bpf_trace_printk: Process ID: 3840345 enter sys openat
<...>-3840345 [010] d... 3220702.158000: bpf_trace_printk: Process ID: 3840345 enter sys openat

此时,我们已经能够捕获进程打开文件的系统调用了。

使用全局变量在 eBPF 中过滤进程 pid

全局变量在 eBPF 程序中充当一种数据共享机制,它们允许用户态程序与 eBPF 程序之间进行数据交互。这在过滤特定条件或修改 eBPF 程序行为时非常有用。这种设计使得用户态程序能够在运行时动态地控制 eBPF 程序的行为。

在我们的例子中,全局变量 pid_target 用于过滤进程 PID。用户态程序可以设置此变量的值,以便在 eBPF 程序中只捕获与指定 PID 相关的 sys_openat 系统调用。

使用全局变量的原理是,全局变量在 eBPF 程序的数据段(data section)中定义并存储。当 eBPF 程序加载到内核并执行时,这些全局变量会保持在内核中,可以通过 BPF 系统调用进行访问。用户态程序可以使用 BPF 系统调用中的某些特性,如bpf_obj_get_info_by_fdbpf_obj_get_info,获取 eBPF 对象的信息,包括全局变量的位置和值。

可以通过执行 ecli -h 命令来查看 opensnoop 的帮助信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ecli package.json -h
Usage: opensnoop_bpf [--help] [--version] [--verbose] [--pid_target VAR]

Trace open family syscalls.

Optional arguments:
-h, --help shows help message and exits
-v, --version prints version information and exits
--verbose prints libbpf debug information
--pid_target Process ID to trace

Built with eunomia-bpf framework.
See https://github.com/eunomia-bpf/eunomia-bpf for more information.

可以通过--pid_target参数来指定要捕获的进程的 pid,例如:

1
2
$ sudo ./ecli run package.json  --pid_target 618
Runing eBPF program...

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
<...>-3840345 [010] d... 3220701.101179: bpf_trace_printk: Process ID: 618 enter sys openat
<...>-3840345 [010] d... 3220702.158000: bpf_trace_printk: Process ID: 618 enter sys openat

总结

本文介绍了如何使用 eBPF 程序来捕获进程打开文件的系统调用。在 eBPF 程序中,我们可以通过定义tracepoint__syscalls__sys_enter_opentracepoint__syscalls__sys_enter_openat函数并使用 SEC 宏把它们附加到sys_enter_opensys_enter_openat两个 tracepoint 来捕获进程打开文件的系统调用。我们可以使用bpf_get_current_pid_tgid函数获取调用 open 或 openat 系统调用的进程 ID,并使用 bpf_printk 函数在内核日志中打印出来。在 eBPF 程序中,我们还可以通过定义一个全局变量 pid_target 来指定要捕获的进程的 pid,从而过滤输出,只输出指定的进程的信息。

通过学习本教程,您应该对如何在 eBPF 中捕获和过滤特定进程的系统调用有了更深入的了解。这种方法在系统监控、性能分析和安全审计等场景中具有广泛的应用。

在 eBPF 中使用 uprobe 捕获 bash 的 readline 函数调用

本文是 eBPF 入门开发实践教程的第五篇,主要介绍如何使用 uprobe 捕获 bash 的 readline 函数调用。

什么是uprobe

uprobe是一种用户空间探针,uprobe探针允许在用户空间程序中动态插桩,插桩位置包括:函数入口、特定偏移处,以及函数返回处。当我们定义uprobe时,内核会在附加的指令上创建快速断点指令(x86机器上为int3指令),当程序执行到该指令时,内核将触发事件,程序陷入到内核态,并以回调函数的方式调用探针函数,执行完探针函数再返回到用户态继续执行后序的指令。

uprobe基于文件,当一个二进制文件中的一个函数被跟踪时,所有使用到这个文件的进程都会被插桩,包括那些尚未启动的进程,这样就可以在全系统范围内跟踪系统调用。

uprobe适用于在用户态去解析一些内核态探针无法解析的流量,例如http2流量(报文header被编码,内核无法解码),https流量(加密流量,内核无法解密)。

使用 uprobe 捕获 bash 的 readline 函数调用

uprobe 是一种用于捕获用户空间函数调用的 eBPF 的探针,我们可以通过它来捕获用户空间程序调用的系统函数。

例如,我们可以使用 uprobe 来捕获 bash 的 readline 函数调用,从而获取用户在 bash 中输入的命令行。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

#define TASK_COMM_LEN 16
#define MAX_LINE_SIZE 80

/* Format of u[ret]probe section definition supporting auto-attach:
* u[ret]probe/binary:function[+offset]
*
* binary can be an absolute/relative path or a filename; the latter is resolved to a
* full binary path via bpf_program__attach_uprobe_opts.
*
* Specifying uprobe+ ensures we carry out strict matching; either "uprobe" must be
* specified (and auto-attach is not possible) or the above format is specified for
* auto-attach.
*/
SEC("uretprobe//bin/bash:readline")
int BPF_KRETPROBE(printret, const void *ret)
{
char str[MAX_LINE_SIZE];
char comm[TASK_COMM_LEN];
u32 pid;

if (!ret)
return 0;

bpf_get_current_comm(&comm, sizeof(comm));

pid = bpf_get_current_pid_tgid() >> 32;
bpf_probe_read_user_str(str, sizeof(str), ret);

bpf_printk("PID %d (%s) read: %s ", pid, comm, str);

return 0;
};

char LICENSE[] SEC("license") = "GPL";

这段代码的作用是在 bash 的 readline 函数返回时执行指定的BPF_KRETPROBE函数,即 printret 函数。

在 printret 函数中,我们首先获取了调用 readline 函数的进程的进程名称和进程 ID,然后通过 bpf_probe_read_user_str函数读取了用户输入的命令行字符串,最后通过 bpf_printk 函数打印出进程 ID、进程名称和输入的命令行字符串。

除此之外,我们还需要通过 SEC 宏来定义 uprobe 探针,并使用 BPF_KRETPROBE 宏来定义探针函数。

在 SEC 宏中,我们需要指定 uprobe 的类型、要捕获的二进制文件的路径和要捕获的函数名称。例如,上面的代码中的 SEC 宏的定义如下:

1
SEC("uprobe//bin/bash:readline")

这表示我们要捕获的是 /bin/bash 二进制文件中的 readline 函数。

接下来,我们需要使用 BPF_KRETPROBE 宏来定义探针函数,例如:

1
BPF_KRETPROBE(printret, const void *ret)

这里的 printret 是探针函数的名称,const void *ret是探针函数的参数,它代表被捕获的函数的返回值。

然后,我们使用了bpf_get_current_comm函数获取当前任务的名称,并将其存储在 comm 数组中。

1
bpf_get_current_comm(&comm, sizeof(comm));

使用 bpf_get_current_pid_tgid 函数获取当前进程的 PID,并将其存储在 pid 变量中。

1
pid = bpf_get_current_pid_tgid() >> 32;

使用bpf_probe_read_user_str函数从用户空间读取 readline 函数的返回值,并将其存储在 str 数组中。

1
bpf_probe_read_user_str(str, sizeof(str), ret);

最后使用 bpf_printk 函数输出 PID、任务名称和用户输入的字符串。

1
bpf_printk("PID %d (%s) read: %s ", pid, comm, str);

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。我们使用 eunomia-bpf 编译运行这个例子。

编译运行上述代码:

1
2
3
4
5
$ ecc bashreadline.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...
$ sudo ecli run package.json
Runing eBPF program...

运行这段程序后,可以通过查看 /sys/kernel/debug/tracing/trace_pipe 文件来查看 eBPF 程序的输出:

1
2
3
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
bash-32969 [000] d..31 64001.375748: bpf_trace_printk: PID 32969 (bash) read: fff
bash-32969 [000] d..31 64002.056951: bpf_trace_printk: PID 32969 (bash) read: fff

可以看到,我们成功的捕获了 bash 的 readline 函数调用,并获取了用户在 bash 中输入的命令行。

总结

在上述代码中,我们使用了 SEC 宏来定义了一个 uprobe 探针,它指定了要捕获的用户空间程序 (bin/bash) 和要捕获的函数 (readline)。此外,我们还使用了 BPF_KRETPROBE 宏来定义了一个用于处理 readline 函数返回值的回调函数 (printret)。该函数可以获取到 readline 函数的返回值,并将其打印到内核日志中。通过这样的方式,我们就可以使用 eBPF 来捕获 bash 的 readline 函数调用,并获取用户在 bash 中输入的命令行。

捕获进程发送信号的系统调用集合,使用 hash map 保存状态

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具,它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

本文是 eBPF 入门开发实践教程的第六篇,主要介绍如何实现一个 eBPF 工具,捕获进程发送信号的系统调用集合,使用 hash map 保存状态。

sigsnoop
示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

#define MAX_ENTRIES 10240
#define TASK_COMM_LEN 16

struct event {
unsigned int pid;
unsigned int tpid;
int sig;
int ret;
char comm[TASK_COMM_LEN];
};

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, __u32);
__type(value, struct event);
} values SEC(".maps");


static int probe_entry(pid_t tpid, int sig)
{
struct event event = {};
__u64 pid_tgid;
__u32 tid;

pid_tgid = bpf_get_current_pid_tgid();
tid = (__u32)pid_tgid;
event.pid = pid_tgid >> 32;
event.tpid = tpid;
event.sig = sig;
bpf_get_current_comm(event.comm, sizeof(event.comm));
bpf_map_update_elem(&values, &tid, &event, BPF_ANY);
return 0;
}

static int probe_exit(void *ctx, int ret)
{
__u64 pid_tgid = bpf_get_current_pid_tgid();
__u32 tid = (__u32)pid_tgid;
struct event *eventp;

eventp = bpf_map_lookup_elem(&values, &tid);
if (!eventp)
return 0;

eventp->ret = ret;
bpf_printk("PID %d (%s) sent signal %d to PID %d, ret = %d",
eventp->pid, eventp->comm, eventp->sig, eventp->tpid, ret);

cleanup:
bpf_map_delete_elem(&values, &tid);
return 0;
}

SEC("tracepoint/syscalls/sys_enter_kill")
int kill_entry(struct trace_event_raw_sys_enter *ctx)
{
pid_t tpid = (pid_t)ctx->args[0];
int sig = (int)ctx->args[1];

return probe_entry(tpid, sig);
}

SEC("tracepoint/syscalls/sys_exit_kill")
int kill_exit(struct trace_event_raw_sys_exit *ctx)
{
return probe_exit(ctx, ctx->ret);
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

上面的代码定义了一个 eBPF 程序,用于捕获进程发送信号的系统调用,包括 kill、tkill 和 tgkill。它通过使用 tracepoint 来捕获系统调用的进入和退出事件,并在这些事件发生时执行指定的探针函数,例如 probe_entry 和 probe_exit。

在探针函数中,我们使用 bpf_map 存储捕获的事件信息,包括发送信号的进程 ID、接收信号的进程 ID、信号值和系统调用的返回值。在系统调用退出时,我们将获取存储在 bpf_map 中的事件信息,并使用 bpf_printk 打印进程 ID、进程名称、发送的信号和系统调用的返回值。

最后,我们还需要使用 SEC 宏来定义探针,并指定要捕获的系统调用的名称,以及要执行的探针函数。

我们使用 eunomia-bpf 编译运行这个例子。编译运行上述代码:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

或者

1
2
3
4
5
6
$ ecc sigsnoop.bpf.c
Compiling bpf object...
Generating export types...
Packing ebpf object and config into package.json...
$ sudo ecli run package.json
Runing eBPF program...

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
4
5
6
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
node-3517 [003] d..31 82575.798191: bpf_trace_printk: PID 3517 (node) sent signal 0 to PID 3427, ret = 0
node-15194 [003] d..31 82575.849227: bpf_trace_printk: PID 15194 (node) sent signal 0 to PID 3427, ret = 0
node-30016 [003] d..31 82576.001361: bpf_trace_printk: PID 30016 (node) sent signal 0 to PID 3427, ret = 0
cpptools-srv-38617 [002] d..31 82576.461085: bpf_trace_printk: PID 38617 (cpptools-srv) sent signal 0 to PID 30496, ret = 0
node-30040 [002] d..31 82576.467720: bpf_trace_printk: PID 30016 (node) sent signal 0 to PID 3427, ret = 0

总结

本文主要介绍如何实现一个 eBPF 工具,捕获进程发送信号的系统调用集合,使用 hash map 保存状态。使用 hash map 需要定义一个结构体:

1
2
3
4
5
6
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, __u32);
__type(value, struct event);
} values SEC(".maps");

并使用一些对应的 API 进行访问,例如bpf_map_lookup_elembpf_map_update_elembpf_map_delete_elem等。

捕获进程执行/退出时间,通过 perf event array 向用户态打印输出

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具,它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

本文是 eBPF 入门开发实践教程的第七篇,主要介绍如何捕获 Linux 内核中进程执行的事件,并且通过 perf event array 向用户态命令行打印输出,不需要再通过查看 /sys/kernel/debug/tracing/trace_pipe 文件来查看 eBPF 程序的输出。通过 perf event array 向用户态发送信息之后,可以进行复杂的数据处理和分析。

perf buffer

eBPF 提供了两个环形缓冲区,可以用来将信息从 eBPF 程序传输到用户区控制器。第一个是perf环形缓冲区,,它至少从内核v4.15开始就存在了。第二个是后来引入的 BPF 环形缓冲区。本文只考虑perf环形缓冲区。

execsnoop

通过 perf event array 向用户态命令行打印输出,需要编写一个头文件,一个 C 源文件。示例代码如下:

头文件:execsnoop.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef __EXECSNOOP_H
#define __EXECSNOOP_H

#define TASK_COMM_LEN 16

struct event {
int pid;
int ppid;
int uid;
int retval;
bool is_exit;
char comm[TASK_COMM_LEN];
};

#endif /* __EXECSNOOP_H */

源文件:execsnoop.bpf.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include "execsnoop.h"

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_execve")
int tracepoint__syscalls__sys_enter_execve(struct trace_event_raw_sys_enter* ctx)
{
u64 id;
pid_t pid, tgid;
struct event event={0};
struct task_struct *task;

uid_t uid = (u32)bpf_get_current_uid_gid();
id = bpf_get_current_pid_tgid();
pid = (pid_t)id;
tgid = id >> 32;

event.pid = tgid;
event.uid = uid;
task = (struct task_struct*)bpf_get_current_task();
event.ppid = BPF_CORE_READ(task, real_parent, tgid);
bpf_get_current_comm(&event.comm, sizeof(event.comm));
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
return 0;
}

char LICENSE[] SEC("license") = "GPL";

这段代码定义了个 eBPF 程序,用于捕获进程执行 execve 系统调用的入口。

在入口程序中,我们首先获取了当前进程的进程 ID 和用户 ID,然后通过 bpf_get_current_task 函数获取了当前进程的 task_struct 结构体,并通过 bpf_probe_read_str 函数读取了进程名称。最后,我们通过 bpf_perf_event_output 函数将进程执行事件输出到 perf buffer。

使用这段代码,我们就可以捕获 Linux 内核中进程执行的事件, 并分析进程的执行情况。

使用容器编译:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

或者使用 ecc 编译:

1
ecc execsnoop.bpf.c execsnoop.h

运行

1
2
3
4
5
6
7
8
9
$ sudo ./ecli run package.json 
TIME PID PPID UID COMM
21:28:30 40747 3517 1000 node
21:28:30 40748 40747 1000 sh
21:28:30 40749 3517 1000 node
21:28:30 40750 40749 1000 sh
21:28:30 40751 3517 1000 node
21:28:30 40752 40751 1000 sh
21:28:30 40753 40752 1000 cpuUsage.sh

总结

本文介绍了如何捕获 Linux 内核中进程执行的事件,并且通过 perf event array 向用户态命令行打印输出,通过 perf event array 向用户态发送信息之后,可以进行复杂的数据处理和分析。在 libbpf 对应的内核态代码中,定义这样一个结构体和对应的头文件:

1
2
3
4
5
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");

就可以往用户态直接发送信息。

在 eBPF 中使用 exitsnoop 监控进程退出事件,使用 ring buffer 向用户态打印输出

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具。它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

本文是 eBPF 入门开发实践教程的第八篇,在 eBPF 中使用 exitsnoop 监控进程退出事件。

ring buffer

现在有一个新的 BPF 数据结构可用,eBPF 环形缓冲区(ring buffer)。它解决了 BPF perf buffer(当今从内核向用户空间发送数据的事实上的标准)的内存效率和事件重排问题,同时达到或超过了它的性能。它既提供了与 perf buffer 兼容以方便迁移,又有新的保留/提交API,具有更好的可用性。另外,合成和真实世界的基准测试表明,在几乎所有的情况下,所以考虑将其作为从BPF程序向用户空间发送数据的默认选择。

eBPF ringbuf vs eBPF perfbuf

只要 BPF 程序需要将收集到的数据发送到用户空间进行后处理和记录,它通常会使用 BPF perf buffer(perfbuf)来实现。Perfbuf 是每个CPU循环缓冲区的集合,它允许在内核和用户空间之间有效地交换数据。它在实践中效果很好,但由于其按CPU设计,它有两个主要的缺点,在实践中被证明是不方便的:内存的低效使用和事件的重新排序。

为了解决这些问题,从Linux 5.8开始,BPF提供了一个新的BPF数据结构(BPF map)。BPF环形缓冲区(ringbuf)。它是一个多生产者、单消费者(MPSC)队列,可以同时在多个CPU上安全共享。

BPF ringbuf 支持来自 BPF perfbuf 的熟悉的功能:

  • 变长的数据记录。
  • 能够通过内存映射区域有效地从用户空间读取数据,而不需要额外的内存拷贝和/或进入内核的系统调用。
  • 既支持epoll通知,又能以绝对最小的延迟进行忙环操作。

同时,BPF ringbuf解决了BPF perfbuf的以下问题:

  • 内存开销。
  • 数据排序。
  • 浪费的工作和额外的数据复制。

exitsnoop

本文是 eBPF 入门开发实践教程的第八篇,在 eBPF 中使用 exitsnoop 监控进程退出事件,并使用 ring buffer 向用户态打印输出。

使用 ring buffer 向用户态打印输出的步骤和 perf buffer 类似,首先需要定义一个头文件:

头文件:exitsnoop.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef __BOOTSTRAP_H
#define __BOOTSTRAP_H

#define TASK_COMM_LEN 16
#define MAX_FILENAME_LEN 127

struct event {
int pid;
int ppid;
unsigned exit_code;
unsigned long long duration_ns;
char comm[TASK_COMM_LEN];
};

#endif /* __BOOTSTRAP_H */

源文件:exitsnoop.bpf.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "exitsnoop.h"
char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");

SEC("tp/sched/sched_process_exit")
int handle_exit(struct trace_event_raw_sched_process_template* ctx)
{
struct task_struct *task;
struct event *e;
pid_t pid, tid;
u64 id, ts, *start_ts, duration_ns = 0;

/* get PID and TID of exiting thread/process */
id = bpf_get_current_pid_tgid();
pid = id >> 32;
tid = (u32)id;

/* ignore thread exits */
if (pid != tid)
return 0;

/* reserve sample from BPF ringbuf */
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

/* fill out the sample with data */
task = (struct task_struct *)bpf_get_current_task();

e->duration_ns = duration_ns;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
e->exit_code = (BPF_CORE_READ(task, exit_code) >> 8) & 0xff;
bpf_get_current_comm(&e->comm, sizeof(e->comm));

/* send data to user-space for post-processing */
bpf_ringbuf_submit(e, 0);
return 0;
}

这段代码展示了如何使用 exitsnoop 监控进程退出事件并使用 ring buffer 向用户态打印输出:

  1. 首先,我们引入所需的头文件和 exitsnoop.h。
  2. 定义一个名为 “LICENSE” 的全局变量,内容为 “Dual BSD/GPL”,这是 eBPF 程序的许可证要求。
  3. 定义一个名为rbBPF_MAP_TYPE_RINGBUF 类型的映射,它将用于将内核空间的数据传输到用户空间。指定 max_entries256 * 1024,代表 ring buffer 的最大容量。
  4. 定义一个名为 handle_exit 的 eBPF 程序,它将在进程退出事件触发时执行。传入一个名为 ctx 的 trace_event_raw_sched_process_template 结构体指针作为参数。
  5. 使用 bpf_get_current_pid_tgid() 函数获取当前任务的 PID 和 TID。对于主线程,PID 和 TID 相同;对于子线程,它们是不同的。我们只关心进程(主线程)的退出,因此在 PID 和 TID 不同时返回 0,忽略子线程退出事件。
  6. 使用 bpf_ringbuf_reserve 函数为事件结构体 e 在 ring buffer 中预留空间。如果预留失败,返回 0。
  7. 使用 bpf_get_current_task() 函数获取当前任务的 task_struct 结构指针。
  8. 将进程相关信息填充到预留的事件结构体 e 中,包括进程持续时间、PID、PPID、退出代码以及进程名称。
  9. 最后,使用 bpf_ringbuf_submit 函数将填充好的事件结构体 e 提交到 ring buffer,之后在用户空间进行处理和输出。

这个示例展示了如何使用 exitsnoop 和 ring buffer 在 eBPF 程序中捕获进程退出事件并将相关信息传输到用户空间。这对于分析进程退出原因和监控系统行为非常有用。

Compile and Run

我们使用 eunomia-bpf 编译运行这个例子。

Compile:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

Or

1
2
3
4
$ ecc exitsnoop.bpf.c exitsnoop.h
Compiling bpf object...
Generating export types...
Packing ebpf object and config into package.json...

Run:

1
2
3
4
5
6
7
8
9
10
11
$ sudo ./ecli run package.json 
TIME PID PPID EXIT_CODE DURATION_NS COMM
21:40:09 42050 42049 0 0 which
21:40:09 42049 3517 0 0 sh
21:40:09 42052 42051 0 0 ps
21:40:09 42051 3517 0 0 sh
21:40:09 42055 42054 0 0 sed
21:40:09 42056 42054 0 0 cat
21:40:09 42057 42054 0 0 cat
21:40:09 42058 42054 0 0 cat
21:40:09 42059 42054 0 0 cat

总结

本文介绍了如何使用 eunomia-bpf 开发一个简单的 BPF 程序,该程序可以监控 Linux 系统中的进程退出事件, 并将捕获的事件通过 ring buffer 发送给用户空间程序。在本文中,我们使用 eunomia-bpf 编译运行了这个例子。

捕获进程调度延迟,以直方图方式记录

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具。它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

runqlat 是一个 eBPF 工具,用于分析 Linux 系统的调度性能。具体来说,runqlat 用于测量一个任务在被调度到 CPU 上运行之前在运行队列中等待的时间。这些信息对于识别性能瓶颈和提高 Linux 内核调度算法的整体效率非常有用。

runqlat 原理

本教程是 eBPF 入门开发实践系列的第九部分,主题是 “捕获进程调度延迟”。在此,我们将介绍一个名为 runqlat 的程序,其作用是以直方图的形式记录进程调度延迟。

Linux 操作系统使用进程来执行所有的系统和用户任务。这些进程可能被阻塞、杀死、运行,或者正在等待运行。处在后两种状态的进程数量决定了 CPU 运行队列的长度。

进程有几种可能的状态,如:

  • 可运行或正在运行
  • 可中断睡眠
  • 不可中断睡眠
  • 停止
  • 僵尸进程

等待资源或其他函数信号的进程会处在可中断或不可中断的睡眠状态:进程被置入睡眠状态,直到它需要的资源变得可用。然后,根据睡眠的类型,进程可以转移到可运行状态,或者保持睡眠。

即使进程拥有它需要的所有资源,它也不会立即开始运行。它会转移到可运行状态,与其他处在相同状态的进程一起排队。CPU可以在接下来的几秒钟或毫秒内执行这些进程。调度器为 CPU 排列进程,并决定下一个要执行的进程。

根据系统的硬件配置,这个可运行队列(称为 CPU 运行队列)的长度可以短也可以长。短的运行队列长度表示 CPU 没有被充分利用。另一方面,如果运行队列长,那么可能意味着 CPU 不够强大,无法执行所有的进程,或者 CPU 的核心数量不足。在理想的 CPU 利用率下,运行队列的长度将等于系统中的核心数量。

进程调度延迟,也被称为 “run queue latency”,是衡量线程从变得可运行(例如,接收到中断,促使其处理更多工作)到实际在 CPU 上运行的时间。在 CPU 饱和的情况下,你可以想象线程必须等待其轮次。但在其他奇特的场景中,这也可能发生,而且在某些情况下,它可以通过调优减少,从而提高整个系统的性能。

我们将通过一个示例来阐述如何使用 runqlat 工具。这是一个负载非常重的系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# runqlat
Tracing run queue latency... Hit Ctrl-C to end.
^C
usecs : count distribution
0 -> 1 : 233 |*********** |
2 -> 3 : 742 |************************************ |
4 -> 7 : 203 |********** |
8 -> 15 : 173 |******** |
16 -> 31 : 24 |* |
32 -> 63 : 0 | |
64 -> 127 : 30 |* |
128 -> 255 : 6 | |
256 -> 511 : 3 | |
512 -> 1023 : 5 | |
1024 -> 2047 : 27 |* |
2048 -> 4095 : 30 |* |
4096 -> 8191 : 20 | |
8192 -> 16383 : 29 |* |
16384 -> 32767 : 809 |****************************************|
32768 -> 65535 : 64 |*** |

在这个输出中,我们看到了一个双模分布,一个模在0到15微秒之间,另一个模在16到65毫秒之间。这些模式在分布(它仅仅是 “count” 列的视觉表示)中显示为尖峰。例如,读取一行:在追踪过程中,809个事件落入了16384到32767微秒的范围(16到32毫秒)。

在后续的教程中,我们将深入探讨如何利用 eBPF 对此类指标进行深度跟踪和分析,以更好地理解和优化系统性能。同时,我们也将学习更多关于 Linux 内核调度器、中断处理和 CPU 饱

runqlat 的实现利用了 eBPF 程序,它通过内核跟踪点和函数探针来测量进程在运行队列中的时间。当进程被排队时,trace_enqueue 函数会在一个映射中记录时间戳。当进程被调度到 CPU 上运行时,handle_switch 函数会检索时间戳,并计算当前时间与排队时间之间的时间差。这个差值(或 delta)被用于更新进程的直方图,该直方图记录运行队列延迟的分布。该直方图可用于分析 Linux 内核的调度性能。

runqlat 代码实现

首先我们需要编写一个源代码文件 runqlat.bpf.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// SPDX-License-Identifier: GPL-2.0
// Copyright (c) 2020 Wenbo Zhang
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>
#include "runqlat.h"
#include "bits.bpf.h"
#include "maps.bpf.h"
#include "core_fixes.bpf.h"

#define MAX_ENTRIES 10240
#define TASK_RUNNING 0

const volatile bool filter_cg = false;
const volatile bool targ_per_process = false;
const volatile bool targ_per_thread = false;
const volatile bool targ_per_pidns = false;
const volatile bool targ_ms = false;
const volatile pid_t targ_tgid = 0;

struct {
__uint(type, BPF_MAP_TYPE_CGROUP_ARRAY);
__type(key, u32);
__type(value, u32);
__uint(max_entries, 1);
} cgroup_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u32);
__type(value, u64);
} start SEC(".maps");

static struct hist zero;

/// @sample {"interval": 1000, "type" : "log2_hist"}
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u32);
__type(value, struct hist);
} hists SEC(".maps");

static int trace_enqueue(u32 tgid, u32 pid)
{
u64 ts;

if (!pid)
return 0;
if (targ_tgid && targ_tgid != tgid)
return 0;

ts = bpf_ktime_get_ns();
bpf_map_update_elem(&start, &pid, &ts, BPF_ANY);
return 0;
}

static unsigned int pid_namespace(struct task_struct *task)
{
struct pid *pid;
unsigned int level;
struct upid upid;
unsigned int inum;

/* get the pid namespace by following task_active_pid_ns(),
* pid->numbers[pid->level].ns
*/
pid = BPF_CORE_READ(task, thread_pid);
level = BPF_CORE_READ(pid, level);
bpf_core_read(&upid, sizeof(upid), &pid->numbers[level]);
inum = BPF_CORE_READ(upid.ns, ns.inum);

return inum;
}

static int handle_switch(bool preempt, struct task_struct *prev, struct task_struct *next)
{
struct hist *histp;
u64 *tsp, slot;
u32 pid, hkey;
s64 delta;

if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

if (get_task_state(prev) == TASK_RUNNING)
trace_enqueue(BPF_CORE_READ(prev, tgid), BPF_CORE_READ(prev, pid));

pid = BPF_CORE_READ(next, pid);

tsp = bpf_map_lookup_elem(&start, &pid);
if (!tsp)
return 0;
delta = bpf_ktime_get_ns() - *tsp;
if (delta < 0)
goto cleanup;

if (targ_per_process)
hkey = BPF_CORE_READ(next, tgid);
else if (targ_per_thread)
hkey = pid;
else if (targ_per_pidns)
hkey = pid_namespace(next);
else
hkey = -1;
histp = bpf_map_lookup_or_try_init(&hists, &hkey, &zero);
if (!histp)
goto cleanup;
if (!histp->comm[0])
bpf_probe_read_kernel_str(&histp->comm, sizeof(histp->comm),
next->comm);
if (targ_ms)
delta /= 1000000U;
else
delta /= 1000U;
slot = log2l(delta);
if (slot >= MAX_SLOTS)
slot = MAX_SLOTS - 1;
__sync_fetch_and_add(&histp->slots[slot], 1);

cleanup:
bpf_map_delete_elem(&start, &pid);
return 0;
}

SEC("raw_tp/sched_wakeup")
int BPF_PROG(handle_sched_wakeup, struct task_struct *p)
{
if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

return trace_enqueue(BPF_CORE_READ(p, tgid), BPF_CORE_READ(p, pid));
}

SEC("raw_tp/sched_wakeup_new")
int BPF_PROG(handle_sched_wakeup_new, struct task_struct *p)
{
if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

return trace_enqueue(BPF_CORE_READ(p, tgid), BPF_CORE_READ(p, pid));
}

SEC("raw_tp/sched_switch")
int BPF_PROG(handle_sched_switch, bool preempt, struct task_struct *prev, struct task_struct *next)
{
return handle_switch(preempt, prev, next);
}

char LICENSE[] SEC("license") = "GPL";

这其中定义了一些常量和全局变量,用于过滤对应的追踪目标:

1
2
3
4
5
6
7
8
9
#define MAX_ENTRIES 10240
#define TASK_RUNNING 0

const volatile bool filter_cg = false;
const volatile bool targ_per_process = false;
const volatile bool targ_per_thread = false;
const volatile bool targ_per_pidns = false;
const volatile bool targ_ms = false;
const volatile pid_t targ_tgid = 0;

这些变量包括最大映射项数量、任务状态、过滤选项和目标选项。这些选项可以通过用户空间程序设置,以定制 eBPF 程序的行为。

接下来,定义了一些 eBPF 映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct {
__uint(type, BPF_MAP_TYPE_CGROUP_ARRAY);
__type(key, u32);
__type(value, u32);
__uint(max_entries, 1);
} cgroup_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u32);
__type(value, u64);
} start SEC(".maps");

static struct hist zero;

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u32);
__type(value, struct hist);
} hists SEC(".maps");

这些映射包括:

  • cgroup_map 用于过滤 cgroup;
  • start 用于存储进程入队时的时间戳;
  • hists 用于存储直方图数据,记录进程调度延迟。

接下来是一些辅助函数:

trace_enqueue 函数用于在进程入队时记录其时间戳:

1
2
3
4
5
6
7
8
9
10
11
12
13
static int trace_enqueue(u32 tgid, u32 pid)
{
u64 ts;

if (!pid)
return 0;
if (targ_tgid && targ_tgid != tgid)
return 0;

ts = bpf_ktime_get_ns();
bpf_map_update_elem(&start, &pid, &ts, BPF_ANY);
return 0;
}

pid_namespace 函数用于获取进程所属的 PID namespace:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static unsigned int pid_namespace(struct task_struct *task)
{
struct pid *pid;
unsigned int level;
struct upid upid;
unsigned int inum;

/* get the pid namespace by following task_active_pid_ns(),
* pid->numbers[pid->level].ns
*/
pid = BPF_CORE_READ(task, thread_pid);
level = BPF_CORE_READ(pid, level);
bpf_core_read(&upid, sizeof(upid), &pid->numbers[level]);
inum = BPF_CORE_READ(upid.ns, ns.inum);

return inum;
}

handle_switch 函数是核心部分,用于处理调度切换事件,计算进程调度延迟并更新直方图数据:

1
2
3
4
static int handle_switch(bool preempt, struct task_struct *prev, struct task_struct *next)
{
...
}

首先,函数根据 filter_cg 的设置判断是否需要过滤 cgroup。然后,如果之前的进程状态为 TASK_RUNNING,则调用 trace_enqueue 函数记录进程的入队时间。接着,函数查找下一个进程的入队时间戳,如果找不到,直接返回。计算调度延迟(delta),并根据不同的选项设置(targ_per_process,targ_per_thread,targ_per_pidns),确定直方图映射的键(hkey)。然后查找或初始化直方图映射,更新直方图数据,最后删除进程的入队时间戳记录。

接下来是 eBPF 程序的入口点。程序使用三个入口点来捕获不同的调度事件:

  • handle_sched_wakeup:用于处理 sched_wakeup 事件,当一个进程从睡眠状态被唤醒时触发。
  • handle_sched_wakeup_new:用于处理 sched_wakeup_new 事件,当一个新创建的进程被唤醒时触发。
  • handle_sched_switch:用于处理 sched_switch 事件,当调度器选择一个新的进程运行时触发。

这些入口点分别处理不同的调度事件,但都会调用 handle_switch 函数来计算进程的调度延迟并更新直方图数据。

最后,程序包含一个许可证声明:

1
char LICENSE[] SEC("license") = "GPL";

这一声明指定了 eBPF 程序的许可证类型,这里使用的是 “GPL”。这对于许多内核功能是必需的,因为它们要求 eBPF 程序遵循 GPL 许可证。

runqlat.h

然后我们需要定义一个头文件runqlat.h,用来给用户态处理从内核态上报的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
#ifndef __RUNQLAT_H
#define __RUNQLAT_H

#define TASK_COMM_LEN 16
#define MAX_SLOTS 26

struct hist {
__u32 slots[MAX_SLOTS];
char comm[TASK_COMM_LEN];
};

#endif /* __RUNQLAT_H */

编译运行

我们使用 eunomia-bpf 编译运行这个例子。

Compile:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

或者

1
2
3
4
$ ecc runqlat.bpf.c runqlat.h
Compiling bpf object...
Generating export types...
Packing ebpf object and config into package.json...

Run:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
$ sudo ecli run examples/bpftools/runqlat/package.json -h
Usage: runqlat_bpf [--help] [--version] [--verbose] [--filter_cg] [--targ_per_process] [--targ_per_thread] [--targ_per_pidns] [--targ_ms] [--targ_tgid VAR]

A simple eBPF program

Optional arguments:
-h, --help shows help message and exits
-v, --version prints version information and exits
--verbose prints libbpf debug information
--filter_cg set value of bool variable filter_cg
--targ_per_process set value of bool variable targ_per_process
--targ_per_thread set value of bool variable targ_per_thread
--targ_per_pidns set value of bool variable targ_per_pidns
--targ_ms set value of bool variable targ_ms
--targ_tgid set value of pid_t variable targ_tgid

Built with eunomia-bpf framework.
See https://github.com/eunomia-bpf/eunomia-bpf for more information.

$ sudo ecli run examples/bpftools/runqlat/package.json
key = 4294967295
comm = rcu_preempt

(unit) : count distribution
0 -> 1 : 9 |**** |
2 -> 3 : 6 |** |
4 -> 7 : 12 |***** |
8 -> 15 : 28 |************* |
16 -> 31 : 40 |******************* |
32 -> 63 : 83 |****************************************|
64 -> 127 : 57 |*************************** |
128 -> 255 : 19 |********* |
256 -> 511 : 11 |***** |
512 -> 1023 : 2 | |
1024 -> 2047 : 2 | |
2048 -> 4095 : 0 | |
4096 -> 8191 : 0 | |
8192 -> 16383 : 0 | |
16384 -> 32767 : 1 | |

$ sudo ecli run examples/bpftools/runqlat/package.json --targ_per_process
key = 3189
comm = cpptools

(unit) : count distribution
0 -> 1 : 0 | |
2 -> 3 : 0 | |
4 -> 7 : 0 | |
8 -> 15 : 1 |*** |
16 -> 31 : 2 |******* |
32 -> 63 : 11 |****************************************|
64 -> 127 : 8 |***************************** |
128 -> 255 : 3 |********** |

完整源代码请见:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/9-runqlat

总结

runqlat 是一个 Linux 内核 BPF 程序,通过柱状图来总结调度程序运行队列延迟,显示任务等待运行在 CPU 上的时间长度。编译这个程序可以使用 ecc 工具,运行时可以使用 ecli 命令。

runqlat 是一种用于监控Linux内核中进程调度延迟的工具。它可以帮助您了解进程在内核中等待执行的时间,并根据这些信息优化进程调度,提高系统的性能。可以在 libbpf-tools 中找到最初的源代码:https://github.com/iovisor/bcc/blob/master/libbpf-tools/runqlat.bpf.c

在 eBPF 中使用 hardirqs 或 softirqs 捕获中断事件

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具。它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

本文是 eBPF 入门开发实践教程的第十篇,在 eBPF 中使用 hardirqs 或 softirqs 捕获中断事件。 hardirqs 和 softirqs 是 Linux 内核中两种不同类型的中断处理程序。它们用于处理硬件设备产生的中断请求,以及内核中的异步事件。在 eBPF 中,我们可以使用同名的 eBPF 工具 hardirqs 和 softirqs 来捕获和分析内核中与中断处理相关的信息。

hardirqs 和 softirqs 是什么?

hardirqs 是硬件中断处理程序。当硬件设备产生一个中断请求时,内核会将该请求映射到一个特定的中断向量,然后执行与之关联的硬件中断处理程序。硬件中断处理程序通常用于处理设备驱动程序中的事件,例如设备数据传输完成或设备错误。

softirqs 是软件中断处理程序。它们是内核中的一种底层异步事件处理机制,用于处理内核中的高优先级任务。softirqs 通常用于处理网络协议栈、磁盘子系统和其他内核组件中的事件。与硬件中断处理程序相比,软件中断处理程序具有更高的灵活性和可配置性。

实现原理

在 eBPF 中,我们可以通过挂载特定的 kprobe 或者 tracepoint 来捕获和分析 hardirqs 和 softirqs。为了捕获 hardirqs 和 softirqs,需要在相关的内核函数上放置 eBPF 程序。这些函数包括:

  • 对于 hardirqs:irq_handler_entry 和 irq_handler_exit。
  • 对于 softirqs:softirq_entry 和 softirq_exit。

当内核处理 hardirqs 或 softirqs 时,这些 eBPF 程序会被执行,从而收集相关信息,如中断向量、中断处理程序的执行时间等。收集到的信息可以用于分析内核中的性能问题和其他与中断处理相关的问题。

为了捕获 hardirqs 和 softirqs,可以遵循以下步骤:

  1. 在 eBPF 程序中定义用于存储中断信息的数据结构和映射。
  2. 编写 eBPF 程序,将其挂载到相应的内核函数上,以捕获 hardirqs 或 softirqs。
  3. 在 eBPF 程序中,收集中断处理程序的相关信息,并将这些信息存储在映射中。
  4. 在用户空间应用程序中,读取映射中的数据以分析和展示中断处理信息。

通过上述方法,我们可以在 eBPF 中使用 hardirqs 和 softirqs 捕获和分析内核中的中断事件,以识别潜在的性能问题和与中断处理相关的问题。

hardirqs 代码实现

hardirqs 程序的主要目的是获取中断处理程序的名称、执行次数和执行时间,并以直方图的形式展示执行时间的分布。让我们一步步分析这段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// SPDX-License-Identifier: GPL-2.0
// Copyright (c) 2020 Wenbo Zhang
#include <vmlinux.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include "hardirqs.h"
#include "bits.bpf.h"
#include "maps.bpf.h"

#define MAX_ENTRIES 256

const volatile bool filter_cg = false;
const volatile bool targ_dist = false;
const volatile bool targ_ns = false;
const volatile bool do_count = false;

struct {
__uint(type, BPF_MAP_TYPE_CGROUP_ARRAY);
__type(key, u32);
__type(value, u32);
__uint(max_entries, 1);
} cgroup_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, u64);
} start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, struct irq_key);
__type(value, struct info);
} infos SEC(".maps");

static struct info zero;

static int handle_entry(int irq, struct irqaction *action)
{
if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

if (do_count) {
struct irq_key key = {};
struct info *info;

bpf_probe_read_kernel_str(&key.name, sizeof(key.name), BPF_CORE_READ(action, name));
info = bpf_map_lookup_or_try_init(&infos, &key, &zero);
if (!info)
return 0;
info->count += 1;
return 0;
} else {
u64 ts = bpf_ktime_get_ns();
u32 key = 0;

if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

bpf_map_update_elem(&start, &key, &ts, BPF_ANY);
return 0;
}
}

static int handle_exit(int irq, struct irqaction *action)
{
struct irq_key ikey = {};
struct info *info;
u32 key = 0;
u64 delta;
u64 *tsp;

if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

tsp = bpf_map_lookup_elem(&start, &key);
if (!tsp)
return 0;

delta = bpf_ktime_get_ns() - *tsp;
if (!targ_ns)
delta /= 1000U;

bpf_probe_read_kernel_str(&ikey.name, sizeof(ikey.name), BPF_CORE_READ(action, name));
info = bpf_map_lookup_or_try_init(&infos, &ikey, &zero);
if (!info)
return 0;

if (!targ_dist) {
info->count += delta;
} else {
u64 slot;

slot = log2(delta);
if (slot >= MAX_SLOTS)
slot = MAX_SLOTS - 1;
info->slots[slot]++;
}

return 0;
}

SEC("tp_btf/irq_handler_entry")
int BPF_PROG(irq_handler_entry_btf, int irq, struct irqaction *action)
{
return handle_entry(irq, action);
}

SEC("tp_btf/irq_handler_exit")
int BPF_PROG(irq_handler_exit_btf, int irq, struct irqaction *action)
{
return handle_exit(irq, action);
}

SEC("raw_tp/irq_handler_entry")
int BPF_PROG(irq_handler_entry, int irq, struct irqaction *action)
{
return handle_entry(irq, action);
}

SEC("raw_tp/irq_handler_exit")
int BPF_PROG(irq_handler_exit, int irq, struct irqaction *action)
{
return handle_exit(irq, action);
}

char LICENSE[] SEC("license") = "GPL";

这段代码是一个 eBPF 程序,用于捕获和分析内核中硬件中断处理程序(hardirqs)的执行信息。程序的主要目的是获取中断处理程序的名称、执行次数和执行时间,并以直方图的形式展示执行时间的分布。让我们一步步分析这段代码。

包含必要的头文件和定义数据结构:

1
2
3
4
5
6
7
#include <vmlinux.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include "hardirqs.h"
#include "bits.bpf.h"
#include "maps.bpf.h"

该程序包含了 eBPF 开发所需的标准头文件,以及用于定义数据结构和映射的自定义头文件。

定义全局变量和映射:

1
2
3
4
5
6
7
8
#define MAX_ENTRIES 256

const volatile bool filter_cg = false;
const volatile bool targ_dist = false;
const volatile bool targ_ns = false;
const volatile bool do_count = false;

...

该程序定义了一些全局变量,用于配置程序的行为。例如,filter_cg 控制是否过滤 cgroup,targ_dist 控制是否显示执行时间的分布等。此外,程序还定义了三个映射,分别用于存储 cgroup 信息、开始时间戳和中断处理程序的信息。

定义两个辅助函数 handle_entryhandle_exit

这两个函数分别在中断处理程序的入口和出口处被调用。handle_entry 记录开始时间戳或更新中断计数,handle_exit 计算中断处理程序的执行时间,并将结果存储到相应的信息映射中。

定义 eBPF 程序的入口点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
SEC("tp_btf/irq_handler_entry")
int BPF_PROG(irq_handler_entry_btf, int irq, struct irqaction *action)
{
return handle_entry(irq, action);
}

SEC("tp_btf/irq_handler_exit")
int BPF_PROG(irq_handler_exit_btf, int irq, struct irqaction *action)
{
return handle_exit(irq, action);
}

SEC("raw_tp/irq_handler_entry")
int BPF_PROG(irq_handler_entry, int irq, struct irqaction *action)
{
return handle_entry(irq, action);
}

SEC("raw_tp/irq_handler_exit")
int BPF_PROG(irq_handler_exit, int irq, struct irqaction *action)
{
return handle_exit(irq, action);
}

这里定义了四个 eBPF 程序入口点,分别用于捕获中断处理程序的入口和出口事件。tp_btfraw_tp 分别代表使用 BPF Type Format(BTF)和原始 tracepoints 捕获事件。这样可以确保程序在不同内核版本上可以移植和运行。

Softirq 代码也类似,这里就不再赘述了。

运行代码

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。我们使用 eunomia-bpf 编译运行这个例子。

要编译这个程序,请使用 ecc 工具:

1
2
3
$ ecc hardirqs.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...

然后运行:

1
sudo ecli run ./package.json

总结

在本章节(eBPF 入门开发实践教程十:在 eBPF 中使用 hardirqs 或 softirqs 捕获中断事件)中,我们学习了如何使用 eBPF 程序捕获和分析内核中硬件中断处理程序(hardirqs)的执行信息。我们详细讲解了示例代码,包括如何定义数据结构、映射以及 eBPF 程序入口点,以及如何在中断处理程序的入口和出口处调用辅助函数来记录执行信息。

在 eBPF 中使用 libbpf 开发用户态程序并跟踪 exec() 和 exit() 系统调用

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具。它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

在本教程中,我们将了解内核态和用户态的 eBPF 程序是如何协同工作的。我们还将学习如何使用原生的 libbpf 开发用户态程序,将 eBPF 应用打包为可执行文件,实现跨内核版本分发。

libbpf 库,以及为什么需要使用它

libbpf 是一个 C 语言库,伴随内核版本分发,用于辅助 eBPF 程序的加载和运行。它提供了用于与 eBPF 系统交互的一组 C API,使开发者能够更轻松地编写用户态程序来加载和管理 eBPF 程序。这些用户态程序通常用于分析、监控或优化系统性能。

使用 libbpf 库有以下优势:

  • 它简化了 eBPF 程序的加载、更新和运行过程。
  • 它提供了一组易于使用的 API,使开发者能够专注于编写核心逻辑,而不是处理底层细节。
  • 它能够确保与内核中的 eBPF 子系统的兼容性,降低了维护成本。

同时,libbpf 和 BTF(BPF Type Format)都是 eBPF 生态系统的重要组成部分。它们各自在实现跨内核版本兼容方面发挥着关键作用。BTF(BPF Type Format)是一种元数据格式,用于描述 eBPF 程序中的类型信息。BTF 的主要目的是提供一种结构化的方式,以描述内核中的数据结构,以便 eBPF 程序可以更轻松地访问和操作它们。

BTF 在实现跨内核版本兼容方面的关键作用如下:

  • BTF 允许 eBPF 程序访问内核数据结构的详细类型信息,而无需对特定内核版本进行硬编码。这使得 eBPF 程序可以适应不同版本的内核,从而实现跨内核版本兼容。
  • 通过使用 BPF CO-RE(Compile Once, Run Everywhere)技术,eBPF 程序可以利用 BTF 在编译时解析内核数据结构的类型信息,进而生成可以在不同内核版本上运行的 eBPF 程序。

结合 libbpf 和 BTF,eBPF 程序可以在各种不同版本的内核上运行,而无需为每个内核版本单独编译。这极大地提高了 eBPF 生态系统的可移植性和兼容性,降低了开发和维护的难度。

什么是 bootstrap

Bootstrap 是一个使用 libbpf 的完整应用,它利用 eBPF 程序来跟踪内核中的 exec() 系统调用(通过 SEC("tp/sched/sched_process_exec") handle_exec BPF 程序),这主要对应于新进程的创建(不包括 fork() 部分)。此外,它还跟踪进程的 exit() 系统调用(通过 SEC("tp/sched/sched_process_exit") handle_exit BPF 程序),以了解每个进程何时退出。

这两个 BPF 程序共同工作,允许捕获关于新进程的有趣信息,例如二进制文件的文件名,以及测量进程的生命周期,并在进程结束时收集有趣的统计信息,例如退出代码或消耗的资源量等。这是深入了解内核内部并观察事物如何真正运作的良好起点。

Bootstrap 还使用 argp API(libc 的一部分)进行命令行参数解析,使得用户可以通过命令行选项配置应用行为。这种方式提供了灵活性,让用户能够根据实际需求自定义程序行为。虽然这些功能使用 eunomia-bpf 工具也可以实现,但是这里我们使用 libbpf 可以在用户态提供更高的可扩展性,不过也带来了不少额外的复杂度。

Bootstrap

Bootstrap 分为两个部分:内核态和用户态。内核态部分是一个 eBPF 程序,它跟踪 exec() 和 exit() 系统调用。用户态部分是一个 C 语言程序,它使用 libbpf 库来加载和运行内核态程序,并处理从内核态程序收集的数据。

内核态 eBPF 程序 bootstrap.bpf.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
/* Copyright (c) 2020 Facebook */
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "bootstrap.h"

char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, pid_t);
__type(value, u64);
} exec_start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");

const volatile unsigned long long min_duration_ns = 0;

SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
struct task_struct *task;
unsigned fname_off;
struct event *e;
pid_t pid;
u64 ts;

/* remember time exec() was executed for this PID */
pid = bpf_get_current_pid_tgid() >> 32;
ts = bpf_ktime_get_ns();
bpf_map_update_elem(&exec_start, &pid, &ts, BPF_ANY);

/* don't emit exec events when minimum duration is specified */
if (min_duration_ns)
return 0;

/* reserve sample from BPF ringbuf */
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

/* fill out the sample with data */
task = (struct task_struct *)bpf_get_current_task();

e->exit_event = false;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));

fname_off = ctx->__data_loc_filename & 0xFFFF;
bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)ctx + fname_off);

/* successfully submit it to user-space for post-processing */
bpf_ringbuf_submit(e, 0);
return 0;
}

SEC("tp/sched/sched_process_exit")
int handle_exit(struct trace_event_raw_sched_process_template* ctx)
{
struct task_struct *task;
struct event *e;
pid_t pid, tid;
u64 id, ts, *start_ts, duration_ns = 0;

/* get PID and TID of exiting thread/process */
id = bpf_get_current_pid_tgid();
pid = id >> 32;
tid = (u32)id;

/* ignore thread exits */
if (pid != tid)
return 0;

/* if we recorded start of the process, calculate lifetime duration */
start_ts = bpf_map_lookup_elem(&exec_start, &pid);
if (start_ts)
duration_ns = bpf_ktime_get_ns() - *start_ts;
else if (min_duration_ns)
return 0;
bpf_map_delete_elem(&exec_start, &pid);

/* if process didn't live long enough, return early */
if (min_duration_ns && duration_ns < min_duration_ns)
return 0;

/* reserve sample from BPF ringbuf */
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

/* fill out the sample with data */
task = (struct task_struct *)bpf_get_current_task();

e->exit_event = true;
e->duration_ns = duration_ns;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
e->exit_code = (BPF_CORE_READ(task, exit_code) >> 8) & 0xff;
bpf_get_current_comm(&e->comm, sizeof(e->comm));

/* send data to user-space for post-processing */
bpf_ringbuf_submit(e, 0);
return 0;
}

这段代码是一个内核态 eBPF 程序(bootstrap.bpf.c),主要用于跟踪 exec() 和 exit() 系统调用。它通过 eBPF 程序捕获进程的创建和退出事件,并将相关信息发送到用户态程序进行处理。下面是对代码的详细解释。

首先,我们引入所需的头文件,定义 eBPF 程序的许可证以及两个 eBPF maps:exec_start 和 rb。exec_start 是一个哈希类型的 eBPF map,用于存储进程开始执行时的时间戳。rb 是一个环形缓冲区类型的 eBPF map,用于存储捕获的事件数据,并将其发送到用户态程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "bootstrap.h"

char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, pid_t);
__type(value, u64);
} exec_start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");

const volatile unsigned long long min_duration_ns = 0;

接下来,我们定义了一个名为 handle_exec 的 eBPF 程序,它会在进程执行 exec() 系统调用时触发。首先,我们从当前进程中获取 PID,记录进程开始执行的时间戳,然后将其存储在 exec_start map 中。

1
2
3
4
5
6
7
8
9
10
SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
// ...
pid = bpf_get_current_pid_tgid() >> 32;
ts = bpf_ktime_get_ns();
bpf_map_update_elem(&exec_start, &pid, &ts, BPF_ANY);

// ...
}

然后,我们从环形缓冲区 map rb 中预留一个事件结构,并填充相关数据,如进程 ID、父进程 ID、进程名等。之后,我们将这些数据发送到用户态程序进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// reserve sample from BPF ringbuf
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

// fill out the sample with data
task = (struct task_struct *)bpf_get_current_task();

e->exit_event = false;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));

fname_off = ctx->__data_loc_filename & 0xFFFF;
bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)ctx + fname_off);

// successfully submit it to user-space for post-processing
bpf_ringbuf_submit(e, 0);
return 0;

最后,我们定义了一个名为 handle_exit 的 eBPF 程序,它会在进程执行 exit() 系统调用时触发。首先,我们从当前进程中获取 PID 和 TID(线程 ID)。如果 PID 和 TID 不相等,说明这是一个线程退出,我们将忽略此事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SEC("tp/sched/sched_process_exit")
int handle_exit(struct trace_event_raw_sched_process_template* ctx)
{
// ...
id = bpf_get_current_pid_tgid();
pid = id >> 32;
tid = (u32)id;

/* ignore thread exits */
if (pid != tid)
return 0;

// ...
}

接着,我们查找之前存储在 exec_start map 中的进程开始执行的时间戳。如果找到了时间戳,我们将计算进程的生命周期(持续时间),然后从 exec_start map 中删除该记录。如果未找到时间戳且指定了最小持续时间,则直接返回。

1
2
3
4
5
6
7
8
9
10
11
// if we recorded start of the process, calculate lifetime duration
start_ts = bpf_map_lookup_elem(&exec_start, &pid);
if (start_ts)
duration_ns = bpf_ktime_get_ns() - *start_ts;
else if (min_duration_ns)
return 0;
bpf_map_delete_elem(&exec_start, &pid);

// if process didn't live long enough, return early
if (min_duration_ns && duration_ns < min_duration_ns)
return 0;

然后,我们从环形缓冲区 map rb 中预留一个事件结构,并填充相关数据,如进程 ID、父进程 ID、进程名、进程持续时间等。最后,我们将这些数据发送到用户态程序进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    /* reserve sample from BPF ringbuf */
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

/* fill out the sample with data */
task = (struct task_struct *)bpf_get_current_task();

e->exit_event = true;
e->duration_ns = duration_ns;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
e->exit_code = (BPF_CORE_READ(task, exit_code) >> 8) & 0xff;
bpf_get_current_comm(&e->comm, sizeof(e->comm));

/* send data to user-space for post-processing */
bpf_ringbuf_submit(e, 0);
return 0;
}

这样,当进程执行 exec() 或 exit() 系统调用时,我们的 eBPF 程序会捕获相应的事件,并将详细信息发送到用户态程序进行后续处理。这使得我们可以轻松地监控进程的创建和退出,并获取有关进程的详细信息。

除此之外,在 bootstrap.h 中,我们还定义了和用户态交互的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
/* Copyright (c) 2020 Facebook */
#ifndef __BOOTSTRAP_H
#define __BOOTSTRAP_H

#define TASK_COMM_LEN 16
#define MAX_FILENAME_LEN 127

struct event {
int pid;
int ppid;
unsigned exit_code;
unsigned long long duration_ns;
char comm[TASK_COMM_LEN];
char filename[MAX_FILENAME_LEN];
bool exit_event;
};

#endif /* __BOOTSTRAP_H */
用户态,bootstrap.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
/* Copyright (c) 2020 Facebook */
#include <argp.h>
#include <signal.h>
#include <stdio.h>
#include <time.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include "bootstrap.h"
#include "bootstrap.skel.h"

static struct env {
bool verbose;
long min_duration_ms;
} env;

const char *argp_program_version = "bootstrap 0.0";
const char *argp_program_bug_address = "<bpf@vger.kernel.org>";
const char argp_program_doc[] =
"BPF bootstrap demo application.\n"
"\n"
"It traces process start and exits and shows associated \n"
"information (filename, process duration, PID and PPID, etc).\n"
"\n"
"USAGE: ./bootstrap [-d <min-duration-ms>] [-v]\n";

static const struct argp_option opts[] = {
{ "verbose", 'v', NULL, 0, "Verbose debug output" },
{ "duration", 'd', "DURATION-MS", 0, "Minimum process duration (ms) to report" },
{},
};

static error_t parse_arg(int key, char *arg, struct argp_state *state)
{
switch (key) {
case 'v':
env.verbose = true;
break;
case 'd':
errno = 0;
env.min_duration_ms = strtol(arg, NULL, 10);
if (errno || env.min_duration_ms <= 0) {
fprintf(stderr, "Invalid duration: %s\n", arg);
argp_usage(state);
}
break;
case ARGP_KEY_ARG:
argp_usage(state);
break;
default:
return ARGP_ERR_UNKNOWN;
}
return 0;
}

static const struct argp argp = {
.options = opts,
.parser = parse_arg,
.doc = argp_program_doc,
};

static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
if (level == LIBBPF_DEBUG && !env.verbose)
return 0;
return vfprintf(stderr, format, args);
}

static volatile bool exiting = false;

static void sig_handler(int sig)
{
exiting = true;
}

static int handle_event(void *ctx, void *data, size_t data_sz)
{
const struct event *e = data;
struct tm *tm;
char ts[32];
time_t t;

time(&t);
tm = localtime(&t);
strftime(ts, sizeof(ts), "%H:%M:%S", tm);

if (e->exit_event) {
printf("%-8s %-5s %-16s %-7d %-7d [%u]",
ts, "EXIT", e->comm, e->pid, e->ppid, e->exit_code);
if (e->duration_ns)
printf(" (%llums)", e->duration_ns / 1000000);
printf("\n");
} else {
printf("%-8s %-5s %-16s %-7d %-7d %s\n",
ts, "EXEC", e->comm, e->pid, e->ppid, e->filename);
}

return 0;
}

int main(int argc, char **argv)
{
struct ring_buffer *rb = NULL;
struct bootstrap_bpf *skel;
int err;

/* Parse command line arguments */
err = argp_parse(&argp, argc, argv, 0, NULL, NULL);
if (err)
return err;

/* Set up libbpf errors and debug info callback */
libbpf_set_print(libbpf_print_fn);

/* Cleaner handling of Ctrl-C */
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);

/* Load and verify BPF application */
skel = bootstrap_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open and load BPF skeleton\n");
return 1;
}

/* Parameterize BPF code with minimum duration parameter */
skel->rodata->min_duration_ns = env.min_duration_ms * 1000000ULL;

/* Load & verify BPF programs */
err = bootstrap_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load and verify BPF skeleton\n");
goto cleanup;
}

/* Attach tracepoints */
err = bootstrap_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}

/* Set up ring buffer polling */
rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
if (!rb) {
err = -1;
fprintf(stderr, "Failed to create ring buffer\n");
goto cleanup;
}

/* Process events */
printf("%-8s %-5s %-16s %-7s %-7s %s\n",
"TIME", "EVENT", "COMM", "PID", "PPID", "FILENAME/EXIT CODE");
while (!exiting) {
err = ring_buffer__poll(rb, 100 /* timeout, ms */);
/* Ctrl-C will cause -EINTR */
if (err == -EINTR) {
err = 0;
break;
}
if (err < 0) {
printf("Error polling perf buffer: %d\n", err);
break;
}
}

cleanup:
/* Clean up */
ring_buffer__free(rb);
bootstrap_bpf__destroy(skel);

return err < 0 ? -err : 0;
}

这个用户态程序主要用于加载、验证、附加 eBPF 程序,以及接收 eBPF 程序收集的事件数据,并将其打印出来。我们将分析一些关键部分。

首先,我们定义了一个 env 结构,用于存储命令行参数:

1
2
3
4
static struct env {
bool verbose;
long min_duration_ms;
} env;

接下来,我们使用 argp 库来解析命令行参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static const struct argp_option opts[] = {
{ "verbose", 'v', NULL, 0, "Verbose debug output" },
{ "duration", 'd', "DURATION-MS", 0, "Minimum process duration (ms) to report" },
{},
};

static error_t parse_arg(int key, char *arg, struct argp_state *state)
{
// ...
}

static const struct argp argp = {
.options = opts,
.parser = parse_arg,
.doc = argp_program_doc,
};

main() 函数中,首先解析命令行参数,然后设置 libbpf 的打印回调函数 libbpf_print_fn,以便在需要时输出调试信息:

1
2
3
4
5
err = argp_parse(&argp, argc, argv, 0, NULL, NULL);
if (err)
return err;

libbpf_set_print(libbpf_print_fn);

接下来,我们打开 eBPF 脚手架(skeleton)文件,将最小持续时间参数传递给 eBPF 程序,并加载和附加 eBPF 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
skel = bootstrap_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open and load BPF skeleton\n");
return 1;
}

skel->rodata->min_duration_ns = env.min_duration_ms * 1000000ULL;

err = bootstrap_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load and verify BPF skeleton\n");
goto cleanup;
}

err = bootstrap_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}

然后,我们创建一个环形缓冲区(ring buffer),用于接收 eBPF 程序发送的事件数据:

1
2
3
4
5
6
rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
if (!rb) {
err = -1;
fprintf(stderr, "Failed to create ring buffer\n");
goto cleanup;
}

handle_event() 函数会处理从 eBPF 程序收到的事件。根据事件类型(进程执行或退出),它会提取并打印事件信息,如时间戳、进程名、进程 ID、父进程 ID、文件名或退出代码等。

最后,我们使用 ring_buffer__poll() 函数轮询环形缓冲区,处理收到的事件数据:

1
2
3
4
while (!exiting) {
err = ring_buffer__poll(rb, 100 /* timeout, ms */);
// ...
}

当程序收到 SIGINT 或 SIGTERM 信号时,它会最后完成清理、退出操作,关闭和卸载 eBPF 程序:

1
2
3
4
5
6
7
cleanup:
/* Clean up */
ring_buffer__free(rb);
bootstrap_bpf__destroy(skel);

return err < 0 ? -err : 0;
}
安装依赖

构建示例需要 clang、libelf 和 zlib。包名在不同的发行版中可能会有所不同。

在 Ubuntu/Debian 上,你需要执行以下命令:

1
sudo apt install clang libelf1 libelf-dev zlib1g-dev

在 CentOS/Fedora 上,你需要执行以下命令:

1
sudo dnf install clang elfutils-libelf elfutils-libelf-devel zlib-devel
编译运行

编译运行上述代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ make
BPF .output/bootstrap.bpf.o
GEN-SKEL .output/bootstrap.skel.h
CC .output/bootstrap.o
BINARY bootstrap
$ sudo ./bootstrap
[sudo] password for yunwei:
TIME EVENT COMM PID PPID FILENAME/EXIT CODE
03:16:41 EXEC sh 110688 80168 /bin/sh
03:16:41 EXEC which 110689 110688 /usr/bin/which
03:16:41 EXIT which 110689 110688 [0] (0ms)
03:16:41 EXIT sh 110688 80168 [0] (0ms)
03:16:41 EXEC sh 110690 80168 /bin/sh
03:16:41 EXEC ps 110691 110690 /usr/bin/ps
03:16:41 EXIT ps 110691 110690 [0] (49ms)
03:16:41 EXIT sh 110690 80168 [0] (51ms)

总结

通过这个实例,我们了解了如何将 eBPF 程序与用户态程序结合使用。这种结合为开发者提供了一个强大的工具集,可以实现跨内核和用户空间的高效数据收集和处理。通过使用 eBPF 和 libbpf,您可以构建更高效、可扩展和安全的监控和性能分析工具。

使用 eBPF 程序 profile 进行性能分析

本教程将指导您使用 libbpf 和 eBPF 程序进行性能分析。我们将利用内核中的 perf 机制,学习如何捕获函数的执行时间以及如何查看性能数据。

libbpf 是一个用于与 eBPF 交互的 C 库。它提供了创建、加载和使用 eBPF 程序所需的基本功能。本教程中,我们将主要使用 libbpf 完成开发工作。perf 是 Linux 内核中的性能分析工具,允许用户测量和分析内核及用户空间程序的性能,以及获取对应的调用堆栈。它利用内核中的硬件计数器和软件事件来收集性能数据。

eBPF 工具:profile 性能分析示例

profile 工具基于 eBPF 实现,利用 Linux 内核中的 perf 事件进行性能分析。profile 工具会定期对每个处理器进行采样,以便捕获内核函数和用户空间函数的执行。它可以显示栈回溯的以下信息:

  • 地址:函数调用的内存地址
  • 符号:函数名称
  • 文件名:源代码文件名称
  • 行号:源代码中的行号

这些信息有助于开发人员定位性能瓶颈和优化代码。更进一步,可以通过这些对应的信息生成火焰图,以便更直观的查看性能数据。

在本示例中,可以通过 libbpf 库编译运行它(以 Ubuntu/Debian 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ git submodule update --init --recursive
$ sudo apt install clang libelf1 libelf-dev zlib1g-dev
$ make
$ sudo ./profile
COMM: chronyd (pid=156) @ CPU 1
Kernel:
0 [<ffffffff81ee9f56>] _raw_spin_lock_irqsave+0x16
1 [<ffffffff811527b4>] remove_wait_queue+0x14
2 [<ffffffff8132611d>] poll_freewait+0x3d
3 [<ffffffff81326d3f>] do_select+0x7bf
4 [<ffffffff81327af2>] core_sys_select+0x182
5 [<ffffffff81327f3a>] __x64_sys_pselect6+0xea
6 [<ffffffff81ed9e38>] do_syscall_64+0x38
7 [<ffffffff82000099>] entry_SYSCALL_64_after_hwframe+0x61
Userspace:
0 [<00007fab187bfe09>]
1 [<000000000ee6ae98>]

COMM: profile (pid=9843) @ CPU 6
No Kernel Stack
Userspace:
0 [<0000556deb068ac8>]
1 [<0000556dec34cad0>]

实现原理

profile 工具由两个部分组成,内核态中的 eBPF 程序和用户态中的 profile 符号处理程序。profile 符号处理程序负责加载 eBPF 程序,以及处理 eBPF 程序输出的数据。

内核态部分

内核态 eBPF 程序的实现逻辑主要是借助 perf event,对程序的堆栈进行定时采样,从而捕获程序的执行流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
/* Copyright (c) 2022 Meta Platforms, Inc. */
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

#include "profile.h"

char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");

SEC("perf_event")
int profile(void *ctx)
{
int pid = bpf_get_current_pid_tgid() >> 32;
int cpu_id = bpf_get_smp_processor_id();
struct stacktrace_event *event;
int cp;

event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event)
return 1;

event->pid = pid;
event->cpu_id = cpu_id;

if (bpf_get_current_comm(event->comm, sizeof(event->comm)))
event->comm[0] = 0;

event->kstack_sz = bpf_get_stack(ctx, event->kstack, sizeof(event->kstack), 0);

event->ustack_sz = bpf_get_stack(ctx, event->ustack, sizeof(event->ustack), BPF_F_USER_STACK);

bpf_ringbuf_submit(event, 0);

return 0;
}

接下来,我们将重点讲解内核态代码的关键部分。

定义 eBPF maps events

1
2
3
4
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");

这里定义了一个类型为 BPF_MAP_TYPE_RINGBUF 的 eBPF maps 。Ring Buffer 是一种高性能的循环缓冲区,用于在内核和用户空间之间传输数据。max_entries 设置了 Ring Buffer 的最大大小。

定义 perf_event eBPF 程序:

1
2
SEC("perf_event")
int profile(void *ctx)

这里定义了一个名为 profile 的 eBPF 程序,它将在 perf 事件触发时执行。

获取进程 ID 和 CPU ID:

1
2
int pid = bpf_get_current_pid_tgid() >> 32;
int cpu_id = bpf_get_smp_processor_id();

bpf_get_current_pid_tgid() 函数返回当前进程的 PID 和 TID,通过右移 32 位,我们得到 PID。bpf_get_smp_processor_id() 函数返回当前 CPU 的 ID。

预留 Ring Buffer 空间:

1
2
3
event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event)
return 1;

通过 bpf_ringbuf_reserve() 函数预留 Ring Buffer 空间,用于存储采集的栈信息。若预留失败,返回错误.

获取当前进程名:

1
2
if (bpf_get_current_comm(event->comm, sizeof(event->comm)))
event->comm[0] = 0;

使用 bpf_get_current_comm() 函数获取当前进程名并将其存储到 event->comm

获取内核栈信息:

1
event->kstack_sz = bpf_get_stack(ctx, event->kstack, sizeof(event->kstack), 0);

使用 bpf_get_stack() 函数获取内核栈信息。将结果存储在 event->kstack,并将其大小存储在 event->kstack_sz

获取用户空间栈信息:

1
event->ustack_sz = bpf_get_stack(ctx, event->ustack, sizeof(event->ustack), BPF_F_USER_STACK);

同样使用 bpf_get_stack() 函数,但传递 BPF_F_USER_STACK 标志以获取用户空间栈信息。将结果存储在 event->ustack,并将其大小存储在 event->ustack_sz

将事件提交到 Ring Buffer:

1
bpf_ringbuf_submit(event, 0);

最后,使用 bpf_ringbuf_submit() 函数将事件提交到 Ring Buffer,以便用户空间程序可以读取和处理。

这个内核态 eBPF 程序通过定期采样程序的内核栈和用户空间栈来捕获程序的执行流程。这些数据将存储在 Ring Buffer 中,以便用户态的 profile 程序能读取。

用户态部分

这段代码主要负责为每个在线 CPU 设置 perf event 并附加 eBPF 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static long perf_event_open(struct perf_event_attr *hw_event, pid_t pid,
int cpu, int group_fd, unsigned long flags)
{
int ret;

ret = syscall(__NR_perf_event_open, hw_event, pid, cpu, group_fd, flags);
return ret;
}

int main(){
...
for (cpu = 0; cpu < num_cpus; cpu++) {
/* skip offline/not present CPUs */
if (cpu >= num_online_cpus || !online_mask[cpu])
continue;

/* Set up performance monitoring on a CPU/Core */
pefd = perf_event_open(&attr, pid, cpu, -1, PERF_FLAG_FD_CLOEXEC);
if (pefd < 0) {
fprintf(stderr, "Fail to set up performance monitor on a CPU/Core\n");
err = -1;
goto cleanup;
}
pefds[cpu] = pefd;

/* Attach a BPF program on a CPU */
links[cpu] = bpf_program__attach_perf_event(skel->progs.profile, pefd);
if (!links[cpu]) {
err = -1;
goto cleanup;
}
}
...
}

perf_event_open 这个函数是一个对 perf_event_open 系统调用的封装。它接收一个 perf_event_attr 结构体指针,用于指定 perf event 的类型和属性。pid 参数用于指定要监控的进程 ID(-1 表示监控所有进程),cpu 参数用于指定要监控的 CPU。group_fd 参数用于将 perf event 分组,这里我们使用 -1,表示不需要分组。flags 参数用于设置一些标志,这里我们使用 PERF_FLAG_FD_CLOEXEC 以确保在执行 exec 系列系统调用时关闭文件描述符。

在 main 函数中:

1
2
3
for (cpu = 0; cpu < num_cpus; cpu++) {
// ...
}

这个循环针对每个在线 CPU 设置 perf event 并附加 eBPF 程序。首先,它会检查当前 CPU 是否在线,如果不在线则跳过。然后,使用 perf_event_open() 函数为当前 CPU 设置 perf event,并将返回的文件描述符存储在 pefds 数组中。最后,使用 bpf_program__attach_perf_event() 函数将 eBPF 程序附加到 perf event。links 数组用于存储每个 CPU 上的 BPF 链接,以便在程序结束时销毁它们。

通过这种方式,用户态程序为每个在线 CPU 设置 perf event,并将 eBPF 程序附加到这些 perf event 上,从而实现对系统中所有在线 CPU 的监控。

以下这两个函数分别用于显示栈回溯和处理从 ring buffer 接收到的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
static void show_stack_trace(__u64 *stack, int stack_sz, pid_t pid)
{
const struct blazesym_result *result;
const struct blazesym_csym *sym;
sym_src_cfg src;
int i, j;

if (pid) {
src.src_type = SRC_T_PROCESS;
src.params.process.pid = pid;
} else {
src.src_type = SRC_T_KERNEL;
src.params.kernel.kallsyms = NULL;
src.params.kernel.kernel_image = NULL;
}

result = blazesym_symbolize(symbolizer, &src, 1, (const uint64_t *)stack, stack_sz);

for (i = 0; i < stack_sz; i++) {
if (!result || result->size <= i || !result->entries[i].size) {
printf(" %d [<%016llx>]\n", i, stack[i]);
continue;
}

if (result->entries[i].size == 1) {
sym = &result->entries[i].syms[0];
if (sym->path && sym->path[0]) {
printf(" %d [<%016llx>] %s+0x%llx %s:%ld\n",
i, stack[i], sym->symbol,
stack[i] - sym->start_address,
sym->path, sym->line_no);
} else {
printf(" %d [<%016llx>] %s+0x%llx\n",
i, stack[i], sym->symbol,
stack[i] - sym->start_address);
}
continue;
}

printf(" %d [<%016llx>]\n", i, stack[i]);
for (j = 0; j < result->entries[i].size; j++) {
sym = &result->entries[i].syms[j];
if (sym->path && sym->path[0]) {
printf(" %s+0x%llx %s:%ld\n",
sym->symbol, stack[i] - sym->start_address,
sym->path, sym->line_no);
} else {
printf(" %s+0x%llx\n", sym->symbol,
stack[i] - sym->start_address);
}
}
}

blazesym_result_free(result);
}

/* Receive events from the ring buffer. */
static int event_handler(void *_ctx, void *data, size_t size)
{
struct stacktrace_event *event = data;

if (event->kstack_sz <= 0 && event->ustack_sz <= 0)
return 1;

printf("COMM: %s (pid=%d) @ CPU %d\n", event->comm, event->pid, event->cpu_id);

if (event->kstack_sz > 0) {
printf("Kernel:\n");
show_stack_trace(event->kstack, event->kstack_sz / sizeof(__u64), 0);
} else {
printf("No Kernel Stack\n");
}

if (event->ustack_sz > 0) {
printf("Userspace:\n");
show_stack_trace(event->ustack, event->ustack_sz / sizeof(__u64), event->pid);
} else {
printf("No Userspace Stack\n");
}

printf("\n");
return 0;
}

show_stack_trace() 函数用于显示内核或用户空间的栈回溯。它接收一个 stack 参数,是一个指向内核或用户空间栈的指针,stack_sz 参数表示栈的大小,pid 参数表示要显示的进程的 ID(当显示内核栈时,设置为 0)。函数中首先根据 pid 参数确定栈的来源(内核或用户空间),然后调用 blazesym_symbolize() 函数将栈中的地址解析为符号名和源代码位置。最后,遍历解析结果,输出符号名和源代码位置信息。

event_handler() 函数用于处理从 ring buffer 接收到的事件。它接收一个 data 参数,指向 ring buffer 中的数据,size 参数表示数据的大小。函数首先将 data 指针转换为 stacktrace_event 结构体指针,然后检查内核和用户空间栈的大小。如果栈为空,则直接返回。接下来,函数输出进程名称、进程 ID 和 CPU ID 信息。然后分别显示内核栈和用户空间栈的回溯。调用 show_stack_trace() 函数时,分别传入内核栈和用户空间栈的地址、大小和进程 ID。

这两个函数作为 eBPF profile 工具的一部分,用于显示和处理 eBPF 程序收集到的栈回溯信息,帮助用户了解程序的运行情况和性能瓶颈。

总结

通过本篇 eBPF 入门实践教程,我们学习了如何使用 eBPF 程序进行性能分析。在这个过程中,我们详细讲解了如何创建 eBPF 程序,监控进程的性能,并从 ring buffer 中获取数据以分析栈回溯。我们还学习了如何使用 perf_event_open() 函数设置性能监控,并将 BPF 程序附加到性能事件上。在本教程中,我们还展示了如何编写 eBPF 程序来捕获进程的内核和用户空间栈信息,进而分析程序性能瓶颈。通过这个例子,您可以了解到 eBPF 在性能分析方面的强大功能。

统计 TCP 连接延时,并使用 libbpf 在用户态处理数据

eBPF (Extended Berkeley Packet Filter) 是一项强大的网络和性能分析工具,被应用在 Linux 内核上。eBPF 允许开发者动态加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。

本文是 eBPF 入门开发实践教程的第十三篇,主要介绍如何使用 eBPF 统计 TCP 连接延时,并使用 libbpf 在用户态处理数据。

背景

在进行后端开发时,不论使用何种编程语言,我们都常常需要调用 MySQL、Redis 等数据库,或执行一些 RPC 远程调用,或者调用其他的 RESTful API。这些调用的底层,通常都是基于 TCP 协议进行的。原因是 TCP 协议具有可靠连接、错误重传、拥塞控制等优点,因此在网络传输层协议中,TCP 的应用广泛程度超过了 UDP。然而,TCP 也有一些缺点,如建立连接的延时较长。因此,也出现了一些替代方案,例如 QUIC(Quick UDP Internet Connections,快速 UDP 网络连接)。

分析 TCP 连接延时对网络性能分析、优化以及故障排查都非常有用。

tcpconnlat 工具概述

tcpconnlat 这个工具能够跟踪内核中执行活动 TCP 连接的函数(如通过 connect() 系统调用),并测量并显示连接延时,即从发送 SYN 到收到响应包的时间。

TCP 连接原理

TCP 连接的建立过程,常被称为“三次握手”(Three-way Handshake)。以下是整个过程的步骤:

  1. 客户端向服务器发送 SYN 包:客户端通过 connect() 系统调用发出 SYN。这涉及到本地的系统调用以及软中断的 CPU 时间开销。
  2. SYN 包传送到服务器:这是一次网络传输,涉及到的时间取决于网络延迟。
  3. 服务器处理 SYN 包:服务器内核通过软中断接收包,然后将其放入半连接队列,并发送 SYN/ACK 响应。这主要涉及 CPU 时间开销。
  4. SYN/ACK 包传送到客户端:这是另一次网络传输。
  5. 客户端处理 SYN/ACK:客户端内核接收并处理 SYN/ACK 包,然后发送 ACK。这主要涉及软中断处理开销。
  6. ACK 包传送到服务器:这是第三次网络传输。
  7. 服务器接收 ACK:服务器内核接收并处理 ACK,然后将对应的连接从半连接队列移动到全连接队列。这涉及到一次软中断的 CPU 开销。
  8. 唤醒服务器端用户进程:被 accept() 系统调用阻塞的用户进程被唤醒,然后从全连接队列中取出来已经建立好的连接。这涉及一次上下文切换的CPU开销。

完整的流程图如下所示:

tcpconnlat1

在客户端视角,在正常情况下一次TCP连接总的耗时也就就大约是一次网络RTT的耗时。但在某些情况下,可能会导致连接时的网络传输耗时上涨、CPU处理开销增加、甚至是连接失败。这种时候在发现延时过长之后,就可以结合其他信息进行分析。

tcpconnlat 的 eBPF 实现

为了理解 TCP 的连接建立过程,我们需要理解 Linux 内核在处理 TCP 连接时所使用的两个队列:

  • 半连接队列(SYN 队列):存储那些正在进行三次握手操作的 TCP 连接,服务器收到 SYN 包后,会将该连接信息存储在此队列中。
  • 全连接队列(Accept 队列):存储已经完成三次握手,等待应用程序调用 accept() 函数的 TCP 连接。服务器在收到 ACK 包后,会创建一个新的连接并将其添加到此队列。

理解了这两个队列的用途,我们就可以开始探究 tcpconnlat 的具体实现。tcpconnlat 的实现可以分为内核态和用户态两个部分,其中包括了几个主要的跟踪点:tcp_v4_connect, tcp_v6_connecttcp_rcv_state_process

这些跟踪点主要位于内核中的 TCP/IP 网络栈。当执行相关的系统调用或内核函数时,这些跟踪点会被激活,从而触发 eBPF 程序的执行。这使我们能够捕获和测量 TCP 连接建立的整个过程。

让我们先来看一下这些挂载点的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(tcp_v4_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("kprobe/tcp_v6_connect")
int BPF_KPROBE(tcp_v6_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("kprobe/tcp_rcv_state_process")
int BPF_KPROBE(tcp_rcv_state_process, struct sock *sk)
{
return handle_tcp_rcv_state_process(ctx, sk);
}

这段代码展示了三个内核探针(kprobe)的定义。tcp_v4_connecttcp_v6_connect 在对应的 IPv4 和 IPv6 连接被初始化时被触发,调用 trace_connect() 函数,而 tcp_rcv_state_process 在内核处理 TCP 连接状态变化时被触发,调用 handle_tcp_rcv_state_process() 函数。

接下来的部分将分为两大块:一部分是对这些挂载点内核态部分的分析,我们将解读内核源代码来详细说明这些函数如何工作;另一部分是用户态的分析,将关注 eBPF 程序如何收集这些挂载点的数据,以及如何与用户态程序进行交互。

tcp_v4_connect 函数解析

tcp_v4_connect函数是Linux内核处理TCP的IPv4连接请求的主要方式。当用户态程序通过socket系统调用创建了一个套接字后,接着通过connect系统调用尝试连接到远程服务器,此时就会触发tcp_v4_connect函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/* This will initiate an outgoing connection. */
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
struct sockaddr_in *usin = (struct sockaddr_in *)uaddr;
struct inet_timewait_death_row *tcp_death_row;
struct inet_sock *inet = inet_sk(sk);
struct tcp_sock *tp = tcp_sk(sk);
struct ip_options_rcu *inet_opt;
struct net *net = sock_net(sk);
__be16 orig_sport, orig_dport;
__be32 daddr, nexthop;
struct flowi4 *fl4;
struct rtable *rt;
int err;

if (addr_len < sizeof(struct sockaddr_in))
return -EINVAL;

if (usin->sin_family != AF_INET)
return -EAFNOSUPPORT;

nexthop = daddr = usin->sin_addr.s_addr;
inet_opt = rcu_dereference_protected(inet->inet_opt,
lockdep_sock_is_held(sk));
if (inet_opt && inet_opt->opt.srr) {
if (!daddr)
return -EINVAL;
nexthop = inet_opt->opt.faddr;
}

orig_sport = inet->inet_sport;
orig_dport = usin->sin_port;
fl4 = &inet->cork.fl.u.ip4;
rt = ip_route_connect(fl4, nexthop, inet->inet_saddr,
sk->sk_bound_dev_if, IPPROTO_TCP, orig_sport,
orig_dport, sk);
if (IS_ERR(rt)) {
err = PTR_ERR(rt);
if (err == -ENETUNREACH)
IP_INC_STATS(net, IPSTATS_MIB_OUTNOROUTES);
return err;
}

if (rt->rt_flags & (RTCF_MULTICAST | RTCF_BROADCAST)) {
ip_rt_put(rt);
return -ENETUNREACH;
}

if (!inet_opt || !inet_opt->opt.srr)
daddr = fl4->daddr;

tcp_death_row = &sock_net(sk)->ipv4.tcp_death_row;

if (!inet->inet_saddr) {
err = inet_bhash2_update_saddr(sk, &fl4->saddr, AF_INET);
if (err) {
ip_rt_put(rt);
return err;
}
} else {
sk_rcv_saddr_set(sk, inet->inet_saddr);
}

if (tp->rx_opt.ts_recent_stamp && inet->inet_daddr != daddr) {
/* Reset inherited state */
tp->rx_opt.ts_recent = 0;
tp->rx_opt.ts_recent_stamp = 0;
if (likely(!tp->repair))
WRITE_ONCE(tp->write_seq, 0);
}

inet->inet_dport = usin->sin_port;
sk_daddr_set(sk, daddr);

inet_csk(sk)->icsk_ext_hdr_len = 0;
if (inet_opt)
inet_csk(sk)->icsk_ext_hdr_len = inet_opt->opt.optlen;

tp->rx_opt.mss_clamp = TCP_MSS_DEFAULT;

/* Socket identity is still unknown (sport may be zero).
* However we set state to SYN-SENT and not releasing socket
* lock select source port, enter ourselves into the hash tables and
* complete initialization after this.
*/
tcp_set_state(sk, TCP_SYN_SENT);
err = inet_hash_connect(tcp_death_row, sk);
if (err)
goto failure;

sk_set_txhash(sk);

rt = ip_route_newports(fl4, rt, orig_sport, orig_dport,
inet->inet_sport, inet->inet_dport, sk);
if (IS_ERR(rt)) {
err = PTR_ERR(rt);
rt = NULL;
goto failure;
}
/* OK, now commit destination to socket. */
sk->sk_gso_type = SKB_GSO_TCPV4;
sk_setup_caps(sk, &rt->dst);
rt = NULL;

if (likely(!tp->repair)) {
if (!tp->write_seq)
WRITE_ONCE(tp->write_seq,
secure_tcp_seq(inet->inet_saddr,
inet->inet_daddr,
inet->inet_sport,
usin->sin_port));
tp->tsoffset = secure_tcp_ts_off(net, inet->inet_saddr,
inet->inet_daddr);
}

inet->inet_id = get_random_u16();

if (tcp_fastopen_defer_connect(sk, &err))
return err;
if (err)
goto failure;

err = tcp_connect(sk);

if (err)
goto failure;

return 0;

failure:
/*
* This unhashes the socket and releases the local port,
* if necessary.
*/
tcp_set_state(sk, TCP_CLOSE);
inet_bhash2_reset_saddr(sk);
ip_rt_put(rt);
sk->sk_route_caps = 0;
inet->inet_dport = 0;
return err;
}
EXPORT_SYMBOL(tcp_v4_connect);

参考链接:https://elixir.bootlin.com/linux/latest/source/net/ipv4/tcp_ipv4.c#L340

接下来,我们一步步分析这个函数:

首先,这个函数接收三个参数:一个套接字指针sk,一个指向套接字地址结构的指针uaddr和地址的长度addr_len

1
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)

函数一开始就进行了参数检查,确认地址长度正确,而且地址的协议族必须是IPv4。不满足这些条件会导致函数返回错误。

接下来,函数获取目标地址,如果设置了源路由选项(这是一个高级的IP特性,通常不会被使用),那么它还会获取源路由的下一跳地址。

1
2
3
4
5
6
7
8
nexthop = daddr = usin->sin_addr.s_addr;
inet_opt = rcu_dereference_protected(inet->inet_opt,
lockdep_sock_is_held(sk));
if (inet_opt && inet_opt->opt.srr) {
if (!daddr)
return -EINVAL;
nexthop = inet_opt->opt.faddr;
}

然后,使用这些信息来寻找一个路由到目标地址的路由项。如果不能找到路由项或者路由项指向一个多播或广播地址,函数返回错误。

接下来,它更新了源地址,处理了一些TCP时间戳选项的状态,并设置了目标端口和地址。之后,它更新了一些其他的套接字和TCP选项,并设置了连接状态为SYN-SENT

然后,这个函数使用inet_hash_connect函数尝试将套接字添加到已连接的套接字的散列表中。如果这步失败,它会恢复套接字的状态并返回错误。

如果前面的步骤都成功了,接着,使用新的源和目标端口来更新路由项。如果这步失败,它会清理资源并返回错误。

接下来,它提交目标信息到套接字,并为之后的分段偏移选择一个安全的随机值。

然后,函数尝试使用TCP Fast Open(TFO)进行连接,如果不能使用TFO或者TFO尝试失败,它会使用普通的TCP三次握手进行连接。

最后,如果上面的步骤都成功了,函数返回成功,否则,它会清理所有资源并返回错误。

总的来说,tcp_v4_connect函数是一个处理TCP连接请求的复杂函数,它处理了很多情况,包括参数检查、路由查找、源地址选择、源路由、TCP选项处理、TCP Fast Open,等等。它的主要目标是尽可能安全和有效地建立TCP连接。

内核态代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// SPDX-License-Identifier: GPL-2.0
// Copyright (c) 2020 Wenbo Zhang
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>
#include "tcpconnlat.h"

#define AF_INET 2
#define AF_INET6 10

const volatile __u64 targ_min_us = 0;
const volatile pid_t targ_tgid = 0;

struct piddata {
char comm[TASK_COMM_LEN];
u64 ts;
u32 tgid;
};

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 4096);
__type(key, struct sock *);
__type(value, struct piddata);
} start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");

static int trace_connect(struct sock *sk)
{
u32 tgid = bpf_get_current_pid_tgid() >> 32;
struct piddata piddata = {};

if (targ_tgid && targ_tgid != tgid)
return 0;

bpf_get_current_comm(&piddata.comm, sizeof(piddata.comm));
piddata.ts = bpf_ktime_get_ns();
piddata.tgid = tgid;
bpf_map_update_elem(&start, &sk, &piddata, 0);
return 0;
}

static int handle_tcp_rcv_state_process(void *ctx, struct sock *sk)
{
struct piddata *piddatap;
struct event event = {};
s64 delta;
u64 ts;

if (BPF_CORE_READ(sk, __sk_common.skc_state) != TCP_SYN_SENT)
return 0;

piddatap = bpf_map_lookup_elem(&start, &sk);
if (!piddatap)
return 0;

ts = bpf_ktime_get_ns();
delta = (s64)(ts - piddatap->ts);
if (delta < 0)
goto cleanup;

event.delta_us = delta / 1000U;
if (targ_min_us && event.delta_us < targ_min_us)
goto cleanup;
__builtin_memcpy(&event.comm, piddatap->comm,
sizeof(event.comm));
event.ts_us = ts / 1000;
event.tgid = piddatap->tgid;
event.lport = BPF_CORE_READ(sk, __sk_common.skc_num);
event.dport = BPF_CORE_READ(sk, __sk_common.skc_dport);
event.af = BPF_CORE_READ(sk, __sk_common.skc_family);
if (event.af == AF_INET) {
event.saddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
event.daddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_daddr);
} else {
BPF_CORE_READ_INTO(&event.saddr_v6, sk,
__sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
BPF_CORE_READ_INTO(&event.daddr_v6, sk,
__sk_common.skc_v6_daddr.in6_u.u6_addr32);
}
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
&event, sizeof(event));

cleanup:
bpf_map_delete_elem(&start, &sk);
return 0;
}

SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(tcp_v4_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("kprobe/tcp_v6_connect")
int BPF_KPROBE(tcp_v6_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("kprobe/tcp_rcv_state_process")
int BPF_KPROBE(tcp_rcv_state_process, struct sock *sk)
{
return handle_tcp_rcv_state_process(ctx, sk);
}

SEC("fentry/tcp_v4_connect")
int BPF_PROG(fentry_tcp_v4_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("fentry/tcp_v6_connect")
int BPF_PROG(fentry_tcp_v6_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("fentry/tcp_rcv_state_process")
int BPF_PROG(fentry_tcp_rcv_state_process, struct sock *sk)
{
return handle_tcp_rcv_state_process(ctx, sk);
}

char LICENSE[] SEC("license") = "GPL";

这个eBPF(Extended Berkeley Packet Filter)程序主要用来监控并收集TCP连接的建立时间,即从发起TCP连接请求(connect系统调用)到连接建立完成(SYN-ACK握手过程完成)的时间间隔。这对于监测网络延迟、服务性能分析等方面非常有用。

首先,定义了两个eBPF maps:starteventsstart是一个哈希表,用于存储发起连接请求的进程信息和时间戳,而events是一个PERF_EVENT_ARRAY类型的map,用于将事件数据传输到用户态。

1
2
3
4
5
6
7
8
9
10
11
12
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 4096);
__type(key, struct sock *);
__type(value, struct piddata);
} start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");

tcp_v4_connecttcp_v6_connect的kprobe处理函数trace_connect中,会记录下发起连接请求的进程信息(进程名、进程ID和当前时间戳),并以socket结构作为key,存储到start这个map中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int trace_connect(struct sock *sk)
{
u32 tgid = bpf_get_current_pid_tgid() >> 32;
struct piddata piddata = {};

if (targ_tgid && targ_tgid != tgid)
return 0;

bpf_get_current_comm(&piddata.comm, sizeof(piddata.comm));
piddata.ts = bpf_ktime_get_ns();
piddata.tgid = tgid;
bpf_map_update_elem(&start, &sk, &piddata, 0);
return 0;
}

当TCP状态机处理到SYN-ACK包,即连接建立的时候,会触发tcp_rcv_state_process的kprobe处理函数handle_tcp_rcv_state_process。在这个函数中,首先检查socket的状态是否为SYN-SENT,如果是,会从start这个map中查找socket对应的进程信息。然后计算出从发起连接到现在的时间间隔,将该时间间隔,进程信息,以及TCP连接的详细信息(源端口,目标端口,源IP,目标IP等)作为event,通过bpf_perf_event_output函数发送到用户态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
static int handle_tcp_rcv_state_process(void *ctx, struct sock *sk)
{
struct piddata *piddatap;
struct event event = {};
s64 delta;
u64 ts;

if (BPF_CORE_READ(sk, __sk_common.skc_state) != TCP_SYN_SENT)
return 0;

piddatap = bpf_map_lookup_elem(&start, &sk);
if (!piddatap)
return 0;

ts = bpf_ktime_get_ns();
delta = (s64)(ts - piddatap->ts);
if (delta < 0)
goto cleanup;

event.delta_us = delta / 1000U;
if (targ_min_us && event.delta_us < targ_min_us)
goto

cleanup;
__builtin_memcpy(&event.comm, piddatap->comm,
sizeof(event.comm));
event.ts_us = ts / 1000;
event.tgid = piddatap->tgid;
event.lport = BPF_CORE_READ(sk, __sk_common.skc_num);
event.dport = BPF_CORE_READ(sk, __sk_common.skc_dport);
event.af = BPF_CORE_READ(sk, __sk_common.skc_family);
if (event.af == AF_INET) {
event.saddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
event.daddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_daddr);
} else {
BPF_CORE_READ_INTO(&event.saddr_v6, sk,
__sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
BPF_CORE_READ_INTO(&event.daddr_v6, sk,
__sk_common.skc_v6_daddr.in6_u.u6_addr32);
}
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
&event, sizeof(event));

cleanup:
bpf_map_delete_elem(&start, &sk);
return 0;
}

理解这个程序的关键在于理解Linux内核的网络栈处理流程,以及eBPF程序的运行模式。Linux内核网络栈对TCP连接建立的处理过程是,首先调用tcp_v4_connecttcp_v6_connect函数(根据IP版本不同)发起TCP连接,然后在收到SYN-ACK包时,通过tcp_rcv_state_process函数来处理。eBPF程序通过在这两个关键函数上设置kprobe,可以在关键时刻得到通知并执行相应的处理代码。

一些关键概念说明:

  • kprobe:Kernel Probe,是Linux内核中用于动态追踪内核行为的机制。可以在内核函数的入口和退出处设置断点,当断点被触发时,会执行与kprobe关联的eBPF程序。
  • map:是eBPF程序中的一种数据结构,用于在内核态和用户态之间共享数据。
  • socket:在Linux网络编程中,socket是一个抽象概念,表示一个网络连接的端点。内核中的struct sock结构就是对socket的实现。

用户态数据处理

用户态数据处理是使用perf_buffer__poll来接收并处理从内核发送到用户态的eBPF事件。perf_buffer__poll是libbpf库提供的一个便捷函数,用于轮询perf event buffer并处理接收到的数据。

首先,让我们详细看一下主轮询循环:

1
2
3
4
5
6
7
8
9
10
/* main: poll */
while (!exiting) {
err = perf_buffer__poll(pb, PERF_POLL_TIMEOUT_MS);
if (err < 0 && err != -EINTR) {
fprintf(stderr, "error polling perf buffer: %s\n", strerror(-err));
goto cleanup;
}
/* reset err to return 0 if exiting */
err = 0;
}

这段代码使用一个while循环来反复轮询perf event buffer。如果轮询出错(例如由于信号中断),会打印出错误消息。这个轮询过程会一直持续,直到收到一个退出标志exiting

接下来,让我们来看看handle_event函数,这个函数将处理从内核发送到用户态的每一个eBPF事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void handle_event(void* ctx, int cpu, void* data, __u32 data_sz) {
const struct event* e = data;
char src[INET6_ADDRSTRLEN];
char dst[INET6_ADDRSTRLEN];
union {
struct in_addr x4;
struct in6_addr x6;
} s, d;
static __u64 start_ts;

if (env.timestamp) {
if (start_ts == 0)
start_ts = e->ts_us;
printf("%-9.3f ", (e->ts_us - start_ts) / 1000000.0);
}
if (e->af == AF_INET) {
s.x4.s_addr = e->saddr_v4;
d.x4.s_addr = e->daddr_v4;
} else if (e->af == AF_INET6) {
memcpy(&s.x6.s6_addr, e->saddr_v6, sizeof(s.x6.s6_addr));
memcpy(&d.x6.s6_addr, e->daddr_v6, sizeof(d.x6.s6_addr));
} else {
fprintf(stderr, "broken event: event->af=%d", e->af);
return;
}

if (env.lport) {
printf("%-6d %-12.12s %-2d %-16s %-6d %-16s %-5d %.2f\n", e->tgid,
e->comm, e->af == AF_INET ? 4 : 6,
inet_ntop(e->af, &s, src, sizeof(src)), e->lport,
inet_ntop(e->af, &d, dst, sizeof(dst)), ntohs(e->dport),
e->delta_us / 1000.0);
} else {
printf("%-6d %-12.12s %-2d %-16s %-16s %-5d %.2f\n", e->tgid, e->comm,
e->af == AF_INET ? 4 : 6, inet_ntop(e->af, &s, src, sizeof(src)),
inet_ntop(e->af, &d, dst, sizeof(dst)), ntohs(e->dport),
e->delta_us / 1000.0);
}
}

handle_event函数的参数包括了CPU编号、指向数据的指针以及数据的大小。数据是一个event结构体,包含了之前在内核态计算得到的TCP连接的信息。

首先,它将接收到的事件的时间戳和起始时间戳(如果存在)进行对比,计算出事件的相对时间,并打印出来。接着,根据IP地址的类型(IPv4或IPv6),将源地址和目标地址从网络字节序转换为主机字节序。

最后,根据用户是否选择了显示本地端口,将进程ID、进程名称、IP版本、源IP地址、本地端口(如果有)、目标IP地址、目标端口以及连接建立时间打印出来。这个连接建立时间是我们在内核态eBPF程序中计算并发送到用户态的。

编译运行

1
2
3
4
5
6
7
8
9
10
11
12
$ make
...
BPF .output/tcpconnlat.bpf.o
GEN-SKEL .output/tcpconnlat.skel.h
CC .output/tcpconnlat.o
BINARY tcpconnlat
$ sudo ./tcpconnlat
PID COMM IP SADDR DADDR DPORT LAT(ms)
222564 wget 4 192.168.88.15 110.242.68.3 80 25.29
222684 wget 4 192.168.88.15 167.179.101.42 443 246.76
222726 ssh 4 192.168.88.15 167.179.101.42 22 241.17
222774 ssh 4 192.168.88.15 1.15.149.151 22 25.31

源代码:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/13-tcpconnlat

总结

通过本篇 eBPF 入门实践教程,我们学习了如何使用 eBPF 来跟踪和统计 TCP 连接建立的延时。我们首先深入探讨了 eBPF 程序如何在内核态监听特定的内核函数,然后通过捕获这些函数的调用,从而得到连接建立的起始时间和结束时间,计算出延时。

我们还进一步了解了如何使用 BPF maps 来在内核态存储和查询数据,从而在 eBPF 程序的多个部分之间共享数据。同时,我们也探讨了如何使用 perf events 来将数据从内核态发送到用户态,以便进一步处理和展示。

在用户态,我们介绍了如何使用 libbpf 库的 API,例如 perf_buffer__poll,来接收和处理内核态发送过来的数据。我们还讲解了如何对这些数据进行解析和打印,使得它们能以人类可读的形式显示出来。

记录 TCP 连接状态与 TCP RTT

eBPF (扩展的伯克利数据包过滤器) 是一项强大的网络和性能分析工具,被广泛应用在 Linux 内核上。eBPF 使得开发者能够动态地加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。

在我们的 eBPF 入门实践教程系列的这一篇,我们将介绍两个示例程序:tcpstatestcprtttcpstates 用于记录 TCP 连接的状态变化,而 tcprtt 则用于记录 TCP 的往返时间 (RTT, Round-Trip Time)。

tcprtttcpstates

网络质量在当前的互联网环境中至关重要。影响网络质量的因素有许多,包括硬件、网络环境、软件编程的质量等。为了帮助用户更好地定位网络问题,我们引入了 tcprtt 这个工具。tcprtt 可以监控 TCP 链接的往返时间,从而评估网络质量,帮助用户找出可能的问题所在。

当 TCP 链接建立时,tcprtt 会自动根据当前系统的状况,选择合适的执行函数。在执行函数中,tcprtt 会收集 TCP 链接的各项基本信息,如源地址、目标地址、源端口、目标端口、耗时等,并将这些信息更新到直方图型的 BPF map 中。运行结束后,tcprtt 会通过用户态代码,将收集的信息以图形化的方式展示给用户。

tcpstates 则是一个专门用来追踪和打印 TCP 连接状态变化的工具。它可以显示 TCP 连接在每个状态中的停留时长,单位为毫秒。例如,对于一个单独的 TCP 会话,tcpstates 可以打印出类似以下的输出:

1
2
3
4
5
6
7
8
SKADDR           C-PID C-COMM     LADDR           LPORT RADDR           RPORT OLDSTATE    -> NEWSTATE    MS
ffff9fd7e8192000 22384 curl 100.66.100.185 0 52.33.159.26 80 CLOSE -> SYN_SENT 0.000
ffff9fd7e8192000 0 swapper/5 100.66.100.185 63446 52.33.159.26 80 SYN_SENT -> ESTABLISHED 1.373
ffff9fd7e8192000 22384 curl 100.66.100.185 63446 52.33.159.26 80 ESTABLISHED -> FIN_WAIT1 176.042
ffff9fd7e819

2000 0 swapper/5 100.66.100.185 63446 52.33.159.26 80 FIN_WAIT1 -> FIN_WAIT2 0.536
ffff9fd7e8192000 0 swapper/5 100.66.100.185 63446 52.33.159.26 80 FIN_WAIT2 -> CLOSE 0.006

以上输出中,最多的时间被花在了 ESTABLISHED 状态,也就是连接已经建立并在传输数据的状态,这个状态到 FIN_WAIT1 状态(开始关闭连接的状态)的转变过程中耗费了 176.042 毫秒。

在我们接下来的教程中,我们会更深入地探讨这两个工具,解释它们的实现原理,希望这些内容对你在使用 eBPF 进行网络和性能分析方面的工作有所帮助。

tcpstate

由于篇幅所限,这里我们主要讨论和分析对应的 eBPF 内核态代码实现。以下是 tcpstate 的 eBPF 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
const volatile bool filter_by_sport = false;
const volatile bool filter_by_dport = false;
const volatile short target_family = 0;

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, __u16);
__type(value, __u16);
} sports SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, __u16);
__type(value, __u16);
} dports SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, struct sock *);
__type(value, __u64);
} timestamps SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(__u32));
__uint(value_size, sizeof(__u32));
} events SEC(".maps");

SEC("tracepoint/sock/inet_sock_set_state")
int handle_set_state(struct trace_event_raw_inet_sock_set_state *ctx)
{
struct sock *sk = (struct sock *)ctx->skaddr;
__u16 family = ctx->family;
__u16 sport = ctx->sport;
__u16 dport = ctx->dport;
__u64 *tsp, delta_us, ts;
struct event event = {};

if (ctx->protocol != IPPROTO_TCP)
return 0;

if (target_family && target_family != family)
return 0;

if (filter_by_sport && !bpf_map_lookup_elem(&sports, &sport))
return 0;

if (filter_by_dport && !bpf_map_lookup_elem(&dports, &dport))
return 0;

tsp = bpf_map_lookup_elem(&timestamps, &sk);
ts = bpf_ktime_get_ns();
if (!tsp)
delta_us = 0;
else
delta_us = (ts - *tsp) / 1000;

event.skaddr = (__u64)sk;
event.ts_us = ts / 1000;
event.delta_us = delta_us;
event.pid = bpf_get_current_pid_tgid() >> 32;
event.oldstate = ctx->oldstate;
event.newstate = ctx->newstate;
event.family = family;
event.sport = sport;
event.dport = dport;
bpf_get_current_comm(&event.task, sizeof(event.task));

if (family == AF_INET) {
bpf_probe_read_kernel(&event.saddr, sizeof(event.saddr), &sk->__sk_common.skc_rcv_saddr);
bpf_probe_read_kernel(&event.daddr, sizeof(event.daddr), &sk->__sk_common.skc_daddr);
} else { /* family == AF_INET6 */
bpf_probe_read_kernel(&event.saddr, sizeof(event.saddr), &sk->__sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
bpf_probe_read_kernel(&event.daddr, sizeof(event.daddr), &sk->__sk_common.skc_v6_daddr.in6_u.u6_addr32);
}

bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));

if (ctx->newstate == TCP_CLOSE)
bpf_map_delete_elem(&timestamps, &sk);
else
bpf_map_update_elem(&timestamps, &sk, &ts, BPF_ANY);

return 0;
}

tcpstates主要依赖于 eBPF 的 Tracepoints 来捕获 TCP 连接的状态变化,从而跟踪 TCP 连接在每个状态下的停留时间。

定义 BPF Maps

tcpstates程序中,首先定义了几个 BPF Maps,它们是 eBPF 程序和用户态程序之间交互的主要方式。sportsdports分别用于存储源端口和目标端口,用于过滤 TCP 连接;timestamps用于存储每个 TCP 连接的时间戳,以计算每个状态的停留时间;events则是一个 perf_event 类型的 map,用于将事件数据发送到用户态。

追踪 TCP 连接状态变化

程序定义了一个名为handle_set_state的函数,该函数是一个 tracepoint 类型的程序,它将被挂载到sock/inet_sock_set_state这个内核 tracepoint 上。每当 TCP 连接状态发生变化时,这个 tracepoint 就会被触发,然后执行handle_set_state函数。

handle_set_state函数中,首先通过一系列条件判断确定是否需要处理当前的 TCP 连接,然后从timestampsmap 中获取当前连接的上一个时间戳,然后计算出停留在当前状态的时间。接着,程序将收集到的数据放入一个 event 结构体中,并通过bpf_perf_event_output函数将该 event 发送到用户态。

更新时间戳

最后,根据 TCP 连接的新状态,程序将进行不同的操作:如果新状态为 TCP_CLOSE,表示连接已关闭,程序将从timestampsmap 中删除该连接的时间戳;否则,程序将更新该连接的时间戳。

用户态的部分主要是通过 libbpf 来加载 eBPF 程序,然后通过 perf_event 来接收内核中的事件数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static void handle_event(void* ctx, int cpu, void* data, __u32 data_sz) {
char ts[32], saddr[26], daddr[26];
struct event* e = data;
struct tm* tm;
int family;
time_t t;

if (emit_timestamp) {
time(&t);
tm = localtime(&t);
strftime(ts, sizeof(ts), "%H:%M:%S", tm);
printf("%8s ", ts);
}

inet_ntop(e->family, &e->saddr, saddr, sizeof(saddr));
inet_ntop(e->family, &e->daddr, daddr, sizeof(daddr));
if (wide_output) {
family = e->family == AF_INET ? 4 : 6;
printf(
"%-16llx %-7d %-16s %-2d %-26s %-5d %-26s %-5d %-11s -> %-11s "
"%.3f\n",
e->skaddr, e->pid, e->task, family, saddr, e->sport, daddr,
e->dport, tcp_states[e->oldstate], tcp_states[e->newstate],
(double)e->delta_us / 1000);
} else {
printf(
"%-16llx %-7d %-10.10s %-15s %-5d %-15s %-5d %-11s -> %-11s %.3f\n",
e->skaddr, e->pid, e->task, saddr, e->sport, daddr, e->dport,
tcp_states[e->oldstate], tcp_states[e->newstate],
(double)e->delta_us / 1000);
}
}

handle_event就是这样一个回调函数,它会被 perf_event 调用,每当内核有新的事件到达时,它就会处理这些事件。

handle_event函数中,我们首先通过inet_ntop函数将二进制的 IP 地址转换成人类可读的格式,然后根据是否需要输出宽格式,分别打印不同的信息。这些信息包括了事件的时间戳、源 IP 地址、源端口、目标 IP 地址、目标端口、旧状态、新状态以及在旧状态停留的时间。

这样,用户就可以清晰地看到 TCP 连接状态的变化,以及每个状态的停留时间,从而帮助他们诊断网络问题。

总结起来,用户态部分的处理主要涉及到了以下几个步骤:

  1. 使用 libbpf 加载并运行 eBPF 程序。
  2. 设置回调函数来接收内核发送的事件。
  3. 处理接收到的事件,将其转换成人类可读的格式并打印。

以上就是tcpstates程序用户态部分的主要实现逻辑。通过这一章的学习,你应该已经对如何在用户态处理内核事件有了更深入的理解。在下一章中,我们将介绍更多关于如何使用 eBPF 进行网络监控的知识。

tcprtt

在本章节中,我们将分析tcprtt eBPF 程序的内核态代码。tcprtt是一个用于测量 TCP 往返时间(Round Trip Time, RTT)的程序,它将 RTT 的信息统计到一个 histogram 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/// @sample {"interval": 1000, "type" : "log2_hist"}
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u64);
__type(value, struct hist);
} hists SEC(".maps");

static struct hist zero;

SEC("fentry/tcp_rcv_established")
int BPF_PROG(tcp_rcv, struct sock *sk)
{
const struct inet_sock *inet = (struct inet_sock *)(sk);
struct tcp_sock *ts;
struct hist *histp;
u64 key, slot;
u32 srtt;

if (targ_sport && targ_sport != inet->inet_sport)
return 0;
if (targ_dport && targ_dport != sk->__sk_common.skc_dport)
return 0;
if (targ_saddr && targ_saddr != inet->inet_saddr)
return 0;
if (targ_daddr && targ_daddr != sk->__sk_common.skc_daddr)
return 0;

if (targ_laddr_hist)
key = inet->inet_saddr;
else if (targ_raddr_hist)
key = inet->sk.__sk_common.skc_daddr;
else
key = 0;
histp = bpf_map_lookup_or_try_init(&hists, &key, &zero);
if (!histp)
return 0;
ts = (struct tcp_sock *)(sk);
srtt = BPF_CORE_READ(ts, srtt_us) >> 3;
if (targ_ms)
srtt /= 1000U;
slot = log2l(srtt);
if (slot >= MAX_SLOTS)
slot = MAX_SLOTS - 1;
__sync_fetch_and_add(&histp->slots[slot], 1);
if (targ_show_ext) {
__sync_fetch_and_add(&histp->latency, srtt);
__sync_fetch_and_add(&histp->cnt, 1);
}
return 0;
}

首先,我们定义了一个 hash 类型的 eBPF map,名为hists,它用来存储 RTT 的统计信息。在这个 map 中,键是 64 位整数,值是一个hist结构,这个结构包含了一个数组,用来存储不同 RTT 区间的数量。

接着,我们定义了一个 eBPF 程序,名为tcp_rcv,这个程序会在每次内核中处理 TCP 收包的时候被调用。在这个程序中,我们首先根据过滤条件(源/目标 IP 地址和端口)对 TCP 连接进行过滤。如果满足条件,我们会根据设置的参数选择相应的 key(源 IP 或者目标 IP 或者 0),然后在hists map 中查找或者初始化对应的 histogram。

接下来,我们读取 TCP 连接的srtt_us字段,这个字段表示了平滑的 RTT 值,单位是微秒。然后我们将这个 RTT 值转换为对数形式,并将其作为 slot 存储到 histogram 中。

如果设置了show_ext参数,我们还会将 RTT 值和计数器累加到 histogram 的latencycnt字段中。

通过以上的处理,我们可以对每个 TCP 连接的 RTT 进行统计和分析,从而更好地理解网络的性能状况。

总结起来,tcprtt eBPF 程序的主要逻辑包括以下几个步骤:

  1. 根据过滤条件对 TCP 连接进行过滤。
  2. hists map 中查找或者初始化对应的 histogram。
  3. 读取 TCP 连接的srtt_us字段,并将其转换为对数形式,存储到 histogram 中。
  4. 如果设置了show_ext参数,将 RTT 值和计数器累加到 histogram 的latencycnt字段中。

tcprtt 挂载到了内核态的 tcp_rcv_established 函数上:

1
void tcp_rcv_established(struct sock *sk, struct sk_buff *skb);

这个函数是在内核中处理TCP接收数据的主要函数,主要在TCP连接处于ESTABLISHED状态时被调用。这个函数的处理逻辑包括一个快速路径和一个慢速路径。快速路径在以下几种情况下会被禁用:

  • 我们宣布了一个零窗口 - 零窗口探测只能在慢速路径中正确处理。
  • 收到了乱序的数据包。
  • 期待接收紧急数据。
  • 没有剩余的缓冲区空间。
  • 接收到了意外的TCP标志/窗口值/头部长度(通过检查TCP头部与预设标志进行检测)。
  • 数据在两个方向上都在传输。快速路径只支持纯发送者或纯接收者(这意味着序列号或确认值必须保持不变)。
  • 接收到了意外的TCP选项。

当这些条件不满足时,它会进入一个标准的接收处理过程,这个过程遵循RFC793来处理所有情况。前三种情况可以通过正确的预设标志设置来保证,剩下的情况则需要内联检查。当一切都正常时,快速处理过程会在tcp_data_queue函数中被开启。

编译运行

对于 tcpstates,可以通过以下命令编译和运行 libbpf 应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ make
...
BPF .output/tcpstates.bpf.o
GEN-SKEL .output/tcpstates.skel.h
CC .output/tcpstates.o
BINARY tcpstates
$ sudo ./tcpstates
SKADDR PID COMM LADDR LPORT RADDR RPORT OLDSTATE -> NEWSTATE MS
ffff9bf61bb62bc0 164978 node 192.168.88.15 0 52.178.17.2 443 CLOSE -> SYN_SENT 0.000
ffff9bf61bb62bc0 0 swapper/0 192.168.88.15 41596 52.178.17.2 443 SYN_SENT -> ESTABLISHED 225.794
ffff9bf61bb62bc0 0 swapper/0 192.168.88.15 41596 52.178.17.2 443 ESTABLISHED -> CLOSE_WAIT 901.454
ffff9bf61bb62bc0 164978 node 192.168.88.15 41596 52.178.17.2 443 CLOSE_WAIT -> LAST_ACK 0.793
ffff9bf61bb62bc0 164978 node 192.168.88.15 41596 52.178.17.2 443 LAST_ACK -> LAST_ACK 0.086
ffff9bf61bb62bc0 228759 kworker/u6 192.168.88.15 41596 52.178.17.2 443 LAST_ACK -> CLOSE 0.193
ffff9bf6d8ee88c0 229832 redis-serv 0.0.0.0 6379 0.0.0.0 0 CLOSE -> LISTEN 0.000
ffff9bf6d8ee88c0 229832 redis-serv 0.0.0.0 6379 0.0.0.0 0 LISTEN -> CLOSE 1.763
ffff9bf7109d6900 88750 node 127.0.0.1 39755 127.0.0.1 50966 ESTABLISHED -> FIN_WAIT1 0.000

对于 tcprtt,我们可以使用 eunomia-bpf 编译运行这个例子:

Compile:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

或者

1
2
3
4
$ ecc runqlat.bpf.c runqlat.h
Compiling bpf object...
Generating export types...
Packing ebpf object and config into package.json...

运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
$ sudo ecli run package.json -h
A simple eBPF program


Usage: package.json [OPTIONS]

Options:
--verbose Whether to show libbpf debug information
--targ_laddr_hist Set value of `bool` variable targ_laddr_hist
--targ_raddr_hist Set value of `bool` variable targ_raddr_hist
--targ_show_ext Set value of `bool` variable targ_show_ext
--targ_sport <targ_sport> Set value of `__u16` variable targ_sport
--targ_dport <targ_dport> Set value of `__u16` variable targ_dport
--targ_saddr <targ_saddr> Set value of `__u32` variable targ_saddr
--targ_daddr <targ_daddr> Set value of `__u32` variable targ_daddr
--targ_ms Set value of `bool` variable targ_ms
-h, --help Print help
-V, --version Print version

Built with eunomia-bpf framework.
See https://github.com/eunomia-bpf/eunomia-bpf for more information.

$ sudo ecli run package.json
key = 0
latency = 0
cnt = 0

(unit) : count distribution
0 -> 1 : 0 | |
2 -> 3 : 0 | |
4 -> 7 : 0 | |
8 -> 15 : 0 | |
16 -> 31 : 0 | |
32 -> 63 : 0 | |
64 -> 127 : 0 | |
128 -> 255 : 0 | |
256 -> 511 : 0 | |
512 -> 1023 : 4 |******************** |
1024 -> 2047 : 1 |***** |
2048 -> 4095 : 0 | |
4096 -> 8191 : 8 |****************************************|

key = 0
latency = 0
cnt = 0

(unit) : count distribution
0 -> 1 : 0 | |
2 -> 3 : 0 | |
4 -> 7 : 0 | |
8 -> 15 : 0 | |
16 -> 31 : 0 | |
32 -> 63 : 0 | |
64 -> 127 : 0 | |
128 -> 255 : 0 | |
256 -> 511 : 0 | |
512 -> 1023 : 11 |*************************** |
1024 -> 2047 : 1 |** |
2048 -> 4095 : 0 | |
4096 -> 8191 : 16 |****************************************|
8192 -> 16383 : 4 |********** |

总结

通过本篇 eBPF 入门实践教程,我们学习了如何使用tcpstates和tcprtt这两个 eBPF 示例程序,监控和分析 TCP 的连接状态和往返时间。我们了解了tcpstates和tcprtt的工作原理和实现方式,包括如何使用 BPF map 存储数据,如何在 eBPF 程序中获取和处理 TCP 连接信息,以及如何在用户态应用程序中解析和显示 eBPF 程序收集的数据。

使用 USDT 捕获用户态 Java GC 事件耗时

eBPF (扩展的伯克利数据包过滤器) 是一项强大的网络和性能分析工具,被广泛应用在 Linux 内核上。eBPF 使得开发者能够动态地加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。这个特性使得 eBPF 能够提供极高的灵活性和性能,使其在网络和系统性能分析方面具有广泛的应用。此外,eBPF 还支持使用 USDT (用户级静态定义跟踪点) 捕获用户态的应用程序行为。

在我们的 eBPF 入门实践教程系列的这一篇,我们将介绍如何使用 eBPF 和 USDT 来捕获和分析 Java 的垃圾回收 (GC) 事件的耗时。

USDT 介绍

USDT 是一种在应用程序中插入静态跟踪点的机制,它允许开发者在程序的关键位置插入可用于调试和性能分析的探针。这些探针可以在运行时被 DTrace、SystemTap 或 eBPF 等工具动态激活,从而在不重启应用程序或更改程序代码的情况下,获取程序的内部状态和性能指标。USDT 在很多开源软件,如 MySQL、PostgreSQL、Ruby、Python 和 Node.js 等都有广泛的应用。

用户层面的追踪机制:用户级动态跟踪和 USDT

在用户层面进行动态跟踪,即用户级动态跟踪(User-Level Dynamic Tracing)允许我们对任何用户级别的代码进行插桩。比如,我们可以通过在 MySQL 服务器的 dispatch_command() 函数上进行插桩,来跟踪服务器的查询请求:

1
2
3
4
5
# ./uprobe 'p:cmd /opt/bin/mysqld:_Z16dispatch_command19enum_server_commandP3THDPcj +0(%dx):string'
Tracing uprobe cmd (p:cmd /opt/bin/mysqld:0x2dbd40 +0(%dx):string). Ctrl-C to end.
mysqld-2855 [001] d... 19957757.590926: cmd: (0x6dbd40) arg1="show tables"
mysqld-2855 [001] d... 19957759.703497: cmd: (0x6dbd40) arg1="SELECT * FROM numbers"
[...]

这里我们使用了 uprobe 工具,它利用了 Linux 的内置功能:ftrace(跟踪器)和 uprobes(用户级动态跟踪,需要较新的 Linux 版本,例如 4.0 左右)。其他的跟踪器,如 perf_events 和 SystemTap,也可以实现此功能。

许多其他的 MySQL 函数也可以被跟踪以获取更多的信息。我们可以列出和计算这些函数的数量:

1
2
3
4
5
6
7
8
9
# ./uprobe -l /opt/bin/mysqld | more
account_hash_get_key
add_collation
add_compiled_collation
add_plugin_noargs
adjust_time_range
[...]
# ./uprobe -l /opt/bin/mysqld | wc -l
21809

这有 21,000 个函数。我们也可以跟踪库函数,甚至是单个的指令偏移。

用户级动态跟踪的能力是非常强大的,它可以解决无数的问题。然而,使用它也有一些困难:需要确定需要跟踪的代码,处理函数参数,以及应对代码的更改。

用户级静态定义跟踪(User-level Statically Defined Tracing, USDT)则可以在某种程度上解决这些问题。USDT 探针(或者称为用户级 “marker”)是开发者在代码的关键位置插入的跟踪宏,提供稳定且已经过文档说明的 API。这使得跟踪工作变得更加简单。

使用 USDT,我们可以简单地跟踪一个名为 mysql:query__start 的探针,而不是去跟踪那个名为 _Z16dispatch_command19enum_server_commandP3THDPcj 的 C++ 符号,也就是 dispatch_command() 函数。当然,我们仍然可以在需要的时候去跟踪 dispatch_command() 以及

其他 21,000 个 mysqld 函数,但只有当 USDT 探针无法解决问题的时候我们才需要这么做。

在 Linux 中的 USDT,无论是哪种形式的静态跟踪点,其实都已经存在了几十年。它最近由于 Sun 的 DTrace 工具的流行而再次受到关注,这使得许多常见的应用程序,包括 MySQL、PostgreSQL、Node.js、Java 等都加入了 USDT。SystemTap 则开发了一种可以消费这些 DTrace 探针的方式。

你可能正在运行一个已经包含了 USDT 探针的 Linux 应用程序,或者可能需要重新编译(通常是 –enable-dtrace)。你可以使用 readelf 来进行检查,例如对于 Node.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# readelf -n node
[...]
Notes at offset 0x00c43058 with length 0x00000494:
Owner Data size Description
stapsdt 0x0000003c NT_STAPSDT (SystemTap probe descriptors)
Provider: node
Name: gc__start
Location: 0x0000000000bf44b4, Base: 0x0000000000f22464, Semaphore: 0x0000000001243028
Arguments: 4@%esi 4@%edx 8@%rdi
[...]
stapsdt 0x00000082 NT_STAPSDT (SystemTap probe descriptors)
Provider: node
Name: http__client__request
Location: 0x0000000000bf48ff, Base: 0x0000000000f22464, Semaphore: 0x0000000001243024
Arguments: 8@%rax 8@%rdx 8@-136(%rbp) -4@-140(%rbp) 8@-72(%rbp) 8@-80(%rbp) -4@-144(%rbp)
[...]

这就是使用 –enable-dtrace 重新编译的 node,以及安装了提供 “dtrace” 功能来构建 USDT 支持的 systemtap-sdt-dev 包。这里显示了两个探针:node:gc__start(开始进行垃圾回收)和 node:http__client__request

在这一点上,你可以使用 SystemTap 或者 LTTng 来跟踪这些探针。然而,内置的 Linux 跟踪器,比如 ftrace 和 perf_events,目前还无法做到这一点(尽管 perf_events 的支持正在开发中)。

Java GC 介绍

Java 作为一种高级编程语言,其自动垃圾回收(GC)是其核心特性之一。Java GC 的目标是自动地回收那些不再被程序使用的内存空间,从而减轻程序员在内存管理方面的负担。然而,GC 过程可能会引发应用程序的停顿,对程序的性能和响应时间产生影响。因此,对 Java GC 事件进行监控和分析,对于理解和优化 Java 应用的性能是非常重要的。

在接下来的教程中,我们将演示如何使用 eBPF 和 USDT 来监控和分析 Java GC 事件的耗时,希望这些内容对你在使用 eBPF 进行应用性能分析方面的工作有所帮助。

eBPF 实现机制

Java GC 的 eBPF 程序分为内核态和用户态两部分,我们会分别介绍这两部分的实现机制。

内核态程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
/* Copyright (c) 2022 Chen Tao */
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/usdt.bpf.h>
#include "javagc.h"

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 100);
__type(key, uint32_t);
__type(value, struct data_t);
} data_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__type(key, int);
__type(value, int);
} perf_map SEC(".maps");

__u32 time;

static int gc_start(struct pt_regs *ctx)
{
struct data_t data = {};

data.cpu = bpf_get_smp_processor_id();
data.pid = bpf_get_current_pid_tgid() >> 32;
data.ts = bpf_ktime_get_ns();
bpf_map_update_elem(&data_map, &data.pid, &data, 0);
return 0;
}

static int gc_end(struct pt_regs *ctx)
{
struct data_t data = {};
struct data_t *p;
__u32 val;

data.cpu = bpf_get_smp_processor_id();
data.pid = bpf_get_current_pid_tgid() >> 32;
data.ts = bpf_ktime_get_ns();
p = bpf_map_lookup_elem(&data_map, &data.pid);
if (!p)
return 0;

val = data.ts - p->ts;
if (val > time) {
data.ts = val;
bpf_perf_event_output(ctx, &perf_map, BPF_F_CURRENT_CPU, &data, sizeof(data));
}
bpf_map_delete_elem(&data_map, &data.pid);
return 0;
}

SEC("usdt")
int handle_gc_start(struct pt_regs *ctx)
{
return gc_start(ctx);
}

SEC("usdt")
int handle_gc_end(struct pt_regs *ctx)
{
return gc_end(ctx);
}

SEC("usdt")
int handle_mem_pool_gc_start(struct pt_regs *ctx)
{
return gc_start(ctx);
}

SEC("usdt")
int handle_mem_pool_gc_end(struct pt_regs *ctx)
{
return gc_end(ctx);
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

首先,我们定义了两个映射(map):

  • data_map:这个 hashmap 存储每个进程 ID 的垃圾收集开始时间。data_t 结构体包含进程 ID、CPU ID 和时间戳。
  • perf_map:这是一个 perf event array,用于将数据发送回用户态程序。

然后,我们有四个处理函数:gc_startgc_end 和两个 USDT 处理函数 handle_mem_pool_gc_starthandle_mem_pool_gc_end。这些函数都用 BPF 的 SEC("usdt") 宏注解,以便在 Java 进程中捕获到与垃圾收集相关的 USDT 事件。

gc_start 函数在垃圾收集开始时被调用。它首先获取当前的 CPU ID、进程 ID 和时间戳,然后将这些数据存入 data_map

gc_end 函数在垃圾收集结束时被调用。它执行与 gc_start 类似的操作,但是它还从 data_map 中检索开始时间,并计算垃圾收集的持续时间。如果持续时间超过了设定的阈值(变量 time),那么它将数据发送回用户态程序。

handle_gc_starthandle_gc_end 是针对垃圾收集开始和结束事件的处理函数,它们分别调用了 gc_startgc_end

handle_mem_pool_gc_starthandle_mem_pool_gc_end 是针对内存池的垃圾收集开始和结束事件的处理函数,它们也分别调用了 gc_startgc_end

最后,我们有一个 LICENSE 数组,声明了该 BPF 程序的许可证,这是加载 BPF 程序所必需的。

用户态程序

用户态程序的主要目标是加载和运行eBPF程序,以及处理来自内核态程序的数据。它是通过 libbpf 库来完成这些操作的。这里我们省略了一些通用的加载和运行 eBPF 程序的代码,只展示了与 USDT 相关的部分。

第一个函数 get_jvmso_path 被用来获取运行的Java虚拟机(JVM)的 libjvm.so 库的路径。首先,它打开了 /proc/<pid>/maps 文件,该文件包含了进程地址空间的内存映射信息。然后,它在文件中搜索包含 libjvm.so 的行,然后复制该行的路径到提供的参数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static int get_jvmso_path(char *path)
{
char mode[16], line[128], buf[64];
size_t seg_start, seg_end, seg_off;
FILE *f;
int i = 0;

sprintf(buf, "/proc/%d/maps", env.pid);
f = fopen(buf, "r");
if (!f)
return -1;

while (fscanf(f, "%zx-%zx %s %zx %*s %*d%[^\n]\n",
&seg_start, &seg_end, mode, &seg_off, line) == 5) {
i = 0;
while (isblank(line[i]))
i++;
if (strstr(line + i, "libjvm.so")) {
break;
}
}

strcpy(path, line + i);
fclose(f);

return 0;
}

接下来,我们看到的是将 eBPF 程序(函数 handle_gc_starthandle_gc_end)附加到Java进程的相关USDT探针上。每个程序都通过调用 bpf_program__attach_usdt 函数来实现这一点,该函数的参数包括BPF程序、进程ID、二进制路径以及探针的提供者和名称。如果探针挂载成功,bpf_program__attach_usdt 将返回一个链接对象,该对象将存储在skeleton的链接成员中。如果挂载失败,程序将打印错误消息并进行清理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
skel->links.handle_mem_pool_gc_start = bpf_program__attach_usdt(skel->progs.handle_gc_start, env.pid,
binary_path, "hotspot", "mem__pool__gc__begin", NULL);
if (!skel->links.handle_mem_pool_gc_start) {
err = errno;
fprintf(stderr, "attach usdt mem__pool__gc__begin failed: %s\n", strerror(err));
goto cleanup;
}

skel->links.handle_mem_pool_gc_end = bpf_program__attach_usdt(skel->progs.handle_gc_end, env.pid,
binary_path, "hotspot", "mem__pool__gc__end", NULL);
if (!skel->links.handle_mem_pool_gc_end) {
err = errno;
fprintf(stderr, "attach usdt mem__pool__gc__end failed: %s\n", strerror(err));
goto cleanup;
}

skel->links.handle_gc_start = bpf_program__attach_usdt(skel->progs.handle_gc_start, env.pid,
binary_path, "hotspot", "gc__begin", NULL);
if (!skel->links.handle_gc_start) {
err = errno;
fprintf(stderr, "attach usdt gc__begin failed: %s\n", strerror(err));
goto cleanup;
}

skel->links.handle_gc_end = bpf_program__attach_usdt(skel->progs.handle_gc_end, env.pid,
binary_path, "hotspot", "gc__end", NULL);
if (!skel->links.handle_gc_end) {
err = errno;
fprintf(stderr, "attach usdt gc__end failed: %s\n", strerror(err));
goto cleanup;
}

最后一个函数 handle_event 是一个回调函数,用于处理从perf event array收到的数据。这个函数会被 perf event array 触发,并在每次接收到新的事件时调用。函数首先将数据转换为 data_t 结构体,然后将当前时间格式化为字符串,并打印出事件的时间戳、CPU ID、进程 ID,以及垃圾回收的持续时间。

1
2
3
4
5
6
7
8
9
10
11
12
static void handle_event(void *ctx, int cpu, void *data, __u32 data_sz)
{
struct data_t *e = (struct data_t *)data;
struct tm *tm = NULL;
char ts[16];
time_t t;

time(&t);
tm = localtime(&t);
strftime(ts, sizeof(ts), "%H:%M:%S", tm);
printf("%-8s %-7d %-7d %-7lld\n", ts, e->cpu, e->pid, e->ts/1000);
}

安装依赖

构建示例需要 clang、libelf 和 zlib。包名在不同的发行版中可能会有所不同。

在 Ubuntu/Debian 上,你需要执行以下命令:

1
sudo apt install clang libelf1 libelf-dev zlib1g-dev

在 CentOS/Fedora 上,你需要执行以下命令:

1
sudo dnf install clang elfutils-libelf elfutils-libelf-devel zlib-devel

编译运行

在对应的目录中,运行 Make 即可编译运行上述代码:

1
2
3
4
5
6
7
8
9
$ make
$ sudo ./javagc -p 12345
Tracing javagc time... Hit Ctrl-C to end.
TIME CPU PID GC TIME
10:00:01 10% 12345 50ms
10:00:02 12% 12345 55ms
10:00:03 9% 12345 47ms
10:00:04 13% 12345 52ms
10:00:05 11% 12345 50ms

编写 eBPF 程序 Memleak 监控内存泄漏

eBPF(扩展的伯克利数据包过滤器)是一项强大的网络和性能分析工具,被广泛应用在 Linux 内核上。eBPF 使得开发者能够动态地加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。

在本篇教程中,我们将探讨如何使用 eBPF 编写 Memleak 程序,以监控程序的内存泄漏。

背景及其重要性

内存泄漏是计算机编程中的一种常见问题,其严重程度不应被低估。内存泄漏发生时,程序会逐渐消耗更多的内存资源,但并未正确释放。随着时间的推移,这种行为会导致系统内存逐渐耗尽,从而显著降低程序及系统的整体性能。

内存泄漏有多种可能的原因。这可能是由于配置错误导致的,例如程序错误地配置了某些资源的动态分配。它也可能是由于软件缺陷或错误的内存管理策略导致的,如在程序执行过程中忘记释放不再需要的内存。此外,如果一个应用程序的内存使用量过大,那么系统性能可能会因页面交换(swapping)而大幅下降,甚至可能导致应用程序被系统强制终止(Linux 的 OOM killer)。

调试内存泄漏的挑战

调试内存泄漏问题是一项复杂且挑战性的任务。这涉及到详细检查应用程序的配置、内存分配和释放情况,通常需要应用专门的工具来帮助诊断。例如,有一些工具可以在应用程序启动时将 malloc() 函数调用与特定的检测工具关联起来,如 Valgrind memcheck,这类工具可以模拟 CPU 来检查所有内存访问,但可能会导致应用程序运行速度大大减慢。另一个选择是使用堆分析器,如 libtcmalloc,它相对较快,但仍可能使应用程序运行速度降低五倍以上。此外,还有一些工具,如 gdb,可以获取应用程序的核心转储并进行后处理以分析内存使用情况。然而,这些工具通常在获取核心转储时需要暂停应用程序,或在应用程序终止后才能调用 free() 函数。

eBPF 的作用

在这种背景下,eBPF 的作用就显得尤为重要。eBPF 提供了一种高效的机制来监控和追踪系统级别的事件,包括内存的分配和释放。通过 eBPF,我们可以跟踪内存分配和释放的请求,并收集每次分配的调用堆栈。然后,我们可以分析这些信息,找出执行了内存分配但未执行释放操作的调用堆栈,这有助于我们找出导致内存泄漏的源头。这种方式的优点在于,它可以实时地在运行的应用程序中进行,而无需暂停应用程序或进行复杂的前后处理。

memleak eBPF 工具可以跟踪并匹配内存分配和释放的请求,并收集每次分配的调用堆栈。随后,memleak 可以打印一个总结,表明哪些调用堆栈执行了分配,但是并没有随后进行释放。例如,我们运行命令:

1
2
3
4
5
6
7
8
9
10
11
# ./memleak -p $(pidof allocs)
Attaching to pid 5193, Ctrl+C to quit.
[11:16:33] Top 2 stacks with outstanding allocations:
80 bytes in 5 allocations from stack
main+0x6d [allocs]
__libc_start_main+0xf0 [libc-2.21.so]

[11:16:34] Top 2 stacks with outstanding allocations:
160 bytes in 10 allocations from stack
main+0x6d [allocs]
__libc_start_main+0xf0 [libc-2.21.so]

运行这个命令后,我们可以看到分配但未释放的内存来自于哪些堆栈,并且可以看到这些未释放的内存的大小和数量。

随着时间的推移,很显然,allocs 进程的 main 函数正在泄漏内存,每次泄漏 16 字节。幸运的是,我们不需要检查每个分配,我们得到了一个很好的总结,告诉我们哪个堆栈负责大量的泄漏。

memleak 的实现原理

在基本层面上,memleak 的工作方式类似于在内存分配和释放路径上安装监控设备。它通过在内存分配和释放函数中插入 eBPF 程序来达到这个目标。这意味着,当这些函数被调用时,memleak 就会记录一些重要信息,如调用者的进程 ID(PID)、分配的内存地址以及分配的内存大小等。当释放内存的函数被调用时,memleak 则会在其内部的映射表(map)中删除相应的内存分配记录。这种机制使得 memleak 能够准确地追踪到哪些内存块已被分配但未被释放。

对于用户态的常用内存分配函数,如 malloccalloc 等,memleak 利用了用户态探测(uprobe)技术来实现监控。uprobe 是一种用于用户空间应用程序的动态追踪技术,它可以在运行时不修改二进制文件的情况下在任意位置设置断点,从而实现对特定函数调用的追踪。

对于内核态的内存分配函数,如 kmalloc 等,memleak 则选择使用了 tracepoint 来实现监控。Tracepoint 是一种在 Linux 内核中提供的动态追踪技术,它可以在内核运行时动态地追踪特定的事件,而无需重新编译内核或加载内核模块。

内核态 eBPF 程序实现

memleak 内核态 eBPF 程序实现

memleak 的内核态 eBPF 程序包含一些用于跟踪内存分配和释放的关键函数。在我们深入了解这些函数之前,让我们首先观察 memleak 所定义的一些数据结构,这些结构在其内核态和用户态程序中均有使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef __MEMLEAK_H
#define __MEMLEAK_H

#define ALLOCS_MAX_ENTRIES 1000000
#define COMBINED_ALLOCS_MAX_ENTRIES 10240

struct alloc_info {
__u64 size; // 分配的内存大小
__u64 timestamp_ns; // 分配时的时间戳,单位为纳秒
int stack_id; // 分配时的调用堆栈ID
};

union combined_alloc_info {
struct {
__u64 total_size : 40; // 所有未释放分配的总大小
__u64 number_of_allocs : 24; // 所有未释放分配的总次数
};
__u64 bits; // 结构的位图表示
};

#endif /* __MEMLEAK_H */

这里定义了两个主要的数据结构:alloc_infocombined_alloc_info

alloc_info 结构体包含了一个内存分配的基本信息,包括分配的内存大小 size、分配发生时的时间戳 timestamp_ns,以及触发分配的调用堆栈 ID stack_id

combined_alloc_info 是一个联合体(union),它包含一个嵌入的结构体和一个 __u64 类型的位图表示 bits。嵌入的结构体有两个成员:total_sizenumber_of_allocs,分别代表所有未释放分配的总大小和总次数。其中 40 和 24 分别表示 total_size 和 number_of_allocs这两个成员变量所占用的位数,用来限制其大小。通过这样的位数限制,可以节省combined_alloc_info结构的存储空间。同时,由于total_size和number_of_allocs在存储时是共用一个unsigned long long类型的变量bits,因此可以通过在成员变量bits上进行位运算来访问和修改total_size和number_of_allocs,从而避免了在程序中定义额外的变量和函数的复杂性。

接下来,memleak 定义了一系列用于保存内存分配信息和分析结果的 eBPF 映射(maps)。这些映射都以 SEC(".maps") 的形式定义,表示它们属于 eBPF 程序的映射部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const volatile size_t min_size = 0;
const volatile size_t max_size = -1;
const volatile size_t page_size = 4096;
const volatile __u64 sample_rate = 1;
const volatile bool trace_all = false;
const volatile __u64 stack_flags = 0;
const volatile bool wa_missing_free = false;

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, pid_t);
__type(value, u64);
__uint(max_entries, 10240);
} sizes SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u64); /* address */
__type(value, struct alloc_info);
__uint(max_entries, ALLOCS_MAX_ENTRIES);
} allocs SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u64); /* stack id */
__type(value, union combined_alloc_info);
__uint(max_entries, COMBINED_ALLOCS_MAX_ENTRIES);
} combined_allocs SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u64);
__type(value, u64);
__uint(max_entries, 10240);
} memptrs SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_STACK_TRACE);
__type(key, u32);
} stack_traces SEC(".maps");

static union combined_alloc_info initial_cinfo;

这段代码首先定义了一些可配置的参数,如 min_size, max_size, page_size, sample_rate, trace_all, stack_flagswa_missing_free,分别表示最小分配大小、最大分配大小、页面大小、采样率、是否追踪所有分配、堆栈标志和是否工作在缺失释放(missing free)模式。

接着定义了五个映射:

  1. sizes:这是一个哈希类型的映射,键为进程 ID,值为 u64 类型,存储每个进程的分配大小。
  2. allocs:这也是一个哈希类型的映射,键为分配的地址,值为 alloc_info 结构体,存储每个内存分配的详细信息。
  3. combined_allocs:这是另一个哈希类型的映射,键为堆栈 ID,值为 combined_alloc_info 联合体,存储所有未释放分配的总大小和总次数。
  4. memptrs:这也是一个哈希类型的映射,键和值都为 u64 类型,用于在用户空间和内核空间之间传递内存指针。
  5. stack_traces:这是一个堆栈追踪类型的映射,键为 u32 类型,用于存储堆栈 ID。

以用户态的内存分配追踪部分为例,主要是挂钩内存相关的函数调用,如 malloc, free, calloc, realloc, mmapmunmap,以便在调用这些函数时进行数据记录。在用户态,memleak 主要使用了 uprobes 技术进行挂载。

每个函数调用被分为 “enter” 和 “exit” 两部分。”enter” 部分记录的是函数调用的参数,如分配的大小或者释放的地址。”exit” 部分则主要用于获取函数的返回值,如分配得到的内存地址。

这里,gen_alloc_enter, gen_alloc_exit, gen_free_enter 是实现记录行为的函数,他们分别用于记录分配开始、分配结束和释放开始的相关信息。

函数原型示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
SEC("uprobe")
int BPF_KPROBE(malloc_enter, size_t size)
{
// 记录分配开始的相关信息
return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(malloc_exit)
{
// 记录分配结束的相关信息
return gen_alloc_exit(ctx);
}

SEC("uprobe")
int BPF_KPROBE(free_enter, void *address)
{
// 记录释放开始的相关信息
return gen_free_enter(address);
}

其中,malloc_enterfree_enter 是分别挂载在 mallocfree 函数入口处的探针(probes),用于在函数调用时进行数据记录。而 malloc_exit 则是挂载在 malloc 函数的返回处的探针,用于记录函数的返回值。

这些函数使用了 BPF_KPROBEBPF_KRETPROBE 这两个宏来声明,这两个宏分别用于声明 kprobe(内核探针)和 kretprobe(内核返回探针)。具体来说,kprobe 用于在函数调用时触发,而 kretprobe 则是在函数返回时触发。

gen_alloc_enter 函数是在内存分配请求的开始时被调用的。这个函数主要负责在调用分配内存的函数时收集一些基本的信息。下面我们将深入探讨这个函数的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int gen_alloc_enter(size_t size)
{
if (size < min_size || size > max_size)
return 0;

if (sample_rate > 1) {
if (bpf_ktime_get_ns() % sample_rate != 0)
return 0;
}

const pid_t pid = bpf_get_current_pid_tgid() >> 32;
bpf_map_update_elem(&sizes, &pid, &size, BPF_ANY);

if (trace_all)
bpf_printk("alloc entered, size = %lu\n", size);

return 0;
}

SEC("uprobe")
int BPF_KPROBE(malloc_enter, size_t size)
{
return gen_alloc_enter(size);
}

首先,gen_alloc_enter 函数接收一个 size 参数,这个参数表示请求分配的内存的大小。如果这个值不在 min_sizemax_size 之间,函数将直接返回,不再进行后续的操作。这样可以使工具专注于追踪特定范围的内存分配请求,过滤掉不感兴趣的分配请求。

接下来,函数检查采样率 sample_rate。如果 sample_rate 大于1,意味着我们不需要追踪所有的内存分配请求,而是周期性地追踪。这里使用 bpf_ktime_get_ns 获取当前的时间戳,然后通过取模运算来决定是否需要追踪当前的内存分配请求。这是一种常见的采样技术,用于降低性能开销,同时还能够提供一个代表性的样本用于分析。

之后,函数使用 bpf_get_current_pid_tgid 函数获取当前进程的 PID。注意这里的 PID 实际上是进程和线程的组合 ID,我们通过右移 32 位来获取真正的进程 ID。

函数接下来更新 sizes 这个 map,这个 map 以进程 ID 为键,以请求的内存分配大小为值。BPF_ANY 表示如果 key 已存在,那么更新 value,否则就新建一个条目。

最后,如果启用了 trace_all 标志,函数将打印一条信息,说明发生了内存分配。

最后定义了 BPF_KPROBE(malloc_enter, size_t size),它会在 malloc 函数被调用时被 BPF uprobe 拦截执行,并通过 gen_alloc_enter 来记录内存分配大小。 我们刚刚分析了内存分配的入口函数 gen_alloc_enter,现在我们来关注这个过程的退出部分。具体来说,我们将讨论 gen_alloc_exit2 函数以及如何从内存分配调用中获取返回的内存地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
static int gen_alloc_exit2(void *ctx, u64 address)
{
const pid_t pid = bpf_get_current_pid_tgid() >> 32;
struct alloc_info info;

const u64* size = bpf_map_lookup_elem(&sizes, &pid);
if (!size)
return 0; // missed alloc entry

__builtin_memset(&info, 0, sizeof(info));

info.size = *size;
bpf_map_delete_elem(&sizes, &pid);

if (address != 0) {
info.timestamp_ns = bpf_ktime_get_ns();

info.stack_id = bpf_get_stackid(ctx, &stack_traces, stack_flags);

bpf_map_update_elem(&allocs, &address, &info, BPF_ANY);

update_statistics_add(info.stack_id, info.size);
}

if (trace_all) {
bpf_printk("alloc exited, size = %lu, result = %lx\n",
info.size, address);
}

return 0;
}
static int gen_alloc_exit(struct pt_regs *ctx)
{
return gen_alloc_exit2(ctx, PT_REGS_RC(ctx));
}

SEC("uretprobe")
int BPF_KRETPROBE(malloc_exit)
{
return gen_alloc_exit(ctx);
}

gen_alloc_exit2 函数在内存分配操作完成时被调用,这个函数接收两个参数,一个是上下文 ctx,另一个是内存分配函数返回的内存地址 address

首先,它获取当前线程的 PID,然后使用这个 PID 作为键在 sizes 这个 map 中查找对应的内存分配大小。如果没有找到(也就是说,没有对应的内存分配操作的入口),函数就会直接返回。

接着,函数清除 info 结构体的内容,并设置它的 size 字段为之前在 map 中找到的内存分配大小。并从 sizes 这个 map 中删除相应的元素,因为此时内存分配操作已经完成,不再需要这个信息。

接下来,如果 address 不为 0(也就是说,内存分配操作成功了),函数就会进一步收集一些额外的信息。首先,它获取当前的时间戳作为内存分配完成的时间,并获取当前的堆栈跟踪。这些信息都会被储存在 info 结构体中,并随后更新到 allocs 这个 map 中。

最后,函数调用 update_statistics_add 更新统计数据,如果启用了所有内存分配操作的跟踪,函数还会打印一些关于内存分配操作的信息。

请注意,gen_alloc_exit 函数是 gen_alloc_exit2 的一个包装,它将 PT_REGS_RC(ctx) 作为 address 参数传递给 gen_alloc_exit2。在我们的讨论中,我们刚刚提到在gen_alloc_exit2函数中,调用了update_statistics_add 函数以更新内存分配的统计数据。下面我们详细看一下这个函数的具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void update_statistics_add(u64 stack_id, u64 sz)
{
union combined_alloc_info *existing_cinfo;

existing_cinfo = bpf_map_lookup_or_try_init(&combined_allocs, &stack_id, &initial_cinfo);
if (!existing_cinfo)
return;

const union combined_alloc_info incremental_cinfo = {
.total_size = sz,
.number_of_allocs = 1
};

__sync_fetch_and_add(&existing_cinfo->bits, incremental_cinfo.bits);
}

update_statistics_add 函数接收两个参数:当前的堆栈 ID stack_id 以及内存分配的大小 sz。这两个参数都在内存分配事件中收集到,并且用于更新内存分配的统计数据。

首先,函数尝试在 combined_allocs 这个 map 中查找键值为当前堆栈 ID 的元素,如果找不到,就用 initial_cinfo(这是一个默认的 combined_alloc_info 结构体,所有字段都为零)来初始化新的元素。

接着,函数创建一个 incremental_cinfo,并设置它的 total_size 为当前内存分配的大小,设置 number_of_allocs 为 1。这是因为每次调用 update_statistics_add 函数都表示有一个新的内存分配事件发生,而这个事件的内存分配大小就是 sz

最后,函数使用 __sync_fetch_and_add 函数原子地将 incremental_cinfo 的值加到 existing_cinfo 中。请注意这个步骤是线程安全的,即使有多个线程并发地调用 update_statistics_add 函数,每个内存分配事件也能正确地记录到统计数据中。

总的来说,update_statistics_add 函数实现了内存分配统计的更新逻辑,通过维护每个堆栈 ID 的内存分配总量和次数,我们可以深入了解到程序的内存分配行为。 在我们对内存分配的统计跟踪过程中,我们不仅要统计内存的分配,还要考虑内存的释放。在上述代码中,我们定义了一个名为 update_statistics_del 的函数,其作用是在内存释放时更新统计信息。而 gen_free_enter 函数则是在进程调用 free 函数时被执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void update_statistics_del(u64 stack_id, u64 sz)
{
union combined_alloc_info *existing_cinfo;

existing_cinfo = bpf_map_lookup_elem(&combined_allocs, &stack_id);
if (!existing_cinfo) {
bpf_printk("failed to lookup combined allocs\n");
return;
}

const union combined_alloc_info decremental_cinfo = {
.total_size = sz,
.number_of_allocs = 1
};

__sync_fetch_and_sub(&existing_cinfo->bits, decremental_cinfo.bits);
}

update_statistics_del 函数的参数为堆栈 ID 和要释放的内存块大小。函数首先在 combined_allocs 这个 map 中使用当前的堆栈 ID 作为键来查找相应的 combined_alloc_info 结构体。如果找不到,就输出错误信息,然后函数返回。如果找到了,就会构造一个名为 decremental_cinfocombined_alloc_info 结构体,设置它的 total_size 为要释放的内存大小,设置 number_of_allocs 为 1。然后使用 __sync_fetch_and_sub 函数原子地从 existing_cinfo 中减去 decremental_cinfo 的值。请注意,这里的 number_of_allocs 是负数,表示减少了一个内存分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int gen_free_enter(const void *address)
{
const u64 addr = (u64)address;

const struct alloc_info *info = bpf_map_lookup_elem(&allocs, &addr);
if (!info)
return 0;

bpf_map_delete_elem(&allocs, &addr);
update_statistics_del(info->stack_id, info->size);

if (trace_all) {
bpf_printk("free entered, address = %lx, size = %lu\n",
address, info->size);
}

return 0;
}

SEC("uprobe")
int BPF_KPROBE(free_enter, void *address)
{
return gen_free_enter(address);
}

接下来看 gen_free_enter 函数。它接收一个地址作为参数,这个地址是内存分配的结果,也就是将要释放的内存的起始地址。函数首先在 allocs 这个 map 中使用这个地址作为键来查找对应的 alloc_info 结构体。如果找不到,那么就直接返回,因为这意味着这个地址并没有被分配过。如果找到了,那么就删除这个元素,并且调用 update_statistics_del 函数来更新统计数据。最后,如果启用了全局追踪,那么还会输出一条信息,包括这个地址以及它的大小。 在我们追踪和统计内存分配的同时,我们也需要对内核态的内存分配和释放进行追踪。在Linux内核中,kmem_cache_alloc函数和kfree函数分别用于内核态的内存分配和释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SEC("tracepoint/kmem/kfree")
int memleak__kfree(void *ctx)
{
const void *ptr;

if (has_kfree()) {
struct trace_event_raw_kfree___x *args = ctx;
ptr = BPF_CORE_READ(args, ptr);
} else {
struct trace_event_raw_kmem_free___x *args = ctx;
ptr = BPF_CORE_READ(args, ptr);
}

return gen_free_enter(ptr);
}

上述代码片段定义了一个函数memleak__kfree,这是一个bpf程序,会在内核调用kfree函数时执行。首先,该函数检查是否存在kfree函数。如果存在,则会读取传递给kfree函数的参数(即要释放的内存块的地址),并保存到变量ptr中;否则,会读取传递给kmem_free函数的参数(即要释放的内存块的地址),并保存到变量ptr中。接着,该函数会调用之前定义的gen_free_enter函数来处理该内存块的释放。

1
2
3
4
5
6
7
8
9
10
SEC("tracepoint/kmem/kmem_cache_alloc")
int memleak__kmem_cache_alloc(struct trace_event_raw_kmem_alloc *ctx)
{
if (wa_missing_free)
gen_free_enter(ctx->ptr);

gen_alloc_enter(ctx->bytes_alloc);

return gen_alloc_exit2(ctx, (u64)(ctx->ptr));
}

这段代码定义了一个函数 memleak__kmem_cache_alloc,这也是一个bpf程序,会在内核调用 kmem_cache_alloc函数时执行。如果标记 wa_missing_free 被设置,则调用 gen_free_enter 函数处理可能遗漏的释放操作。然后,该函数会调用 gen_alloc_enter 函数来处理内存分配,最后调用gen_alloc_exit2函数记录分配的结果。

这两个 bpf 程序都使用了 SEC 宏定义了对应的 tracepoint,以便在相应的内核函数被调用时得到执行。在Linux内核中,tracepoint 是一种可以在内核中插入的静态钩子,可以用来收集运行时的内核信息,它在调试和性能分析中非常有用。

在理解这些代码的过程中,要注意 BPF_CORE_READ 宏的使用。这个宏用于在 bpf 程序中读取内核数据。在 bpf 程序中,我们不能直接访问内核内存,而需要使用这样的宏来安全地读取数据。

用户态程序

在理解 BPF 内核部分之后,我们转到用户空间程序。用户空间程序与BPF内核程序紧密配合,它负责将BPF程序加载到内核,设置和管理BPF map,以及处理从BPF程序收集到的数据。用户态程序较长,我们这里可以简要参考一下它的挂载点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int attach_uprobes(struct memleak_bpf *skel)
{
ATTACH_UPROBE_CHECKED(skel, malloc, malloc_enter);
ATTACH_URETPROBE_CHECKED(skel, malloc, malloc_exit);

ATTACH_UPROBE_CHECKED(skel, calloc, calloc_enter);
ATTACH_URETPROBE_CHECKED(skel, calloc, calloc_exit);

ATTACH_UPROBE_CHECKED(skel, realloc, realloc_enter);
ATTACH_URETPROBE_CHECKED(skel, realloc, realloc_exit);

ATTACH_UPROBE_CHECKED(skel, mmap, mmap_enter);
ATTACH_URETPROBE_CHECKED(skel, mmap, mmap_exit);

ATTACH_UPROBE_CHECKED(skel, posix_memalign, posix_memalign_enter);
ATTACH_URETPROBE_CHECKED(skel, posix_memalign, posix_memalign_exit);

ATTACH_UPROBE_CHECKED(skel, memalign, memalign_enter);
ATTACH_URETPROBE_CHECKED(skel, memalign, memalign_exit);

ATTACH_UPROBE_CHECKED(skel, free, free_enter);
ATTACH_UPROBE_CHECKED(skel, munmap, munmap_enter);

// the following probes are intentinally allowed to fail attachment

// deprecated in libc.so bionic
ATTACH_UPROBE(skel, valloc, valloc_enter);
ATTACH_URETPROBE(skel, valloc, valloc_exit);

// deprecated in libc.so bionic
ATTACH_UPROBE(skel, pvalloc, pvalloc_enter);
ATTACH_URETPROBE(skel, pvalloc, pvalloc_exit);

// added in C11
ATTACH_UPROBE(skel, aligned_alloc, aligned_alloc_enter);
ATTACH_URETPROBE(skel, aligned_alloc, aligned_alloc_exit);

return 0;
}

在这段代码中,我们看到一个名为attach_uprobes的函数,该函数负责将uprobes(用户空间探测点)挂载到内存分配和释放函数上。在Linux中,uprobes是一种内核机制,可以在用户空间程序中的任意位置设置断点,这使得我们可以非常精确地观察和控制用户空间程序的行为。

这里,每个内存相关的函数都通过两个uprobes进行跟踪:一个在函数入口(enter),一个在函数退出(exit)。因此,每当这些函数被调用或返回时,都会触发一个uprobes事件,进而触发相应的BPF程序。

在具体的实现中,我们使用了ATTACH_UPROBEATTACH_URETPROBE两个宏来附加uprobes和uretprobes(函数返回探测点)。每个宏都需要三个参数:BPF程序的骨架(skel),要监视的函数名,以及要触发的BPF程序的名称。

这些挂载点包括常见的内存分配函数,如malloc、calloc、realloc、mmap、posix_memalign、memalign、free等,以及对应的退出点。另外,我们也观察一些可能的分配函数,如valloc、pvalloc、aligned_alloc等,尽管它们可能不总是存在。

这些挂载点的目标是捕获所有可能的内存分配和释放事件,从而使我们的内存泄露检测工具能够获取到尽可能全面的数据。这种方法可以让我们不仅能跟踪到内存分配和释放,还能得到它们发生的上下文信息,例如调用栈和调用次数,从而帮助我们定位和修复内存泄露问题。

注意,一些内存分配函数可能并不存在或已弃用,比如valloc、pvalloc等,因此它们的附加可能会失败。在这种情况下,我们允许附加失败,并不会阻止程序的执行。这是因为我们更关注的是主流和常用的内存分配函数,而这些已经被弃用的函数往往在实际应用中较少使用。

完整的源代码:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/16-memleak

编译运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git clone https://github.com/iovisor/bcc.git --recurse-submodules 
$ cd libbpf-tools/
$ make memleak
$ sudo ./memleak
using default object: libc.so.6
using page size: 4096
tracing kernel: true
Tracing outstanding memory allocs... Hit Ctrl-C to end
[17:17:27] Top 10 stacks with outstanding allocations:
1236992 bytes in 302 allocations from stack
0 [<ffffffff812c8f43>] <null sym>
1 [<ffffffff812c8f43>] <null sym>
2 [<ffffffff812a9d42>] <null sym>
3 [<ffffffff812aa392>] <null sym>
4 [<ffffffff810df0cb>] <null sym>
5 [<ffffffff81edc3fd>] <null sym>
6 [<ffffffff82000b62>] <null sym>
...

总结

通过本篇 eBPF 入门实践教程,您已经学习了如何编写 Memleak eBPF 监控程序,以实时监控程序的内存泄漏。您已经了解了 eBPF 在内存监控方面的应用,学会了使用 BPF API 编写 eBPF 程序,创建和使用 eBPF maps,并且明白了如何用 eBPF 工具监测和分析内存泄漏问题。我们展示了一个详细的例子,帮助您理解 eBPF 代码的运行流程和原理。

第一章 引言

  • BPF提供了一种在各种内核时间和应用程序事件发生时运行一段小程序的机制。由指令集、存储对象和辅助函数等几部分组成。应用领域分别是网络、可观测性和安全。
  • 跟踪(tracing)是基于事件记录。嗅探(snoop)、时间记录和跟踪,通常指的是一回事。
  • 采样(sampling):通过获取全部观测量的子集来描绘目标的大致图像;这也被称为生成性能剖析样本或profiling。有一个BPF工具就叫profile,它基于计时器来对运行中的代码定时采样。
  • 可观测性(observability):通过全面观测来理解一个系统,可以实现这一目标的工具可以归类为可观测工具。
  • BCC(BPF编辑器集合,BPF Compiler Collection)是最早用于开发BPF跟踪程序的高级框架。它提供了一个编写内核BPF程序的C语言环境,同时还提供了其他高级语言环境来实现用户端接口。
  • bpftrace是一个新近出现的前端,它提供了专门用于创建BPF工具的高级语言支持。
1
2
3
bpftrace -e 'tracepoint:syscalls:sys_enter_open { printf("%s %s\n",comm,str(args->filename)); }'
bpftrace -l 'tracepoint:syscalls:sys_enter_open*'
bpftrace -e 'tracepoint:syscalls:sys_enter_open*{@[probe]=count();}'
  • bpftrace在编写功能强大的单行程序、短小的脚本方面甚为理想;BCC则更适合开发复杂的脚本和作为后台进程使用,它还可以调试其他库的支持。
  • 动态插桩:kprobes和uprobes。可能存在由于软件改名而动态插桩不可用
  • 静态插桩:tracepoint和USDT。将稳定的事件名字编码到软件代码中

/img/bcc_tracing_tools

/img/image-20230904104906639

第二章 扩展版BPF

  • BPF指令通过BPF验证器验证,再由BPF虚拟机执行。BPF虚拟机的实现既包括一个解释器【非即时编译】,又包括一个JIT编译器:JIT编译器负责生成处理器可直接执行的机器指令。验证器会拒绝那些不安全的操作,这包括针对无界循环的检查。BPF程序必须在有限时间内完成。

  • BPF可以利用辅助函数获取内核状态,利用BPF映射表进行存储。BPF程序在特定时间发生时执行,包括kprobes、uprobes和跟踪点等信息。

  • BPF具有高效率和生产环境安全性等特点,它已经内置在Linux内核中。有了BPF,就可以在生产环境中直接运行这些工具,而无需增加新的内核组件。

  • BPF指令集查看:bpftool

1
2
3
bpftool prog show
bpftool prog dump xlated id 36
bpftool prog dump xlated id 37 opcodes
  • bpftrace查看BPF指令集
1
bpftrace -v /usr/share/bpftrace/tools/biolatency.bt
  • 调用栈回溯
    • 基于栈指针的调用栈回溯
    • 调试信息
    • 最后分支记录LBR,目前BPF不支持
    • ORC:oops回滚能力
    • 符号:调用栈信息目前在内核中是以地址数组形式记录的,这些地址可以通过用户态的程序翻译为符号(比如函数的名字)
  • 火焰图
    • Linux的perf剖析器将其样本摘要为调用树格式,显示每个分支所占的百分比。
    • BCC的profile工具则采用了另外一种摘要方式:对每个独特的调用栈分别计数。
  • 事件源
    • kprobes:内核动态插桩支持。可以对任何内核函数进行插桩,它还可以对函数内部的指令进行插桩。kretprobes可以用来对内核函数的返回进行插桩以获得返回值。当用kprobes和kretprobes对同一个函数进行插桩时,可以使用时间戳来记录函数执行时长。过程如下
      • 将在要插桩的目标地址中的字节内容复制并保存(为的是给单步断点指令腾出位置)
      • 以单步中断指令覆盖目标地址:在x86_64上是int3指令。(如果kprobes开启了优化,则使用jmp指令)
      • 当指令流执行到断点时,断点处理函数会检查这个断点是否是由kprobes注册的,如果是,就会执行kprobes处理函数。
      • 原始的指令会接着执行,指令流继续。
      • 当不再需要kprobes时,原始的字节内容会被复制回目标地址上,这样这些指令就回到了它们的初始状态。
1
bpftrace -e 'kprobe:vfs_* {@[probe]=count();}'
  • uprobes:用户态程序动态插桩。uprobes可以在用户态程序的函数入口、特定偏移处,以及函数返回处进行插桩。
    • 过程:和kprobes类似。将一个快速断点指令插入目标指令处,该指令将执行转交给uprobes处理函数。当不再需要uprobes时,目标指令会恢复为原来的样子。
1
2
3
4
5
perf top 
find / -name 'libc-2.17.so'
# /usr/lib64/libc-2.17.so
bpftrace -l 'uprobe:/usr/lib64/libc-2.17.so:gethost*'
bpftrace -e 'uprobe:/usr/lib64/libc-2.17.so:gethost*{@[probe]=count();}'
  • 跟踪点tracepoints:对内核进行静态插桩。内核开发者在内核函数中的特定逻辑位置处,有意放置了这些插桩点;然后这些跟踪点会被编译到内核的二进制文件中。原理如下:
    • 在内核编译阶段会在跟踪点位置插入一条不做任何具体工作的指令。在x86_64架构上,这是一个5字节nop指令。这个长度的选择是为了确保以后可以将它替换为一个5字节的jump指令。
    • 在函数尾部会插入一个跟踪点处理函数,也叫做蹦床函数。这个函数会遍历一个存储跟踪点探针回调函数的数组。(之所以叫蹦床函数,是因为在执行过程中函数会跳入,然后再跳出这个处理函数)。
    • 在执行过程中,当某个跟踪器启用跟踪点时(该跟踪点可能已经被其他跟踪器所启用)
      • 在跟踪点回调函数数组中插入一条新的跟踪器回调函数,以RCU形式进行同步更新。
      • 如果之前跟踪点处于禁用状态,nop指令地址会重写为跳转到蹦床函数的指令。
    • 当跟踪器禁用某个跟踪点时:
      在跟踪点回调函数数组中删除该跟踪器的回调函数,并且以RCU形式进行同步更新。
      如果最后一个回调函数也被去除了,那么jmp指令再重写为nop指令。

RCU(Read-Copy Update)机制:首先将需要修改的内容复制出一份副本,然后在副本上进行修改操作。在写者进行修改操作的过程中,旧数据没有做任何更新,不会产生读写竞争,因此依然可以被读者并行访问。当写者修改完成后,写者直接将新数据内存地址替换掉旧数据的内存地址,由于内存地址替换操作是原子的,因此可以保证读写不会产生冲突。内存地址替换后,原有读者访问旧数据,新的读者将访问新数据。当原有读者访问完旧数据,进入静默期后,旧数据将被写者删除回收。当然,通常写者只进行更新、删除指针操作,旧数据内存的回收由另一个线程完成。

1
bpftrace -e 'tracepoint:sched:sched_process_exec {printf("exec by %s\n",comm);}'
  • USDT:用户预定义静态跟踪提供了一个用户空间版的跟踪点机制。需要被添加到源代码并编译到最终的二进制文件中,在插桩点留下nop指令,在ELF notes段中存放元数据。
    • 原理:当编译应用程序时,在USDT探针的地址放置一个nop指令。在插桩时,这个地址会由内核使用uprobes动态将其修改为一个断点指令。
  • PMC性能监控计数器。
    • 模式
      • 计数:PMC能够跟踪事件发生的频率。
      • 溢出采样:PMC在所监控的事件发生到一定次数时通知内核,这样内核可以获得额外的状态。监控的事件可能会以每秒百万、亿级别的频率发生,如果对每次事件都进行中断会导致系统性能下降到不可用。解决方案就是利用一个可编程的计数器进行采样,具体来说,是当计数器溢出时就向内核发送信号(比如每10000次LLC缓存未命中事件,或者每百万次阻塞的指令时钟周期)。
    • 采样模式对BPF跟踪来说更值得关注,因为它产生的事件给BPF程序提供了执行的时机。BCC和bpftrace都支持PMC事件跟踪。
  • perf_events:perf所依赖的采样和跟踪机制。

第三章 性能分析

  • 目标:改进最终用户的体验以及降低运行成本。
  • 目标量化:延迟、速率、吞吐量、利用率、成本。
  • 业务负载画像:理解实际运行的业务负载。推荐步骤如下:
    • 负载是谁产生的(比如,进程ID、用户ID、进程名、IP地址)?
    • 负载为什么会产生(代码路径、调用栈、火焰图)?
    • 负载的组成是什么(IOPS、吞吐量、负载类型)?
    • 负载怎样随着时间发生变化(比较每个周期的摘要信息)?
1
2
vfsstat
bpftrace -e 'kprobe:vfs_read {@[comm] = count();}'
  • 下钻分析:从一个指标开始,然后将这个指标拆分成多个组成部分,再将最大的组件进一步拆分为更小的组件,不断重复这个过程直到定位出一个或多个根因。推荐步骤如下
    • 从业务最高层级开始分析
    • 检查下一层次的细节
    • 挑出最感兴趣的部分或者线索。
    • 如果问题没有解决,跳转到第二步。
  • USE方法论:针对每一个资源,分别去检查:使用率、饱和度、错误。
  • 检查清单发:列出一系列工具和指标,用于对照运行和检查。
  • Linux60秒分析
1
2
3
4
5
6
7
8
9
10
uptime # 平均负载
dmesg|tail # 系统日志
vmstat 1 # r CPU正在执行和等待执行的进程数量。不包括I/O
mpstat -P ALL 1 # 将每个CPU分解到各个状态下的时间打印出来。
pidstat 1 # 按每个进程展示CPU的使用情况
iostat -xz 1 # 显示存储设备的I/O指标
free -m
sar -n DEV 1 # 网络设备指标
sar -n TCP,ETCP 1 # TCP指标和TCP错误信息。active/s 本地发起的TCP连接数/s。passive/s 远端发起的TCP连接数/s。 retrans/s TCP重传数/s
top

BCC工具检查列表

1
2
3
4
5
6
7
8
9
10
11
execsnoop # 跟踪execve系统调用
opensnoop # 跟踪open系统调用,包括打开文件的路径、打开操作是否成功
ext4slower(或者brtfs*、xfs*、zfs*) # 跟踪ext4文件系统的常见操作
biolatency # 跟踪磁盘I/O延迟
biosnoop # 将每一次磁盘I/O请求打印出来,包含延迟之类的细节信息。
cachestat # 展示文件系统缓存的统计信息。
tcpconnect
tcpaccept
tcpretrans
runqlat # 线程等待CPU运行的时间进行统计
profile # CPU剖析器,可以用来理解那些代码路径消耗了CPU资源。周期性的对调用栈进行采样,然后将消重后的调用栈连同出现的次数一起打印出来。

第四章 BCC

  • funccount:对事件–特别是函数调用–进行计数
1
2
3
4
5
6
7
8
9
10
11
12
13
funccount [-h] [-p PID] [-i INTERVAL] [-d DURATION] [-T] [-r] [-D] pattern

Count all malloc() calls in libc:
# funccount c:malloc

展示每秒块I/O事件的数量
# funccount -i 1 't:block:*'
展示每秒libc中getaddrinfo()(域名解析)函数的调用次数
# funccount -i 1 c:getaddrinfo
对libgo中全部的“os.*”调用进行计数
# funccount 'go:os.*'
u:lib:name 对lib库中名为name的USDT探针进行插桩
path:name 对位于path路径下文件中的用户态函数name()进行插桩
  • stackcount:对导致某事件发生的函数调用栈进行计数。stackcount可以回答如下问题:
    • 某个事件为什么会被调用?调用的代码路径是什么?
    • 有哪些不同的代码路径会调用该事件,它们的调用频次如何。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
火焰图
stackcount -fP -D 10 ktime_get>out.stackcount01.txt
git clone https://github.com/brendangregg/FlameGraph.git
cd FlameGraph
/Users/junmo/www/FlameGraph/flamegraph.pl --hash --bgcolors=grey < out.stackcount01.txt > out.stackcount01.svg

对创建块I/O的函数调用栈进行计数
stackcount t:block:block_rq_insert
对发送IP数据包的调用栈进行计数
stackcount ip_output
对导致线程阻塞并且导致脱离CPU的调用栈进行计数
stackcount t:sched:sched_switch
对导致系统调用read()的调用栈进行计数
stackcount t:syscalls:sys_enter_read
  • trace是一个BCC多用途工具,可以针对多个数据源进行每个事件的跟踪,支持kprobes、uprobes、跟踪点和USDT探针。它可以回答如下解决问题:
    • 当某个内核态/用户态函数被调用时,调用参数是什么?
    • 这个函数的返回值是什么?调用失败了吗?
    • 这个函数是如何被调用的?相应的用户态或内核态函数调用栈是什么?
    • 因为trace会对每个事件产生一行输出,因此它比较适用于低频事件。对于高频事件,可以采用过滤表达式,只打印感兴趣的事件。
1
2
3
4
5
6
7
8
9
10
11
12
# arg2是do_sys_open()函数的第二个参数,打印文件名
trace 'do_sys_open "%s",arg2'
# 跟踪内核函数do_sys_open(),并打印返回值
trace 'r::do_sys_open "ret: %d", retval'
# 跟踪do_nanosleep(),并且打印用户态的调用栈
trace -U 'do_nanosleep "mode: %d",arg2'
# 跟踪通过pam库进行身份鉴别的请求
trace 'pam:pam_start "%s: %s",arg1,arg2'

# bcc使用系统头文件和内核头文件来获取结构体信息
trace 'do_nanosleep(struct hrtimer_sleeper *t) "task: %x",t->task'
trace -I 'net/sock.h' 'udpv6_sendmsg(struct sock *sk) (sk->sk_dport == 13568)'
  • argdist:针对函数调用参数分析的多用途工具
    • $retval:函数的返回值
    • $latency:从进入到返回的时长,单位是纳秒
    • $entry(param):在探针进入(entry)时param的值。
1
2
3
4
5
6
7
argdist -H 'r::__tcp_select_window():int:$retval'
# 将内核函数vfs_read的返回值以直方图的形式打印出来
argdist -H 'r::vfs_read()'
# 以直方图对pid为1005的进程的用户态调用libc的read()函数的返回值(size)进行统计输出
argdist -p 1005 -H 'r:c:read()'
# Aggregate interrupts by interrupt request (IRQ)
argdist -C 't:irq:irq_handler_entry():int:args->irq'

第五章 bpftrace

1
2
3
4
5
bpftrace -e 'tracepoint:syscalls:sys_enter_execve {printf("%s -> %s\n",comm,str(args->filename))}'
# 展示新进程的创建,以及参数信息
bpftrace -e 'tracepoint:syscalls:sys_enter_execve {join(args->argv)}'
# 展示进程的磁盘I/O尺寸
bpftrace -e 'tracepoint:block:block_rq_issue {printf("%d %s %d\n",pid,comm,args->bytes)}'
  • 探针格式:
    • kprobe对内核进行插桩,只需要一个标识符:内核函数名
    • uprobe对用户态函数进行插桩,需要两个标识符:二进制文件的路径和函数名
    • 可以使用逗号将多个探针并列,指向同一个执行动作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/bpftrace
BEGIN
{
printf("Hello world!\n");
}
END
{
printf("Game over!\n");
}
kprobe:vfs_read
{
@start[tid] = nsecs;
}

kretprobe:vfs_read
/@start[tid]/ // 过滤器
{
$duration_us = (nsecs - @start[tid]) / 1000;
@us[pid,comm] = hist($duration_us);
delete(@start[tid]);
}
  • 变量

    • 内置变量:由bpftrace预先定义好,通常是一个只读的信息源
    • 临时变量:被用于临时计算,字首加”$”作为前缀。
    • 映射表变量:使用BPF映射表来存储对象,名字带有”@”前缀。它们可以用作全局存储,在不同动作之间传递数据。
  • 探针类型

    • 内核静态插桩点 tracepoint[t]
    • 用户静态定义插桩点 usdt[U]
    • 内核动态函数插桩 kprobe[k]
    • 内核动态函数返回值插桩 kretprobe[kr]
    • 用户态动态函数插桩 uprobe[u]
    • 用户态动态函数返回值插桩 uretprobe[ur]
    • 内核软件事件 software[s]
      • cpu-clock[cpu] CPU真实时间,默认采样间隔1000000
      • task-clock CPU任务时间,默认采样间隔1000000
      • page-faults[faults] 缺页中断,默认采样间隔100
      • context-switches[cs] 上下文切换,默认采样间隔100
      • 略…
    • 硬件基于计数器的插桩 hardware[h]
    • 对全部CPU进行时间采样 profile[p]
      • profile:[hz|s|ms|us]:rate;对于全部CPU激活
    • 周期性报告(从一个CPU上) interval[i]
      • interval:[s:ms]:rate ;对于一个CPU
    • BEGIN bpftrace启动
    • END bpftrace退出
1
bpftrace -lv tracepoint:syscalls:sys_enter_read
  • bpftrace控制流
    • 过滤器 probe /filter/ {action}
    • 三元运算符 test ? true_statement : false_statement
    • if 语句 if(test){} else{}
    • 循环展开 unroll(count){statements}
  • bpftrace内置变量
    • pid tid uid username
    • nsecs 时间戳 纳秒
    • elapsed 时间错,单位纳秒,字bpftrace启动开始计时
    • cpu 处理器ID
    • comm 进程名
    • kstack ustack 调试栈信息
    • func 被跟踪函数名字
    • probe 当前探针全名
    • arg0…argN
    • retval
    • curtask 内核task_struct地址
    • cgroup
    • 1 , . . . , 1,…,1,…,N bpftrace程序的位置参数
  • bpftrace函数
    • printf time str
    • join 将多个字符串用空格进行连接并打印出来
    • kstack ustack
    • ksym usym 地址转换为字符串形式名字
    • system 执行shell命令
  • bpftrace映射表的操作函数
    • count() sum(int n) avg(int n) min(int n) max(int n)
    • stats(int n) 返回事假次数、平均值和总和
    • hist(int n) 打印2的幂方的直方图
    • lhist(int n,int min,int max,int step) 打印线性直方图
    • delete(@m[key]) 删除key
    • print(@m[,top[,div]]) 打印映射表,top指明只打印最高的top项目,div是一个整数分母,用来将数值整除后输出
    • clear(@m) 删除映射表中全部的键
    • zero(@m) 将映射表中所有的值设置为0
1
bpftrace -e 'k:vfs_* {@[probe] = count();} END {print(@,5);clear(@);}'

第六章 CPU

  • 事件源
    • 软中断 irq:softirq* 跟踪点[t:irq:softirq*]
    • 硬中断 irq:irq_handler* 跟踪点[t:irq:irq_handler*]
    • 运行队列 t:workqueue:*跟踪点
    • 定时采样 PMC或是基于定时器的采样器
    • CPU电源控制事件 power跟踪点 [t:workqueue:*]
    • CPU周期 PMC数据
  • 查看所有cpu是否正常使用 mpstat -P ALL
  • perf火焰图
1
2
3
4
5
6
7
8
9
10
11
# perf.data
perf record -F 99 -a -g -o cycle_0526.perf -- sleep 30

# 用perf script工具对cycle_0526.perf进行解析
perf script -i cycle_0526.perf &> perf.unfold

# 将perf.unfold中的符号进行折叠:
./stackcollapse-perf.pl perf.unfold &> perf.folded

# svg
./flamegraph.pl perf.folded > perf.svg
  • execsnoop跟踪全系统中新进程执行信息的工具。利用这个工具可以找到消耗大量CPU的短进程,并且可以用来分析软件执行过程,包括启动脚本等。
1
2
# bpftrace版本实现
bpftrace -e 't:syscalls:sys_enter_execve {printf ("%-10u %-5d ",elapsed/1000000,pid);join(args->argv);}'
  • exitsnoop 跟踪进程退出事件,打印出进程的总运行时长和退出原因。可以帮助调试短时进程。

  • runqlat: CPU调度延迟分析工具。在需求超过供给,CPU资源处于饱和状态时,这个工具可以用来识别和量化问题的严重性。runqlat利用对CPU调度器的线程唤醒事件和线程上下文切换事件的跟踪来计算线程从唤醒到运行之间的时间间隔。

1
2
3
4
5
6
runqlat 10 1 # 运行10s,只打印1次。nsecs单位为微秒
# -m 以毫秒为单位输出
# -P 给每个进程打印一个直方图
# --pidnss 给每个PID空间打印一个直方图
# -p PID 指定进程
# -T 输出包含时间戳
  • runqlen 采样CPU运行队列的长度信息,可以统计有多少线程正在等待运行,并以直方图的方式输出。
1
2
3
4
runqlen -C 10 1
# -C 每个CPU输出一个直方图
# -O 运行队列占有率信息,运行队列不为0的时长百分比
# -T 输出时间戳信息
  • runqslower 可以列出运行队列中等待延迟超过阈值的线程名字,可以输出受延迟影响的进程名和对应的延时时长。

  • cpudist 用来展示每次线程唤醒之后在CPU上执行的时长分布。在内部跟踪CPU调度器的上下文切换事件,在繁忙的生产环境中发生的频率很高,额外消耗显著,使用时多小心。

  • profile:定时采样调用栈信息并且汇报调用栈出现频率信息。默认以49Hz的频率同时采样所有的CPU的用户态和内核态的调用栈。

    • U 仅输出用户态调用栈信息
    • K 仅输出内核态调用栈
    • a 在函数名称上加上标记(例如,在内核态函数加上”_[k]”)
    • d 在用户态和内核态调用栈之间加上分隔符
    • f 以折叠方式输出
    • p PID 仅跟踪给定的进程
1
2
3
4
profile -af 30 > out.stacks01
./flamegraph.pl --color=java < ../out.stacks01 > out.svg
# 核心实现等同于如下bpftrace
bpftrace -e 'profile:hz:49 /pid/ { @samples[ustack,kstack,comm]=count();}'
  • offcputime 用于统计线程阻塞和脱离CPU运行的时间,同时输出调用栈信息,以便理解阻塞原因。这个工具正好是profile工具的对立面;这两个工具结合起来覆盖了线程的全部生命周期:profile覆盖在CPU之上运行的分析,而offcputime则分析脱离CPU运行的时间
    • U 仅输出用户态调用栈信息
    • K 仅输出内核态调用栈
    • u 仅包括用户态线程
    • k 仅包括内核态线程
    • f 以折叠方式输出
    • p PID 仅跟踪给定的进程
1
2
3
4
# 内核调用栈5秒的火焰图
offcputime -fKu 5 > out.offcuptime01.txt
./flamegraph.pl --hash --bgcolors=blue --title="OFF-CPU Time Flame Graph" \
< out.offcputime01.txt > out.offcputime01.svg
  • syscount 统计系统中的系统调用数量.
1
2
/usr/share/bcc/tools/syscount -LP
bpftrace -e 't:syscalls:sys_enter_*{@[probe]=count();}'
  • argdist和trace:针对每个事件自定义处理方法、
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 获取参数名字
$ tplist -v syscalls:sys_enter_read
syscalls:sys_enter_read
int __syscall_nr;
unsigned int fd;
char * buf;
size_t count;
# count read调用缓存的大小
argdist -H 't:syscalls:sys_enter_read():int:args->count'
argdist -H 't:syscalls:sys_exit_read():int:args->ret'
# 等价bpftrace程序如下
bpftrace -e 't:syscalls:sys_enter_read {@ = hist(args->count);}'
bpftrace -e 't:syscalls:sys_exit_read {@ = hist(args->ret);}'
# bpftrace针对负值有一个单独的统计区间([...,0]),read的返回值如果是负数就说明出现了错误
# 统计出现的错误频率
bpftrace -e 't:syscalls:sys_exit_read /args->ret<0/ {@ = lhist(- args->ret,0,100,1);}'

trace 't:syscalls:sys_enter_execve "-> %s", args->filename'
    • funccount 可以统计事件和函数调用频率。此工具是根据函数动态跟踪来统计的:对内核态函数使用kprobes,对用户态函数使用uprobes
1
2
3
4
funccount 'tcp_*' # 统计内核以tcp_开头的所有函数
funccount -i 1 get_page_from_freelist

bpftrace -e 'k:tcp_* {@[probe] = count();} interval:s:1{print(@);clear(@);}'
  • softirqs 显示系统中软中断消耗的CPU时间。
1
bpftrace -e 't:irq:softirq_entry {@[args->vec] = count();}'
  • hardirqs 显示系统处理硬中断的时间。
  • cpuwalk.bt 采样每个CPU上运行的进程名,并且以线性直方图的方式输出。

第七章 内存

  • Linux操作系统采用的虚拟内存机制,每个进程都有自己的虚拟内存地址空间,仅当实际使用内存的时候才会映射到物理内存之上。

  • 内存管理机制

    • 页换出守护进程(kswapd):会被定期唤醒,它会批量扫描活跃页的LRU列表和非活跃页的LRU列表以寻找可以释放的内存。当空闲内存低于某个阈值的时候,该进程就会被唤醒,当空闲内存高于另外一个阈值的时才会休息。
    • 物理换页设备(swap device):当系统内存不够时,换页设备允许系统以一种降级模式运行:进程可以继续申请内存,但是不经常使用的页将会被换入换出到对应的换页设备上,但是这一般会导致应用程序运行速度大幅下降。
    • 在极端情况下)直接杀掉内存溢出的进程(OOM Killer):按预定规则(将除内核关键任务和init(PID 1)进程之外的占用内存最多的进程杀死)杀掉进程。
  • 堆内存:存储在进程虚拟内存地址空间的一段动态区间中的内存

  • 空闲内存列表freelist:内核为每个CPU和DRAM组维护一组空闲内存列表,这样可以直接响应内存分配请求。同时,内核软件本身的内存分配需求也从这个空闲内存列表直接获取,一般通过内核内存分配器进行,例如,slab分配器。

  • 内存页的生命周期:

    • 应用程序发起内存分配请求
    • 应用程序库代码要么直接从空闲列表中响应请求,要么先扩展虚拟内存地址空间再分配。根据内存分配库的不同实现,有以下两种选项:
      • 利用brk()系统调用来扩展堆的尺寸,以便用新的堆地址响应请求。
      • 利用mmap()系统调用来创建一个新的内存段地址。
  • 内存分配之后,应用程序试图使用store/load指令来使用之前分配的内存地址,这就要调用CPU内部的内存管理单元来进行虚拟地址到物理地址的转换。当虚拟地址没有对应的物理地址时,会导致MMU发出一个缺页错误(page fault)。

  • 缺页错误由内核处理。在对应的处理函数中,内核会在物理内存空闲列表中找到一个空闲地址并映射到该虚拟地址。接下来,内存会通知MMU以便未来直接查找该映射。现在用户进程占用了一个物理内存页。进程所使用的全部物理内存数量称为常驻集大小(RSS)。

  • 当系统内存需求超过一定水平时,内核中的页换出守护进程就开始寻找可以释放的内存页。

    • 文件系统页:从磁盘中读出并且是没有被修改过的页(blacked by disk),这些页可以立即释放,需要再读取回来。
    • 被修改过的文件系统页:这些页被称为“脏页”,这些页需要先写回磁盘才能被释放。
    • 应用程序内存页:这些页被称为匿名页(anonymous memory),因为这些页不是来源于某个文件的。如果系统中有换页设备(swap device),那么这些页可以先存入换页设备,再被释放。将内存页写入换页设备成为换页。
  • 页压缩:内核中有一个压缩程序来移动内存页,以便扩大连续内存。

  • 文件系统缓存和缓冲区:Linux会借用空闲内存作为文件系统的缓存,如果有需要的话会再释放。

事件源

1
2
3
4
5
6
7
8
9
10
用户态内存分配  usdt:/usr/lib64/libc-2.28.so:*
内核态内存分配 t:kmem:*
堆内存扩展
共享内存函数
缺页错误 kprobes、软件事件,以及exception跟踪点
页迁移 t:migration:*'
页压缩 t:compaction:*
VM扫描器 t:vmscan:*
内存访问周期 PMC
bpftrace -l 'usdt:/usr/lib64/libc-2.28.so:*'
  • 内存分析策略

    • 检查OOM Killer杀掉的进程的信息。(dmesg)
    • 检查系统中是否有换页设备,以及使用的换页空间大小;并且检查这些换页设备是否有活跃的I/O操作(iostat vmstat)
    • 检查系统中的空闲内存的数量,以及整个系统的缓存使用情况(free)
    • 按进程检查内存使用量(top ps)
    • 检查系统的缺页错误的发生频率,并且检查缺页错误发生时的调用栈信息,这可以解释RSS增长的原因
    • 检查缺页错误与那些文件有关
    • 通过跟踪brk()和mmap()系统调用
    • BPF工具
    • 使用PMC测量硬件缓存命中率和内存空间,以便分析导致内存I/O发生的函数和指令信息(perf)
  • 传统工具

    • dmesg 内核日志
    • 内核统计信息:/proc/meminfo /proc/swaps
      • swapon:显示系统是否使用换页设备
      • free:统计全系统内存使用量
      • ps:进程状态命令按进程显示内存用量。%MEM 物理内存占比所有内存;VSZ虚拟内存;RSS常驻集大小
      • pmap:按地址空间段展示进程内存用量。pmap -x PID
      • vmstat:按时间展示各种全系统的统计数据,包括内存、CPU,以及存储I/O。
      • sar:是一个可以打印不同目标、不同监控指标的复合工具。-B选项打印的是页统计信息。
    • 硬件统计和硬件采样:用PMC来观测内存I/O事件,这些PMC统计的是处理器中的CPU单元到主内存之间的I/O操作,中间还涉及了CPU缓存。PMC提供两种方式:累计和采样。累计方式提供的是统计信息,额外消耗几乎为0;而采样模式则将发生的事件存入一个文件中供后期分析。
  • oomkill:用来跟踪OOM Killer事件的信息,以及打印出平均负载等详细信息。

  • memleak:用来跟踪内存分配和释放事件对应的调用栈信息。随着时间的推移,这个工具可以显示长期不被释放的内存。根据内存分配频繁程度,性能会下降,只能用来调试。

  • mmapsnoop:跟踪全系统的mmap系统调用并打印出映射请求的详细信息,这对内存映射调试来说是很有用的。

1
bpftrace -e 't:syscalls:sys_enter_mmap {@[comm] = count();}'
  • brkstack:跟踪brk调用
1
2
3
4
trace -U t:syscalls:sys_enter_brk
stackcount -PU t:syscalls:sys_enter_brk

bpftrace -e 't:syscalls:sys_enter_brk {@[ustack,comm]=count();}'
  • shmsnoop:可以跟踪System V的共享内存系统调用:shmget()、shmat()、shmdt、以及shmctl()。
  • faults:跟踪缺页错误和对应的调用栈信息,截取首次使用该内存触发缺页错误的代码路径。缺页错误会直接导致RSS的增长,所以这里截取的调用栈信息可以用来解释进程内存用量的增长。
1
2
3
4
5
6
7
8
9
stackcount -U t:exceptions:page_fault_user
stackcount t:exceptions:page_fault_kernel

火焰图
stackcount -f -PU t:exceptions:page_fault_user > out.pagefaults01.txt
flamegraph.pl --hash --width=800 --title="Page Fault Flame Graph" \
--color=java --bgcolor=green < out.pagefaults01.txt >out.pagefaults01.svg

bpftrace -e 's:page-faults:1 {@[ustack,comm]=count();}'
  • ffaults根据文件名来跟踪缺页错误。
1
2
find / -name "mm.h"
bpftrace --include "/usr/src/kernels/4.18.0-193.28.1.el8_2.x86_64/include/linux/mm.h" -e 'k:handle_mm_fault{$vma=(struct vm_area_struct *)arg0; $file=$vma->vm_file->f_path.dentry->d_name.name; @[str($file)]=count();}'
  • vmscan:使用vmscan跟踪点来观察页换出守护进程(kswapd)的操作,该进程在系统内存压力上升时负责释放内存以便重用。
1
funccount 't:vmscan:*'
  • drsnoop:用来跟踪内存释放过程中直接回收部分,可以显示受到影响的进程,以及对应的延迟:直接回收所需的时间。可以用来分析内存受限的系统中应用程序的性能影响。

  • swapin:展示了那个进程正在从换页设备中换入页,前提是系统有正在使用的换页设备。

1
bpftrace -e 'k:swap_readpage{@[comm,pid]=count();} interval:s:1{time();print(@);clear(@)}'
  • hfaults:通过跟踪巨页相关的缺页错误信息,按进程展示详细信息。
1
bpftrace -e 'k:hugetlb_fault{@[pid,comm]=count();}'

第八章 文件系统

  • 逻辑I/O是指向文件系统发送的请求。如果这些请求最终必须要由磁盘设备服务,那么它们就变成了物理I/O。很多逻辑I/O直接从文件缓存中返回,而不必发往磁盘设备。
  • 裸I/O:一种应用程序绕过文件系统层直接使用磁盘设备的方式。
  • 文件系统缓存:
    • 页缓存:该缓存的内容是虚拟内存页,包括文件的内容,以及I/O缓冲的信息,该缓存的主要作用是提高文件性能和目录I/O性能。
    • inode缓存:inodes(索引节点)是文件系统用来描述所存对象的一个数据结构体。VFS层有一个通用版本的inode,Linux维护这个缓存,是因为检查权限以及读取其他元数据的时候,对这些结构体的读取非常频繁。
    • 目录缓存:又叫dcache,这个缓存包括目录元素名到VFS inode之间的映射信息,这可以提高路径名查找速度。
  • 预读取Read-Ahead:又叫预缓存,该功能如果检测到一个顺序式的读操作,就会预测出接下来会使用的页,主动将其加载到页缓存中。
  • 写回Write-Back:先在内存中缓存要修改的页,再在一段时间后由内核的工作线程将修改写入磁盘,这样可以避免应用程序阻塞于较慢的磁盘I/O。
  • 传统工具
    • df显示文件系统的磁盘用量
    • mount可以将文件系统挂载到系统上,并且可以列出这些文件系统的类型和挂载参数。
    • strace可以跟踪系统中的系统调用,可以用这个命令来观察系统中的文件系统调用操作。
1
strace -tttT cksum /usr/bin/cksum
  • perf可以跟踪文件系统跟踪点,利用kprobes来跟踪VFS和文件系统的内部函数
1
2
3
4
5
perf trace cksum /usr/bin/cksum
perf stat -e 'ext4:*' -a
perf record -e ext4:ext4_da_write_begin -a // 由于perf.data是写入文件系统的,如果跟踪的是文件系统的写事件,那么就会产生一个自反馈循环

bpftrace -e 't:ext4:ext4_da_write_begin{@ = hist(args->len);}'
  • opensnoop:跟踪文件打开事件,对发现系统中使用的数据文件、日志文件以及配置文件来说十分有用。该工具还可以揭示由于快速打开大量文件导致的性能问题

  • statsnoop:跟踪stats类型的系统调用。stats返回的是文件的信息。

  • syncsnoop:可以配合时间戳展示sync调用信息。sync的作用是将修改过的数据写回磁盘。

  • mmapfiles: 跟踪mmap调用,并且统计映射入内存地址范围的文件频率信息

1
2
3
4
5
6
7
8
9
#!/usr/bin/bpftrace
#include <linux/mm.h>
kprobe:do_mmap{
$file = (struct file *)arg0;
$name = $file->f_path.dentry;
$dir1 = $name->d_parent;
$dir2 = $dir1->d_parent;
@[str($dir2->d_name.name), str($dir1->d_name.name),str($name->d_name.name)] = count();
}
  • scread:跟踪read系统调用,同时展示对应的文件名
1
2
3
4
5
6
7
8
9
#!/usr/bin/bpftrace
#include <linux/sched.h>
#include <linux/fs.h>
#include <linux/fdtable.h>
t:syscalls:sys_enter_read{
$task = (struct task_struct *)curtask;
$file = (struct file *)*($task->files->fdt->fd + args->fd); // 运行失败
@filename[str($file->f_path.dentry->d_name.name)] = count();
}
  • fmapfault 跟踪内存映射文件的缺页错误,按进程名和文件名来统计。
1
2
3
4
5
6
7
#!/usr/bin/bpftrace
#include <linux/mm.h>
kprobe:filemap_fault{
$vf = (struct vm_fault *)arg0;
$file = $vf->vma->vm_file->f_path.dentry->d_name.name;
@[comm, str($file)] = count();
}
  • filelife 展示短期文件的生命周期:这些文件在跟踪过程中产生并且随后就被删除了
  • vfsstat 可以摘要统计常见的VFS调用:读/写(I/O)、创建、打开,以及fsync。这个工具可以提供一个最高层次的虚拟文件系统操作负载分析。
  • vfscount 统计所有的VFS函数。
1
2
funccount 'vfs_*'
bpftrace -e 'kprobe:vfs_* {@[func] = count();}'
  • vfssize 可以以直方图方式统计VFS读取尺寸和写入尺寸,并按进程名、VFS文件名以及操作类型进行分类。
  • fileslower 用于显示延迟超过某个阈值的同步模式的文件读取和写入操作。
  • filetop 显示读写最频繁的文件的文件名
  • 同步写操作必须要等待存储I/O完全完成,即写穿透(write-through)模式,而普通文件读写的写入操作只要写入缓存就成功了,即写回模式(write-back)。
  • cachestat 展示页缓存的命中率统计信息。可以用来检查页缓存的命中率和有效程度。
  • cachetop 按进程统计cachestat
  • writeback.bt 展示页缓存的写回操作:页扫描的时间、脏页写入磁盘的时间、写回事件的类型,以及持续的时间。
    • periodic 周期性写回操作,涉及的页不多
    • background 后台写回操作,每次写入很多页,一般是在系统空闲内存低的情况下进行的异步页写回操作。
  • dcstat 可以展示目录缓存(dcache)的统计信息.
  • dcsnoop 跟踪目录缓存的查找操作,展示每次查找的详细信息。
  • mountsnoop 输出挂载的文件系统。
  • xfsslower 跟踪常见的XFS文件系统操作:对超过阈值的慢速操作打印出每个事件的详细信息。
  • xfsdist 作用是观察XFS文件系统,以直方图方式统计常见的操作延迟
  • ext4dist 以直方图方式统计常见的操作延迟
  • icstat 跟踪inode缓存的查找操作,并打印出每秒统计结果

部署spack

下载

release(推荐)

github下载最新的release:github.com/spack/spack…

git

1
2
3
git clone https://github.com/spack/spack.git ~/spack
cd ~/spack
git checkout releases/v0.17

✨tips: github 国内下载慢,百度 github镜像加速站 或 油猴脚本 - github 高速下载

激活

对于超算/lab/hpc等多用户场景,建议将spack本地放在全局/共享目录下

单个普通用户建议放在 ~/.spack/ 或 /opt下

解压下载好的压缩包, 加载环境变量(根据具体情况变更路径):

1
source spack/share/spack/setup-env.sh

可以将此命令写在~/.bashrc/etc/profile中 , 打开终端自动生效

配置

spack 的用户配置文件均在 ~/.spack 下,首次使用 spack 可能没有此目录,使用 3.2 会自动创建

基本配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
$ spack config get config > ~/.spack/config.yaml
$ spack config get config
config:
install_tree:
root: $spack/opt/spack
projections:
all: ${ARCHITECTURE}/${COMPILERNAME}-${COMPILERVER}/${PACKAGE}-${VERSION}-${HASH}
template_dirs:
- $spack/share/spack/templates
build_stage:
- $tempdir/$user/spack-stage
- $user_cache_path/stage
test_stage: $user_cache_path/test
source_cache: $spack/var/spack/cache
misc_cache: $user_cache_path/cache
connect_timeout: 10
verify_ssl: true
suppress_gpg_warnings: false
install_missing_compilers: false
checksum: true
deprecated: false
dirty: false
build_language: C
locks: true
url_fetch_method: urllib
ccache: false
concretizer: clingo
db_lock_timeout: 3
package_lock_timeout: null
shared_linking: rpath
allow_sgid: true
terminal_title: false
debug: false
build_jobs: 16

解析:

  • install_tree 中 root 为当前用户 软件安装的路径 , projections 为软件路径的命名规范
  • verify_ssl 在install时会校验url的ssl证书,离线环境可以选择关闭(值为 fasle)
  • locks 锁机制: 默认开启,同一用户分别安装相同的软件,只允许有一个进程执行,执行完后,另一个进程将跳过该软件安装;关闭的话,可能制造重复安装,但在某些文件系统的特殊权限限制下,需要关闭,否则会报奇怪的读写错误
  • build_job 安装时默认使用的最大cpu核心数
  • checksum 校验源码包的hash值

编译器

使用spack compiler find将会自动查找本机的所有编译器,生成配置文件~/.spack/linux/compilers.yaml

spack compilers查看添加到配置文件中的编译器

1
2
3
4
5
6
7
8
9
10
11
12
13
$ spack compilers
==> Available compilers
-- clang centos7-x86_64 -----------------------------------------
clang@12.0.1 clang@3.4.2

-- gcc centos7-x86_64 -------------------------------------------
gcc@10.2.0 gcc@8.5.0 gcc@7.5.0 gcc@6.5.0 gcc@4.9.4 gcc@4.8.5

-- intel centos7-x86_64 -----------------------------------------
intel@2021.4.0 intel@19.0.5.281

-- oneapi centos7-x86_64 ----------------------------------------
oneapi@2021.4.0

源码镜像仓库

自建源码镜像仓库

1
$ spack mirror create -d <PATH> --all

一个标准的源码镜像仓库符合以下目录规范:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mirror/
cmake/
cmake-2.8.10.2.tar.gz
dyninst/
dyninst-8.1.1.tgz
dyninst-8.1.2.tgz
libdwarf/
libdwarf-20130126.tar.gz
libdwarf-20130207.tar.gz
libdwarf-20130729.tar.gz
libelf/
libelf-0.8.12.tar.gz
libelf-0.8.13.tar.gz
libunwind/
libunwind-1.1.tar.gz
mpich/
mpich-3.0.4.tar.gz
mvapich2/
mvapich2-1.9.tgz

添加私有源码镜像仓库

1
$ spack mirror add <scope> <path>

生成配置文件~/.spack/linux/mirrors.yaml

例如 添加 一个 在/opt/mirror下的源码镜像仓库,给它取名为haha:

1
spack mirror add haha /opt/mirror

查看已添加的源码镜像仓库

1
2
$ spack mirror list
spack-public https://mirror.spack.io

默认使用 位于美国亚马逊云的 spack公共仓库 mirror.spack.io ,在国内获取 源码/索引 的速度可能会很慢

脚本源

查看当前使用的脚本源

1
2
3
$ spack repo list
==> 1 package repository.
builtin /root/spack/var/spack/repos/builtin

默认使用spack官方的脚本源

添加私有脚本源

一个标准的spack脚本源符合如下的路径规范:

1
2
3
4
5
6
7
$ tree -L /root/spack/var/spack/repos/yeesuan
/root/spack/var/spack/repos/yeesuan
├── packages
│ ├── gromacs
│ │ ├── package.py
│ │ └── __pycache__
└── repo.yaml

其中 repo.yaml 定义了该脚本源的命名空间:

1
2
repo:
namespace: 'yeesuan'

添加该脚本源:

1
$ spack repo add /root/spack/var/spack/repos/yeesuan

modules

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
$ spack config get modules > ~/.spack/modules
$ spack config get modules
modules:
prefix_inspections:
lib:
- LD_LIBRARY_PATH
lib64:
- LD_LIBRARY_PATH
bin:
- PATH
man:
- MANPATH
share/man:
- MANPATH
share/aclocal:
- ACLOCAL_PATH
lib/pkgconfig:
- PKG_CONFIG_PATH
lib64/pkgconfig:
- PKG_CONFIG_PATH
share/pkgconfig:
- PKG_CONFIG_PATH
? ''
: - CMAKE_PREFIX_PATH

# These are configurations for the module set named "default"
default:
# roots:
# tcl: $spack/share/spack/modules
# lmod: $spack/share/spack/lmod
enable:
- tcl

# Default configurations if lmod is enabled
lmod:
hierarchy:
- mpi

prefix_inspections:定义了声明的环境变量和路径的对应关系

其中defalut中roots的tcl /lmod 定义了 安装软件后,modules 文件的安装位置

自定义packages

spack external find可以添加系统中的软件,为spack 管理 (超算环境不推荐)

1
2
3
4
5
6
7
8
$ spack external find
==> The following specs have been detected on this system and added to /root/.spack/packages.yaml
bash@4.2.46 gawk@4.0.2 krb5@1.15.1 pkg-config@0.27.1 sqlite@3.7.17
bzip2@1.0.6 gcc@4.8.5 llvm@3.4.2 python@2.7.5 tar@1.26
cpio@2.11 git@2.36.0 llvm-doe@3.4.2 python@3.6.8 texinfo@5.1
diffutils@3.3 gmake@3.82 openssh@7.4p1 rsync@3.1.2 xz@5.2.2
file@5.11 go@1.18.1 openssl@1.0.2k-fips rust@1.60.0
findutils@4.5.11 groff@1.22.2 perl@5.16.3 sed@4.2.2

这是我们自定义添加的两块软件:

1
2
3
4
5
6
7
8
9
packages:
cmake:
externals:
- spec: cmake@3.21.4
prefix: /yeesuan/linux-centos7-haswell/gcc-4.8.5/cmake-3.21.4-4q7yowzqqc6x36tsxd2bsgeenwci6iqt
util-linux-uuid:
externals:
- spec: util-linux-uuid@2.36.2
prefix: /yeesuan/linux-centos7-haswell/gcc-4.8.5/util-linux-uuid-2.36.2-psepywix72fmt453hwcmrepmqslrai3a

可以按照上述规范添加自己的软件

基本使用

spack中有什么?

基础命令:spack list

默认返回所有支持spack安装的软件:

1
2
3
4
$ spack list
==> 5969 packages.
3dtk intel-oneapi-inspector pexsi py-pygeos r-parallelmap
3proxy intel-oneapi-ipp pfapack py-pygetwindow r-param helpers

支持通配符查找:

1
2
3
4
5
$ spack list *blas*
==> 18 packages.
blaspp blast-legacy cblas hipblas ncbi-magicblast openblas
blasr blast-plus flexiblas libblastrampoline ncbi-rmblastn rocblas
blasr-libcpp blast2go graphblast liblas netlib-xblas samblaster

查找描述里包含某个关键词:

1
2
3
4
5
6
7
8
9
$ spack list -d physics
==> 46 packages.
albany cradl flecsph herwig3 n2p2 py-openmc sombrero
alps damask freefem herwigpp openmc py-uproot3-methods thepeg
amp datatransferkit geant4 jali pennant pythia6 trilinos
ascent delphes genfit libsakura podio pythia8 yambo
axom exciting hepmc lorene portage r-qvalue
clhep fastjet hepmc3 mcutils precice recola
cp2k flecsi heputils minuit py-espresso rivet

查看软件详细信息

基础命令:spack info <package_name>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
$ spack info vasp
MakefilePackage: vasp

Description:
The Vienna Ab initio Simulation Package (VASP) is a computer program
for atomic scale materials modelling, e.g. electronic structure
calculations and quantum-mechanical molecular dynamics, from first
principles.

Homepage: https://vasp.at

Externally Detectable:
False

Tags:
None

Preferred version:
6.1.1 file:///root/vasp.6.1.1.tgz

Safe versions:
6.1.1 file:///root/vasp.6.1.1.tgz
5.4.4.pl2 file:///root/vasp.5.4.4.pl2.tgz
5.4.4 file:///root/vasp.5.4.4.tgz

Deprecated versions:
None

Variants:
Name [Default] When Allowed values Description
=============== ==== =============== ====================================

cuda [off] -- on, off Enables running on Nvidia GPUs
scalapack [off] -- on, off Enables build with SCALAPACK
vaspsol [off] -- on, off Enable VASPsol implicit solvation model
https://github.com/henniggroup/VASPsol
Installation Phases:
edit build install

Build Dependencies:
blas cuda fftw lapack mpi netlib-scalapack qd rsync

Link Dependencies:
blas cuda fftw lapack mpi netlib-scalapack qd

Run Dependencies:
mpi

Virtual Packages:
None

解析
1.MakefilePackage表示vasp用make构建

其他的,像gromacs 是CMakePackage,表示用cmake构建,fftw 是 Autotools, 流程中需要先configuremake

2.Description: 软件的介绍

3.Homepage:软件官网

4.Preferred version是推荐版本,Safe versions是安全的(经过充分验证的)版本

其中,左侧列是版本号,右侧列是软件的URL地址

spack默认使用 系统命令 curl “下载”软件 ,curl支持的协议有 https/http/file/…. ,上面例子表示默认使用当前路径下的源码包
5.Variants 我翻译为“特性”

第一列为特性的名字,第二列为特性的条件,第三列为特性的值,其中,第一列,变量名后的中括号内,是默认的变量值

6.Installation Phases 表示了该软件安装时的三个步骤

7.Build Dependencies 表示了构建前需要加载的环境

8.Link Dependencies 表示了构建时用到的链接库

9.Run Dependencies 表示了在使用软件时,需要加载的环境

安装软件

基础命令:spack install <package_name>

1
2
3
4
5
6
7
8
9
10
$ spack install zlib
==> Bootstrapping clingo from pre-built binaries
==> Installing zlib-1.2.11-3rlgy7ycxtoho44una6o3itgfjltkmpd
==> No binary for zlib-1.2.11-3rlgy7ycxtoho44una6o3itgfjltkmpd found: installing from source
==> Fetching https://mirror.spack.io/_source-cache/archive/c3/c3e5e9fdd5004dcb542feda5ee4f0ff0744628baf8ed2dd5d66f8ca1197cb1a1.tar.gz
==> No patches needed for zlib
==> zlib: Executing phase: 'install'
==> zlib: Successfully installed zlib-1.2.11-3rlgy7ycxtoho44una6o3itgfjltkmpd
Fetch: 1.06s. Build: 3.09s. Total: 4.15s.
[+] /home/spack/spack/opt/spack/linux-ubuntu18.04-x86_64/gcc-7.5.0/zlib-1.2.11-3rlgy7ycxtoho44una6o3itgfjltkmpd

进阶-高度自定义的安装命令

指定编译器

编译器需要搭配 百分号 “%”,只能跟在软件名后面

例如使用gcc编译器:

1
spack install zlib%gcc

软件名和编译器之间允许有多个空格,所以也可以写成这样:

1
spack install zlib %gcc

一般地,我们的系统中存在多个版本的gcc编译器,所以需要指定使用哪一个版本的编译器:

1
spack install zlib %gcc@6.5.0

需要说明的是,这里的编译器 名称,可能不会和 spack list 中一一对应,

例如,英特尔oneapi的编译器,list表里的名称为 intel-oneapi-compilers, 但spack compilers里则是intel

指定依赖

以2.1为例,vasp的依赖,其中的blas、lapack、netlib-scalapack 这三个数学库在英特尔的mkl库中均有实现,

所以安装vasp时可以指定使用mkl,搭配 “^”

1
spack install vasp ^intel-mkl

2020之后的mkl库的包名发生了变化,我们指定适合用2021.4.0版本的

1
spack install vasp ^intel-oneapi-mkl@2021.4.0

我们的cc编译器也想用intel的icc, 版本号和mkl相同

1
spack install vasp %intel@2021.4.0 ^intel-oneapi-mkl@2021.4.0

特性

仍以2.1为例,官方提供的vasp有一个叫vaspsol的特性,它是vasp的一个扩展包,默认关闭状态,如果想开启此特性,需要搭配“+”使用

1
spack install vasp +vaspsol

mpich的特性pmi的值有多个,默认为mpi

1
pmi [pmi]            --      off, pmi, pmi2, pmix    PMI interface.

我们想全都使用,则使用“=”表达,以英文小写逗号“,”分隔不同的值

1
spack install mpich mpi=pmi,pmi2,pmix

mpich的特性fortran的值默认时开启状态,我们不想使用它,搭配波浪线

1
spack install mpich ~fortran

多线程

构建时可以指定使用的最大cpu核心数,在install后面 加入 -j 参数, 后接核心数

1
spack install -j 64 vasp

✨tips: 可以通过系统命令 nproc 查询到最大核心数 ,也可以写成变量的形式

综合

以安装gcc6.5.0为例,使用系统自带的gcc4.8.5编译,需要开启几乎全部的特性,其中的go使用1.16版本

1
spack install -j 64 -y gcc@6.5.0 %gcc@4.8.5 +binutils+bootstrap+piclibs+strip languages=ada,c,c++,fortran,go,java,jit,lto,objc,obj-c++ ^go@1.16

✨tips: 其中的 “-y”可以默认选择一些软件的提示性的编译选项或其他选项

卸载软件

基础命令: spakc uninstall <package_name>

1
2
3
4
5
6
7
8
9
10
11
12
13
$ spack uninstall autoconf
==> Error: autoconf matches multiple packages:

-- linux-centos7-cascadelake / gcc@10.2.0 -----------------------
kwq2zrg autoconf@2.69

-- linux-centos7-skylake_avx512 / gcc@6.5.0 ---------------------
2vv3qa2 autoconf@2.69

==> Error: You can either:
a) use a more specific spec, or
b) specify the spec by its hash (e.g. `spack uninstall /hash`), or
c) use `spack uninstall --all` to uninstall ALL matching specs.

像上面,卸载报错,我们可以指定编译器:

1
spack uninstall autoconf %gcc@6.5.0

方便地,使用其hash值:

1
spack uninstall /2vv3qa2

默认卸载全部autoconf:

1
spack uninstall --all autoconf

有时候,卸载该软件也需要卸载其依赖:

1
spack uninstall -d <package_name>

查找已安装的软件

基础命令:spack find

1
2
3
4
5
6
7
8
9
10
11
$ spack find
==> 805 installed packages
-- linux-centos7-cascadelake / gcc@10.2.0 -----------------------
boost@1.77.0 intel-oneapi-mpi@2021.4.0 python@3.8.12
bzip2@1.0.8 libbsd@0.11.3 readline@8.1
cereal@1.3.0 libffi@3.3 sqlite@3.36.0

-- linux-centos7-cascadelake / intel@19.0.5.281 -----------------
abinit@9.4.2 intel-mpi@2019.5.281 parmetis@4.0.3
amg@1.2 intel-tbb@2020.3 parmetis@4.0.3
...

指定包名

1
2
3
4
5
6
7
$ spack find vasp
==> 2 installed packages
-- linux-centos7-cascadelake / intel@19.0.5.281 -----------------
vasp@5.4.4

-- linux-centos7-cascadelake / intel@2021.4.0 -------------------
vasp@6.1.0

指定编译器

1
2
3
4
$ spack find vasp %intel@2021.4.0
==> 1 installed package
-- linux-centos7-cascadelake / intel@2021.4.0 -------------------
vasp@6.1.0

指定版本号

1
2
3
4
$ spack find vasp@5.4.4
==> 1 installed package
-- linux-centos7-cascadelake / intel@19.0.5.281 -----------------
vasp@5.4.4

查看软件的特征值

1
2
3
4
5
6
7
$ spack find -l openmpi
==> 2 installed packages
-- linux-centos7-haswell / gcc@4.8.5 ----------------------------
pt2putc openmpi@4.1.1

-- linux-centos7-skylake_avx512 / gcc@6.5.0 ---------------------
tchnk2v openmpi@4.1.1

✨tips: 1.spack支持使用/<hash_value>的方式替代<package_name>,hash_vaule支持长写(完整 )和短写(前面3到7个字符) 2.可以使用 -L 查看完整的特征值

查看软件的特征值和依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ spack find -l -d openmpi
==> 2 installed packages
-- linux-centos7-haswell / gcc@4.8.5 ----------------------------
pt2putc openmpi@4.1.1
y5iroad hwloc@2.6.0
bmzkyfq libpciaccess@0.16
xkbs7ll libxml2@2.9.12
rpsecj5 libiconv@1.16
e6cbjcd xz@5.2.5
z6uqlxn zlib@1.2.11
ez4gk67 ncurses@6.2
owe3cjs libevent@2.1.12
jwr4s44 openssl@1.1.1l
2hfxs5z numactl@2.0.14
tjk74sh openssh@8.7p1
ugfkpjd libedit@3.1-20210216

查看软件的路径

1
2
3
4
5
6
7
spack find -p openmpi
==> 2 installed packages
-- linux-centos7-haswell / gcc@4.8.5 ----------------------------
openmpi@4.1.1 /yeesuan495/.spack/software/linux-centos7-haswell/gcc-4.8.5/openmpi-4.1.1-pt2putcrv2o53naxpesk3rsb4xl44pqa

-- linux-centos7-skylake_avx512 / gcc@6.5.0 ---------------------
openmpi@4.1.1 /yeesuan495/.spack/software/linux-centos7-skylake_avx512/gcc-6.5.0/openmpi-4.1.1-tchnk2vf4iskpfkh7giwurtmyipwmjma

location (建议用find -p)

location 命令也可以定位软件的路径

1
2
$ spack location -i openmpi%gcc@4.8.5
/yeesuan495/.spack/software/linux-centos7-haswell/gcc-4.8.5/openmpi-4.1.1-pt2putcrv2o53naxpesk3rsb4xl44pqa

cd 进入软件根目录

1
2
$ spack cd -i openmpi%gcc@4.8.5
[yxxxx@xxxx openmpi-4.1.1-pt2putcrv2o53naxpesk3rsb4xl44pqa]$

查看软件的命名空间

1
2
3
4
5
6
7
8
9
10
$ spack find -N mpich
==> 1 installed package
-- linux-centos7-skylake_avx512 / gcc@6.5.0 ---------------------
local.mpich@3.4.2

$ spack find -N xz
==> 3 installed packages
-- linux-centos7-cascadelake / gcc@10.2.0 -----------------------
builtin.xz@5.2.5
...

使用软件

基础命令:

加载软件环境变量 spack load <package_name>

卸载软件环境变量 spack unload <package_name>

查看已加载的依赖

1
2
3
4
5
$ spack find --loaded
==> 4 loaded packages
-- linux-centos7-cascadelake / intel@2021.4.0 -------------------
fftw@3.3.10 intel-oneapi-mpi@2021.4.0
intel-oneapi-mkl@2021.4.0 intel-oneapi-tbb@2021.4.0

仅加载软件本身或它的依赖

仅软件本身:

1
spack load --only package <package_name>

仅依赖:

1
spack load --only dependencies <package_name>

查看加载软件的所有环境变量

1
spack load --sh <package_name>

指定参数加载环境变量

指定版本号spack load <package_name>@<package_version>

指定编译器spack load <package_name> %<compiler_name>@<compiler_version>

指定用到的依赖/特性spack load <package_name> ^<dependency_name>

指定命名空间spack load <scope>.<package_name>

上述加载方式都可以组合使用

使用hash值 : spack load /<hash_value>

✨tips: 有时候我们查到的软件版本号和编译器甚至命名空间都一样,它将很难区分

通常,使用spack find -l -d <package_name> 可以打印多个“相同”的软件的依赖详情,我们选择其中一个的hash值加载

打工人的时间是如何计算的

作者:GPUS开发者
链接:https://zhuanlan.zhihu.com/p/339478619
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

今天主要说两点, 一点是如何正确的计算一段操作所用的时间。这里的一段操作是指的, GPU设备上的kernel计算, 以及, 数据传输操作。

正确的计时也是从今天开始的, CUDA优化章节的重要基础,因为你的代码干了什么, 例如对一张图片进行边缘查找, 或者颜色分布进行直方图统计, 这些工作量你本身, 作为代码的编写者, 是知道的. 此时再加上了正确的计时方法, 则你可以立刻衡量出来, “我的具体XXX操作过程, 在XXX ms内完成, 性能是XXX”(例如10张图片/秒)。

但是我们历年来, 很遗憾的看到, 大部分人的做法都是错误的. 甚至使用了错误的测时结果, 来气势汹汹询问一些问题. 此时, 因为你的基础部分(计时)是错误的, 从而导致了你的问题整体无效.

这点无论是从, 我们的论坛上的帖子中, 还是我们的直接的客户支持用, 用户给出的他们的代码中, 都可以看到这样的错误.

今天我们就说一下, 这些错误的根源, 和正确的计时方式该如何进行. 错误的计时根源往往有两种, 一种是对GPU上的代码片段的执行的特性, 具有误解。

例如在我们之前的文章中, 我们知道一个kernel的启动是异步的, 也就是一旦该kernel成功启动后, 它就开始在GPU上执行了. CPU这边的诸如<<<>>>()的菱形启动符, 是会在kernel完成了启动后, 就立刻返回CPU上的下一行代码执行的.

CPU并不自动等待GPU上的工作完成!

这点是CUDA在设计的时候, 为了充分能让GPU作为一个劳工的身份, 去完成一些重活而设计的; 而CPU作为CEO, 并不需要在”GPU劳工”辛苦忙碌的时候, 必须啥都不干的同步等待的。

就如同一家公司里的老板, 布置出来了活给员工, 那么员工在干活的期间, 老板并不是必须等待员工慢慢干完, 才能返回老板自己的下一个工作事项的。老板完全是布置完活后就没事了,然后可以继续给另外一个员工布置活, 或者自己悠闲地去喝着茶了。

这点说起来很简单, 但是很多人都在理解上犯了错. 我来举个例子.

https://bbs.gpuworld.cn/index.php?topic=73413.0

img

例如本帖, 本帖楼主犯了一个常见的错误, 没有等待kernel完成, 就立刻对它进行计时, 然后得出了错误的问题前提: “一个kernel如果被反复调用的话, 是会越来越慢的”。

我们看下该楼主的具体做法:

1
2
3
start = clock();
DeModuate <<<BLOCK_NUM, THREAD_NUM >>> (....);
end = clock();

楼主这里直接测了起始时刻start, 然后立刻用<<<>>>调用了自己的kernel, 然后不等该kernel”实际上的完成工作”, 就立刻测量了结束时间end, 然后就认为从start到end, 这两段时刻的差值, 是kernel的实际执行时间, 这是严重错误的。

这就像公司老板, 先看了一下手表, 现在的时刻是1点29分, 记录成Start; 然后叫了员工如花说,”如花,去把上次和我们合作活动的NV公司的联系人, 沟通一下XXX事宜”; 然后对如花说完这话后, 立刻又看了一下手表, 现在是1点30分, 记录成End.

然后老板认为,如花完成和某公司的沟通工作, 一共用时: 从1点29分到1点30分, 共总1分钟.

这显然是严重错误的. 这样的计时方式, 并不是员工实质完成一个工作的时间, 而只是老板(CPU)对员工(GPU)的派活, 所耗费的时间. 并不能实质衡量某工作的时间的.

类似的, 该帖子的楼主也犯了这个错误, 他也是立刻用<<<>>>给GPU派活后, 立刻看了一下表, 从而导致他理解得到了错误的信息, 从而让整个问题化为无意义. (错误的前提下, 给出的提问是无意义的)。

那么正确的做法是什么呢?

正确的做法(之一)是, CPU在给GPU派活前, 的确可以记录时刻Start; 但是一旦给GPU派活后, 必须等待GPU完成该活, 才能记录时刻End. 此时的End减去Start, 才是真正的干活耗时。

这就像公司老板给员工如花布置活前, 记录了1点29分为start时刻; 然后给如花布置了沟通联系的活了后, 老板等待, 例如2点00分, 如花届时完成了该活后, 才记录为end时刻.

此时的end - start = 2:00 - 1:29 = 31分钟, 才是如花真正干完该活所用的时间. 这样才是正确的.

不仅仅如此, 我们还会在今天的内容中看到, 除了老板自己去计时的方式, 我们还可以要求员工(GPU)去计时, 即员工如花自行在自己干活前记录一下开始时刻, 然后去干活, 然后员工如花在干完后, 自行也再记录一下结束时刻, 然后并将结束和开始的差值, 作为干活时间, 汇报给老板(CPU)即可.

回到该楼主的帖子, 我们很遗憾的看到, 该楼主在我们给出了两次回答和解决方式建议后, 即分别要求楼主用第三方工具验证他的计时错误的前提(这样他可以自行发现他的错误, 从而增长经验), 和直接给出了建议(即明确的告诉了他哪里是理解错了后), 他均无视了我们. 并继续在后续的跟帖中, 给出他自行认为的理解. 这点我们是感觉非常可惜的.

实际上人是互相尊重的, 特别是在作为提问者, 你更加应该尊重回答者给出的信息的. 无视这一点, 并取得”面子上”的好处, 是无益于事情的. 我们在这里今天严肃的提出这一点。

是希望其他的客户或者非客户, 在论坛提出了问题后, 在看到论坛给出的解答后, 不要为了”面子”, 带着有色眼镜, 从而实质上的无益于楼主们在论坛的经验的获取, 和以后遭遇相似问题时候的快速解决.

(反过来, 如果你尊重了论坛, 则你本次能反思得到了经验, 得到技术上的成长; 下次遇到后还能快速回忆场景, 快速解决, 节省干活时间, 增加在老板心中好的评价).

然后我们继续说一下该例子, 楼主的正当做法应该是:

  1. CPU记录开始时间
  2. CPU给GPU派活
  3. CPU等待GPU完成该活
  4. CPU记录结束时间

我们在这里插入了步骤3, 也是手册上今天的CPU计时内容章节, 所推荐的做法(cudaDeviceSynchronize()同步等待, 或者其他任何等效的同步方式). 只有加上了该等待, 你的开始到结束的时间差, 才是真正的干活时间.

似是而非的计时方法

作者:GPUS开发者
链接:https://zhuanlan.zhihu.com/p/339698093
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

我们继续回到今天的第一个大话题, 正确的计时. 因为这话题的确很重要了, 没有了正确的计时, 一切对工作效果(代码运行快慢)的衡量工作, 都会变成虚有.

首先, 我们已经说了, 正确的逻辑顺序. 即CPU开始时刻记录->CPU发布任务给GPU->CPU等待GPU完成->CPU记录结束时刻。 这4个步骤, 任何一个步骤错误了, 都会导致错误的结果。

我们还在论坛上经常看到有人会: 启动kernel->记录开始时刻->记录结束时刻, 这样的做法也是错误的(记录开始时刻必须在启动kernel前)。特别的, 在使用非专业卡, 或者在WDDM驱动下, 该问题可能会被掩盖,即哪怕你先启动了kernel, 然后才记录开始时刻, 有的时候看起来”结果是对的”, 这是因为WDDM驱动之类的有缓冲, 它可能在某种情况下会导致kernel的启动, 是被延后执行(正好插入到你记录完开始时刻后), 从而导致可能的错误的”咦, 我本次这样写也对了”的假象,从而隐藏了问题, 从而让书写者本次可能会认为, 这样写没问题的假象。我们这里严重强调一下, 必须保证正确的逻辑顺序, 才能得到正确答案。

回到今天的计时章节. 除了正确的逻辑顺序, 和工作逻辑流程上的保证(即在开始工作前计第1次时间, 必须等待工作实际完成后, 才能计第2次时间). 我们还需要对基本的计时工具本身, 进行讨论. 本实践指南手册上, 在今天这里简单的说了一下, 你应当在正确的操作系统平台上, 使用正确的计时工具/函数调用. 而我们准备详细的说一下这点, 分别对应常见的台式机上的Windows开发平台, 和我们目前在售的嵌入式上的Jetson平台(Linux)上的正确做法. 这里我们只推荐两种正确做法(也有其他的, 但这两种是推荐的).

(1) 在Windows上请使用QueryPerformanceCounter()/Frequency()这两个函数来进行计时. (技术上的理由: 它使用了主板上ACPI提供的HPET计时器, 该计时器在常见的主板上, 保证了至少几个Mhz+以上的时间分辨率, 足够)。

(2) 在Jetson嵌入式平台上, 使用gettimeofday()系统调用来进行时刻的记录. 它也同样具有很好的时间分辨率/精确性.

然后在任何一个平台上, 均不建议使用__rdtsc()或者clock()函数进行计时. 这里要重要说一下。这两个函数均是多年来, 在我们的用户中, 流行的两种方式, 可惜它们均存在一种问题(真的是好可惜. 对的选择的不多, 错误的大家都很喜欢).

我们还用上文提到的那个帖子的错误做法(https://bbs.gpuworld.cn/index.php?topic=73413.0)举例好了(没错, 除了之前的那个计时错误, 该帖子还有更多的计时错误). 该帖子是今天的实践手册上的正确做法的, 多个连续反面例子. 还是很起到很好的警示作用的.

该帖子中, 记录时刻使用了clock(). 很可惜, 该函数在不同的环境下, 有不同的返回值解释. 我们分别看一下MSDN上的clock解释, 和Linux上的man(3)手册中的解释.

首先是Linux上man手册的解释, 这是我们的jetson平台. 在该平台上, 它返回的是调用者进程所耗费的”CPU时间”, 什么叫CPU时间, 和我们常用的时间有什么不同?

举个例子说, 我在CPU上执行了一个可执行文件, 上去执行了3秒, 然后突然打开了一个巨大的磁盘文件(假设我们用的是普通的机械硬盘), 或者需要从网络读取一个资源, 而突然卡顿上了10秒无响应, 然后又立刻执行了4秒. 那么此时, 它实际上使用的CPU时间是7秒(4+3), 也就是中途它卡在磁盘读取或者等待网络数据返回的时候, 那10秒虽然流逝了, 基本上是不耗费CPU的。这和我们常用概念中的实际时间(一共经过了17秒, 4+10+3=17), 是不同的.

因此你看, 虽然实际上计算了很久, 但是从”CPU时间”的角度说, 可能很短, 因此该函数和我们常用的实际生活中的时间概念是不同的, 在我们的jetson的linux平台上, 是不能使用的.

我们平常日使用的时间, 在计算机中, 叫real time, 即中文翻译是”实时钟”;也叫wall time, 就是你挂载墙上的钟上经过的时间.

幸运的是, 微软的Windows平台上的clock()实现(请参考MSDN), 是返回的wall time, 也就是可用的我们的实际时间, 那么看上去, Windows上至少能用它?

实际上很可惜, 和jetson上一样, Windows也不能用. 这是为何? 因为很多客户在使用的时候, 只考虑了该计时方式的逻辑上的意义, 而没有考虑该计时方式的精度/时间分辨率. 在Windows平台上, 该函数的分辨率只有计时Hz到小于1Khz, 用人话说就是, 假设是50Hz, 它最小的分辨率只有20ms(1秒=1000ms, 分成50个周期). 而我们的代码运行的速度往往很快, 某个片段往往很短, 例如13ms, 和37ms(随意的举例), 用该函数得到的结果可能就会分别取整到了0ms, 和20或者40ms了.

此时时间都错误的离谱. 这就像我们用墙上的钟表的秒针(最长的那个指针)来计时一样, 它的分辨率只有1s级别, 如果我们的代码运行了300ms, 你会发现秒针没动, 运行时间为0;

或者我们的代码运行了1.7s, 你会发现秒针动了1下或者2下, 时间也错的离谱.

因为秒针的时间分辨率/精度不够, 所以不能用来计时。而clock()也存在类似的这个问题. 所以也不能用。

我们需要的是逻辑正确, 精度足够的计时器.

也是今天实践手册上, 在说”计时器”的选择章节, 强调的重要因素. 幸运的是, 我们的QueryPerformanceCounter()和gettimeofday()在2个平台上均可以满足这两点要求. 因此它们才变成了今天我们推荐的计时工具(CPU端, 或者你理解成老板专用工具). 此外, 今天的两个被拒绝的工具中(clock & __rdtsc), 后者也存在一处或者多处问题, 因此也不能用.

rdtsc主要是存在时基漂移(在后期的CPU和主板中逐渐的解决了), 不能跨核心同步, 以及, 还有rdtscp版本来解决其他”CPU乱序执行上”的其他问题. 这些问题或多或少的在后来的CPU/主板/操作系统中都逐渐解决了, 但是我们不敢打包票. 因此也不推荐使用.

GPU端的CUDA Event计时

好了. 你已经会了CPU端计时了, 记住, 正确的计时逻辑顺序, 和使用正确的计时工具, 这两点满足了, 你就会有正确的测时结果. 我们继续说一下GPU端的计时. 和CPU端的计时类似, 它同样需要2个方面: 正确的逻辑, 和正确的工具使用.

在开始这两点之前, 我们先说一下GPU端计时的优势和特色.

优势和特色主要有两点, 1个就是可以将计时本身当作命令发布下去, 而不需要一定在特定的时刻, CPU亲自动手去记录. 2个就是可以方便记录比较复杂的计时场景(特别是多流和传输/计算异步重叠的时候). 我们先说一下1点.

还记得我们之前的例子么? 老板让员工如花去完成一个活, 然后老板在如花开始动手之前, 和如花完整的完成了工作后, 分别进行了时间记录. 这个例子还可以这样做——老板: “如花,你去干XXX活. 干活前后你记下时间, 最后将这个活和用时都汇报给我”. 这种方式相当于是老板将计时本身的任务, 当成活布置给了员工, 这样老板可以在半夜12点突发奇想, 通过微信给员工如花布置任务: “明天9点上班后, 干YYY. 我晚点来, 你统计一下时间”. 而不需要老板必须在明天9点那一瞬间, 亲自不布置记录.

也不需要老板时刻的焦急的等待如花去完成, 最后在如花于11点完成的瞬间, 立刻找笔纸记录下来结束时间.

大大减轻了老板的调度成本, 和指挥公司运营的压力. 类似的, 我们的GPU作为一个劳力或者说协处理器的角色, CPU也需要调度它.

通过GPU端计时, 我们可以将计时本身的任务, 布置给GPU即可. 这样CPU上的调度(代码)可以有更自由的安排, 也减轻了用户们写代码上的逻辑安排的压力. 我们具体看看怎么做:

GPU上的计时, 是通过CUDA Event来完成的, 它可以理解成一种非常轻量的空白kernel, 只用来记录一下时间而已 (因此很多用户忧虑的, GPU上执行event的记录工作, 会不会拖慢GPU — 完全不会的).

具体说, 是通过在特定的CUDA流中, 发布一种叫cudaEventRecord()的任务进去而已.

这样, 该流中的命令们, 一旦当GPU执行到”记录Event”的时刻, GPU就立刻记录一下当前的时间(注意, 是从GPU的角度, 有它的时间分辨率. 本实践手册保证了至少2Mhz+的分辨率/精度). 然后继续往下执行该流中的其他常规任务(例如kernel计算). 这种记录几乎完全不占用GPU的处理能力.

所以在GPU上, 我们可以知道, 该工具(CUDA Event)是精确可靠的计时工具, 那么只剩下来逻辑的正确性了. 保证了后者, 你就可以得到了GPU上的正确计时, 不能保证, 则一切无从谈起. 但是很遗憾的, 我们从这10年来的客户反馈上来看, 很多客户并不能合理的安排一个GPU上的计时逻辑. 从而导致了错误的解决.

我先说一下GPU上正确的逻辑安排应当是一个什么顺序的:

假设用户已经有了1个CUDA流stream, 2个CUDA Event分别是start和end, 现在需要对该流中的1个kernel K, 进行计时, 正确的逻辑是:

  1. cudaEventRecord(start, stream); //在流中发布计时命令, 要求记录start时间
  2. K<<<….stream>>>(); //在流中发布kernel K
  3. cudaEventRecord(end, stream); //在流中发布计时end时间
  4. 同步

其中第4点非常重要, 常见的有3中做法. 即cudaDeviceSynchronize()进行设备同步, cudaStreamSynchronize()进行流同步, cudaEventSynchronize()进行Event同步.

其中设备同步是大家喜闻乐见的, 相当于老板等待公司人员全部空闲下来的时候, 再检查两个start和end时间(的差). 例如老板可能会等待晚上9点, 发现都下班了, 然后再优先的拿出今天如花完成工作K的记录本, 查看一下K的前后时间, 得到一个用时.

这种方式虽然最简单方便, 但是老板可能会在一个很晚的时间后, 才能得到今天的工作汇总(因为你进行了设备同步, 等待设备(公司)上的所有工作完成后才能得到这个汇总), 很多时候不恰当, 或者导致GPU设备/公司运营效率低下.

第二种方式, 则是进行流同步, 大致相当于员工同步. 老板可以等待如花突然闲置下来了, 然后拿出如花的工作记录本, 查看一下她完成工作K的信息, 和前后工作的记录时刻. 从而知道了如花对工作K的计时. 这种方式好很多, 因为此时, 另外一个员工翠花可能依然有活在干, 时间也不过是下午3点, 老板及早的知道了, 还说不定有余力能调度其他事项. 提高公司运营效率.

第三种方式, 则是进行事件(Event)同步, 这相当于员工同步里的细项. 特别是在该员工有连续的多个活的时候非常好用(例如老板给如花布置了活K和K2, 并要求在K完成后立刻计时). 老板可以等待员工如花完成了工作K, 并记录了结束时刻的那一个瞬间, 立刻从沉睡的沙发上惊醒, 然后立刻检查如花该工作的信息和前后时刻. 而如花此时本身, 已经继续去干下一个活K2了.

这样老板不仅及时的在惊醒的瞬间, 慢慢开始泡茶喝(相当于CPU上的后续调度处理)检查如花的活K的相关信息的时候, 如花自身还在干下一个活. 提高了老板和该员工的同时的调度和工作效率.

所以你看, 最应当做的应该是方式3(对事件进行同步).

但是虽然事件同步很好用. 但是我们很遗憾的看到, 很多用户并不能正确的使用它.

毕竟这就如同很多家公司存在, 并不是所有的公司的老板, 都有能完善强力的调度协调能力的. 我们分析了一下历年来用户们不能正确的通过事件同步, 来计时的一些问题, 主要暴露出来的问题有这些点:

用户不能理解cudaEventRecord()只是发布了一个让GPU计时的”任务”. 这种发布并非是当前的CPU发布命令时候的时刻, 而是GPU上实际执行到了该计时任务处的时刻.

还用我们刚才的例子吧. 老板半夜在12点发布了微信命令, 如花在第二天的9点才开始干活, 那么实际上执行开始时间记录(cudaEventRecord(start, straem))的时刻, 是第二天的9点! 而不是半夜的12点!

这点相当多的用户都理解错了. 一定要注意.

其次则是, 必须要等待实际上的stream中的K任务完成了, 并记录了后续的stop时间后, 才能用两个时间做减法, 得到夹在中间的K任务的真正耗时.

也可以看我们之前的举例, 如花在9点开始干活, 然后干了2个小时的K任务, 完成于11点, 并记录完成事件stop; 然后她继续从11点又干了3个小时的任务K2, 以及其他各种任务到下午5点下班. 然后工作里的其他员工都干到了晚上11点才下班.

那么作为老板, 你在10点立刻去尝试减掉开始时刻9点是不对的, 因为该活并没有实际上的完成. 从晚上11点(设备同步)去检查, 发现是上午11点完成的, 得到11-9=2, 是对的; 从下午5点(如花下班, 流同步)去检查, 发现也是上午11点完成的, 也得到2个小时, 也是对的; 从上午11点整去检查(如花完成记录K完成后的stop事件时间), 也能得到2个小时, 也是对的.

这分别对应了我们的cudaEvent/Stream和DeviceSynchronize()三个同步调用.

读者们可以大致评估一下效果, 但不管怎样, 你要记住, 发布记录命令本身也是一个任务, 必须等到该任务实际上完成了记录才可以(用3大同步去等!). 以及, 切记任务实际上的完成记录的时间, 和你发布这一系列命令的时间毫无关系(你在半夜12点的微信上发布的好么!)

记录这两点, 大致你对GPU端的cuda event计时就没有大问题了.

GPU端Event计时的重要特色

作者:GPUS开发者
链接:https://zhuanlan.zhihu.com/p/340203355
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

我们在上面的内容中说过, cuda event计时还有它的丰富的特色, 你已经看到了它能正确的计时, 还不耽误老板(CPU)上的提前半夜调度的便利. 我们下一个要讲的, 就是说它可以方便的跨流, 跨一堆任务进行计时. 但在说这个特色前, 我们需要将手册的一点说法进行修正。

本实践手册在今天的GPU计时章节, 说了, 一定要在默认流中进行调用啊, 等等的话, 这个说法实际上是不对的。

(坑:手册的这段有它的历史来源, 已经10年不管了,不用看手册里的解释,它的确是不对的。CUDA这10年来, 历经了v2 API变更, 从每个host线程独享一个context变化到共享; 历经了对非默认流同步的流的变更, 等等。)

手册这里的一定要在默认流中同步的做法, 减少了调度上的灵活性, 并实际上导致了性能的下降。我们不建议一定需要在默认流中记录并同步, 这是完全没有必要的. 什么手册上说的, 在其他流中记录会不准的, 在我们的多年使用中, 并不存在这个现象。

我们在修正了手册的这点说法后, 继续到GPU端的event计时上的其他特色。

这种其他特色是指, 可以对一个实际的操作序列整体, 进行计时。

我们还用一家公司来举例好了. 该公司今天有3个主题: 联系另外一家B, 得到信息. 然后根据信息开始工作K. 同时今天还有消防检查, 要一大早的就进行安全隐患排查。

于是老板想知道, 在有消防检查主题存在的情况下, 今天的工作效率(联系合作伙伴公司, 并后续的进行工作K)还能有多少, 这就涉及到了计时, 一个比较复杂的计时。

这实际上是比较实际的应用, 因为你单纯的今天公司啥都没有, 只专心干1-2个主题的情况比较少见,很多时候都是公司里有这种复杂场景的, 此时的工作效率评估很有必要。

这里可以用修复了手册上”必须在0流/默认流中”进行记录的说法, 此时完全可以有3个员工, Tina, Linda, Rabbit, 来同时开始做事, 它们大致对应了3个流。

老板首先对Tina下令, 序列如下: 记录你的开始工作时间Start, 你联系公司S光, 然后将结果告诉同事Linda, 你继续干你的日常其他活。

接着老板对Linda下令, 你先干你手工的日常活, 等Tina告诉你了S光的信息后, 你根据这个修改报价, 并完成当前积攒的出货. 然后你这两个干完后, 记录结束时间End。

最后老板对Rabbit下令, 你今天刷一天的墙, 并将灭火器的位置和容量都检查好, 等待迎接消防检查.

然后老板感兴趣的是这里的在Rabbit忙碌于今天的卫生环境处理的时候, Tina从联系S光开始(Start时刻), 到最后的Linda完成改价格和出货的时刻(End时刻), 这两个人都用了多久, 效率如何.(注意, 为了消防检查, 已经占用了一部分公司Rabbit资源).

这个时刻即可使用上刚才说的GPU端的Event计时, 配合多流同步操作. 即分别是:

流Tina中: Record Event Start -> 联系S光 -> Recrod Event 联系Done-> other jobs
流Linda中: 设备端同步Event: 联系Done -> 改价格并发货 -> 记录完成事件Stop -> Other jobs
流Rabbit: 刷墙 -> 消防任务

这是一个比较实际的GPU应用的流程, 你将这些都替换成流中的异步传输, 替换成kernel间的依赖, 替换Rabbit成一个不能压满设备的需要持续调用的小kernel. 即可符合实际中的常见情况。

这种常见情况必须这样操作, 才有可能充分利用GPU. 手册里的必须在特定流中做特定事情, 无法正确评估实际应用场景中的时间和性能. 所以我们举了这个例子.

好了, 回到这个例子. 注意我们这里有好多Event, 那么老板关系的主工作流程是从哪里到哪里? 是到Tina完成联系S光(Event 联系_Done)吗? 不是的.

实际上是到流Linda中的”发货完成事件”。

需要老板在至少等待到该事件完成后, 才可以评估联系同行–修改价格–发货(或者发布新报价)这一系列流程中的复杂任务, 的时间和工作性能表现. (当然, 老板也可能等晚上全部员工散场后再看, 但那样可能会影响老板本来调度能力, 因为她可能半下午就可以根据情况决定是否要发布新任务或者修改计划了)

你会看到, 我们这里复杂案例, 跨越了多个流来计时, 并在轻量背景GPU任务持续存在的情况下(Rabbit), 合理的评估了员工Tina和Linda的整体工作序列的表现. 这是修复了手册上的错误说法, 并给出的非常实际的例子。

GPU显存的特色

今天的内容将首先介绍GPU的显存的特色, 包括巨大的带宽, 巨大的延迟, 和最小传输粒度等. 这些都是很多讲CUDA的书容易忽视的地方(它们往往注重在计算本身),然后再具体分析GPU的片外的显存, 片内的shared memory, 和片内的各级缓存的情况. 这些情况的分析对于写出一个效率良好的CUDA代码, 还是很重要的. 你只有先了解一个东西, 才能应对好这个东西. 而且今天的实践手册章节还介绍了一些坑和注意事项, 以及, 给出了一些经典的例子(但是今天我们可能不说这个例子).

我们先看看GPU的显存的特色.

GPU的显存的特色主要在于, 相比CPU依靠一级, 二级, 三级(4级)等各级缓存, 以及, 充分的硬件prefetch等措施, 来降低硬件所预估的某个小范围内的某个随机数据的交付时间(即, 降低一定范围内的读取延迟), GPU并不依靠这点。

GPU上的显存大部分情况, 是具有非常长的延迟的. 这种非常长的延迟并不会影响显存的巨大带宽的发挥, 手册今天你看到了一张7.0计算能力的V100的卡, 具有将近900GB/s的峰值带宽, 非常惊人的。

而之所以能不影响显存本身的巨大带宽的发挥, 是依靠我们上次说过的, GPU上能够海量并发执行的线程数量, 当一个线程在等待超长延迟的读取的结果的返回的时候, 能够切换到下一个线程(或者更高的执行调度单位)上去执行, 从而将这个延迟给掩盖掉(详见我们之前的CUDA C编程指南手册). 这样, GPU的显存和CPU的内存, 或者说GPU和CPU上的存储器的体系结构, 具有截然不同的鲜明特别. 也就是我们常说的, CPU(的各级大容量和快速访问)的缓存, 是为了降低延迟而设计的; 而GPU(的缺乏这种各级大容量的缓存), 则是为了尽量将有用的晶体管交给计算或者资源(例如寄存器)进行延迟掩盖, 从而能发挥接近峰值的吞吐率, 而设计的.

所以, 这种”高延迟”和”大带宽”并存的显存访问方式, 将会伴随我们几乎以后所有的CUDA代码的编写时候的考虑。

此外, 今日的实践手册还指出了. 大部分的CUDA代码在追求极致性能的时候, 所面临的主要矛盾, 往往是在显存访问上. (而不是在其他的几个矛盾, 例如计算性能上), 不过这是不一定的, 虽然大部分的GPU程序可能如此, 但是很多还是强烈的需求计算性能的, 而不一定短板是在显存的带宽之类的上. 而大部分GPU程序只所以如此, 往往来源于以下3个方面:

(1) NV在生产显卡的时候, 往往搭配一定的计算性能和显存性能. 例如说, 低端的只有几个TFlops或者TIOPS的卡, 可能往往配备128-bit的GDDR5/6的显存, 带宽本身就较低. 一般这种搭配, 对于很多算法实现, 总是计算性能够, 而访存带宽不够的. 所以出现了这种现象。

这种现象往往需要通过深入的实践GPU上的CUDA代码书写, 才能逐渐在具体硬件上得到缓解(例如今天下面要讲的, 如果在固定的一定峰值性能的显存的卡上, 充分发挥出来global memory的性能. 或者充分使用shared/texture之类的, 来尽量节省显存的访问, 来发挥性能, 这样的优化),总之你是直接买回来一张卡, 就试图认为我随意写写, 显存就不是问题, 问题在GPU核心芯片的计算上, 那是不可能的。

老黄的搭配总是有它的一定道理的, 总是你不好好优化存储器访问, 随意的写写, 是会卡住的. 当然, 这也是为何本手册的下面的各个不同的存储器/缓存的优化指南存在的意义。

(2) 很多代码是从CPU迁移过来的, 或者说老代码逐渐优化到GPU上了. 以前在CPU上的时候, 计算性能和访存性能拉得并不开, 但是随着GPU的到来和飞速发展, 它的计算比访存的差距, 拉得越来越大,例如原本在CPU上已经做好的东西, 在CPU上计算和显存是7:1(随意的假设)时候, 大致内存和CPU性能都没有太大的短板. 一切都很好; 而如果你迁移到GPU上的时候, 因为GPU的计算峰值提升的幅度, 远远比显存的带宽提升的幅度大, 这个比值可能变成了77:1,此时原本的各方面均衡的代码, 往往会优先的主要体现在访存上, 出现短板. 因此就需要优化。而且特别的, 如果我们之前说的, 因为CPU上有各级较大容量的缓存在为你服务着, 而GPU上(相比来说)往往没有甚至非常缺乏, 此时就算是提升的幅度比一样, 你也需要考虑优化访存了。

(3) 随着老黄历代的GPU的性能发展, GPU本身的前后代对比(而不是GPU比CPU), 依然会呈现一个计算峰值的提升比, 会远远超过显存带宽的提升比的问题. 也就是我们常说的, 随着一代一代的新卡的问世, 计算性能将比访存性能提升的更快。

例如我们知道大约10年前就有100GB/s-200GB/s的显存的卡了. 例如当年我们使用的经典老卡GTX480. 而今天手册的例子上的V100才不过到890多GB/s, 这是将近过去了一个10年了,只有几倍的提升,而考虑计算峰值, 当年480的1TFLOPS的卡, 而现在的卡不考虑TensorCore也有几十TFlops了(最新的30系列的卡),这就几十倍了. 算上TensorCore那就计算性能提升的更惊人了.

你会看到, 因为这种一代代的GPU卡, 计算性能总是更快速的提升的现象. 就自动会让GPU上的代码, 包括哪些从头就是为GPU设计和实现的代码, 本身会随着时间的发展, 自动趋向于卡访存的情况.

因此大部分的代码, 应当首先考虑本手册说的, 对显存的特性的了解和优化. (GPU上原生设计的代码本身如此, 更不用说那些从CPU迁移过来的代码了)。

这三点可能是主要的为何本手册上去先说这个的原因。

GPU显存的粒度

我们先看一下具体的, 一些使用显存所需要注意的事项, 以及, 一些简单的 技巧, 来提高显存的性能的使用和发挥.

我们回到之前的话题, 之前主要说了GPU显存的特点, 即高带宽和高延迟, 以及, 在GPU上面应用的主要容易遇到的矛盾或者说短板是显存带宽, 而往往不是计算。

这里还需要说一下, 显存的访问, 是具有最低的粒度要求的. 这个粒度很多时候是32B (但HBM的系列可能除外, 之前看到的第三方资料—我没有使用过HBM的N卡, 说是64B),

粒度(granularity)实际上是指, 你访问了某段地址的哪怕1B, GPU也要至少传输32B的问题. 从已有的资料和历年的GTC的PDF和PPT来看, 这个32B, 指的是从显存(DRAM)传输到L2, 和L2传输到L1的粒度. 注意我们现在都是Pascal+的卡了, 以前的老卡还有L2->L1的overfetch的问题, 现在也没有了, 就是32B. 虽然32B很小, 但是不当的访存, 例如本手册上的例子, 大跨步(strided)的访问4B或者1B, 会导致周围的连续32B都会被读取的. 这样会严重的浪费有效带宽的. 然后手册上还说了, 除了读取的这种情况下, 对带宽的浪费, 在有ECC存在的情况下(依然先不考虑HBM显存的卡, 那个很特殊),

也会导致浪费. 为何? 因为ECC的不是对1B为单位的显存中存储的内容的保护和纠错, 而是对一个计算单位的. 对一个计算单位整体进行ECC编码的计算, 然后附加的跟随写入. 这样就导致了两个问题:

(1) 普通的无ECC的显存, 可以通过掩码的方式, 例如我可以直接传输一定大小的内容, 假设是4B of 32B, 其中将这4B(32-bit的)范围, 设定有效写入, 其他28B, 设定忽略. 但是ECC的做不到这点, 他必须将整个一定的纠错基本计算单位粒度的那么大小的,读取到L2,然后L2就地修改其中的1个或者多个字节, 才能重新计算ECC. 这样就导致了, 在一定传输和ECC粒度存在的情况下, 写入会导致读取的现象存在.

(2)不仅仅如此, 伴随写入的ECC编码, 是在这个基本计算粒度的后面跟随的, 这样哪怕你写入了1B这么小, 实际光额外追加的ECC编码就很大了(例如8B, 具体得看相应的某代的GPU的白皮书或者其他资料才能知道)。所以这个粒度的问题, 存在于对显存的读取, 更加也存在于有ECC的情况下, 对显存的写入. 反而是无ECC的显卡, 纯写入的时候可能受影响的要小一点.

更不用说本章节手册提到过的, 一些GDDR5/GDDR6显存的卡的ECC的编码还要跟随占用传输带宽和存储容量的问题(这个影响读取和写入, 本章节手册已经说过了)。

好了. 继续回到GPU显存的传输粒度这个话题上.

虽然看起来这样很坑, 但是本手册同时提到了, GPU很适合这种零散的随机性的大范围跳转的读取, 相比CPU依然具有很大的优势, 这听起来很反直觉, 是为何呢? 这是因为CPU的内存, 同样具有这个特点, 也有它的粒度. 而且这个粒度比GPU的还大(依然暂时不考虑HBM显存的情况). 依然应该是后者。

前者存在CPU同样的过大的cache大小, 和overfetch的情况, 也存在每个CPU核心, 只能应付少量的outstanding进行中的cache miss后的读取. 而后者, 作为集成的GPU, 具有GPU的特别, 可以同时有较大数量的从SM到L2的进行中的请求, 来等待取得. 从而可能有更好的性能. 我们之前在图灵系列实体显卡的测试中, 大范围的4B为单位的读取, 能提供有效的带宽是大约在显存的4%左右. 或者以32B传输为单位, 大约在30%+这个数值, 和今天手册上给出的图, 并不一致. 我们将有机会重新在新一代的卡(8.6的安培), 或者我们的Jetson产品上给出重新测试. 以便让用户知道毫无规则的访存的时候, GPU依然具有的, 比CPU好很多的优势. 这点切记. (因为实际点的代码总是可能会涉及到一个大查找表的, 此时的下标可能毫无规律)。

Okay. 我们已经理解了GPU显存的特点, 高带宽, 高延迟, 最小粒度(其中这点是和CPU共有的). 以及, 刚才上面说了, 大部分人的主要短板可能是在访存上. 那么我们下篇就继续进一步的, 说说Global Memory的相关优化.

GPU卡和Jetson上显存优化的特色

我们下面就继续进一步的, 说说Global Memory的相关优化.

要说对它的优化, 我们得先知道Global Memory是什么, 和很多人的印象里的不同, 它不一定是显存. 还可能是映射的内存. (例如zero-copy时候的手工分配的, 和退化的Unified Memory的情况). 我们主要说一下当Global Memory是显存, 和是zero-copy的情况, 而暂时忽略是退化的Unified Memory的情况。

说一下这两者时候的注意事项和优化.

首先本实践手册这里, 提到了zero-copy内存, 这种是锁定在物理页面中, 而不能被交换到磁盘上, 同时又能被GPU设备, 直接访问到的内存(映射成了global memory)。

这种内存, 具有多个特点.

先说一下实体显卡上的, 再说一下我们的Jetson上的。

在实体显卡上, 因为卡是通过PCI-E连接到CPU的, 此时的一切传输(从内存到显存), 均需要通过PCI-E进行. 而PCI-E的带宽非常有限. 通常只有16GB/s的理论带宽. 注意手册这里第一次给出了实际能达到的, 在PCI-E 3.0 x16下的传输带宽, 往往是只有12GB/s左右, 这个结果是和实际日常使用中的情况是一样的. 手册这里给出的的确是比较准确的数字. 而这个带宽, 很多时候, 只有通过锁定在页面中的缓冲区传输, 才能达到的, 为何? 主要是这个涉及了CUDA C编程指南中, 和CUDA Runtime API手册中的最前面说的一些情况. 我简单这里重复一下吧.

一个是作为PCI-E上的GPU设备, 他用自带的DMA引擎, 进行BUS Mastering的时候, 本身就只能访问物理页面(或者说, 物理页面范围). 而不能支持CPU端的虚拟内存. (否则它将需要理解CPU端的页表结构等一系列问题, 才能自动转换)。我们知道当年的某蓝色巨人的XGA显卡, 作为它的8514显卡的继承人, 具有CPU端的虚拟内存支持, 并重新实现了兼容于x86 CPU的页表和相应的CR寄存器,作为当年的该机型的用户, 超越了20年的感动依然在心中。然而其他所有后来的显卡都没有这个特点, 因此他们就只能用自己的DMA Engines访问物理内存(好吧, 某些DGX例外). 因此, 当用户要求传输一段普通的可换页内存的时候, 要么显卡驱动内部, 先将该段内存缓冲区的内容, 复制到自己内部的一个小的锁定的物理页面范围上去, 然后再从这里安全的传输; 要么就是就地尝试锁定, 然后传输. 但前者多了一次内存<->内存的传输, 后者则有锁定和解锁开销(这里面的细节可以看CUDA Runtime API手册的前面, 历年GTC也有过详细描述) 。

前者几乎将内存的有效带宽降低了一半,例如我们上次距离的那个68GB/s峰值的内存的机器(4通道DDR4-2133), 这样一倒腾, 内存带宽就实际上只有34GB/s了. 哪怕你不考虑在CPU上运行的应用程序的需要, 光这点带宽, 传输两三张卡就撑不住了,而使用这种锁定了页面的内存, 则可以就地开始传输, 节省了一半的内存的带宽, 因为过程从内存->内存->显存, 变成了内存->显存了. 还是很容易喂饱你的卡们的. 这个是很适合在实体GPU上传输的特性。

这是对于实体卡说的,对于我们的Jetson产品, 实际上是并没有独立的显存和内存的, 也不是通过PCI-E总线传输的, CPU和GPU都在SoC的内部, 共享SoC提供的内存(显存)控制器. 此时如果你照搬之前的经验, 直接来一个cudaMemcpy*()系列函数. 则实际上你无辜的在该内存(显存)内部, 倒腾了一次. 无任何意义. 当时他们提出, 一定要利用页面锁定内存, 能无传输的就地访问的特性(俗称zero-copy, 可以看成是最最简化版的Unified Memory). 取消掉这个在Jetson上的无辜传输. 然后让GPU就地访问CPU的工作数据. 这点还是非常重要的。

不过在Jetson系列产品上, 直接使用zero-copy的方式会禁用GPU的缓存(L2)的, 不如后来的更好用一点(但限制更大)的Jetson上的Unified Memory方式好(也是直接共享, 但启用L2, 但会禁用CPU-GPU同时访问). 这点等我们到了Unified Memory的时候再说。

好了.回到本章节前面的页面锁定内存的传输和作为zero-copy上的特性内容。

注意本章节这里提到了, 要在CUDA 2.2+, 无显存的那种集成显卡上, 使用zero-copy, 云云的. 而没有提到Jetson. 这是因为手册10年来没改的缘故了.

我们现在已经将近10年买不到这种无显存的卡了(以前一些笔记本上有),而Jetson产品也已经普及。

所以我们这里做了调整, 取消了手册上说的过时的不存在的内容, 而增加了Jetson上应当考虑zero-copy就地使用的做法.

此外, 手册上介绍了如何能利用页面锁定的内存, 有效的进行计算和传输重叠的特性. 手册这里给出了一个很好用的东西:

就是当我们只有1个kernel要启动, 和1份缓冲区要使用的时候, 如果能让这个进行多次传输和计算的重叠. 本实践手册这里给出了如下建议: 即将你的kernel修改里面的线程或者block的坐标/下标映射, 将原本一次启动的kernel, 工作于一个大缓冲区上, 改成N次启动(注意每次里面的坐标的变化), 并每次传输1/N内容的缓冲区, 这样可以尽量达到传输和计算的重叠. 这个是一个很好的实践方式. 因为很多时候, 你想利用传输和计算重叠这个优化, 但是你找不到多余的kernel计算任务, 和多余的传输任务来重叠,此时, 你应当考虑本手册中的, 拆分计算规模, 和拆分成1/N每次传输的建议. 这种建议还是很好的. 当然这样做, 你需要注意坐标的偏移和变化. 不要计算错了.

实际上, 在某OpenCL对等的规范中, 在友商家的卡上, 我们可以直接在启动kernel的时候, 提供这个坐标偏移量. 从而完成类似的操作, 而不需怎么改动代码. 所以你看, 这是一个相当实用的东西, 实用到友商家已经提供了API以方便你这样做了.

此外, 手册还提供了另外一个建议, 就是直接不要传输了, 直接就地使用. 因为zero-copy可以以一定的粒度, 直接从内存跨越PCI-E到L2(然后进一步到SM中). 这样根据手册这里的说法, 能在kernel的指令级别, 实现计算和传输的重叠. 但是根据我们的实践, 大部分的使用效果实际上并不好. 我们怀疑是可能是跨越PCI-E带来了更大更难掩盖的延迟或者其他因素, 这些需要等待确定. 但是手册中不强调的另外一个用法, 实际上效果非常好. 即将zero-copy作为容纳结果数据的, 写入的缓冲区. 这种可以将结果的回传, 和kernel的计算, 也在kernel的指令级别, 进行重叠. 而无需你事后开一个异步传输在某流中. 在我们的日常实践中, 这种具有非常好的使用效果. 好到了很多人都发现了这点. 并且在arvix上发文, 用FPGA拦截了zero-copy作为写入结果的缓冲区的时候, kernel的写入, 在指令级别的重叠, 通过L2, 跨越PCI-E回传的时候的情况, 并做了分析. 我们现在大致知道, 根据此文(我不记得番号了, 但是容易找到. 用FPGA + Zero-Copy的字样, 近期文章),

手册中的这种作为直接回写操作的做法, L2会产生跨越PCI-E的32B, 64B, 128B大小的传输的.

然后本文章还分析了, 为何只能达到约12GB/s, out of 16GB/s的原因(因为PCI-E的包大小的问题的浪费, 而不是编码问题). (注意这是说的PCI-E 3.0, 而不是4.0, 后者可能大约达到25GB/s, 这是根据我们客户的反馈, 而不是实际我们的测试).

大致是分析完了显存的特性、一个令人意外的(非常分散的不合并读写的优势)、 两种global memory中的前一种, 以及它的传输上的好用的地方, 和在Jetson上的应用。

一些规避的坑和优化的要点

我们的CPU在读取的时候, 从它的内存读取到它的L2的时候(L3或者L4, 作为LLC, 很多时候是victim cache, 也就是读取的时候不经过, 只有被淘汰的数据才尽最大挽留的存放, 所以这里不提), 粒度往往是至少64B的,这样, 同样零散的分布的读取1B的数据, GPU效率是1/32, 而CPU可能只有1/64. 更加可怕的是, CPU往往会对邻近的cache块/行, 进行预读, 和预测性的预读 实际上很可能会导致, 读取1B, 传输了上百B甚至更多的情况, 此时从效率来说, GPU的1/32要远远超过了CPU. 更何况, 这个是从效率上的说法, 实际能有效提供的带宽, 要用效率乘以各自的峰值, 显存具有大得多的峰值, 此时再乘以更高的效率,就得到了在这种严重不适合GPU, 也不适合CPU的情况下, GPU的性能依然要更好的情况出现. 这点很多书上往往进行了忽略. 因为这些书教育我们, 一定要使用合并性的访问, 要使用适合GPU的访问. 从而导致了很多用户, 不敢将这种不适合GPU的访存, 进行CUDA化改写, 这是很错误的。

本实践手册的这个章节, 破除了这个迷信思想, 还是需要的. 特别的, 在我们的jetson产品上, 存储器的体系结构(hierarchy), 是缺乏一个主芯片级别的统一最后一级缓存的, 即所有的数据, 都最终要通过存储器(LPDDR4), 才能得到一致. 哪怕此时问题来说, 同样的一个渣代码, 无论用CPU还是迁移到GPU上, 访存都是很零散的, 用户你究竟是准备用自带的ARM CPU核心来读取呢? 还是准备用集成的GPU部分来读取呢?

前面的话题已经说了, 如何在Global Memory做, 以尽量取得较好的性能优势. 以及, 和传输相关的方面的话题. 但是有一点没有说, 就是8.0+计算能力新引入的, 将部分Global memory中的缓冲区, 形成一个较长时间片段内, 锁定在L2 Cache中的效果. 或者用户可以理解成, 在一定的时间范围内, 将L2的某部分设定成尽量类似L1之于shared那样的, 类似手工管理, 或者说缓慢淘汰的效果. 这个不说是因为我们还没有测试, 同时, 我们所有在售的Jetson产品都不支持这个特性. 我们可能在最后的时候, 在8.0+上进行测试, 然后重新说这个话题。

好了. 先进行今天的内容. 今天的内容是如何尽量发挥shared memory的性能. 这个其实也是老生常谈了. 要发挥shared memory的性能, 我们得知道为何我们要用shared memory, 为何它的性能是在某些特定的kernel中, 是性能影响因素,因为你既然读到这里, 如果你的kernel本身不卡在shared memory性能上, 甚至根本连shared memory都不会用到, 则自然你不继续看了, 如果你需要继续看, 则至少你已经用了shared, 或者想用, 并且想解决使用中的性能瓶颈, 或者提前避开一些坑. 所以我们就说点这些。

如同本实践手册所说, shared memory在某种意义上, 等于是手工管理的L1 cache. 这种说法, 对于来自CPU的用户来说, 听起来还是比较有吸引力的.

因为一个传统的L1 cache你只能被动的使用它, 并且预估自己的那些访存模式, 适合被L1缓冲, 从而尽量的去好好使用. 而Shared Memory作为完全用户管理的东西, 你有充分的自由可以随意使用, 任何情况下都不会像L1那样, 数据存在自动淘汰可能, 总是可以安全的存储, 高速的使用的.

但是我们作为GPU, 一个追求吞吐率的设备(上次说过的), 很多时候用户们追求近乎100%的压榨出来上面的某些单元的性能, shared也不例外。

今天就大致说了一下, 哪些是影响因素, 并再次(再N次)的给出了使用shared memory进行分块矩阵乘法和转置的例子, 用来显出使用了shared后的高速度来。

我们直接说一下一些规避的坑,和优化的要点:

第一点则是, 尽量规避shared memory上的bank conflict. 这个也是老生常谈了. 我们现在用的, 能买到的新卡, 都是4B宽的Bank. 每个Bank用户应当理解成在每个周期内, 能独立给出4B数据的独立单元,这样每个SM里面, 如果有32个Banks的话, 能给出128B/周期的性能. 这个还是很惊人的,因为对于从CPU迁移过来的老代码来说, 自家的L1 cache, 也不过常见每个周期能给出2个32B读取, 和1个32B写入这种. 也就是96B/周期. 但是CPU的核心数才多少, GPU的SM数量又多少。

一个动辄80多个SM的GPU, shared能聚合给出10TB+到20TB+/s的性能(假设频率从1Ghz~2Ghz的GPU主频). 所以很多老代码, 进行了优化, 迁移到GPU后, 第一步就是考虑尽量利用shared的这个高速特性. 从而发挥性能. 然而, 这个高速度只是理想状态, 一旦shared发生了bank conflict后, 性能会下降的. 下降的程度和你bank conflict的程度有关系. 而具体bank conflict是什么, 我们这里不讲. 因为实在是讲的太多太多次了(几十次是有了). 感兴趣的用户可以回看我们的编程手册内容, 或者回看Sisiy的阿三书. 里面都扯了好多好多。

这里主要说的一点是, 在近期的NV给出的资料中, 揭露了一个新的现象.

就是我们以前一直说Bank Conflict的时候,根据手册,都是用的warp整体(在现在你能买到的卡上), 作为bank conflict分析的, 也就是32个线程内部之间的有无bank冲突. 从而尝试优化. 但是这种手册上给出的分析方法, 和实际的使用中的profiler给出的conflict的报告, 和实际因为达到的性能, 很多时候是理论和实际结果不符合的。很多情况下, profiler给出的bank conflict数量要少很多, 性能指标也要好很多.

例如本论坛的这个例子:

https://bbs.gpuworld.cn/index.php?topic=73410.0

该例子的楼主们, 以及, 奈奈同学, 给出了自己观察到的不同于手册说明的现象. 并且进一步的挖掘出来了, NV只在GTC上给出的一个PDF资料. 该资料里有不同于手册的说法: 即: 在8B, 16B的这种非4B的访问情况下, 也就是类似float2, float4, double, double2这种访问的情况下, bank conflict的计算不是按照warp进行的, 而是分别实际上按照half-warp和1/4 warp进行的. 这点符合实际实践中的profiler的报告的性能结果. 我们今天在这里额外的从论坛揪出这个案例, 同时用NV的这个资料, 进行说明:

在特定的访存方式下, bank conflict的计算应当采用另外的范围(即1/4或者1/2的warp), 而不是从warp整体. 当读者或者用户正好使用这种访存方式的时候, 无需过度的去考虑优化Bank Conflict的问题, 因为很可能此时conflict根本就不存在.

这点需要注意了. 此外, 我们还想给出一点说明的是, 有的时候, 将shared memory作为一个高速的查找表的时候(参考我们之前编程指南手册说过的, shared memory的三大用途之一), 如果下标高度规律性的一致, 在warp内或者block内部如此, 则编译器可能会生成另外一种带有LDS.U后缀的shared读取指令, 会让实际的读取的延迟降低很多, 等效吞吐率提升很多. 该现象很容易发现, 也不报告任何的bank conflict. 但是我们目前还不知道为何会这样, 以及, 如何能让编译器触发这点. 这里的给出只是用来说明, 很多时候本实践手册中的conflict方面的相关优化并不成立, 用户应当以实际的应用中的性能分析器对相关单元的指标报告为准. 然后手册今天不出乎意料的, 继续引入了矩阵乘法/转置的内容, 用来说明shared memory在重复使用数据, 和转换不适合的低效global memory的访存为适合的高效的shared memory访问的特点.

重复使用数据就不用说了, 既然shared memory作为手工管理的L1 cache, 他自然也有cache的这种提供缓冲和高速性能的特点; 而转换不当的访存模式(例如常见的纵向坐标优先或者说大跨步等的方式), 经过shared中转了一次, 变成了恰当的模式, 则用户应该看一下. 后者是很多用户容易忽略的, 特别是对于一些案例, 数据明明只需要使用1次, 那么为何我还需要先读到shared memory中缓冲一下, 然后再从shared memory读取一下呢? 因为对于很多这种的, 哪怕你只用一次, 经过shared memory这么一倒腾, 就可以让访存模式理顺很多, 哪怕只用一次, 也是有性能优势的. 而这种优势, 在直接使用L1 – 不具有不同深度的bank的同时数据供应 – 是做不到的. 但是shared可以。

一些规避的坑和优化的要点(续)

我们需要指出的是, 任何用户现在均不应当手工去尝试进行shared memory上的”优化”, 从而能让自己的”矩阵乘法”变得更快. 因为NV自带的cublas库已经在那里了. 该库超越了大部分人的写作水平, 也包括在给你读这些的今天的我们.因为cublas可能会使用更加底层, 接近硬件的工具, 来书写, 而不是较高层次的CUDA C和PTX.任何用户都应该考虑直接使用该库. 这也是为何我们今天不说这个例子的第二个理由(第一个理由是之前说过太多次了).

越过这个例子之后, 我们再说一下常用的shared memory的, 作为warp和block内部的数据交换缓冲区这点. 这点还是对性能的发挥很重要的. 很多时候, 我们需要在协作的, warp内部的线程之间, block内部的warps之间, 进行数据交换. 来完成特定的算法实现的要求. 此时用shared非常好. 而两个常见的来自论坛的用户的其他做法, 则是不推荐的:

第一个做法是直接分配global memory上的普通缓冲区, 然后每个线程都写入自己指定的位置, 用这个来交流数据. 这个做法还是很不好的. 延迟大, 带宽低. 而且每个block中的每个warp中的每个线程, 都需要计算自己的下标, 就像普通的global中的缓冲区那样.

而使用了shared来作为内部的数据交换, 则具有延迟低, 带宽高, 以及, 更美妙的是, 每个block都有自己的同名shared中的副本, 同样的下标在不同的block中, 自然的被分开了, 从而导致你能用简单的下标来完成交换, 而无需计算完成的, 全局独立的下标. 下标是完全可以在block间的级别重复,而不发生冲突的.

第二个做法则是尝试使用local memory进行数据交换. 这个是错误的, 我们已经在论坛调试了无数这样的代码了. 这主要是针对来自CPU的用户. 甚至是一些高级的CPU用户. 他们本能的认为, 我线程还在活着, 我定义了局部数据, 哪怕是在某些类似stack上的东西, 我也可以临时性的将指向其中的指针, 给其他人(伙伴线程)使用, 只要我存在, stack上的东西就有效.

这点在CPU来说, 的确是的, 而且是在CPU上的多线程数据交换的时候的, 在每个线程都活着的时候, 一个危险而好用的技巧. (因为它规避了小缓冲区动态分配和释放, 直接一个指向stack上的临时性内容的指针就可以了) ,然而, 这点在GPU上并不成立. 在GPU上, 这样将会导致实际上为每个线程分配一种叫local memory的东西, 你的指针指向local memory的内容, 只会在本线程内部, 该指针有效.

如果你用一些技巧获取指针, 传递给另外的线程, 你会发现指针能用还是能用, 但是指向的内容是其他线程的对应指针位置的内容, 这点就非常有意思而难以debug了(其实不难, 因为你载坑一次下次就知道了).

此时依然推荐使用shared进行高效的数据交换. 这点需要注意了. 我们不想在论坛继续解决这种问题了. 我们最后补充2点, shared memory的用途, 是本章节手册没有强调的.

一点则是作为查找表. 当查找表的规模适合的时候, 也就是从128B+到几十个KB的时候, 应当考虑使用shared memory.

(过小的查找表, 例如小于128B, 你应当考虑放在warp的32个线程中的每人的1个寄存器中. 然后用shuffle进行下标查找. 这是不容易出错的方式). (而过大的, 你shared也放不了. 此时可以部分放在shared, 其他部分或者整体考虑放在global中, 或者使用其他策略)

放在shared中这点, 看起来是理所当然的. 但是实际上根据我们的经验, 大部分用户会反直觉的, 首先考虑将查找表放入__constant__中. 这是非常不对的. 因为后者不不能很好的支持无规律的下标访问. 等到constant的时候我们再说.

只是因为它带有”常数”的这种名字, 很多人就用它来顾名思义的进行类似常数/系数查找之类的用途, 这是不对的. 依然这里用shared才会有较好的性能.

第二点则是, 某些kernel无可避免的需要进行某种类似结果compact的操作, 或者说, 先不能一次性的生成最终结果, 而是先生成一个接近最终结果的半成品, 然后最后才能有效的排除/筛掉一部分. 这种操作往往是因为半成品本身, 需要根据前后的值进行进一步的运算, 才能去掉某些结果, 在图像处理中很常见. 此时就有多种选择了.

一种是再开一个kernel, 从global中处理. 另外一种则是回传CPU, 进行这种filtering. 此时完全可以将临时结果先写入到shared中, 然后再从shared中进行筛选. 这个是比在后续的kernel于GPU上, 或者回传后筛选都是高效的. 至少它降低了传输的大小, 我们已经知道了之前的内容说过, PCI-E传输是比较慢的, 应当尽量优化它. 这种将结果分两次写入, 第一次写入shared, 第二次再从shared中写入目标global memory中的做法, 听起来也很简单. 但是根据我司这10年来的经验, 很多用户是会自动无视这点, 或者说自动忘记这点的, 为何? 因为大部分的CUDA书都在教你, 如何有效的用shared缓冲/重用输入数据, 而几乎从来不提, 对结果的写入也可以中途经过shared stage一下,从而完成过滤/压实之类的操作.

从而让读这些书多的人形成了思维定势, 自动对结果写入使用shared这点进行了忽略, 这点还是应该要注意的. 论坛好多人已经这样思维定势了.

最后关于shared memory的则是, 从计算能力5.0开始, shared memory本身具有一定的计算能力. 例如shared本身可以比较高效的计算加法(例如你在atomicAdd的时候). 而5.0之前都是使用了类似锁定–SP加法–解锁的策略, 现在直接是让对应的存储单元(shared)完成这个计算操作了. 因为5.0+的卡是我们现在能买到的唯一的卡, 一些基本的计算, 有的时候可以让shared去完成.

例如我们之前老生常谈N次的纯粹在SP中完成的某些规约操作. 包括图像处理中的, 很经典的, 追加一个List. 可以用shared上的原子操作在shared上尝试构造一个小的. 然后整体在追加到global memory中. 这样可以某种程度的完成行/块级别的内部有序. 也能降低global memory上的, L2级别的原子操作的压力. 特别是当相当多的SM中的请求, 密集的对1个L2上的4B索引位置要求进行原子操作的时候. 因为根据我们的经验, L2上的原子操作可以可以很密集, 但一定要错开, 就想是存在某种类似shared meomry上的分片或者bank机制那样. 如果你不想去研究探讨L2上的这个机制(没错, 我们看到的L2实际上是很多独立的小L2聚合而成的), 则先在各自SM内部的缓冲区上拼接构造, 然后一次性的完成1次, 而不是等效的几十次到几百次的L2原子操作请求, 并最终写入. 还是很好的.

此外, 从目前我们能买到的新卡(例如RTX3070), 已经支持直接从global memory读取到shared memory了. 这是一个极好的特性. 是从友商AMD那里学来的特性.

从Global memory到Shared memory

上一篇里我们说到目前我们能买到的新卡(例如RTX3070), 已经支持直接从global memory读取到shared memory了. 这是一个极好的特性. 是从友商AMD那里学来的特性。我们稍微解释一下。

从大约10年前的GCN的A卡开始, A卡具有一个独家特性, 可以直接从global中加载到LDS中(相当于shared memory), 这样做有很多好处, 例如可以实现异步效果, 可以让某block在请求后台的global->shared的传输中, 主体逻辑在做一些准备或者初始化操作. 而不需要像以前那样, 必须先读到寄存器, 然后从寄存器写入到shared. 读取到寄存器本身无问题, 反正寄存器的占用只是临时的,但会导致主体逻辑卡住, 在主体逻辑一旦试图从寄存器访问到未就绪的数据的时候. 虽说上次内容, 我们都知道, 可以依靠切换warp, 让SM执行其他没有卡住的warp中的内容,但是实际上你在用老nvprof/nvvp或者新的nsight compute的时候, 在选择了PC Sampling的时候, 能看到具体的,往往前面这种一碰载入到寄存器的初始用的数据, 就卡住的情况还是家常便饭的(会显示一个很长的long scoreboard等待采样计数, 类似的东西).

而长期以来,A家则提供了异步的载入, 同时还提供了查询和等待/同步操作, 能让主体逻辑去查询, 后台的异步载入到shared memory进行到哪里了,或者在主体逻辑真的完成了所有前期工作后, 要开始使用shared了, 可以选择的进行一次等待/同步操作.幸运的是, 我们这10年来, 有两点终于得到了满足.

一点是NV终于现在提供了这个特性了, 而且异步载入指令, 选择性的计数等待(例如发出来3批传输, 等待到传输完第一批的时候), 和整体等待/同步等特性. 这样有效的降低了常见的每个warp头部的低效的”冷片段”, 有利于整体显卡性能的发挥. (所以说, 你买一张8.0+的卡还是值得的,)

二点则是, AMD从10年前引入这个特性(其实比NV做的还好, 因为还可以做轮询进度)后, 始终拒绝在自家的OpenCL中, 将该特性导出. 从而实质性的, 能让你买到支持的硬件, 但是就不让你用(有其他方式能用, 但是这里不提, 因为无关今日话题). 从而降低了NV这10年来在追赶优质硬件设计上的压力. 这是新的来自8.0+上的重要的shared memory上的特性和优化,应当注意. 如果你不喜欢现在新版本的C++风格的在CUDA C中的导出, 则你依然可以使用PTX中的传统C风格的调用方式, 手工导出特性即可. 注意对于16B读取(从每个线程的角度), 该新特性允许直接从Global memory中bypass掉L1, 直接送进shared, 避免了对L1中的内容的污染. 这点适合我们今天前面说的, 有些数据哪怕只读取1次, 但为何转换成合适的访存模型, 也可以考虑shared那条的用途. 或者其他的, 任何避免污染L1的情况的用途.

local memory你可能不知道的好处

下面我们简单的再说一下local memory.

首先要注意的是local memory并不local, 它实际上依然是一段显存. 但可能会被各级相关的缓存所缓冲。

主要用途有两点:

一点是你(读者)使用,当你需要每个线程的一段缓冲区的时候,你并不需要单独的开一个全局的大的缓冲区,然后作为参数传递给kernel, 让kernel里的每个线程找到自己对应的一部分使用。你可以直接来一个局部的大数组(不能过大!), 享受类似以前的CPU上的C风格的, stack上的定义的数组, 或者类似CPU上的alloca()的分配风格, 能自动的每人一份, 而且能自动释放, 很是方便,而且不仅仅如此, 你如果传递进来一个大缓冲区这样用, 你需要为所有的一次启动的线程分配缓冲区. 而用local memory, 则只需要保证能真正同时上到SM里执行的那些线程的数量所需要的缓冲区,举个例子说, 前者你启动了1M个线程, 每个线程需要1KB, 则你需要1GB的显存提前手工分配了.而如果你使用后者, 某GPU device实际上只能同时执行10K个这样的线程, 其他的暂时没上的, 在其他block中的线程们, 会等待下次轮批次再上, 则硬件上只需要准备/分配出来100MB的显存, 即可应付, 因为这些线程不是真的”同时”在运行中的(具体参考我们之前的编程指南手册).这点不仅仅降低了手工管理的成本, 还降低了你花钱买一张更大显存的卡的成本.特别的是在Jetson设备上, 显存(内存)容量有限, 用户应当考虑这点.但是很遗憾的, 很多人就是喜欢传递过来一个额外的数组/指针这样使用, 原因我们还未知.

此外, 使用local memory还有一个好处, 就是虽然它像global一样, 被各级缓存缓冲, 但是它有更精细的缓存控制策略, 可以允许对local memory上特定位置的访问, 标记成discard, 或者说last use(PTX手册用语). 允许cache直接将对应的cache line内容, 就地丢弃掉, 而无需必须回写下一级缓存甚至到显存. 这点作为global memory是做不到的。

此外, 今天的实践手册没有说明的是, local memory还具有强制合并访问的特性.我们都说用了local memory, 但是几乎没人讨论”local memory是否是合并的”, 既然我们今天已经知道了它也是用的显存模拟出来的, 为何不讨论这点?这是因为local memory有自动交错的特性. 例如我们定义了一个int dog[N]; 假设dog被编译器选择放置到了local memory上, warp中的每个线程都在访问同样下标的, 例如dog[K]的时候, 实际上来自32个线程的同样下标的访问会被合并成连续的地址空间上排布的一段128B的内容, 非常的合并.用户可以理解成local memory实际上总是按warp排布的, 任何int dog[N]都是内在的被存储为int _dog[N][32]这种自动交错.

从而总是自动形成了, 当下标一致的情况下, 自动合并的效果. (这点最早见于2013年的CUDA Handbook, 这是一本好书, 但是国内翻译的书质量不高,所以我们一直没推荐。也可以参考我们之前的CUDA编程指南中的内容)

因为这种自动交错/合并的存在. 对local memory中, 来自同一个warp的杂乱的下标/指针访问这种, 应当避免. 因为默认是一致的. 杂乱的访问会导致访存被拆分成多次请求, 严重降低效率.这是local memory的用途一.用途二则是, 方便编译器安排一些无法有效的放入寄存器, 例如当前阶段寄存器资源用的太多了, 或者一些访存方式(例如对寄存器试图进行下标索引—N卡不支持这种), 不能放入.

纹理存储优势

根据之前的内容, 你已经知道, 纹理可以提供免费的值变换, 和免费的坐标变换, 以及免费的越界处理, 以及, 更加优化的访存/缓存效果. 我们主要从这4点说开.

先说一下免费的值变换. 有些算法需要将数据作为8-bit或者16-bit整数存储, 然后读取到后, 再转换为float之类的浮点数, 和其他类型进行运算. 而这个转换过程, 需要用户手工写, 哪怕是一个简单的float b = (float)a;这种. 以及, 这种转换还需要占用SFU(特殊功能单元), 注意SFU在新版本的Nsight profiler中已经简单的改名成了XU单元了. 那么此时, 无论是从转换指令本身, 需要占据额外的硬件资源; 还是从编写代码的人的角度, 他需要手写额外的代码行, 都是一种开销. 而纹理读取的时候, 可以利用上其数据路径中的自带的转换功能, 从而节省掉对SFU/XU或者人工编码成本的开销.

这样有可能带来额外的性能提升, 和对人力成本的节省.

例如我们知道, 在很多代卡的架构上, 一次SFU完成的整数到float的转换, 性能只有常规指令的1/4:

img

如图, 我们可以看到了7.x的卡上, 每SM每周期可以执行64条常规的float加法/乘法/乘加, 这往往构成了你的代码的运算主体;

img

而从8-bit或者16-bit或者其他整数类型转换成float的时候, 吞吐率就只有16条/SM/周期了, 相当于在7.X上转换本身只有常规计算的1/4的性能. 甚至这点在8.6上更加糟糕, 因为8.6的双倍速的float运算, 导致如果你读取一个普通的8-bit或者16-bit整数(u)int8/16_t, 然后进行一次手工到float的转换, 相当于大约等效8条后续的正常计算的性能被浪费掉了(某种意义上), 即转换只有1/8的效率. 此时如果你的代码SFU/XU是瓶颈, 或者因为使用SFU而导致了浪费了指令发射能力的话, 应当考虑使用texture自带的免费转换功能, 来节省对应的SFU的I2F之类的转换指令. 这样会可能带来额外的性能提升.

不过需要注意的是, 自动的转换是一个”归一化”的过程, 将会从8-bit或者16-bit的有/无符号整数范围映射到[-1.0f, 1.0f]或者[0.0f, 1.0f], 其中包括了1.0f了, 这点使用的时候应当小心. 例如考虑是等效乘以了1/255还是1/256的系数的问题(包括还是不包括1.0f右边界).

好在大部分的使用float运算的代码, 应当很容易处理这种问题. 这是使用texture的带来的可能的第一个优化上的效果.

注意第一点的值变换除了归一化读取到的值, 还有低精度的插值效果, 这个线性插值效果我们曾经已经在编程指南手册中说过了, 这里就重点说了. (虽然本手册这里强调了一下). 如果适用你的算法, 则利用硬件自动的插值的效果可以进一步节省你的手工运算量, 从而潜在的可能提升性能.

这两点都属于今天的texture带来的4点中的第一大点, 即自动/免费对读取到的值变换的好处.

第二点的好处是, 带来了自动的免费坐标变换, 即所谓归一化的坐标. 这点什么时候有好处?

例如图像处理或者神经网络的输入图像, 可以大小自动适配. 也就是说, 我一个256x256的图片, 和一个512x512的图片, 使用了自动的免费坐标归一化功能后, 后者和前者可以自动的等效缩放. 这点节省了用户单独的写一个kernel进行缩放的过程. 减少了工作量和出错可能, 也节省了一次kernel的代价.

当然, 现在用深度学习的用户可能不在乎这点, 也没法在乎, 因为他们如果使用框架的话, 能配置的只是简单的文本文件描述(例如对网络结构的描述). 不需要手写任何代码, 自然也不需要考虑这点. AI么, 会用记事本就能搞AI. 有数的.

但回到正题, 本章节说的坐标自动映射(或者等效的图像自动缩放功能), 的确节省了用户的开发成本. 此外, 和值变换不同的是, 这种坐标映射是右边界不包含的, 即一个图像(或者2D数组), 会被映射到[0.0, 1.0)的坐标范围, 手册这里的说法是, 映射到[0.0, 1.0 - 1/N], 注意)和]. 这样的映射在N是一定范围内的整数次方的时候, 或者说图像/2D数组宽度/高度是2的倍数的情况下, 可以在缩放的情况下, 依然精确表示坐标. 从而使得这个特性不仅仅适用于图像这类的数据, 也适用一定的需要严格坐标指定的普通2D数组/矩阵之类的算法/代码. 因为一定范围内的1/2^N在我们用的卡上, 是可以被精确表示的浮点数. (注意不是所有的浮点数/坐标都可以被精确表示). 这样texture就又带来了, 免费的而且一定情况下是精确的坐标变换/缩放功能. 使用它依然可以解放掉你的主代码去干其他事情. 从而可能带来无论是编程世间, 还是性能上的提升. 这是第二点.

此外, 我们往往不仅仅需要像(1)(2)点所说的那样, 无论对要读取的坐标进行变换, 还是要对读取到的值做进一步的变换处理, 在实际的2D数组/图像的读取中, 往往还需要考虑边界情况. 不考虑边界情况往往会代码你的代码行为异常, 或者出现无法预测的结果.

继续回到第三点. 我们看下纹理给我们带来的边界/越界处理都有什么好处/优势. 好处有两点:

第一点是, 在指定了一定的边界模式后, 越界不再需要考虑. 即节省了用户的代码编写工作量开销, 也消除了用户哪怕想付出努力/工作量, 却不小心遗漏导致出错的情况.

这点在今天的优化指南手册中, 正好给错过了重点.

我们知道之前在编程指南手册中, 我们和大约一起阅读过有4点边界/越界自动处理, 即自动填充0, 自动重复边界值, 卷绕和镜像模式.

而且我们当时还分别对这4种模式都画了图, 从而让你能够理解, 当时手册上只有文字描述的不好理解的尴尬. 但是今天的优化实践手册中, 只在表格中提到了后两者(卷绕/镜像). 但是实际上, 往往有用的是前两者。

我们已经无数次的在论坛上接到楼主们的求助, 诸如: “我需要在我的矩阵周围绕上一圈0, 应该怎么做” 类似这种的问题, 往往本着就问题回答问题的角度, 我们往往在论坛上给出的答案是: 重新申请一个宽度左右大2个元素, 高度也大2个元素的新矩阵/2D数组/图像, 然后将原始矩阵内容复制到中间, 然后周围一圈写入0. 或者我们给出的建议是, 每次读取都强制的走一个越界处理的code path, 即有效坐标正常读取, 越界/边界的部分, 直接范围0模拟一次读取到了一圈0的效果. 你看到, 无论是那一种, 都需要用户付出功夫. 而如果使用今天手册章节中说到的texture的自动边界/越界处理的话, 你可以免费. 我们具体说一下.

我们设定今天手册中没有说到的边界自动绕0模式, 此时, 就像论坛中很多人试图做的那样, 直接对一个纹理坐标进行读取(纹理中往往较拾取)即可, 如果没有越界, 和你的普通读取效果一样, 如果越界了, 自动返回0. 这样, 你不需要额外的处理或者if之类的判断语句, 效果却自动达到. 注意这不仅仅减少了你的编码工作量负担, 也减少了无论是多一个环绕0的kernel的执行成本, 或者是用if判断越界与否的处理的代码执行成本.

因为要知道, 绝大部分的代码, 都是要上1个或者多个if来对, x或者y坐标之类的进行有效范围判定的. 无论你是看老樊的书, 或者看阿三的书, 或者写过任何CUDA代码, 应当对这点都深有体会. 而今天, 如果纹理能适用你的数据类型/代码, 则你可以自动得到这个免费特性. 从而提升你的编码效率, 也提升了你的代码执行的性能. 这是说的第三大点的边界/越界处理中的自动返回0值的情况. 实际上, 在图像处理中, 往往还需要在边界超出的地方重复最后有效的值, 例如你在做某种梯度或者边缘之类的检测/处理之类的.

纹理读取也对这种提供了直接的免费边界/越界处理. 这就等效于你手工围绕上了一圈或者多圈边界值. 注意这个特性也很常用, 而且不用纹理用其他方式手工实现起来很麻烦. 麻烦主要在于你不知道边界需要涉及到越界出来多深(特别是对图像处理来说, 参考当年某维), 你可能需要围绕1圈, 2圈甚至更多圈, 而使用纹理的这个特性你可以免费绕上任意圈. 而没有成本. 另外则是这种需要考虑越界的方向, 往往需要考虑4个或者8个方向, 例如从左边界线, 上边界线, 右边界线, 下边界下, 或者上下左右4个顶点.

而今天, 你如果使用texture的第3大点的这种特性, 这一切都是免费的, if的多个分支可以被省略了, 从而潜在的可能提升性能. 而且主要是减少了代码编写者的成本, 和出错的可能.

注意对于第三点, 本优化实践手册说的是另外两点, 根据我们的经验和论坛上的各大楼主们的反馈, 另外两点并不常用. 我们这里就不说了, 如果你感兴趣可以看我们之前的编程指南手册, 里面说的很详细.

texture和surface

上一篇说的3大特性, 都等于在访存的同时, 还附加上一定的固定功能的运算/变换处理. 这种特性, 叫采样器特性(sampler). 而我们都知道, 采样器是在只读路径上的. 而去掉了采样器的texture在CUDA里叫做surface.

因为本优化实践手册编写的年代较早, 这里没有怎么提到surface. 我们简单的说法一下surface.

surface不具有刚才说的texture的采样器只读路径上的这些优势,但是surface具有额外的特性, 它可以写入, 而texture不能.除此之外, surface和texture还具有非采样器的另外的一个重要特性.这个重要特性是在多年前, 也包括最近一些年出的而没有动脑更新的书的经常重点强调的地方,即texture本身的缓存效果. 这是很多人至今还在坚持使用texture的重要因素. 我们来简单看下. 这个因素等于是在说存储本身, 而不是在对该存储的读取路径上的优势. 主要优势有两点, 一个是cache效果. 在某些卡上, 普通的读取不具有较好的缓存效果, 而texture读取有. 例如哪怕是到现在依然被CUDA 11.1所支持5.X硬件, 也是如此.

例如5.0的maxwell的卡, 对于普通的读取不能使用L1/read-only cache, 而texture和另外一种只读的读取方式(不维持一致性(NC)只读读取, 或者常见的__ldg()之类), 却可以充分利用. 此时使用texture或者surface读取, 就能获取此缓存效果上的优势了. 否则你的任何读取可能在此卡上都要走L2. 很亏. 这也是手册本章节说的, 具有带宽上的放大效果(注意, 本章节的其他内容这里不赞同, 因为手册可能很久没更改了, 例如手册说, 使用纹理和DRAM的直接读取具有一样的延迟啥的).另外的一种存储上的优势则是, 例如在使用cuda array的时候, 数据在显存中的排列本身, 可能是被重新排布过的,

constant和寄存器

我们继续说一下剩下的两点小内容.

第一个是内容是constant ,这个谁都知道是什么, 也知道它的优势, 例如我们都知道GPU是RISC架构,所有的数据都要单独的load操作进入寄存器后, 才能参与运算.,但是constant例外, 很多指令允许一个操作数, 作为constant的形式存在, 从而在一条指令内部, 聚合了该指令本身的计算功能, 外加对constant的读取在一起.

这里要注意两点:
(1)是7.5+的卡有单独的标量/Uniform路径, 不仅仅可以在SP的计算指令中, 集成对constant数据的读取为操作数, 从而节省了一条单独的load读取数据指令(例如常见的A = K * B + C; 这里的K就可能并不需要单独一条指令载入的). 7.5+(也就是图灵+)的卡, 其标量单元, 还可以单独在SP之外, 执行标量/constant载入指令, 进一步的提供灵活性和释放向量指令(例如可以在很提前的位置进行load, 而不是在遇到了c[Bank][offset]风格的cosntant操作数的后期时刻).

但是很遗憾的是, 目前的NV家的编译器还对标量路径代码生成支持的不好. 虽然我们知道这是竞争对手A家从10年前就有的功能(的确很多方面A卡硬件好), 而且A卡的配套软件质量非常渣, 但是这点上NV的编译器质量还是不如A卡的.

好在随着以后的CUDA Toolkit版本, 驱动版本的提升必然会逐渐的效果提升的. 总之读者现在该用constant就要用.

(2)点则是, 应当正确的使用constant, 这里的constant指的是手工放入__constant__中的内容. 我们在论坛常见很多楼主有很多错误/不当做法, 我举两个例子.

第一个例子是本优化实践手册这里说的, warp内的很多线程读取不同的__constant__数组中的元素, 这样做将完全失去constant的效果, 而且可能会起到反面作用(变慢).constant必须在warp一致的时候才能用. 其他使用将可能拖慢你的代码.这点论坛上已经有N个反面教材了.

另外一点是, 过度的使用__constant__, 表现为用户拼命的较近脑汁的将自己的代码中的常数, 例如1.0f, 233, 666这样的常数单独提出取出来,然后手工放入一个__constant__变量或者数组中. 从而认为这样可以”进一步的优化”.其实不是的. 首先说, 编译器会自动完成这个过程, 如果它认为某些数据能够从代码中自动被提取出来, 它会自动这样做, 并放入constant.
其次, constant并不是最快的, 有些数据如果合适, 可以直接嵌入在指令的内部, 作为”立即数”. 而立即数可以被保存在指令缓存(而不是任何数据缓存), 只要能取指令, 那么数据就已经免费就绪了. 所以用户手工的这样做(手工将kernel中的常数提取出来放入__constant__)是没有必要的, 甚至可能会起到反面的优化效果. 需要注意. 注意本实践手册是将其作为存储器分类的.一种是将寄存器作为存储器分类,一种是将其特化, 它就是寄存器, 而不将其作为通用意义上的存储器, 虽然也有register file(寄存器堆)之类的说法存在.

这里主要提到2个问题. 第一个问题是涉及到寄存器的bank conflict, 这点如同本优化指南说的,用户无法控制这个问题, 这个是编译器在生成目标代码的时候, 自动尽量规避的.这点我赞同. 同时本手册说了, 不用考虑用int4, float4, double2类似这种数据类型所可能带来的寄存器的bank conflict, 该用/不改用就用(不用). 这点可能是有点欲盖弥彰了.

因为在某代著名的3.5/3.7的时候(大Kepler), 压满显卡的峰值性能是如此的困难, 导致用户不得不考虑使用ILP(指令级别的线程内部的前后自我并行, 本优化指南后续章节会说). 而使用了ILP往往会导致使用int4/float4这种向量类型, 而根据已有的资料, 在大Kepler上这样做, 往往会导致严重的寄存器的bank conflict, 同时编译器竭尽全力还无法很好的避免, 这就很尴尬了. 所以手册虽然这里这样说了, 但是用户是否该用, 该如何用才是优化的, 请自行考虑.

好在现在随着时代的发展, K80这种卡已经逐渐的消失了. 再可预见的将来我们应当不太用担心这个问题了.毕竟, 如同人不能同时两次跨入同一条河流, NV总不能在同一个地方(指坑爹的Kepler架构)栽倒两次的. 这是第一点关于寄存器要说的.

第二点关于寄存器要说的则是, 很多代码, 并非使用寄存器越少越好, 也并非使用寄存器越多越好. 其寄存器的使用有个最佳点(甜点). 而这个甜点的值是无法确定的(和具体的kernel, 卡, 以及kernel和kernel间的组合情况有关). 我们前几天在老樊的群里看到有用户一本正经的讨论,我将寄存器从XXX个降低到了YYY个, 结果性能并没有提升, 为何(@#(@(!这个其实很正常。所以我们这里提出尽量可以考虑自动化的尝试寄存器的最佳使用点, 例如写一个脚本自动控制寄存器的用量, 用不同的用量值自动重新编译和运行评估代码, 从而能自动发现这个甜点,而不是用户自己(就像老樊的群里那样)去反复尝试, 费时费力, 可能还找不到这个最有点.

不改变代码本身如何提升性能?

因为GPU的SM是海量超线程的, 远比常见的CPU的一个物理核心的2个或者4个超线程(HT)要多的多, GPU依靠这种海量的超线程数量来提供最大可能的吞吐率(这点我们稍后说)。而这种海量的超线程, 当你每个线程的寄存器资源用的比较多的时候, 则SM上能同时驻留的线程数量就越少, 从而影响了GPU的这种海量的超线程的能力, 从而潜在的可能影响了性能的发挥,所以有的时候我们不能肆无忌惮的使用寄存器资源,而是需要通过某种手段去限制编译器生成的代码中, 对寄存器的具体使用数量。

在日常的应用中, 不改变代码本身, 而是简单的改变每个线程的寄存器资源使用数量(变多或者变少), 就有可能提升性能,所以这是一种常见的优化方式, 具体到今天的手册章节, 手册提出了两种做法:

一种做法是编译的时候, 对每个具体的.cu的CUDA源代码文件, 使用nvcc -maxrregcount=N的参数来编译。这种做法将会把此文件中的所有的kernel, 都统一限定成最多使用N个寄存器。

注意这里有需要注意的地方, 首先是这种限制是以源代码文件为单位生效的, 如果你文件中存在不止一个kernel, 则所有的kernel的限制都是一样的, 你有的时候可能不得不拆分源代码成多个文件, 从而使得每个文件里面只有1个kernel, 从而能单独的用-maxrregcount=N的参数来限定。

其次则是这种做法限定的是Regular Registers, 注意到参数中是maxrreg, 而不是maxreg了么, 中间多了一个r. 而其他种类的寄存器, 例如predicate register或者uniform register(计算能力7.5+), 则无法通过这种方式限制, 但好在一般我们也不需要限制后两种寄存器的数量。

这是手册今天告诉我们的第一种限制方法, 简单明快的限定成N这种具体值, 比较直接.

而手册中说到的另外的一种限制方式, 则是通过__launch__bounds__()来修饰kernel本身,将此行放置在kernel的最前面, 即可限制该kernel的寄存器使用数量。

注意这种方式可以每个kernel单独放置一种修饰, 甚至可以每个kernel根据编译时候的计算能力选择, 放置多种修饰. 控制性比较强,因为它不想-maxrregcount那样的是整个文件一起来的, 人家是单个kernel, 甚至单个kernel的单个计算能力编译下的效果来的, 所以可以很精细的指控。

但是坏处是, __launch__bounds__()无法直接指定一个具体的寄存器用量N, 而是间接的指定我需要1个SM上最少有XX个YY线程的Blocks, 然后编译器再自动计算一下, 这个XX个是需要限制到多少个寄存器的情况下, 才能满足约束, 类似这种的. 比较晦涩一点。

所以实际使用中, 这两种方式可以根据需要来, 一个直接的粗糙的; 一个间接的精细的. 读者们可以尝试根据实际需要使用.

这是我们今天所说的, 通过限制寄存器数量来尝试优化性能的两种具体做法.

下一篇, 我们会说一下菱形启动符号, 也就是<<<>>>这种, 和其他一些方面, 能带来的性能变化。

occupancy越高越好么?

在今天的手册上, 这些统称为对kernel的执行(环境)配置, 来调节性能.

首先是, 手册提出了occupancy的概念, 和这个概念的重要性, 以及, 观察从而能设定occupancy的3种方式.

各位读者, 只要是用CUDA的, 就一定遭遇过occupancy这个词, 俗称”SM占用率”。这是一个百分比值, 例如某kernel在某卡上运行, 取得了90%的占用率; 而某某kernel, 则在此卡上, 只有30%的占用率, 等等。

你的同学, 同事, 朋友, 总在会尝试劝告你说, 一定要提高这个占用率啊, occupancy高了才能性能好啊, 否则你就在浪费你的卡啊, 类似这种说法.

这种说法对也不对。

首先手册说了为何这种说法对:

因为我们的GPU是一个吞吐率为设计目标的处理器, 每一个晶体管都是尽量为了最大化的提供性能而存在, 而不像CPU那样, 为了延迟而设计, 很多晶体管都在为尽快做好1件事情而努力. 这点我们之前说过.

所以为了这点, GPU需要用海量的线程在SM上执行着, 当某些线程(精确的说, warp)卡住了, GPU从而能切换到另外一些线程去执行. 用这种简单的方式, 最大化的发挥性能. 等于我同时在洗衣, 做饭, 看娃多种任务同时进行, 一旦我卡在了等待洗衣机运转上, 我就可以切换去做饭, 一旦做饭正在煮着了, 我就可以去给娃喂奶. 用这种方式反复横向切换, 从而能最大化的利用我的时间(GPU的性能).

而Occupancy则代表了, GPU的SM上能驻留的线程数量(我今天在干的活的数量), 和该SM的最大能驻留的线程数量的(我累死最大能同时干的活的数量)的比值。这是occupancy的定义(实际上略微有差异, 特别是涉及到achieved occupancy的时候)。

100%的occupancy可以看成我一共能干10件事同时, 我今天就是在同时干10件事,而20%的occupancy则是我一共能干10件事今天, 但是我今天只同时干两件事。所以你看到, 一般情况下来说, 越高的occupancy(接近100%), 往往会越可能的发挥性能; 而越低的occupancy, 则往往会可能造成设备的低效运转。

这是今天手册上说, 为何为何尽量提高occupancy, 往往会提高性能的原因, 也是你的同事, 朋友, 同学往往会建议你这样提升的原因,但是事情不是那么绝对的, 有的时候, 较低的occupancy反而可能会带来更好的性能, 这点在历届GTC的演讲中都有提到过, 网上也能搜索到很多案例。

手册这里总的说法是, 因为当SM里的总资源固定的时候(想想成你家的面积好了), 较低的occupancy(想想成今天你只干2件事好了), 会给每件事带来更多的资源(想想成, 你需要一个手工画一个图, 较大的桌子可能让你干的更起劲, 从而比你同时用小桌子绘图憋屈, 同时在煮饭的总产出要好)。

下文我们会和手册一起, 对具体SM里的资源进行逐方面的分解, 看看occupancy vs 资源 vs 性能变化的具体讨论.

测量Occupancy的三种方式

一般的来说, occupancy往往有个折中点, 过高了或者过低了性能都不好. (就如同你干得过少, 或者干得过累都不好一样). 好了, 我们有了occupancy的概念, 知道了无需一味的去追逐occupancy, 就已经是一个很大的胜利了. 我们下面将具体看一下, 如何测量, 调节occupancy, 并从理论的角度看下它们可能带来的性能变化。

手册里先说了计算/测量occupancy的三个方式, 然后再说了调节一些资源的使用, 会occupancy造成怎样的变化可以反映出来。

我们先看看手册说的occupancy的测量/计算方式. 这个其实以前在编程指南手册上也有涉及, 只是可能没有今天的这样的系统一点。

一种是纯手工计算, 纯手工计算是指的人为的设定或者找到某kernel的, 寄存器使用量, shared memory使用量, block里的线程数量这三种因素/资源的使用后,通过和手册中的特定计算能力下的这三种资源的情况(该表在编程指南手册的后面有)对比, 从而手工的计算出来一个理论的occupancy.

这种方式不需要任何工具, 而且可以在你敲代码的时候, 自然的在大脑中提前形成大致的轮廓. 坏处是比较枯燥, 而且你需要比较熟悉特定计算能力的情况, 才能大致的对比出来你的kernel, 将来在该计算能力的卡上运行, 会得到一个怎样的理论occupancy。

第二种计算/测量occupancy的方式, 则是使用工具. 具体的可以分成静态的一个Excel文件(即本章节的图中的occupancy calculator的那个.xlsx文件),在里面选好了寄存器资源, shared memory资源, block中的线程数量, 和对应的计算能力后, 该.xlsx文件中的宏, 会自动为你计算一下.

该静态的计算工具文件好处是可以免除记忆特定计算能力的参数, 而且还提供了一些高级参数(手册所没有提供的), 例如特定计算能力的某些资源是按照什么方式/粒度分配的信息. 同时不需要安装复杂的开发环境, 例如你可以将此excel文件复制到手机上打开, 或者上传到OFFICE365, 一样可以随时随地使用。坏处是你依然需要手工去测量/计算这3个基本资源, 在你的kernel下的具体使用量, 才能进行后续计算。

而同时也存在另外一种工具, 动态的分析工具, 指的是nsight或者nvprof类似这种的profiler, 它们会在你的kernel运行起来后, 自动为你抓取到这个信息, 从而免除了3个基本数据的手工取得, 也免除了后续的计算过程, 全自动。坏处则是你只能取得你所拥有的计算能力的卡(例如, 一张8.6的30HX显卡(尚未问世, 我们假定的30HX是最近的8.6计算能力)), 在此卡上实际运行时候的数据,你无法取得你所没有拥有的一张计算能力的卡上的情况. 但是手工计算和用Excel计算都是可以算出来一张不存在的卡上的情况的。

对于公司开发的情况, 例如拥有所有的要出售的产品所针对的, 市场上的所有代的计算能力的卡或者Jetson产品, 都购买回来的情况的时候(每种至少一个), 则无需担忧这种。这是第二种用工具的方式.

而第三种则比较主动一点了, 可以编程的通过相应的occupancy api (见cuda runtime api的手册, 或者我们之前的编程指南的稍微提到的部分内容), 在运行的时候, 动态的获取到我的某kernel, 在现在的某卡(例如3号卡上), 用XXX的资源配置或者线程形状, 能取得百分之多少的occupancy。

这种方式坏处是需要用户编程, 增加了额外的编码负担(和出错的可能). 好处则是, 你的代码可以在将来的卡上, 在开发的时候无论纸面或者实际的资料都没有的情况下, 在未来的某一天实际运行的时候, 代码自我分析和发现得到occupancy. 例如将来在一张30HX上, 此卡尚未问世, 我们也不知道计算能力的情况, 但是用第三种API的方式, 将来可以动态的得到, 从而潜在的能动态的(用代码)微调occupancy。

好了. 这是关于取得/测量Occupancy的三种方式, 今天我们简单的说了, 寄存器资源的限制, Occupancy的意义和高低对性能的可能影响, 以及, Occupancy的具体测量/计算方式。这三个因素其实还挺重要的,很多时候我们写代码, 当算法固定了, 实现也基本固定的情况下, 想调节性能, 只能从这3种基本不太影响现有不的代码格局的方面入手。所以关于这3方面的优化调节, 也往往排在算法–>实现—>(今天的执行/配置方面的调节)这么的一个重要顺序.

因为例如有更好的排在前面的情况, 例如一个快10倍的算法, 你应当先去考虑选择它, 而不是今天的这些”优化方面”,你很难简单的通过”优化”去将一个GPU上的应用性能继续提升10X, 但是更换算法, 你有可能。所以大家在实际使用中, 不要舍本逐末, 应当至少什么是最先考虑的. 只有当最先考虑的因素都完成后, 再进行这些介绍的经验和手册告诉你的实践操作. 就如同刚才说的某妹子, 她如果直接嫁一个100倍有钱的老公, 还管这些一天怎么干活, 怎么做事? 后面的这些将毫无意义.

我们在下次的内容中, 将会具体结合寄存器, shared memory, block形状这三种因素, 综合occupancy分析, 3因素 vs occupancy vs 性能的情况.

如何执行配置优化以及对性能调优的影响

接上一天的occupancy后面,继续说说寄存器的延迟掩盖,blocks形状和使用,shared memory的使用,以及,concurrent kernels和CUDA Context等方面,对性能调优的影响。

首先我们从寄存器的延迟掩盖开始。本小结首先讲述了,当需要使用寄存器中的数据,而该数据没有准备好的时候,从而无法取得数据喂给SM中的执行单元,从而可能导致执行的线程被卡住(stall)而不能就绪执行的状态。小结只讲述了常见的A = XXX; 这种形式的寄存器上的结果计算延迟。并用volta举例常规的计算有4个周期的延迟,在此期间内,立刻使用结果数据是不可以的,需要等待4个周期才可以。并讲述了可以临时切换到其他warps中的指令继续执行来掩盖的方式。本小结是乐观的,认为这一般不构成对性能的影响。

但是实际上,随着现在nsight compute的流行,long/short scoreboard的stall reason之类的分析指标的公开,很多操作对寄存器的结果写入,可能要超过这例子中的4个周期不少。感兴趣的读者可以参考这个链接:cloudcore:CUDA微架构与指令集(4)-指令发射与warp调度 。这里的讨论比当年scott grey在NV英文论坛的讨论要热闹一些,下面也有一些NV的国人在加入讨论。进一步扩展的读者可以参考里面的相关scoreboard的内容继续展开。

我们这里只额外说一下,使用s_xxx[idx] = d_xxx[idx]形式的,从global memory看似’一步到位’写入到shared memory的做法。实际上会被编译成中间的分步的tmp = d_xxx[idx]; s_xxx[idx] = tmp; 的经过寄存器(tmp)的分解过程,导致中间第二次写入的时候有一次对寄存器的依赖。使用8.6和8.7计算能力的人们,建议考虑新版的cuda::memcpy_async的载入方式,这种可以直接越过寄存器。

这是今天的第一小节。

第二小节讨论了block和grid的形状对性能的影响问题。这个是个喜闻乐见的讨论,在我们夏令营和冬令营的活动中,被人讨论了无数次了。小节首先澄清了,grid和block的1D还是2D还是3D的形状,从本质上并不影响性能,影响性能的只是无论1D还是到3D时候的,计算出来的每个block里的线程总数量,和blocks的总数量。

小节同时说明了,这些线程和blocks的数量(和其他资源),影响了在SM上的active warps的数量。能达到的active warps数量,才是之前的occupancy之类的很重要的原因。而active warps的数量,往往决定了延迟掩盖,和对SM各个单元的利用程度。这样性能就取决于这些单元的利用率情况,因为一旦我们买回来了一张卡,硬件的SM数量,和SM里面的执行单元配置是固定死的了,硬件本身乘以利用率,才会影响最终的性能发挥。

然后小节往下说了,该如何调整kernel启动时候的方括号里的第一个和第二个参数。大部分情况下,调优kernel,需要同时(in tandem)试验性的调整这两个参数。但每个参数也有他们自己的调整策略:

对于第一个参数(blocks数量): 基本的策略是要足够多,至少每个SM上得有1个block。同时,考虑到了1个SM上如果只有1个block的话,一旦该block中的线程们,执行了__syncthreads()进行等待同步的话,很可能导致SM上warps大部分都处于等待状态了,降低该SM的使用率。所以这个至少的1个block还需要调更多。手册的建议是,亲这边应该至少上几千个blocks每张卡。理由很简单:考虑到现在的8.6的3090的卡,有82个SM。每个SM上可以上到多达16个blocks,这样82 * 16等于差不多1000。几千个差不多能将一张卡上个几批次。手册说到,我们要面向未来考虑,将来的卡更强。所以数量不能保守。

阅读到这里,我们应当结合实际一点。因为随着block对资源的使用不同(例如shared memory), 一个批次能上多少个blocks,对于固定的卡,随着kernel的不同是不同的。建议读者使用nsight compute, 观察里面特定kernel的waves数量指标,该指标说明了某kernel的blocks需要分成几个wave(批次),才能上完。

以及,对于某些因为算法的角度的限制,不能有效扩大blocks数量的情况下,针对本章节讨论到的,因为__syncthreads()而导致1给block中的warps在SM上整体stall的问题。可以考虑使用细粒度的部分同步手段。也就是使用cuda::barrier(需要计算能力7.0+),进行1个block中的部分线程进行同步。这样当部分线程在wait()或者arrive_and_wait()进行同步的话。该block中的其他不参与barrier同步的线程依然有机会执行,继续利用SM上的执行单元。

以及,新版本的上一部分手册(CUDA Programming Guide), 现在已经正式引入了很多C++风格的东西了。上一段说到的asynchronous barrier, 在当年我们阅读编程指南的时候,没有涉及。建议读者重新阅读相关章节。

然后继续回到<<<>>>的第二个参数,也就是block中的线程数量的优化考虑。手册这里主要考虑了你不能用过小的blocks,例如只有32个线程的block. 因为SM往往还有例如16个block/SM的硬限制。使用过小的block往往会导致SM上去的总warps数量不足,可能会影响性能。手册这里建议的方式是,至少上64个线程的block,然后逐步调整block中的线程数量, 找到特定kernel的最佳性能点。这个逐步调整,可以从128或者256个线程起步。

手册继续说,调整到适可而止就行了,没必要追求极限。例如通过调整前两个参数,让SM能上到66%的occupancy,和能上到100%的occupancy,可能并不会对性能起到太显著的影响。因为调整的目的是追求性能,而不是单纯追求指标。为了得到过高的occupancy,有的时候你只能降低寄存器数量之类的,从而导致使用了过多的local memory, 反而影响性能。

而另外一方面,因为除了我们之前说过的TLP(例如依靠切换warps)来充分利用硬件的执行单元,还存在ILP的方式,也就是线程内部的前后指令本身的并行性,来提高效率。手册这里指出了,只要内部的ILP程度足够,哪怕较低的occupancy也是足够的。对于这个问题,我们建议读者继续扩展阅读经典文章:《Better performance at lower occupancy》(链接: http://dmacssite.github.io/materials/volkov10-GTC.pdf ),该文章描述了哪怕很低的occupancy,也可以通过ILP取得优异性能的方式。虽然这个文章较老,但是依然非常经典。

另外的,我们夏天搞夏令营活动的时候,客串出场的樊博士,也在他的实践中(GPUMD项目),指出了这点,例如在他的《Efficient molecular dynamics simulations with many-body potentials on GPU》中,老樊写道:“哪怕使用float的时候只有50%的occupancy;或者使用double的时候只能到25%的occupancy。性能也相当不错”。(arvix: https://arxiv.org/abs/1610.03343 ), 感兴趣的读者也可以扩展阅读。

这两篇文章都分别有12年和5年的历史了,但是里面的思想,是正确和不过时的。

函数和指令使用的选择和优化

今天的主要内容是<优化指南>手册里面,对一些函数和指令使用的选择和优化。大致分为普通的计算函数/指令,和访存相关的方面。

我们先从计算函数/指令开始。

首先上去的小节,是关于整数除法和求余操作的优化写法。当除法A / B, 和求余A % B的时候,如果B是2的整数次方,也就是B = 2^N的时候,前者A / B可以直接写成移位操作A >> N;后者A % B, 可以直接写成逻辑与操作A & (N - 1)。 无论是移位操作,还是逻辑与操作,都是单周期的指令,远比老老实实的除法和求余快得多。

手册本节指出了,当B是编译时刻的2^N形式的常数的时候,编译器会自动发现这一点,同时自动为你进行这个优化。但是如果B不能在编译时刻确定,例如作为一个参数,B传递给了kernel,此时为了避免进行昂贵的除法和求余,可以考虑手工将B转换成指数值,然后手工进行移位和逻辑与操作。例如原本要传递进来256, 现在可以传递进来8(也就是log2(256)), 然后直接A >> 8和A && (8 - 1)即可,从而规避了昂贵的代码产生。

这是第一小节。第二小节则依然是说的整数,主要涉及到在使用下标和循环控制变量的时候,对有符号整数和无符号整数的选择。并讨论了C语言默认为有符号整数时候,编写代码的人如果偷懒不写上unsiged字样,则在循环控制变量和下标计算上,将生成较为劣化的代码。

小节说明了,这是因为无符号整数的溢出和累加都很方便,而有符号的则需要处理溢出的特殊情况,需要占用额外的指令。

我们这里以前忽略过这点,今天我们用计算能力8.6上的指令生成,分别测试了默认情况,和标注了unsiged字样的整数,在这两种情况下带来的优势——我们给读者测试了对于常见的形如p[i * 8] = 0,当i是int和unsigned int时候的,单语句的代码生成的效果对比,用来验证一下手册的这个优化的说法:

在i是无符号整数的时候,p[i * 8]编译生成了2条指令的序列。

指令(1):用LEA指令计算p的低32位地址累加i左移3位

指令(2):如果有进位溢出,p高32位+1

我们的GPU是32位机,只能每次进行32位整数运算,对于这p[i * 8]形式的64-bit最终地址计算,这已经是最优的代码生成了。

而在i是常规有符号的整数的时候,却编译生成3条指令的序列,多了一条:

(1)单独计算i * 8的值

(2)整数加法, 并得到是否溢出的标志

(3)根据溢出标志,执行32位符号扩展的LEA.HI.X.SX32指令。

你看,在使用下标的时候,在int i的定义身上,简单的加上unsigned的无符号标注,就能得到性能优化。

类似的,根据手册本小节的说法,当下标在循环里面的时候,编译器还可以对unsigned的下标,进行更强的替换处理(strength reduction,参考: https://en.wikipedia.org/wiki/Strength_reduction ), 例如我们在一个for(i)循环中的p[i * 8]的使用,发现了每次i的递增,乘以8被reduced到每次加8,和地址的计算等方面的指令生成,也有类似的优化效果。所以你付出的代价只是将声明变量的时候,添加一个unsigned标记,就可以得到显著的好处。这点值得考虑。

以及,本小节实际上说的是:对于循环变量尽量使用有符号的整数,理由是,无符号的行为是精确定义的,有符号没有精确定义溢出行为,所以编译器有更多的操作(优化)空间,但是我们编译测试发现是反的,建议读者们自己实验决定究竟是什么情况。

–刚才例子中的无符号情况的生成结果(cuobjdump), 一共两条指令。LEA和加法(8.6上用FP32 path的IMAD.X的A + 0 * B + 进位指令,模拟了A + 进位加法)。

img

–刚才例子中,有符号情况的生成结果, 一共三条指令(移位用FP32路径的IMAD.SHL模拟替换)。

img

也就是移位,第32位加法,高32位符号扩展加法。这三条。

(关于7.5和8.6上,用FP32路径上的IMAD/IMUL的指令模拟常规INT32指令,达到port平衡,是另外一个话题。这两个计算能力都能超过64指令/周期/SM的INT32的指令峰值上限,因为这种模拟替换和平衡,用nsight很容易发现这点)

好了。两个小节的整数指令方面的优化选择说完了,我们下面继续今天的主要内容,关于float方面的优化选择。

首先说的是,计算1/ � ,时候的做法(你想法将X放到根号里面,我不会),本小节指出了,有单独的单精度和双精度的rsqrtf()、rsqrt(), 来直接完成求根号X,然后再求倒数的一体化运算。如果可能,尽量使用这个。会带来更好的效率和精度。编译器并不总是能将1.0 / sqrt()的写法,转换成对应的一体化函数版本的。

然后下一小节手册从上面两个相似名字的数学运算函数(结尾带有f和不带有它)开始,说了容易不小心将float写成double,并生成了double运算代码,导致速度降低很多的情况。主要有这两点:

(1)读者写代码的时候,如果不小心,使用1.0,而不是1.0f这样的常数,根据C的规则,含有这个常数的式子,将在运算过程中,提升到double进行运算,式子算完后,再转换回来成float进行赋值给lvalue对象。这样有了来回转换double<->float的指令开销,也有了慢速的double指令计算的开销。

(2)CUDA编译器实际上是一个C++编译器,在math_functions.h之类的头文件里面,有C++风格的重载。例如sqrt()函数,有double sqrt(double)的版本的,也有float sqrt(float)的版本的。如果用户不小心,在式子里面给出了double的中间结果作为参数,同时函数结尾没有显式的写出f()结尾,那么因为重载的同名函数存在,将实际上使用的是慢速的double版本的。也有生成慢速的代码。

所以我们读者应当尽量小心注意使用浮点常数和函数后面f结尾,避免生成慢速的代码(double总是要慢的,而且会占用更多的资源),特别是在家用卡上(8.6的家用卡走double路径将只有1/64的速度)。

我们的老朋友樊博士,对于忘记了f后缀写上,而导致代码变慢了很多很多,具有惨痛的教训。并在冬令营/夏令营上,给我们深刻的说过这点。

然后这小节还提了在进行概率统计之类的运算的时候,如果要使用正态分布的误差函数,特别要注意这点。因为erfcf()这个函数(注意f结尾),在单精度的时候特别快。例如我们在计算N(0, 0.5)的正态分布的2个西格玛内的概率的时候,使用float p = 1.0f - erfcf(1.0f / 0.707f);类似这种写法(注意好多f结尾),将特别快。

最后这小节还提到了,不仅仅我们浮点数有这种情况,8-bit和16-bit的整数,在直接在我们GPU上使用的时候,通常情况(不考虑深度学习时候的多个打包在一起的运算),都需要转换成32-bit整数,才能进行运算。这是因为我们的N卡,在进行整数计算的时候,是严格的32-bit机器,不像x86 CPU那样能就地干8-bit和16-bit指令。这样不小心就会导致额外的代价产生。

总之,适当的写法,和数据类型的使用,能避免转换的代价,和昂贵代码路径的生成。读者还是需要注意这里的优化的。

下一节则谈论了对我们读者们喜闻乐见的powf()、pow()(分别是float和double版本,如上面说过的)的通用幂函数运算时候的优化,主要针对了几种特殊的指数值,可以不用通用的幂运算完成:

img

如图,通用的指数运算的快速替换写法。

我们这里简单的举几个例子就好:

计算x的1/6次方,可以先计算一次x的平方根倒数,再计算一次立方根倒数,这样就得到1/6次方的值,而无需使用昂贵的pow之类的函数。再例如:x的2/3次方,可以先求一个立方根,然后再求一次平方,这样就快速得到了2/3次方。

注意这个快速替换表格里的公式,很多都使用了特殊的GPU上专用的函数,例如rsqrt, rcbrt(二次方和三次方根的倒数),而不是标准的C库(libm),在CPU上我们能见到的sqrt、cbrt(二次方和三次方根),如果我们读者从以前的代码编写经验来,可能喜欢使用嵌套两次立方根,得到1/9次方的值,我们不推荐读者这样来。因为特殊的rsqrtf()这种,可能在实现上具有更好的精度和性能。例如我们之前的章节知道过,SFU这种喜欢提供平方根的倒数的这种快速的接近,可能有助于性能的提升。总之我们建议保留此表格,直接使用里面的写法,而不是读者使用更熟悉的替换形式,为了能够保证足够的精度和性能。

注意事项

今天的主要内容将讲述三个方面。一个是Global Memory访存时候的优化注意事项,另外则是循环或者条件语句导致的分支时候优化注意事项,和几个消除warp内分支的案例。

我们先从今天的对Global Memory的优化开始:首先,优化指南手册说,要尽量减少对Global Memory的使用。因为uncached的global访问,将有巨大的延迟。应当考虑尽量使用shared memory代替,如果可能的话。同时手册给出了一个s_buffer[index] = g_buffer[index];的简单从global载入shared memory的例子分析。

先说下这里的uncached的意义,在NV的多个文档中,uncached是指通过L2 cache进行的访存,例如对显存或者映射过来的内存,而不是完全没有任何cache,这点需要注意用词。

其次则是对于这个例子来说,手册指出了:看似只有一个简单的等号,就赋值了,但这里实际上是右边进行了从global memory ===> 寄存器的载入,然后再从左边寄存器===> shared memory的写入,这样右侧的载入将导致很大的延迟,影响性能。这种代码实际上经常在我们的代码中出现,例如1个warp或者block,从global memory载入一个矩阵,然后在shared memory重新映射下标,进行转置;或者是基本上大部分的kernel开头,将一些公用常见数据,从global进行载入到shared memory,方便后续的使用。那么这两种情况应当具体怎么优化呢?

首先,无论是哪种情况,手册说了,可以完全不用手工处理,依赖硬件自动优化即可。因为GPU的SM硬件本身,存在warp之间的调度,当一个warp的从global的载入,需要等待而卡住的时候,另外一个warp可以被调度执行,从而可以互相掩盖了。也就是常说的TLP延迟掩盖。但这种情况下想要有较好的效果,你可能需要有较多的warp发出进行中的global memory访存请求才可以。例如论坛最近的这个例子:求问几个有关优化方面的问题 。我们的会员已经明显的意识到了这一点,并试图用__launch_bounds__来自动化的提升驻留的活跃warps数量,从而自动进行这点优化(虽然他的效果不太好)。

此外, 在计算能力8.0+的卡上,可以手工使用memcpy_async进行异步的global memory –> shared memory的载入,从而直接越过global —> register —> shared的中间的寄存器的依赖。这种优化很多时候有正面效果,但是不一定总会发生。例如对于刚才的2种情况来说:

如果是kernel开头就需要载入一些数据到shared memory, 必须等Shared memory中的数据就绪了,才能作为初始值参与运算的话,这种直接载入(用等号),和手写memcpy_async异步载入并无本质区别,因为你都会立刻卡住在初始数据的准备上。

但如果你是kernel运算的中途,需要载入部分数据到shared memory, 则memcpy_async的异步载入就很有用了。像是流程: 载入1—>数据1计算—> 载入2—>数据2继续参与运算—>载入3—->数据3继续参与运算的流程。这样的话,可以按照另外一本手册的流程,稍微变形成为:载入1, 异步载入2—>数据1计算, 异步载入3—>等待之前的异步载入2就绪并参与计算—>如此重叠。

这样的话,根据你重叠的程度不同,如果有一份重叠,像是刚才箭头的那样,则等于你提升了两倍的驻留warps,或者说提升了两倍的occupancy的延迟掩盖效果。而如果你还有余力(手册上有重叠的程度的做法)提升overlap的async load的程度的话,例如有2X的重叠,则等于提升到了三倍的occupancy。效果将很显著。

特别是对于那些occupancy已经提升到了极限了,或者是我们刚才的那个论坛的链接,因为某些原因无法继续提升occupancy了,则用这种方式掩盖global memory的延迟,将是另外一条路的选择,它有效的提升了等效occupancy,或者从另外一个角度说,提升了warp内部的ILP并行。

这是今天的第一点,对于global memory的高延迟的掩盖优化。

今天的第二个方面则是重头戏,也是频繁在我们论坛上出现的内容。实际上,刚才那个论坛链接,也涉及到了下面的内容。即循环和条件控制指令,以及warp边界上的分支,和warp内部的分支,对性能的影响。

手册首先说,if/while/for之类的语句,会生成进行边界判断和流程控制指令,而这些指令本身会影响性能(因为要执行的指令变多了)。这种情况下,对于循环,可以考虑用#pragma unroll将循环展开。展开后,单次循环所需要的边界判断、循环控制变量的增加或者减少运算、跳转之类的操作,就一定程度的消失了,从而执行的更多的循环体的计算本身,提升性能。这是一点。

另外一点则对于循环和判断语句来说,这些附带的流程控制指令,还可能会导致warp边界上和warp内部的分支,也会影响性能。

手册说了,warp边界上的分支就是以32为单位(目前的warpSize是32),不同的warp在执行了流控指令后,跳到不同的位置来执行,这种情况的分支,具有较弱的性能影响,因为可能只有对于I-Cache指令缓存之类的取指令的代价增加。而手册继续说了,warp内部的分支,也就是我们喜(深)闻(恶)乐(痛)见(绝)的divergent branch, 具有比较严重的性能影响。这种分支,是在每32个线程的内部位置,不在32的整数倍的边界上,会导致warp内部不能100%的效率执行代码。因为SIMT的目前的CUDA的执行情况,例如有2个分支,分别在1个warp内部,有13个线程,和19个线程要执行两段不同的代码。只能scheduler分别以13/32的效率(41%)执行一段,和以19/32(59%)的效率执行一段。浪费了执行单元的峰值性能。

例如刚才论坛的例子,这里的profiler报告的实际有效执行效率才13.5/32 = 42%。

img

如果是那种卡计算的kernel(本例不是),一张3090秒变成了3070,对于warp内的分支(divergent branch),先不要着急, 我们稍后说下如何解决这个优化上的重头戏。然后手册继续往说,除了warp边界的分支,和warp内的分支,手册还指出了第三种情况导致的性能损失,就是在7.0+的卡上的独立线程调度(Indepent Thread Scheduling)导致的性能损失。

我们都知道,常用的图灵和安培这两代卡,都有独立线程调度,和因此引入的新的__syncwarp()或者XXX_sync()之类的新函数,例如我们以前用的__shfl, 变成了__shfl_sync(). 这种独立线程调度,有它的好处,一个最大的好处就是可以应对渣代码,和应对直接从CPU上移植过来的线程之间的锁之类的东西。

渣代码这里指的是目前我们好多群里都比较常见的,SM和MEM任何一个都无法用满的情况(例如论坛的这个链接里的情况),看上去不卡计算,也没有卡在访存瓶颈上(这种往往卡在了延迟等待上)。对于这种代码,7.x和8.x的独立线程调度,允许以比warp单位更细的粒度,进行线程调度执行。例如说:

1
2
3
4
5
6
7
8
9
10
if (xxxx) 
{
x = g_xxxx[xxxx];
f1(x);
}
else
{
f2(x);
f3(x);
}

这种代码在7.0+的卡上,因为独立线程调度的存在,当if条件有效的上面的那个路径,卡在了等待x的值读取回来的时候,可以让warp中的其他线程切换到else部分先执行着f2和f3,而不至于因为f1卡在访存等待上,导致其他分支不能执行。这样总的延迟就从 很长的访存—>f1 -> f2和f3,变成了很长的访存(暂停) —> 切换到f2和f3执行—->(差不多之前的延迟完成了),再f1继续执行。这样就有效形成了warp内部的不同分支不同的重叠执行。f2和f3部分的执行,掩盖在了另外一个分支的延迟中了。

我们用数字继续说明,假设从宏观的SM角度看,如果传统上:读取长延迟期间SM 0%有效使用率,10X周期;执行F1期间,因为warp不满,40%的效率,5X周期;F2和F3期间,也是warp不满,60%的效率,8X周期。则没有独立线程调度能力的时候,总宏观上的SM执行成本:10X周期的SM 0% -> 5X周期的40% SM -> 8X周期的60% SM效率。总共用了23X周期,SM有29.5%的平均效率。则在7.0+的卡上,变成了:10X周期的读取等待(期间SM以8X的周期,60的效率执行了另外的F2和F3操作;和2X的空闲),5X的40%效率的F1操作。这样独立线程调度情况下,一共只需要15X周期的时间成本,减少了8/23; 同时期间的平均效率,SM也提升到了45.3%,提升了20%。

所以这种卡上,虽然分支依然是divergent branch的warp内分支,但是总执行时间成本,和平均SM使用效率,都提升了。也就是说,独立线程调度,不能自动解决warp内分支的问题。但在举例的这种情况下,可以减少延迟的等待,和期间的平均执行效率。那么看起来独立线程调度的确是一个好事,但优化指南手册为何说它可能会导致性能降低呢?(刚才的例子明明是提升了啊!)

那是因为,独立线程调度,不会自动汇合不同的分支,从而往后原本正常的warp内完全合并无分支的代码,再继续享受着独立调度的好(恶)处(果)。我们还是用刚才的例子好了,实际的代码中,再宏观点看,往往是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
if (xxxx) 
{
x = g_xxxx[xxxx];
f1(x);
}
else
{
f2(x);
f3(x);
}
//下面是后续操作
zzzzz;

我们刚才说了,在if和else里头,我们享受到了福利了。那么后续的zzzzz可不是这种情况了。因为独立线程调度不会自动愈合,后面的zzzzz将会依然处于独立调度阶段,也就是说:

原本走if分支的那些线程(40%),会以40%的warp中的线程有效状态,执行一遍zzzzz;然后走else分支的那些线程(剩下的60%),会继续再按照独立线程调度,走一遍zzzzz!这样的话,后续的就完全没有福利了,反而是性能下降了。

此时优化指南手册告诉我们,我们需要使用例如一条__syncwarp(), 让warp中的40%和60%的两部分线程们,进行合并。这样合并以后,就会用100%的warp内(完全无分支)效率,一共执行1次后续的zzzzz操作即可。这是我们在较新的卡上写代码需要注意的,可以时不时的加一个__syncwarp(), 往往有助于性能提升。以及,因为__syncwarp()本身会编译出来一条WARPSYNC指令,占了1条指令发射机会。如果你认为不能太频繁的使用过多的话,也可以考虑带有__sync结尾的其他函数,例如说:__shfl_sync(), 它正常的作用执行一次warp内的不同线程间的数据交换(执行成本类似一次无bank conflict的最好情况下的Shared访存,见GTC上的多次演示)。同时它还能给你带来一次warp的sync效果,却不需要任何额外执行,也可以考虑这点。

这是今天手册的第二部分,也就是warp间分支、warp内分支、较新的卡上的独立调度引入的后续隐形的分支的优化注意事项。

那我们继续今天的第三部分,也是最后一部分,关于之前的重点需要优化的:warp内部的分支(divergent branch)的优化处理方式。

还记得刚才的3090秒变3070的例子么?divergent branch很多时候非常伤害性能的。论坛的这个例子,楼主也在苦苦追求减少warp内部分支的方法。我们将大概说明3个常用的小技巧,来处理warp内的divergent branch。两种分别应对warp内有选择性的执行一部分代码,其他线程等待。和一种对应warp内分支有常见的两种代码路径,warp需要部分线程选择性的执行一部分,和其他线程再选择性的执行另外一部分的情况。而其他更复杂的分支,往往是这三种情况的组合,就不说了。

先说一下第一种情况,我举个例子。常见的GPU上,有N个线程,每个线程处理1个任务,一共处理完N个任务,也就是最常见的CUDA的分而治之的典型做法。在这种典型代码中,这N个任务往往处理的成本不同。有的任务可能只需要50%的处理步骤就能完成,有的任务可能却需要90%的时间,而有的任务则特别快,10%的时间就能完成。这种代码跑起来,会性能warp中的空洞。例如所有线程的平均是100%的时间,某warp内有19个线程,只用了10%的就完成了,剩下13人却还需要很长的时间,那么等于有大部分的时间,warp都只有13/32的执行效率,剩下的19/32都变成了空洞袖手旁观。

这种情况有两种常见的处理方法,一种叫warp compaction, 一种叫task reload/lane bubble fill, 搜索相应的文章能看到大量的具体技巧的应用效果演示,有图有文字。

简单的说一下前者就是,如果我有一个256线程的block,也就是里面有8个warp,处理一批256个任务。其中在代码的执行1/3位置,预估有一些人会提前结束(例如每个warp中大约会有40%好了);在代码的2/3位置,每个warp大部分的人会结束(例如大约warp中大约有90%)的人会结束;而剩余的1/3代码执行量,则为warp中的10%的顽固分子。

那么整个流程看起来,前1/3每个warp都有100%的执行效率,中间1/3,每个warp只有60%的有效执行效率,后1/3,每个warp只有10%的可怜效率了。平均起来全程只有56%的总体效率。这下子,一张3090,可能变得连3070都不如了,怎么办?

这个时候可以考虑compact一下warps,例如说,在1/3位置压缩一下,从分布在8个warps中的60%有效线程,和40%的空洞,压缩到前面,变成了前4-5个warp基本全满载,后面3-4个warps无任务了,退出。这个时候效率立刻就又恢复到了几乎100%了。

类似的,我们在90%的位置也压缩一下,密集任务到1个warp里,剩下warps也退出。这样从执行多个warps中的不满lanes的代码,变成了只有较少数量的warps执行满载的代码。提升了效率。注意这个技巧需要前序和(prefix sum, 或者叫scan)操作。能否有效写对scan操作对于很多CUDA用户来说,是个问题。

而另外一个技巧则不需要prefix sum操作(虽然这个操作在NV的博客上已经出现了无数次了),比较简单,但需要每个任务是内部有多次循环,循环量不同的情况。例如我们有N个任务,其中40%的任务需要迭代100次才能完成,50%的任务却需要迭代500次,而10%的顽固的,需要迭代1000次才能搞定。

如果正常的写代码,那种1个线程对应1个任务的,随便抽取出来一个warp来说,平均来说:

整体一共需要1600迭代步,其中前100步是100%效率的;中间500步是60%效率;最后1000步只有可怜10%效率了。warp的整体迭代过程,只有31%的效率!现在3090可以变成3060了!怎么办?这种代码的提升warp中的空泡,可以考虑使用重新加载任务的方式来完成。

例如说:

1
2
3
4
5
6
7
8
9
10
11
12
while(true)
{
if (my_job_done) //将分别在大约100步,500步,100步的时候,为warp的部分线程重新装填任务数据
{
my_id += total_threads;
if (my_id > total_jobs) return;
load_job_data(my_id);
}
_syncwarp();
//继续以填充了warp里的空泡的接近满载的效率执行
....
}

这种技巧不需要任何scan之类的操作,只需要你将线程的数量缩小到任务的1/M,这样每个线程平均执行了M倍的任务量,整体空泡将第1个任务后面的M-1个任务给填充起来。如果任务的时间步是随机分布的话,则这种方式具有较好的效果(平均M个任务后,warp里的每个人总时间都差不多了)。

上面的两种技巧,无论是从blocks中将多个含有空泡的warp,压缩起来;还是重新装填任务,将空泡填满。都能有效的提升warp的执行效率。像是我们论坛的这个例子的warp里的没有被predicated off掉的才30%多的线程的情况,就可以提升大约3X的性能。当然,这两种优化技巧,都需要付出额外的代价,不适合那种非常非常小的空泡/分支/任务,因为此时,你填充的”优化”代码所付出的执行成本,超过了你原本的细小空洞了,无意义。不能为了优化而优化,或者为了追求profiler报告的数字好看,硬上,毕竟小优化怡情,大优化伤身,强上优化灰飞烟灭。

然后我们继续看下另外一种情况,如果代码中不规则的夹杂了可选的代码路径,怎么办,例如这个:

1
2
3
4
5
6
7
8
9
10
11
12
....//正常处理
if (条件1)
{
//额外步骤1
}
....//正常处理
if (条件2)
{
//额外步骤2
}
....
正常处理

如果条件1和条件2在每个warp中对于每个线程,都有50%的概率进去的话,同时这种可选的额外步骤,占据总执行量的50%的话,那么整体执行效率将只有75%.

此时就不好使用刚才的重新装填任务来填充warp里的lanes空泡的方法了,不过可以考虑compact一下warps,但是这里有个问题,我们还需要恢复到正常的执行状态,来执行中间的正常处理过程(因为中间的这些正常处理过程,本来就是100%的warp无分支的效率)。此时你可以将之前的方法1进行变种,compact后,分配到纯空泡的那些warps/线程不能退出,需要在来一个__syncthreads()之类的等待(等待期间不会占用额外的SM里的SP或者其他单元的处理能力)。这样简单变种后,代码整体变成了:

(1)blocks中的所有warps都无分支满载效率

(2)少数warps满载或者接近满载的效率,剩下warps不占用任何执行单元资源。

(3)同步后恢复各自身份,继续回到(1)的情况。

注意这个方法有两倍的block内部数据交换的成本(因为压缩warps空泡的时候,线程间交换了一次数据;恢复身份的时候又交换了回来数据),和最开始介绍的约压缩越小的那种方式的每次压缩只有1次的成本要高的,是否整体合算,读者自行决定(或者搜索了相关的文章后,看他们文章的例子里的数据)。以及,实际上,如果读者能转过来弯,不怕数据的下标映射之类的混乱的话,实际上第二次交换可以省略,但需要较多的脑力成本(你自己想一下)。我们可能会在下一次冬令营讲述完divergent branch后,介绍这个优化方式,并出一道block内部交换数据成本较高的考试题,来尽量诱导大家不进行二次交换。

最后要说的则是上面这三种方法的扩展开来,对于常见的代码中的:

1
2
3
4
5
6
7
8
if (....)
{
}
else
{
}
....
//以上的if else充斥了整个kernel,就像我们的楼主的这个论坛问题贴。

最后如果你的kernel会是这种代码,总是充斥了这种两路分支的话,如果结合上面的方式,在if前重拍成为两组任务,需要两组前序和的计数序列。但是具体怎么做,这里就不说了,很容易能扩展得到。实际上可以简易的证明,对于一个有K个warps的block,总是可以得到至少K - 1的重拍任务后的warps,和最多只有1个的有divergent branch的warp。

这样我们就结束了今天的内容。注意divergent branch总是一个优化重点。以及,注意以上的所有优化方式都有一个前提:优化引入的额外操作的成本,要小于将warp内的空泡lanes填充后的收益。否则,优化就是白忙乎。注意,优化的方式不能万能的,得根据实际问题才能知道是否如此。所以有的时候,干活的人一顿操作猛如虎,最后没有收益,也不要失望。

整体视角

1、高性能编程关注点

1. 系统层面

  • 简化控制流程和数据流程
  • 减少消息传递次数
  • 负载均衡,比如避免个别服务器成为性能瓶颈
  • 充分利用硬件性能,比如打满 CPU
  • 减少系统额外开销,比如上下文切换等
  • 批处理与数据预取、内存屏障、绑核、伪共享、核隔离等

2. 算法层面

  • 高效算法降低时间和空间复杂度
  • 高效的数据结构设计
  • 增加任务的并发性(如协程)、减少锁的开销(lock_free)

3. 代码层面

  • I-cache(指令),D-cache(数据) 优化
  • 代码执行顺序的调整,比如减少分支预测失败率
  • 编译优化选项,比如 PGO、LTO、BOLT等
  • 语言本身相关的优化技巧
  • 减少函数调用栈的深度
  • 操作放置到编译期执行,比如模板
  • 延迟计算:
    • (1)两端构建(当实例能够被静态地构建时,经常会缺少构建对象所需的信息。在构建对象时,我们并 不是一气呵成,而是仅在构造函数中编写建立空对象的最低限度的代码。稍后,程序再调用该对象的初始化成员函数来完成构建。将初始化推迟至有足够的额外数据时,意味 着被构建的对象总是高效的、扁平的数据结构;
    • (2)写时复制(指当一个对象被复制时,并不复制它的动态成员变量,而是让两个实例共享动态变量。只在其中某个实例要修改该变量时,才会真正进行复制)

2、预置知识 - Cache

1. Cache hierarchy

Cache(缓存)一般分为 3 级:L1、L2、L3. 通常来说 L1、L2是集成在 CPU 里面的(可以称之为On-chip cache),而 L3 是放在 CPU 外面(可以称之为 Off-chip cache)。当然这个不是绝对的,不同 CPU 的做法可能会不太一样。当然,Register(寄存器)里的数据读写是最快的。比如,矩阵乘法优化:

2. Cache size

Cache 的容量决定了有多少代码和数据可以放到 Cache 里面,如果一个程序的热点(hotspot)已经完全填充了整个 Cache,那 么再从 Cache 角度考虑优化就是白费力气了。

3. Cache line size

CPU 从内存 Load 数据是一次一个 cache line;往内存里面写也是一次一个 cache line,所以一个 cache line 里面的数据最好是读写分开,否则就会相互影响。

4. Cache associative

全关联(full associative):内存可以映射到任意一个 Cache line;

N-way 关联:这个就是一个哈希表的结构,N 就是冲突链的长度,超过了 N,就需要替换。

5. Cache type

I-cache(指令)、D-cache(数据)、TLB(MMU 的 cache)

3、系统优化方法

1. Asynchronous

异步,yyds!

2. Polling

Polling 是网络设备里面常用的一个技术,比如 Linux 的 NAPI 或者 epoll。与之对应的是中断,或者是事件。Polling 避免了状态切换的开销,所以有更高的性能。但是,如果系统里面有多种任务,如何在 Polling 的时候,保证其他任务的执行时间?Polling 通常意味着独占,此时系统无法响应其他事件,可能会造成严重后果。凡是能用事件或中断的地方都能用 Polling 替代,是否合理,需要结合系统的数据流程来决定。

3. 静态内存池

静态内存有更好的性能,但是适应性较差(特别是系统里面有多个 任务的时候),而且会有浪费(提前分配,还没用到就分配了)。

4. 并发优化:lock-free 和 lock-less。

lock-free 是完全无锁的设计,有两种实现方式:

• Per-cpu data, 上文已经提及过,就是 thread local

• CAS based,CAS 是 compare and swap,这是一个原子操作(spinlock 的实现同样需要 compare and swap,但区别是 spinlock 只有两个状态 LOCKED 和 UNLOCKED,而 CAS 的变量可以有多个状态);其次,CAS 的实现必须由硬件来保障(原子操作),CAS 一次可以操作 32 bits,也有 MCAS,一次可以修改一块内存。基于 CAS 实现的数据结构没有一个统一、一致的实现方法,所以有时不如直接加锁的算法那么简单,直接,针对不同的数据结构,有不同的 CAS 实现方法,读者可以自己搜索。

lock-less 的目的是减少锁的争用(contention),而不是减少锁。这个和锁的粒度(granularity)相关,锁的粒度越小,等待的时间就越短,并发的时间就越长。

锁的争用,需要考虑不同线程在获取锁后,会执行哪些不同的动作。比如多线程队列,一般情况下,我们一把锁锁住整个队列,性能很差。如果所有的 enqueue 操作都是往队列的尾部插入新节点,而所有的 dequeue 操作都是从队列的头部删除节点,那么 enqueue 和 dequeue 大部分时候都是相互独立的,我们大部分时候根本不需要锁住整个队列,白白损失性能!那么一个很自然就能想到的算法优化方案就呼之欲出了:我们可以把那个队列锁拆成两个:一个队列头部锁(head lock)和一个队列尾部锁(tail lock),伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
typedef struct node_t {
TYPE value;
node_t *next
} NODE;

typedef struct queue_t {
NODE *head;
NODE *tail;
LOCK q_h_lock;
LOCK q_t_lock;
} Q;

initialize(Q *q) {
node = new_node() // Allocate a free node
node->next = NULL // Make it the only node in the linked list
q->head = q->tail = node // Both head and tail point to it
q->q_h_lock = q->q_t_lock = FREE // Locks are initially free
}

enqueue(Q *q, TYPE value) {
node = new_node() // Allocate a new node from the free list
node->value = value // Copy enqueued value into node
node->next = NULL // Set next pointer of node to NULL
lock(&q->q_t_lock) // Acquire t_lock in order to access Tail
q->tail->next = node // Link node at the end of the queue
q->tail = node // Swing Tail to node
unlock(&q->q_t_lock) // Release t_lock


dequeue(Q *q, TYPE *pvalue) {
lock(&q->q_h_lock) // Acquire h_lock in order to access Head
node = q->head // Read Head
new_head = node->next // Read next pointer
if new_head == NULL // Is queue empty?
unlock(&q->q_h_lock) // Release h_lock before return
return FALSE // Queue was empty
endif
*pvalue = new_head->value // Queue not empty, read value
q->head = new_head // Swing Head to next node
unlock(&q->q_h_lock) // Release h_lock
free(node) // Free node
return TRUE // Queue was not empty, dequeue succeeded
}

5. 进程间通信 - 共享内存

对于本地进程间需要高频次的大量数据交互,首推共享内存这种方案。

现代操作系统普遍采用了基于虚拟内存的管理方案,在这种内存管理方式之下,各个进程之间进行了强制隔离。程序代码中使用的内存地址均是一个虚拟地址,由操作系统的内存管理算法提前分配映射到对应的物理内存页面,CPU在执行代码指令时,对访问到的内存地址再进行实时的转换翻译。

img

从上图可以看出,不同进程之中,虽然是同一个内存地址,最终在操作系统和 CPU 的配合下,实际存储数据的内存页面却是不同的。而共享内存这种进程间通信方案的核心在于:如果让同一个物理内存页面映射到两个进程地址空间中,双方不是就可以直接读写,而无需拷贝了吗?

img

当然,共享内存只是最终的数据传输载体,双方要实现通信还得借助信号、信号量等其他通知机制。

6. I/O 优化 - 多路复用技术

网络编程中,当每个线程都要阻塞在 recv 等待对方的请求,如果访问的人多了,线程开的就多了,大量线程都在阻塞,系统运转速度也随之下降。这个时候,你需要多路复用技术,使用 select 模型,将所有等待(accept、recv)都放在主线程里,工作线程不需要再等待。

img

但是,select 不能应付海量的网站访问。这个时候,你需要升级多路复用模型为 epoll。select 有三弊,epoll 有三优:

  • select 底层采用数组来管理套接字描述符,同时管理的数量有上限,一般不超过几千个,epoll使用树和链表来管理,同时管理数量可以很大
  • select不会告诉你到底哪个套接字来了消息,你需要一个个去询问。epoll 直接告诉你谁来了消息,不用轮询
  • select进行系统调用时还需要把套接字列表在用户空间和内核空间来回拷贝,循环中调用 select 时简直浪费。epoll 统一在内核管理套接字描述符,无需来回拷贝

7. 线程池技术

使用一个公共的任务队列,请求来临时,向队列中投递任务,各个工作线程统一从队列中不断取出任务来处理,这就是线程池技术。

img

多线程技术的使用一定程度提升了服务器的并发能力,但同时,多个线程之间为了数据同步,常常需要使用互斥体、信号、条件变量等手段来同步多个线程。这些重量级的同步手段往往会导致线程在用户态/内核态多次切换,系统调用,线程切换都是不小的开销。

4、算法优化

比如高效的过滤算法、哈希算法、分治算法等等,大家在刷题的过程中估计都能感受到算法的魅力了,这里不再赘述。

5、代码层次优化

1. I-cache 优化

一是相关的源文件要放在一起;二是相关的函数在object文件里面,也应该是相邻的。这样,在可执行文件被加载到内存里面的时候,函数的位置也是相邻的。相邻的函数,冲突的几率比较小。而且相关的函数放在一起,也符合模块化编程的要求:那就是 高内聚,低耦合。

如果能够把一个 code path 上的函数编译到一起(需要编译器支持,把相关函数编译到一起), 很显然会提高 I-cache 的命中率,减少冲突。但是一个系统有很多个 code path,所以不可能面面俱到。不同的性能指标,在优化的时候可能是冲突的。所以尽量做对所以 case 都有效的优化,虽然做到这一点比较难。

常见的手段有函数重排(获取程序运行轨迹,重排二进制目标文件(elf 文件)里的代码段)、函数冷热分区等。

2. D-cache相关优化

  • Cache line alignment (cache 对齐)

数据跨越两个 cacheline,就意味着两次 load 或者两次 store。如果数据结构是 cacheline 对齐的,就有可能减少一次读写。数据结构的首地址 cache line 对齐,意味着可能有内存浪费(特别是数组这样连续分配的数据结构),所以需要在空间和时间两方面权衡。

  • 分支预测

likely/unlikely

  • Data prefetch (数据预取)

使用 X86 架构下 gcc 内置的预取指令集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

int binarySearch(int *array, int number_of_elements, int key) {
int low = 0, high = number_of_elements-1, mid;
while(low <= high) {
mid = (low + high)/2;
#ifdef DO_PREFETCH
// low path
__builtin_prefetch (&array[(mid + 1 + high)/2], 0, 1);
// high path
__builtin_prefetch (&array[(low + mid - 1)/2], 0, 1);
#endif

if(array[mid] < key)
low = mid + 1;
else if(array[mid] == key)
return mid;
else if(array[mid] > key)
high = mid-1;
}
return -1;
}
  • Register parameters (寄存器参数)

一般来说,函数调用的参数少于某个数,比如 3,参数是通过寄存器传递的(这个要看 ABI 的约定)。所以,写函数的时候,不要带那么多参数。

  • Lazy computation (延迟计算)

延迟计算的意思是最近用不上的变量,就不要去初始化。通常来说,在函数开始就会初始化很多数据,但是这些数据在函数执行过程中并没有用到(比如一个分支判断,就退出了函数),那么这些动作就是浪费了。

变量初始化是一个好的编程习惯,但是在性能优化的时候,有可能就是一个多余的动作,需要综合考虑函数的各个分支,做出决定。

延迟计算也可以是系统层次的优化,比如 COW(copy-on-write) 就是在 fork 子进程的时候,并没有复制父进程所有的页表,而是只复制指令部分。当有写发生的时候,再复制数据部分,这样可以避免不必要的复制,提供进程创建的速度。

  • Early computation (提前计算)

有些变量,需要计算一次,多次使用的时候。最好是提前计算一下,保存结果,以后再引用,避免每次都重新计算一次。

  • Allocation on stack (局部变量)

适当定义一些全局变量避免栈上的变量

  • Per-cpu data structure (非共享的数据结构)

比如并发编程时,给每个线程分配独立的内存空间

  • Move exception path out (把 exception 处理放到另一个函数里面)

只要引入了异常机制,无论系统是否会抛出异常,异常代码都会影响代码的大小与性能;未触发异常时对系统影响并不明显,主要影响一些编译优化手段;触发异常之后按异常实现机制的不同,其对系统性能的影响也不相同,不过一般很明显。所以,不用担心异常对正常代码逻辑性能的影响,同时不要借用异常机制处理业务逻辑。现代 C++ 编译器所使用的异常机制对正常代码性能的影响并不明显,只有出现异常的时候异常机制才会影响整个系统的性能,这里有一些测试数据。

另外,把 exception path 和 critical path 放到一起(代码混合在一起),就会影响 critical path 的 cache 性能。而很多时候,exception path 都是长篇大论,有点喧宾夺主的感觉。如果能把 critical path 和 exception path 完全分离开,这样对 i-cache 有很大帮助

  • Read, write split (读写分离)

伪共享(false sharing):就是说两个无关的变量,一个读,一个写,而这两个变量在一个cache line里面。那么写会导致cache line失效(通常是在多核编程里面,两个变量在不同的core上引用)。读写分离是一个很难运用的技巧,特别是在code很复杂的情况下。需要不断地调试,是个力气活(如果有工具帮助会好一点,比如 cache miss时触发 cpu 的 execption 处理之类的)

6、总结

上面所列举的大多数还是通用的高性能编程手段,从物理硬件 CPU、内存、硬盘、网卡到软件层面的通信、缓存、算法、架构每一个环节的优化都是通往高性能的道路。软件性能瓶颈定位的常用手段有 perf(火焰图)以及在 Intel CPU 上使用 pmu-tools 进行 TopDown 分析。接下来,我们将从 C++ 编程语言本身层面出发,探讨下不同场景下最高效的 C++ 代码实现方式。

img

并发优化

1、单线程中的并发

SIMD 指令集优化

提到并发,大家默认会认为是多核多线程技术,实际上单核单线程内也能利用上硬件细粒度的并发能力:SIMD(Single Instruction Multiple Data),与之相对的就是多核多线程中的 MIMD(Multiple Instruction Multiple Data)。CPU 指令集的发展经历了 MMX(Multi Media eXtension)、SSE(Streaming SIMD Extensions)、AVX(Advanced Vector Extensions)、IMCI 等。笔者在 Intel 实习期间,就是用 SSE 128 位指令集实现了 FFT(快速傅立叶变换),而以前是基于 SSE 64 位指令集实现的。

下面是一个利用 SSE 指令进行优化的例子:将 Mat1 和 Mat2 矩阵元素乘积之后更新到 Mat2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 优化前
void MatMulti(Mat m1, Mat m2) {
for (int i = 0; i < m1.rows; i++) {
float *pixel_1 = (float *)m1.data + i * m1.step / 4; // 32f
float *pixel_2 = (float *)m2.data + i * m2.step / 4; // 32f
for (int j = 0; j < m1.cols; j++) {
*pixel_2 = (*pixel_1) * (*pixel_2);
pixel_1 += 1;
pixel_2 += 1;
}
}
}

// 优化后
void SSEMatMulti(Mat m1, Mat m2)
{
for (int i = 0; i < m1.rows; i++)
{
float *pixel_1 = (float *)m1.data + i * m1.step / 4; // 32f
float *pixel_2 = (float *)m2.data + i * m2.step / 4; // 32f
for (int j = 0; j < m1.cols; j++)
{
__m128 sse_1 = _mm_load_ps(pixel_1); // 将 pixel_1 地址指向的值复制给 sse_1
__m128 sse_2 = _mm_load_ps(pixel_2); // 将 pixel_2 地址指向的值复制给 sse_2
__m128 h = _mm_mul_ss(sse_1, sse_2);
_mm_storer_ps(pixel_2, h);
pixel_1 += 1;
pixel_2 += 1;
}
}
}

关于指令集优化,更多内容请参考下面这篇文章:

C/C++指令集介绍以及优化(主要针对SSE优化)98 赞同 · 1 评论文章

OoOE(Out of Ordered Execution)优化

经典 5 级 RISC 流水线如下图所示,分为 5 个步骤:取指 -> 译码 -> 计算 -> 访存 -> 写回。

img

当执行环节遇到数据依赖,以及缓存未命中等场景,就会导致整体停顿的产生,其中 MEM 环节的影响尤其明显,主要是因为多级缓存及多核共享带来的单次访存所需周期数参差不齐的现象越来越严重。为了减轻停顿的影响,现代 CPU 引入了乱序执行结合超标量的技术,什么意思呢?一方面:对于重点执行部件,比如计算、访存,增加多份来支持并行;另一方面:在执行部件前引入缓冲池/队列机制。最终从流水线模式向类似”多线程”的方式靠拢。

TMAM(Top-down Micro-architecture Analysis Methodology,自顶向下的微架构分析方法)

这是 Intel CPU 工程师归纳总结用于优化 CPU 性能的方法论。TMAM 理论基础就是将各类 CPU 各类微指令进行归类从大的方面先确认可能出现的瓶颈,再进一步分析找到瓶颈点,该方法也符合我们人类的思维,从宏观再到细节,过早的关注细节,往往需要花费更多的时间。这套方法论的优势在于:

  • 即使没有硬件相关的知识也能够基于 CPU 的特性优化程序
  • 系统性的消除我们对程序性能瓶颈的猜测:分支预测成功率低?CPU 缓存命中率低?内存瓶颈?
  • 快速的识别出在多核乱序 CPU 中瓶颈点
  • Intel 提供分析工具:pmu-tools

笔者在华为期间,就是用这套方法对 5G 核心网进行性能优化。TMAM 将各种 CPU 资源大致分为 4 类:

img

  1. Retiring

Retiring 表示运行有效的 uOps 的 pipeline slot,即这些 uOps 最终会退出(注意一个微指令最终结果要么被丢弃、要么退出将结果回写到 register),它可以用于评估程序对 CPU 的相对比较真实的有效率。理想情况下,所有流水线 slot 都应该是”Retiring”。100% 的 Retiring 意味着每个周期的 uOps Retiring数将达到最大化,极致的 Retiring 可以增加每个周期的指令吞吐数(IPC)。需要注意的是,Retiring 这一分类的占比高并不意味着没有优化的空间。例如 retiring 中 Microcode assists 的类别实际上是对性能有损耗的,我们需要避免这类操作。

  1. Bad Speculation

Bad Speculation 表示错误预测导致浪费 pipeline 资源,包括由于提交最终不会 retired 的 uOps 以及部分 slots 是由于从先前的错误预测中恢复而被阻塞的。由于预测错误分支而浪费的工作被归类为”错误预测”类别。例如:if、switch、while、for等都可能会产生 bad speculation。

  1. Front-End-Bound
  • 取指令
  • 将指令进行解码成微指令
  • 将指令分发给 Back-End,每个周期最多分发4条微指令

Front-End Bound 表示处理其的 Front-End 的一部分 slots 没法交付足够的指令给 Back-End。Front-End 作为处理器的第一个部分其核心职责就是获取 Back-End 所需的指令。在 Front-End 中由预测器预测下一个需要获取的地址,然后从内存子系统中获取对应的缓存行,在转换成对应的指令,最后解码成uOps(微指令)。Front-End Bound 意味着,会导致部分slot 即使 Back-End 没有阻塞也会被闲置。例如因为指令 cache misses引起的阻塞是可以归类为 Front-End Bound。

优化建议:

(1)尽可能减少代码的 footprint:C/C++可以利用编译器的优化选项来帮助优化,比如 GCC -O* 都会对 footprint 进行优化或者通过指定 -fomit-frame-pointer 也可以达到效果;

**(2)充分利用 CPU 硬件特性:**宏融合(macro-fusion)特性可以将2条指令合并成一条微指令,它能提升 Front-End 的吞吐。 比如下图中的循环示例:

img

所以建议循环条件中的类型采用无符号的数据类型可以使用到宏融合特性提升 Front-End 吞吐量。

(3)调整代码布局(co-locating-hot-code)

  • 充分利用编译器的 PGO 特性:-fprofile-generate -fprofile-use
  • 可以通过__attribute__ ((hot)) __attribute__ ((code)) 来调整代码在内存中的布局,hot 的代码在解码阶段有利于 CPU 进行预取。

其他优化选项,可以参考:

GCC优化选项link.segmentfault.com/?enc=IsWvcWLv6jLcFmwla6JftA%3D%3D.YzsQCES2I7zZaQzhb4r136cyNY2G%2BnkGtS8mCjA7ZvfQ2MH2GJToX83L1KPVLZs6vG%2F%2BWSB0kM4EFtQyoBwbWA%3D%3D

GCC 通用属性优化link.segmentfault.com/?enc=G2y8z%2BV1BEbm%2BtUNGilarQ%3D%3D.0cpASZjv8hkrvrjlLTKEJMEOD88PlbnAD5vXXrLIdlTU6vZmCvCKEu35f5HdIyoHB373G3%2B0YRdlDSK9Q29tLw%2FSO4vX4EH%2FOAVcrD6IHyyANKmDqlQRQEy9I89OXyAm

(4)分支预测优化

\4. Back-End-Bound

  • 接收 Front-End 提交的微指令
  • 必要时对 Front-End 提交的微指令进行重排
  • 从内存中获取对应的指令操作数
  • 执行微指令、提交结果到内存

Back-End Bound 表示部分 pipeline slots 因为 Back-End 缺少一些必要的资源导致没有 uOps 交付给 Back-End。

Back-End 处理器的核心部分是通过调度器乱序地将准备好的 uOps 分发给对应执行单元,一旦执行完成,uOps 将会根据程序的顺序返回对应的结果。例如:像 cache-misses 引起的阻塞(停顿)或者因为除法运算器过载引起的停顿都可以归为此类。此类别可以在进行细分为两大类:Memory-Bound 、Core Bound。

归纳总结一下就是:

Front End Bound = Bound in Instruction Fetch -> Decode (Instruction Cache, ITLB)Back End Bound = Bound in Execute -> Commit (Example = Execute, load latency)
Bad Speculation = When pipeline incorrectly predicts execution (Example branch mispredict memory ordering nuke)
Retiring = Pipeline is retiring uops

一个微指令状态可以按照下图决策树进行归类:

img

上图中的叶子节点,程序运行一定时间之后各个类别都会有一个 pipeline slot 的占比,只有 Retiring 的才是我们所期望的结果,那么每个类别占比应该是多少才是合理或者说性能相对来说是比较好,没有必要再继续优化?Intel 在实验室里根据不同的程序类型提供了一个参考的标准:

img

只有 Retiring 类别是越高越好,其他三类都是占比越低越好。如果某一个类别占比比较突出,那么它就是我们进行优化时重点关注的对象。

优化建议:

(1)合理使用缓存行对齐

CPU 的缓存是弥足珍贵的,应该尽量的提高其使用率,平常使用过程中可能存在一些误区导致 CPU cache 有效利用率比较低。下面来看一个不适合进行缓存行对齐的例子:

1
2
3
4
5
6
7
8
9
#include <stdlib.h>
#define CACHE_LINE

struct S1 {
int r1;
int r2;
int r3;
S1() : r1(1), r2(2), r3(3) {}
} CACHE_LINE;

下面这个是测试效果:

img

做了缓存行对齐:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <string.h>

#define CACHE_LINE __attribute__((aligned(64)))

struct S1 {
int r1;
int r2;
int r3;
S1() : r1(1), r2(2), r3(3) {}
} CACHE_LINE;

测试结果:

img

通过对比两个 retiring 就知道,这种场景下没有做 cache 对齐缓存利用率高,因为在单线程中采用了缓存行导致 cpu cache 利用率低,在上面的例子中缓存行利用率才 3*4/64 = 18%。缓存行对齐使用原则:

  • 多个线程存在同时写一个对象、结构体的场景(即存在伪共享的场景)
  • 对象、结构体过大的时候
  • 将高频访问的对象属性尽可能的放在对象、结构体首部

2、多线程中的并发

临界区保护技术

  • Mutual Execlusion(pessimistic locking):基本的互斥技术,存在某个时间周期,算法没有任何实质进展,典型的悲观锁算法
  • Lock Free (optimistic locking):组成算法的一个线程没有任何实质进展,基于 CAS 同步提交,若遇到冲突,回滚
  • Wait Free:任意时间周期,算法的任意一个线程都有实质进展

举个例子:**多线程累加,**上述三种技术对应以下实现方案:

  • 上锁后累加
  • 累加后 CAS 提交
  • 累加后 FAA(Fetch and Add)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
uint64_t calc(uint64_t* seq, size_t size) {
for (size_t i = 0; i < size; i++) {
seq[(i + 1) & 7] += seq[i & 7];
}
return seq[i & 7];
}

std::mutex mtx;
uint64_t sum = 0;
size_t workload = 10000;
uint64_t seq[512] = {0};

{
// Mutual Exclusion
std::lock_guard<std::mutex> lock(mtx);
sum += calc(seq, workload);
}

{
// Lock Free / Atomic CAS
auto curr = atomic_sum.load(std::memory_order_relaxed);
auto next = curr;
do {
next = curr + calc(seq, workload)
} while (!atomic_sum.compare_exchange_weak(curr, next, std::memory_ordered_relaxed));
}

{
// Wait Free / Atmoic Modify
atomic_sum.fetch_add(calc(seq, workload), std::memory_order_relaxed);
}

实际操作中,我们该如何选择呢?

  • 优先考虑 Wait Free 的方法,如果可以的话,在性能上接近完全消除了临界区的效果
  • 充分缩减临界区
  • 在临界区足够小,且无 Wait Free 方案时,不必对 Lock Free 过度执着,因为 Lock Free “无效预测执行” 以及支持撤销回滚的两阶段提交算法非常复杂,反而会引起过多的消耗。锁本身的开销虽然稍重于原子操作,但其实可以接受的。真正影响性能的是临界区被迫串行执行所带来的并行能力折损。

并发队列

在上一篇文章中已经提到过,这里不再赘述了。

伪共享

多个 CPU 同时对同一个缓存行的数据进行修改,导致 CPU cache 的数据不一致,也就是缓存失效问题。为什么伪共享只发生在多线程的场景,而多进程的场景不会有问题?这是因为 linux 虚拟内存的特性,各个进程的虚拟地址空间是相互隔离的,也就是说在数据不进行缓存行对齐的情况下,CPU 执行进程 1 时加载的一个缓存行的数据,只会属于进程 1,而不会存在一部分是进程 1、另外一部分是进程 2。

img

(上图中不同型号的 L2 cache 组织形式可能不同,有的可能是每个 core 独占,例如 skylake)

伪共享之所以对性能影响很大,是因为他会导致原本可以并行执行的操作,变成了并发执行。这是高性能服务不能接受的,所以我们需要对齐进行优化,方法就是 CPU 缓存行对齐(cache line align)解决伪共享,本来就是一个以空间换取时间的方案。

Linux系统中采用 MESI 协议处理缓存一致性,所谓 MESI 即是指 CPU 缓存的四种状态:

  • M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有);
  • E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据;
  • S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝;
  • I(无效,Invalid):缓存行失效, 不能使用。

img

MESI状态转换

每个 CPU 缓存行都在四个状态之间互相转换,以此决定 CPU 缓存是否失效,比如 CPU 对一个缓存行执行了写入操作,则此操作会导致其他 CPU 的该缓存行进入 Invalid 无效状态,CPU 需要使用该缓存行的时候需要从内存中重新读取。由此就解决了多 CPU 之间的缓存一致性问题。消除伪共享有如下两种方法:

  1. 缓存行填充(Padding):为了避免伪共享就需要将可能造成伪共享的多个变量处于不同的缓存行中,可以采用在变量后面填充字节的方式达到该目的。
  2. 尽量让相关访问的数据在一个 cache-line
  3. 使用某些语言或编译器中强制变量对齐,将变量都对齐到缓存行大小,避免伪共享发生。

内存优化

1、tcmalloc 和 jemalloc

线程池技术中,每个线程各司其职,完成一个一个的任务。在 malloc 看来,就是多个长生命周期的线程,随机的在各个时间节点进行内存申请和内存释放。基于这样的场景,首先,尽量分配连续地址空间。其次,多线程下需要考虑分区隔离和减少竞争。

tcmalloc 和 jemalloc 共同的思路是引入线程缓存机制。通过一次从后端获取大块内存,放入缓存供线程多次申请,降低对后端的实际竞争强度。主要不同点是,当线程缓存被击穿后,tcmalloc 采用了单一的 page heap(简化了中间的 transfer cache 和 central cache) 来承载;而 jemalloc 采用了多个 arena(甚至超过了服务器 core 数)。一般来讲,在线程数较少,或释放强度较低的情况下,较为简洁的 tcmalloc 性能稍胜 jemalloc。在 core 数较多、申请释放频繁时,jemalloc 因为锁竞争强度远小于 tcmalloc,性能较好

理想的 malloc 模型是什么?

  • 低竞争性和连续性

微服务、流式计算、缓存,这几种业务模型几乎涵盖了所有主流的后端服务场景。而这几种业务对内存的应用有一个重要的特征:拥有边界明确的生命周期。比如在早期的 server 设计中,每个 client 请求都分配一个单独的线程处理,处理完再整体销毁。但随着新型的子任务级线程池并发技术的广泛应用,即请求细分为多个子任务充分利用多核并发来提升计算性能。

std::vectorstd::string 如何优化?这里提供一种思路:

  • 和典型的 vector 处理主要不同点是:在 clear 或者 pop_back 等操作缩减大小之后,内容对象并不实际析构,只是清空重置。因此,再一次用到这个槽位的时候,可以直接拿到已经构造好的元素,而且其 capacity 之内的内存依然持有。当反复使用同一个实例时,容器内存和每个元素自身的 capacity 都会趋于饱和值,反复的分配和构造需求都被减少了。

内存分配和实例构造功能解耦。这也是 PMR(Polymorphic Memory Resource,C++17 的新特性)设计的出发点,大名鼎鼎的 EASTL 就是它的原型,它就是为低延迟、高频、计算密集型任务开发的。

2、string

短字符串分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <chrono>
#include <iostream>

struct Timer {
std::chrono::high_resolution_clock::time_point start, end;
std::chrono::duration<float> duration;
Timer() { start = std::chrono::high_resolution_clock::now(); }
~Timer() {
end = std::chrono::high_resolution_clock::now();
duration = end - start;
float ns = duration.count() * 1000000.0f;
std::cout << "Timer took " << ns << "ns"
<< "\n";
}
};

const int SIZE = 1000000;
void test_stack() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
char buf[12];
}
}

void test_string() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::string str("hello world");
}
}

int main() {
test_stack();
test_string();
return 0;
}

测试结果:

img

短字符串构造,char 和 string 性能差不多

长字符串分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const int SIZE = 1000000;
void test_stack() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
char buf[32];
}
}

void test_string() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::string str("hello world, it is test string.");
}
}

int main() {
test_stack();
test_string();
return 0;
}

测试结果:

img

长字符串构造,string 性能比 char 差很多

string 在 libstadc++ 和 libc++ 的实现方式是不一样的

std::pmr::string

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <memory_resource>

const int SIZE = 1000000;
void test_stack() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::string str("hello world, it is test string.");
}
}

void test_string() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::pmr::string str("hello world, it is test string.");
}
}

测试结果:

img

std::pmr::string允许我们在栈上创建string,当超过 1024 个字节后才会在堆上申请内存。

3、vector

stl 中 vector 的内存增长速度是 2 的幂次方,而这个值是可以调整的,比如:folly 的 small vector

folly/small_vector.md at main · facebook/follygithub.com/facebook/folly/blob/main/folly/docs/small_vector.md

4、map

STL 中的 map 是基于红黑树来实现的,而高效的 map 必然是 hash map,进一步优化的思路就是在 hash map 的基础上引入内存池技术。

C++ 数据结构设计:如何高效地存储并操作超大规模的 70 赞同 · 7 评论文章

https://github.com/ktprime/emhashgithub.com/ktprime/emhash

5、protobuf

比如采取某些字段合并策略,尽量减少序列化、反序列化的次数。

6、高效使用智能指针

  • 使用 std::make_shared 代替 new T
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyClass {
public:
MyClass(std::string s, int i) : s(s), i(i) {} // 使用初始化列表比较快

public:
std::string s;
int i;
};

const int SIZE = 1000000;
void test1() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::shared_ptr<MyClass> p(new MyClass("hello", 123)); // 会调用两次内存管理器,第一次用于创建 MyClass 的实例,第二次用来创建 std::shared_ptr 的内部结构。
}
}

void test2() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::shared_ptr<MyClass> p = std::make_shared<MyClass>("hello", 123); // 一次性分配内存同时保存以上两种数据结构
}
}

int main() {
test1();
test2();
return 0;
}

测试结果:

img

  • 避免使用 std::shared_ptr 作为函数的入参,而是通过 get() 函数传递实际的指针
  • 通过 = delete 修饰,在类定义中禁止不希望发生的复制

1. GPU 简介

GPU(Graphics Processing Unit)是一种图形渲染设备,是显卡(Video Card/Graphics Card)的计算核心。GPU 最初仅用作纹理映射和多边形着色等需要较多存储空间的图形处理任务,不过,现代 GPU 已经不再局限于 3D 图形处理。GPU 已经成为了通用的多核处理器,它在 浮点运算、并行计算 等方面提供数十倍乃至于上百倍于 CPU 的性能。

GPU 和 CPU 有设计理念的不同,GPU 中包含大量的 ALU(Arithmetic Logic Unit),如图 1 所示。

gpu_cpu.gif

Figure 1: GPU 和 CPU 有设计理念的不同,GPU 中包含大量的计算单元

2. CUDA C 编程

Nvidia 提出了 CUDA(Compute Unified Device Architecture)编程模型,它在 C(注:也支持 Fortran)语言的基础上进行了很小的扩展,使得应用程序既可以包含在 CPU 中执行的代码,又可以包含在 GPU 中执行的代码,充分利用了 CPU 和 GPU 各自的优点。

CUDA 程序的执行过程如图 2 所示,这个图演示了先执行 CPU 代码,再执行 GPU 代码,然后又执行 CPU 代码,又再执行 GPU 代码的情况。

gpu_cuda_prog.gif

Figure 2: Execution of a CUDA program

CUDA 程序中, 一个函数用 __global__ 修饰,表明这个函数在 GPU 中运行,且被称为“kernel”。

2.1. Hello World

下面是 CUDA 版本的 Hello World 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>

// Your first kernel (= GPU function)
__global__ void helloFromGPU (void) // __global__ 是 CUDA 的扩展,表明这个函数在 GPU 中运行
{
printf("Hello World from GPU! Thread %d\n", threadIdx.x);
}

int main(void)
{
helloFromGPU <<<1, 4>>>(); // <<< >>> 是 CUDA 的扩展,用于指定GPU线程规模

cudaDeviceReset();
return 0;
}

使用 nvcc 进行编译:

1
$ nvcc hello.cu –o hello

测试运行得到的可执行程序:

1
2
3
4
5
$ ./hello
Hello World from GPU! Thread 0
Hello World from GPU! Thread 1
Hello World from GPU! Thread 2
Hello World from GPU! Thread 3

2.2. 实例:数组相加

下面通过“两个浮点数数组相加”的例子来介绍一下 CUDA 编程。

2.2.1. CPU 版本

先看一下“两个浮点数数组相加”的 CPU 版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <math.h>

// function to add the elements of two arrays
void add(int n, float *x, float *y)
{
for (int i = 0; i < n; i++)
y[i] = x[i] + y[i];
}

int main(void)
{
int N = 1<<20; // 1M elements

float *x = new float[N];
float *y = new float[N];

// initialize x and y arrays on the host
for (int i = 0; i < N; i++) {
x[i] = 1.0f;
y[i] = 2.0f;
}

// Run on the CPU
add(N, x, y);

// Check for errors (all values should be 3.0f)
float maxError = 0.0f;
for (int i = 0; i < N; i++)
maxError = fmax(maxError, fabs(y[i]-3.0f));
std::cout << "Max error: " << maxError << std::endl;

// Free memory
delete [] x;
delete [] y;

return 0;
}

编译并运行:

1
2
3
$ g++ add.cpp -o add
$ ./add
Max error: 0

2.2.2. 改造为 GPU 版本

下面我们来看看如何把前面的程序改造为 GPU 版本。

GPU 只能访问 GPU 中的内存,称为 Device Memory;而 CPU 能访问的内存称为 Host Memory。

gpu_device_memory.gif

Figure 3: Host Memory and Device Memory

对于前面例子,我们要把输入数据(x, y 两个数组)所占的内存从 Host Memory 复制到 Device Memory 中,然后执行 GPU 计算,计算完成后,把计算后的结果从 Device Memory 复制加 Host Memory 中。 这是 GPU 编程的通用编程模式。

下面是改造后的 GPU 版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 这个例子仅启动了 1 个 GPU 线程,没有利用 GPU 优势
#include <iostream>
#include <math.h>

// Kernel function to add the elements of two arrays
__global__ // __global__ 表示其将在 GPU 上运行
void add(int n, float *x, float *y)
{
for (int i = 0; i < n; i++)
y[i] = x[i] + y[i];
}

int main(void)
{
int N = 1<<20; // 1M elements

float *x = new float[N];
float *y = new float[N];

// initialize x and y arrays on the host
for (int i = 0; i < N; i++) {
x[i] = 1.0f;
y[i] = 2.0f;
}

//
float *dev_x, *dev_y;
int size = N * sizeof(float);
cudaError_t err
err = cudaMalloc((void **)&dev_x, size); // 在 GPU 上分配内存
if (err != cudaSuccess) {
printf("%s in %s at line %d\n", cudaGetErrorString(err),__FILE__,__LINE__);
exit(EXIT_FAILURE);
}
err = cudaMalloc((void **)&dev_y, size);
if (err != cudaSuccess) {
printf("%s in %s at line %d\n", cudaGetErrorString(err),__FILE__,__LINE__);
exit(EXIT_FAILURE);
}
cudaMemcpy(dev_x, x, size, cudaMemcpyHostToDevice); // 把输入数据从 Host 内存到 Device 内存
cudaMemcpy(dev_y, y, size, cudaMemcpyHostToDevice);

add<<<1, 1>>>(N, x, y); // 在 GPU 上执行计算

cudaMemcpy(y, dev_y, size, cudaMemcpyDeviceToHost); // 把结果从 Device 内存复制回 Host 内存

cudaFree(dev_x); // 释放 Device 内存
cudaFree(dev_y);

// Check for errors (all values should be 3.0f)
float maxError = 0.0f;
for (int i = 0; i < N; i++)
maxError = fmax(maxError, fabs(y[i]-3.0f));
std::cout << "Max error: " << maxError << std::endl;

// Free Host memory
delete [] x;
delete [] y;

return 0;
}

经过这个改造后,函数 add 在 GPU 上运行,但仅启动了一个 GPU 线程,这并没有利用 GPU 的优势。为了利用 GPU 优势,我们需要对函数 add 本身进行改造。后面将对此进行介绍。

2.2.3. 线程结构 <<<numBlocks, threadsPerBlock>>>

在启用 GPU 线程时,需要使用语法 <<<numBlocks, threadsPerBlock>>> 指定线程结构。

比如: kernel1<<<1, 4>>>(); 表示 1 个 block,每个 block 中有 4 个线程。
在 kernel 函数中,可以通过 threadIdx.x 知道自己是第几个线程。这例子中 kernel1 中打印 threadIdx.x 时会分别得到 0,1,2,3,参考节 2.1 中的例子。

又如: kernel1<<<2, 4>>>(); 表示 2 个 block,每个 block 中有 4 个线程。
在 kernel 函数中, 可以通过 blockIdx.x 知道自己是第几个 block,这个例子中会分别为 0,1;可以通过 threadIdx.x 知道自己是第几个线程。 这例子中 kernel1 中打印 threadIdx.x 时会分别得到 0,1,2,3。也就是说 8 个线程中打印 blockIdx.xthreadIdx.x 时会得到:

1
2
3
4
5
6
7
8
9
blockIdx.x   threadIdx.x
0 0
0 1
0 2
0 3
1 0
1 1
1 2
1 3

在 kernel 函数,通过 blockDim.x 可以知道 threadIdx.x 的维度(最大 x 下标加 1)。也就是说 8 个线程中打印 blockIdx.x, threadIdx.x, blockDim.x 时会得到:

1
2
3
4
5
6
7
8
9
blockIdx.x   threadIdx.x   blockDim.x
0 0 4
0 1 4
0 2 4
0 3 4
1 0 4
1 1 4
1 2 4
1 3 4

这样,以方式 kernel1<<<2, 4>>>(); 启动 kernel1 时,在 kernel1 函数中使用 blockIdx.x * blockDim.x + threadIdx.x 就可以得到 0,1,2,3,4,5,6,7。

这里介绍的线程结构比较简单,关于更多细节,可参考节:3.1

2.2.4. GPU 版本 2(monolithic kernel)

为了充分利用 GPU 优势,我们让每个 GPU 线程仅处理数组中的一个元素。kernel 函数改造为如下:

1
2
3
4
5
6
7
__global__
void add(int n, float *x, float *y) // kernel中仅处理一个元素,每个 kernel处理不同元素
{
int i = blockIdx.x * blockDim.x + threadIdx.x; // 获取元素下标,其含义参考上一节内容
if (i < n)
y[i] = x[i] + y[i];
}

这种类型的 kernel 被称为“monolithic kernel”。

每个线程仅处理 1 个元素,数组有 N=1<<20 (即 1M)元素,故我们需要启动 1M 线程,每个线程处理不同元素,下面这都是可行的:

1
2
add<<<ceil(N/512.0), 512>>>(N, x, y); // ceil(N/512.0) = 2048 个 block,每个 block 中有 512 个线程;2048 * 512 = 1M
add<<<ceil(N/256.0), 256>>>(N, x, y); // ceil(N/256.0) = 4096 个 block,每个 block 中有 256 个线程;4096 * 256 = 1M

我们可以直接配置 1 个 block,让 block 的线程数为 N 吗?像下面这样:

1
add<<<1, N>>>(N, x, y);               // 这是不行的,N 超过了每个 block 中的最大线程数的限制

这是不行的。因为每个 block 中的最大线程数是有限制的:

  1. 当 Compute capability < 2.0 时,每个 block 中的最大线程数为 512;
  2. 当 Compute capability >= 2.0 时,每个 block 中的最大线程数为 1024。

2.2.5. GPU 版本 3(grid-stride loop)

在上一节介绍的 monolithic kernel 是不灵活的。启动 GPU 线程时,必须指定恰当的 <<<numBlocks, threadsPerBlock>>> 参数,否则可能出现数组元素没有被处理的情况(这种情况在指定的 numBlocks 太小时可能出现);此外,当元素规模变得更大时,可能会超过 numBlocks 的最大限制。

这种介绍另外一种更加灵活的 Kernel 函数编写方式——grid-stride loop:

1
2
3
4
5
6
7
8
__global__
void add(int n, float *x, float *y) // grid-stride loop
{
int index = blockIdx.x * blockDim.x + threadIdx.x;
int stride = blockDim.x * gridDim.x;
for (int i = index; i < n; i += stride)
y[i] = x[i] + y[i];
}

grid-stride loop 形式的 kernel 很灵活,其正确性和调用时如何指定线程无关。比如,下面这些调用形式都可以得到正确的结果:

1
2
3
4
5
add<<<1, 256>>>(N, x, y);
add<<<2, 256>>>(N, x, y);
add<<<4096, 256>>>(N, x, y);
add<<<1, 1>>>(N, x, y);
......

下面分别介绍这几种情况。

当使用 add<<<1, 256>>>(N, x, y); 时,共 256 个线程,每个线程处理 4096 个元素,每个线程中各变量如下:

1
2
3
4
5
6
blockIdx.x  blockDim.x  threadIdx.x  gridDim.x  index    stride
0 256 0 1 0 256 * 1
0 256 1 1 1 256 * 1
......
0 256 254 1 254 256 * 1
0 256 255 1 255 256 * 1

例如,index 为 0 的线程将对数组下标为 0, 256, 2256, …, 4095256 的元素(共 4096 个)进行处理。

当使用 add<<<2, 256>>>(N, x, y); 共 512 个线程,每个线程处理 2048 个元素,每个线程中各变量如下:

1
2
3
4
5
6
7
8
9
10
11
blockIdx.x  blockDim.x  threadIdx.x  gridDim.x  index    stride
0 256 0 2 0 256 * 2
0 256 1 2 1 256 * 2
......
0 256 254 2 254 256 * 2
0 256 255 2 255 256 * 2
1 256 0 2 256 256 * 2
1 256 1 2 257 256 * 2
......
1 256 254 2 510 256 * 2
1 256 255 2 511 256 * 2

例如,index 为 0 的线程将对数组下标为 0, 512, 2512, …, 2047512 的元素(共 2048 个)进行处理。

当使用 add<<<4096, 256>>>(N, x, y); 时,共 1M 线程(index 为 515 的线程如图 4 所示,图片摘自 https://developer.nvidia.com/blog/even-easier-introduction-cuda/ ),每个线程处理 1 个元素,每个线程中各变量如下:

1
2
3
4
5
6
7
8
9
10
11
blockIdx.x  blockDim.x  threadIdx.x  gridDim.x  index    stride
0 256 0 4096 0 256 * 4096
0 256 1 4096 1 256 * 4096
......
0 256 254 4096 254 256 * 4096
0 256 255 4096 255 256 * 4096
1 256 0 4096 256 256 * 4096
1 256 1 4096 257 256 * 4096
......
4095 256 254 4096 1048574 256 * 4096
4095 256 255 4096 1048575 256 * 4096

gpu_cuda_indexing.gif

Figure 4: add<<<4096, 256>>>(N, x, y); 中 index 为 515 的线程

当使用 add<<<1, 1>>>(N, x, y); 时,共 1 个线程,每个线程处理 1M 元素,每个线程中各变量如下:

1
2
blockIdx.x  blockDim.x  threadIdx.x  gridDim.x  index    stride
0 1 0 1 0 1 * 1

CUDA Pro Tip: Write Flexible Kernels with Grid-Stride Loops 一文中,总结了 grid-stride loop 的几个优点:

  1. Scalability and thread reuse. By using a loop, you can support any problem size even if it exceeds the largest grid size your CUDA device supports. Moreover, you can limit the number of blocks you use to tune performance.
  2. Debugging. By using a loop instead of a monolithic kernel, you can easily switch to serial processing by launching one block with one thread. add<<<1, 1>>>(N, x, y); This makes it easier to emulate a serial host implementation to validate results.
  3. Portability and readability. The grid-stride loop code is more like the original sequential loop code than the monolithic kernel code, making it clearer for other users.

2.3. Function Execution Space Specifiers

函数除了用 __global__ 修饰外,还可以指定 __device__, __host__ ,它们的含义和区别如表 1 所示。

name Executed on the: Only callable from the:
__global__ device host
__device__ device device
__host__ host host

所谓 host 就是指 CPU,而 device 就是 GPU。

__global__ 不能和 __device__ 同时使用,也不能和 __host__ 同时使用;而 __device____host__ 可以同时使用。

参考:https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#function-declaration-specifiers

2.3.1. __forceinline__ and __noinline__

当一个函数用 __device__ 修饰时,编译器自己会决定是否对该函数进行内联编译。

我们也可以指定 __forceinline__ 或者 __noinline__ 来强制使用(或者不使用)内联编译。

3. 可伸缩的并行执行

3.1. CUDA 线程组织

一个 Grid 内的所有线程会执行相同的 kernel 函数。

在语法 <<<m, n>>> 中,参数 m 和 n 除了可以是 int 类型外,还可以是 dim3 类型(三维数组):

1
2
3
dim3 dimGrid(2, 1, 1);
dim3 dimBlock(4, 1, 1);
kernel1<<<dimGrid, dimBlock>>>(...); // 这相同于 kernel1<<<2, 4>>>(...)

下面我们看一个复杂一些的例子,dimGrid 为 2 x 2 x 1,dimBlock 为 4 x 2 x 2:

1
2
3
dim3 dimGrid(2, 2, 1);
dim3 dimBlock(4, 2, 2);
Kernel1<<<dimGrid, dimBlock>>>(...);

这个例子中,block 共 4 个,表现为二维形式;而 thread 共 8 个,表现为三维形式,如图 5 所示。

gpu_dimgrid_dimblock.png

Figure 5: A multidimensional example of CUDA grid organization.

内置变量 gridDim.x, gridDim.y, gridDim.z 分别保存着 grid 的三个维度的信息,上面例子中,由于 dimGrid 为 2 x 2 x 1,所以有:

1
2
3
gridDim.x = 2
gridDim.y = 2
gridDim.z = 1

内置变量 blockDim.x, blockDim.y, blockDim.z 分别保存着 block 的三个维度的信息,上面例子中,由于 dimBlock 为 4 x 2 x 2,所以有:

1
2
3
blockDim.x = 4
blockDim.y = 2
blockDim.z = 2

blockIdx.x, blockIdx.y, blockIdx.z 保存着 grid 中当前 block 的下标, threadIdx.x, threadIdx.y, threadIdx.z 保存着 block 中当前 thread 的下标。如果以图 5 中面向读者的右下角那个 thread 为当前 thread,则有:

1
2
3
4
5
6
blockIdx.x = 1
blockIdx.y = 1
blockIdx.z = 0
threadIdx.x = 0
threadIdx.y = 1
threadIdx.z = 3

3.1.1. 各种维度情况下线程的编号

不管采用什么维度的 grid 和 block,我们都可以得到当前 thread 的扁平的全局一维下标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 1D grid of 1D blocks
__device__
int getGlobalIdx_1D_1D(){
return blockIdx.x *blockDim.x + threadIdx.x;
}

// 1D grid of 2D blocks
__device__
int getGlobalIdx_1D_2D(){
return blockIdx.x * blockDim.x * blockDim.y
+ threadIdx.y * blockDim.x + threadIdx.x;
}

// 1D grid of 3D blocks
__device__
int getGlobalIdx_1D_3D(){
return blockIdx.x * blockDim.x * blockDim.y * blockDim.z
+ threadIdx.z * blockDim.y * blockDim.x
+ threadIdx.y * blockDim.x + threadIdx.x;
}

// 2D grid of 1D blocks
__device__ int getGlobalIdx_2D_1D(){
int blockId = blockIdx.y * gridDim.x + blockIdx.x;
int threadId = blockId * blockDim.x + threadIdx.x;
return threadId;
}

// 2D grid of 2D blocks
__device__
int getGlobalIdx_2D_2D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x;
int threadId = blockId * (blockDim.x * blockDim.y)
+ (threadIdx.y * blockDim.x) + threadIdx.x;
return threadId;
}

// 2D grid of 3D blocks
__device__
int getGlobalIdx_2D_3D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x;
int threadId = blockId * (blockDim.x * blockDim.y * blockDim.z)
+ (threadIdx.z * (blockDim.x * blockDim.y))
+ (threadIdx.y * blockDim.x) + threadIdx.x;
return threadId;
}

// 3D grid of 1D blocks
__device__
int getGlobalIdx_3D_1D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x
+ gridDim.x * gridDim.y * blockIdx.z;
int threadId = blockId * blockDim.x + threadIdx.x;
return threadId;
}

// 3D grid of 2D blocks
__device__
int getGlobalIdx_3D_2D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x
+ gridDim.x * gridDim.y * blockIdx.z;
int threadId = blockId * (blockDim.x * blockDim.y)
+ (threadIdx.y * blockDim.x) + threadIdx.x;
return threadId;
}

// 3D grid of 3D blocks
__device__
int getGlobalIdx_3D_3D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x
+ gridDim.x * gridDim.y * blockIdx.z;
int threadId = blockId * (blockDim.x * blockDim.y * blockDim.z)
+ (threadIdx.z * (blockDim.x * blockDim.y))
+ (threadIdx.y * blockDim.x) + threadIdx.x;
return threadId;
}

参考:https://cs.calvin.edu/courses/cs/374/CUDA/CUDA-Thread-Indexing-Cheatsheet.pdf

3.1.2. 抽象概念(Grid/Block/Thread)和硬件的映射关系

下面是抽象概念(Grid/Block/Thread)和硬件的映射关系:

  • Grids map to GPUs
  • Blocks map to the MultiProcessors (MP)
  • Threads map to Stream Processors (SP)
  • Warps are groups of (32) threads that execute simultaneously

3.2. 映射线程到多维数据(RGB 转灰度图片实例)

grid 可以是 1D,2D,3D,block 也可以是 1D,2D,3D,那我们应该如何选择线程的组织形式呢?这往往由待处理数组的结构的决定。 比如,处理图片时,由于图片是像素点的二维数组,这时采用 2D grid 和 2D block 是个不错的选择。假设,现在要处理图片的像素规模为 x×y=76×62 。我们决定采用 16 x 16 的 2D block,这时 x 方向上至少需要 5 block,而 y 方向上至少需要 4 block,如图 6 所示。

gpu_img_size.gif

Figure 6: Using a 2D thread grid to process a 76 × 62 picture P.

从图 6 中可以看到,在 x 方向上有 4 个多余的线程,在 y 方向上有 2 个多余的线程。在 kernel 函数中通过边界检查让多余线程不执行操作即可。

假设 GPU 任务为 RGB 彩色图片转灰色图片,则可以这样启动 kernel:

1
2
3
4
5
int m = 76;
int n = 62;
dim3 dimGrid(ceil(m/16.0), ceil(n/16.0), 1); // 5 x 4 x 1
dim3 dimBlock(16, 16, 1); // 16 x 16 x 1
colorToGreyscaleConversion<<<dimGrid,dimBlock>>>(d_Pin, d_Pout, m, n);

关键的 kernel,即 colorToGreyscaleConversion 的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// we have 3 channels corresponding to RGB
// The input image is encoded as unsigned characters [0, 255]
__global__
void colorToGreyscaleConversion(unsigned char * Pout, unsigned
char * Pin, int width, int height) {,
int Col = threadIdx.x + blockIdx.x * blockDim.x; // threadIdx.x: [0, 15] ,blockIdx.x: [0, 4],blockDim.x 总是为 16
int Row = threadIdx.y + blockIdx.y * blockDim.y; // threadIdx.y: [0, 15] ,blockIdx.y: [0, 3],blockDim.y 总是为 16
if (Col < width && Row < height) { // 多余的线程不会通过这个边界检查
// get 1D coordinate for the grayscale image
int greyOffset = Row*width + Col;
// one can think of the RGB image having
// CHANNEL times columns than the grayscale image
int rgbOffset = greyOffset*CHANNELS; // RGB 有 3 个通道,CHANNELS 为 3
unsigned char r = Pin[rgbOffset ]; // red value for pixel
unsigned char g = Pin[rgbOffset + 2]; // green value for pixel
unsigned char b = Pin[rgbOffset + 3]; // blue value for pixel
// perform the rescaling and store it
// We multiply by floating point constants
Pout[grayOffset] = 0.21f*r + 0.71f*g + 0.07f*b; // RGB 转灰色的公式
}
}

3.3. 图片模糊处理实例

下面看一个更复杂的图片处理例子——图片模糊处理。

图片模糊处理的一种方式就是“把当前像素相邻的几个像素的平均值”作为当前像素的值,如图 7 所示,它取的是 3 x 3 小窗口里的像素的平均值(当然这个小窗口也可以更大,如 5 x 5 或 7 x 7 等)。

gpu_img_blur.gif

Figure 7: Each output pixel is the average of a patch of pixels in the input image.

下面是图片模糊处理 blurKernel 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
__global__
void blurKernel(unsigned char * in, unsigned char * out, int w, int h) {
int Col = threadIdx.x + blockIdx.x * blockDim.x;
int Row = threadIdx.y + blockIdx.y * blockDim.y;
if (Col < w && Row < h) {
int pixVal = 0;
int pixels = 0;

// Get the average of the surrounding BLUR_SIZE x BLUE_SIZE box
for (int blurRow = -BLUR_SIZE; blurRow < BLUR_SIZE + 1; ++blurRow) {
for (int blurCol = -BLUE_SIZE; blurCol < BLUR_SIZE + 1; ++blurCol) {
int curRow = Row + blurRow;
int curCol = Col + blurCol;

// Verify we have a valid image pixel
if (curRow > -1 && curRow < h && curCol > -1 && curCol < w) {
pixVal += in[curRow * w + curCol];
pixels++; // Key track of number of pixels in the avg
}
}
}

// Write our new pixel value out
out[Row * w + Col] = (unsigned char)(pixVal / pixels);
}
}

上面代码中,如果计算 3 x 3 小窗口里的像素的平均值(9 个像素点的平均值),则 BLUE_SIZE = 1;如果计算 5 x 5 小窗口里的像素的平均值(25 个像素点的平均值),则 BLUE_SIZE = 2。

需要说明的是,对于角上和边上的像素,其平均值并没有计算 9 个像素点,如图 8 所示。

gpu_img_blur_edge.gif

Figure 8: 角上仅考虑了 4 个像素点的平均,边上仅考虑了 6 个像素点的平均

3.4. Barrier Synchronization(限于 block 内)

CUDA 中,可以使用函数 __syncthreads() ,让同一个 block 中的线程进行同步。也就是说,当一个线程调用 __syncthreads() 后,它会等待同一个 block 中的所有其它线程都到达 __syncthreads() 所在位置后,才往下执行。

不过,需要注意的是。一个 __syncthreads() 必须被同一个 block 中所有线程都执行,或者都不执行。假设在 if-then-else 语句的 if 和 else 分支中各有一个 __syncthreads() 语句,而同一个 block 中的有些线程执行进入了 if 分支,而另外一些线程进入了 else 分支,那么这个程序会一直等待。

这种同步机制限定在同一个 block 内,也就是说 block 之间没有任何的依赖和约束,它们可以以任意顺序执行, 这提供了 Transparent Scalability,如图 9 所示。

gpu_block_no_sync.png

Figure 9: Lack of synchronization constraints between blocks enables transparent scalability for CUDA programs.

3.5. Thread Scheduling

Thread 调度属于硬件的实现细节,了解这些实现细节有助于我们进行性能调优。

CUDA 程序一般会创建一些线程块(Block), Block 会被调度到空闲的 Streaming Multiprocessors(SM)上去。当 Block 执行完毕后,Block 会退出 SM,释放出 SM 的资源,以供其他待 Block 调度进去。

因此,无论是只有 2 个 SM 的 GPU,还是有 4 个 SM 的 GPU,这些线程块都会被调度执行,只不过 4 个 SM 的 GPU 一般会执行得更快。因此,同样的程序,可以在具有不同 SM 数量上的 GPU 运行,这称为 Automatic Scalability。如图 10 所示。

gpu_automatic_scalability.png

Figure 10: Automatic Scalability

更细节一点, 一个 block 分配给 SM 执行时,还会进一步拆分为 Warp,它是以 32 个 thread 组成的小分组(warpSize 是一个硬件的参数,它往往为 32)。Warp 是 SM 内的线程调度的最小单元。 假设,一个 block 中共有 256 个线程,则我们可以计算出这个 block 包含 256/32 = 8 个 Warp。

一个 Warp 在执行时,遵循 Single instruction, multiple threads(SIMT)模式。也就是 32 个 thread 会共享“instruction fetching”过程,并不是每个 thread 分别去“instruction fetching”,而是“instruction fetching”后,给 32 个线程都执行。这种方式可以大大减少频繁的“instruction fetching”过程。

4. Memory and Data Locality

4.1. Memory-Bound Programs

考虑节 3.3 中介绍的图片模糊 kernel 的最核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Get the average of the surrounding BLUR_SIZE x BLUE_SIZE box
for (int blurRow = -BLUR_SIZE; blurRow < BLUR_SIZE + 1; ++blurRow) {
for (int blurCol = -BLUE_SIZE; blurCol < BLUR_SIZE + 1; ++blurCol) {
int curRow = Row + blurRow;
int curCol = Col + blurCol;

// Verify we have a valid image pixel
if (curRow > -1 && curRow < h && curCol > -1 && curCol < w) {
pixVal += in[curRow * w + curCol];
pixels++; // Key track of number of pixels in the avg
}
}
}

在内层 for 循环的每次迭代中,有 1 次 Global Memory 的访问(即对 in[] 数组的访问),有 1 次浮点数的加法运算(即 pixVal += in[curRow * w + curCol] )。

我们把“浮点运算次数”和“取内存次数”的比值定义为 compute-to-globalmemory-access ratio (CGMA),对于上面例子有:
浮点运算次数访问次数CGMA=浮点运算次数Global Memory 访问次数=11=1.0

假设 Global memory 的访问速度是 1000 GB/s(即 1 TB/s),考虑单精度浮点数占用 4 个字节,那么每秒可以加载 1000/4=250 giga 浮点数,也就是说 kernel 每秒处理浮点数不会超过 250 GFLOPS。

设某 GPU 的浮点计算性能为 12 TFLOPS,那么运行上面 kernel 时,仅达到浮点计算能力峰值的 2%,没有充分地利用 GPU。像这种,执行速度的 “瓶颈位于内存访问过程”的程序被称为“memory-bound program”。

后文将介绍如何减少内存的访问次数,以提高程序执行速度。

4.2. 矩阵乘法

下面介绍矩阵 M 和 N 相乘得到结果矩阵 P。

假设每个线程仅计算结果矩阵 P 的一个元素,可以使用下面的 kernel 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__global__
void MatrixMulKernel(float* M, float* N, float* P, int Width) {
// Calculate the row index of the P element and M
int Row = blockIdx.y * blockDim.y + threadIdx.y;
// Calculate the column index of P and N
int Col = blockIdx.x * blockDim.x + threadIdx.x;
if ((Row < Width) && (Col < Width)) {
float Pvalue = 0;
// each thread computes one element of the block sub-matrix
for (int k = 0; k < Width; ++k) {
Pvalue += M[Row*Width+k] * N[k*Width+Col];
}
P[Row*Width+Col] = Pvalue;
}
}

这个 kernel 和节 3.2 介绍的彩色图片转灰度图片的 colorToGreyscaleConversion 基本类似。kernel 中 Row 和 Col 的如图 11 所示。

gpu_matrix_mul.gif

Figure 11: Row 和 Col 的计算

和彩色图片转灰度图片类似,我们也是采用 2D block。假设矩阵为 4 x 4 的,采用 2 x 2 的 block,那么 kernel 执行如 12 图所示。

gpu_matrix_example.gif

Figure 12: MatrixMulKernel 执行示意图

如果仅考虑 block(0,0) 中线程的执行,则如图 13 所示。

gpu_matrix_example_block.png

Figure 13: block(0,0) 中线程的执行

在下面最关键代码中:

1
2
3
for (int k = 0; k < Width; ++k) {
Pvalue += M[Row*Width+k] * N[k*Width+Col];
}

有两次 Global memory 的访问,一次浮点乘法和一次浮点加法。所以上一节介绍的 CGMA 值会为 1,这是一个“memory-bound program”,我们需要想办法减少内存的访问次数。

4.3. CUDA 内存类型

CUDA 设备中有不同的内存类型,可以帮助我们提高 CGMA,以提高程序性能。

CUDA 的内存类型如图 14 所示。

gpu_cuda_memory_types.gif

Figure 14: Overview of the CUDA device memory model

通过表 2 所示语法可以声明程序变量位于哪种内存中。

Variable declaration Memory Scope Lifetime
Automatic variables other than arrays Register Thread Kernel
Automatic array variables Local Thread Kernel
__device__ __shared__ int SharedVar; Shared Block Kernel
__device__ int GlobalVar; Global Grid Application
__device__ __constant__ int ConstVar; Constant Grid Application

4.4. 矩阵相乘优化(Tile 优化)

如何减少矩阵相乘时对 Global memory 的访问呢?我们先看看图 13 的情况。

block(0,0) 中的 4 个线程读取 Global memory 的情况如图 15 所示。可以发现:Global memory 中的数据被读取了多次。

gpu_matrix_block00.png

Figure 15: block(0,0) 线程读取内存的情况

如果同一个 block 中的线程仅从 Global memory 中读取输入矩阵一次,放入到 Shared memory 中,则可以减少对 Global memory 的访问,以提高程序性能。

这种优化被为 Tile 优化。下面是一个采用 Tile 优化的矩阵相乘的 kernel 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
__global__
void MatrixMulKernel(float* d_M, float* d_N, float* d_P,
int Width) {
__shared__ float Mds[TILE_WIDTH][TILE_WIDTH]; // 后面会把 d_M 数据(Global memory)先保存到Shared memory 中
__shared__ float Nds[TILE_WIDTH][TILE_WIDTH]; // 后面会把 d_M 数据(Global memory)先保存到Shared memory 中

int bx = blockIdx.x; int by = blockIdx.y;
int tx = threadIdx.x; int ty = threadIdx.y;
// Identify the row and column of the d_P element to work on
int Row = by * TILE_WIDTH + ty;
int Col = bx * TILE_WIDTH + tx;
float Pvalue = 0;
// Loop over the d_M and d_N tiles required to compute d_P element
for (int ph = 0; ph < Width/TILE_WIDTH; ++ph) {
// Collaborative loading of d_M and d_N tiles into shared memory
Mds[ty][tx] = d_M[Row*Width + ph*TILE_WIDTH + tx];
Nds[ty][tx] = d_N[(ph*TILE_WIDTH + ty)*Width + Col];
__syncthreads(); // 确保当每个线程需要的数据被不同线程加载到 Shared memory 中后,同 block 中的线程才往下执行
for (int k = 0; k < TILE_WIDTH; ++k) {
Pvalue += Mds[ty][k] * Nds[k][tx];
}
__syncthreads(); // 确保当所有线程都执行完上面的计算后,同 block 中的线程才往下执行
}
d_P[Row*Width + Col] = Pvalue;
}

5. Unified Memory

CUDA 6 中引入了 Unified Memory,不用显式地使用 cudaMemcpy 在 Host 和 Device 之间复制内存了,简化了编程步骤,如图 16 所示。

gpu_cuda_6_unified_memory.gif

Figure 16: CUDA 6 Unified Memory

摘自:Unified Memory in CUDA 6

6. 并行计算模式

在《Programming Massively Parallel Processors, 3rd, 2017》一书介绍了一些并行计算模式,如:Convolution、Prefix Sum、Histogram、Sparse Matrix Computation、Merge Sort、Graph Search。

这里不介绍它们,有兴趣的读者可以参考原著。

7. Compute Capability

CUDA 的计算能力 Compute Capability 可以认为是硬件的版本。表 3 列出了不同 Compute Capability 下的一些产品型号。

Compute Capability Micro-architecture GeForce(消费级) Quadro(专业级) Tesla(数据中心) Jetson(嵌入式)
1.0 Tesla GeForce 8800 GTX Quadro FX 5600
2.0 Fermi GeForce GTX 590 Quadro Plex 7000
3.0 Kepler GeForce GTX 770 Quadro K5000 Tesla K10
3.2 Kepler
3.5 Kepler GeForce GTX TITAN Z Quadro K6000 Tesla K40, Tesla K20
3.7 Kepler Tesla K80
5.0 Maxwell GeForce GTX 750 Quadro K1200
5.2 Maxwell GeForce GTX TITAN X Quadro M5000 Tesla M60, Tesla M40
5.3 Maxwell Jetson TX1, Tegra X1
6.0 Pascal Quadro GP100 Tesla P100
6.1 Pascal GeForce GTX 1080 Quadro P6000 Tesla P40, Tesla P4
6.2 Pascal Jetson TX2
7.0 Volta NVIDIA TITAN V Quadro GV100 Tesla V100
7.2 Volta Jetson AGX Xavier
7.5 Turing Geforce RTX 2080 Quadro RTX 8000 Tesla T4
8.0 Ampere

不同 Compute Capability 的区别可以参考:https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#compute-capabilities

8. 开发工具

8.1. nvcc

nvcc 是 CUDA 编程器,在节 2.1 中介绍了它的基本用法。

8.2. nvprof

nvprof 是对 CUDA 程序进行性能瓶颈分析的工具。

下面是使用 nvprof 对矩阵相乘 CUDA 程序进行分析的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
$ nvprof matrixMul
[Matrix Multiply Using CUDA] - Starting...
==27694== NVPROF is profiling process 27694, command: matrixMul
GPU Device 0: "GeForce GT 640M LE" with compute capability 3.0

MatrixA(320,320), MatrixB(640,320)
Computing result using CUDA Kernel...
done
Performance= 35.35 GFlop/s, Time= 3.708 msec, Size= 131072000 Ops, WorkgroupSize= 1024 threads/block
Checking computed result for correctness: OK

Note: For peak performance, please refer to the matrixMulCUBLAS example.
==27694== Profiling application: matrixMul
==27694== Profiling result:
Time(%) Time Calls Avg Min Max Name
99.94% 1.11524s 301 3.7051ms 3.6928ms 3.7174ms void matrixMulCUDA<int=32>(float*, float*, float*, int, int)
0.04% 406.30us 2 203.15us 136.13us 270.18us [CUDA memcpy HtoD]
0.02% 248.29us 1 248.29us 248.29us 248.29us [CUDA memcpy DtoH]

==27964== API calls:
Time(%) Time Calls Avg Min Max Name
49.81% 285.17ms 3 95.055ms 153.32us 284.86ms cudaMalloc
25.95% 148.57ms 1 148.57ms 148.57ms 148.57ms cudaEventSynchronize
22.23% 127.28ms 1 127.28ms 127.28ms 127.28ms cudaDeviceReset
1.33% 7.6314ms 301 25.353us 23.551us 143.98us cudaLaunch
0.25% 1.4343ms 3 478.09us 155.84us 984.38us cudaMemcpy
0.11% 601.45us 1 601.45us 601.45us 601.45us cudaDeviceSynchronize
0.10% 564.48us 1505 375ns 313ns 3.6790us cudaSetupArgument
0.09% 490.44us 76 6.4530us 307ns 221.93us cuDeviceGetAttribute
0.07% 406.61us 3 135.54us 115.07us 169.99us cudaFree
0.02% 143.00us 301 475ns 431ns 2.4370us cudaConfigureCall
0.01% 42.321us 1 42.321us 42.321us 42.321us cuDeviceTotalMem
0.01% 33.655us 1 33.655us 33.655us 33.655us cudaGetDeviceProperties
0.01% 31.900us 1 31.900us 31.900us 31.900us cuDeviceGetName
0.00% 21.874us 2 10.937us 8.5850us 13.289us cudaEventRecord
0.00% 16.513us 2 8.2560us 2.6240us 13.889us cudaEventCreate
0.00% 13.091us 1 13.091us 13.091us 13.091us cudaEventElapsedTime
0.00% 8.1410us 1 8.1410us 8.1410us 8.1410us cudaGetDevice
0.00% 2.6290us 2 1.3140us 509ns 2.1200us cuDeviceGetCount
0.00% 1.9970us 2 998ns 520ns 1.4770us cuDeviceGet

8.3. nvidia-smi

nvidia-smi (NVIDIA System Management Interface) 是管理 NVIDIA GPU 设备的命令行工具。可以监控 GPU 使用情况以及更改 GPU 状态。

下面是 nvidia-smi 的运行例子,输出中 GPU-Util 为 100% 表示 GPU 正在满负载工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ nvidia-smi
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 418.67 Driver Version: 418.67 CUDA Version: 10.1 |
|-------------------------------+----------------------+----------------------+
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
|===============================+======================+======================|
| 0 Tesla P4 On | 00000000:00:08.0 Off | 0 |
| N/A 59C P0 47W / 75W | 1399MiB / 7611MiB | 100% Default |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes: GPU Memory |
| GPU PID Type Process name Usage |
|=============================================================================|
| 0 22589 C ./test 1389MiB |
+-----------------------------------------------------------------------------+

9. 参考

本文主要考虑

什么是rocm?

Radeon Open Computing platform 全套驱动程序,开发工具,API和AMD GPU监控工具的集合。用来支持AMD的GPU以及其他现有的加速器。

CUDA到HIP转码

CUDA与HIP

CUDA是NVIDIA开发的GPU SDK(软件开发框架),主要针对NVIDIA GPU硬件开发,而HIP是AMD开发的GPU SDK,主要是针对AMD GPU硬件开发,同时兼容NVIDIA GPU硬件上的开发。试想AMD为何会如此雄心壮志?其实是无奈之举。显然当今CUDA的生态处于绝对优势(dominant),AMD要想迎头赶上,必须兼容CUDA。如何实现兼容CUDA?答案就是利用HIP。

HIP(Heterogeneous-Computing Interface for Portability)实际上就是构造异构计算的接口,一方面对接AMD HCC(Heterogeneous Compute Compiler),另一方面对接CUDA NVCC。HIP位于HCC和NVCC的上层(或者说在HC和CUDA的上层),HIP的API接口与CUDA API接口类似,但不完全相同。CUDA代码需要通过转码改写为HIP形式才可以在AMD GPU上编译运行,AMD编译环境称为ROCm(Radeon Open Compute Platform),早期使用HCC/HC模式,而今主要发展基于Clang和LLVM开发的编译器,实际上命令行在Clang模式下,hcc就是alias到clang命令。我们都知道Clang+LLVM是一个开源的编译器框架,除了支持C/C++编译,也支持CUDA的编译。AMD将Clang+LLVM进行扩展形成HIP的底层编译器,以支持AMD GPU编译。实际上在ROCm环境,HIP有三种平台模式(通过环境变量HIP_PLATFORM区别):clang、hcc和nvcc。而HIP提供的hipcc命令,实质是一个perl脚本,通过HIP_PLATFORM等环境变量,调用不同的底层编译器,实现统一编译模式。

HIP转码的实现

如果你留意,可以发现ROCm的HIP项目中提供了一个hipify-clang的工具。这个hipify-clang工具是基于Clang编译器的抽象语法树重构引擎机制,实现CUDA到HIP的API函数名和type名的重命名和include头文件名的替换(详见下一节分析),理论上是最可靠的一种代码转换方式。因为字面意思的文本转换难以区分API语义,如分别函数名还是参数名。

hipify-clang从根本上可以解决CUDA到HIP的转码,但不等于说没有困难,困难在于CUDA的版本很多,各版本之间也有不兼容的API问题,而且CUDA少量函数或变量名,在HIP底层并没有实现对应体。

但总的来说,AMD的伙计们还是很给力,不断在更新hipify-clang,也支持最新CUDA 10.1的API转换。基于hipify-clang工具还可以生成perl转码的map文件或python转码的map文件,这里的map文件实质就是转码函数或变量名的映射代码行。一般hipify-clang是随着ROCm环境一起安装的,没法及时更新。导致hipify-clang的新功能没法应用。

HIP项目的bin目录中提供了一个名为hipify-perl的可执行的脚本,借助perl语言定义了CUDA到HIP转码的主体框架以及转换名称的map内容,这个map内容实际上是由hipify-clang工具生成。更新了hipify-clang工具,也应该更新hipify-perl脚本。但hipify-clang工具需要Clang+LLVM的SDK环境,这是一个较复杂的开发软件环境,一般用户难以驾驭,导致编译hipify-clang有困难。不过,本项目中直接提供了最新的hipify-perl脚本。

hipify-clang代码简介

hipify-clang作为HIP的一个子模块而存在,官方代码文件见 https://github.com/ROCm-Developer-Tools/HIP/tree/master/hipify-clang ,理解其需要一些Clang和LLVM知识背景。相关代码文件简介如下:

  • main.cpp 入口函数main的定义文件。
    首先完成命令行参数解析,支持Perl和Python的map导出(见其中的generatePerl和generatePython两个函数),对每个输入待转码的文件,会创建RefactoringTool和actionFactory对象,并填充相应的Clang RefactoringTool的工作参数,最终构建出Clang refactoring的基本框架,核心在于执行Tool.runAndSave(&actionFactory)启动整个重构的工作流程,其中会调用重载的HipifyAction类中定义的转码函数。

  • ArgParse.cpp/.h 定义命令行参数的解析。
    在main函数中被调用。

  • ReplacementsFrontendActionFactory.h 定义一个基于clang::tooling::FrontendActionFactory的工厂类。
    main中实例化为对象actionFactory,供Tool.runAndSave函数调用。

  • LLVMCompat.cpp/.h 新建了命令空间llcompat和定义版本兼容函数。
    其中定义兼容不同版本的各类函数,包括SourceLocation的begin和end定位函数、getReplacements函数、insertReplacement函数和EnterPreprocessorTokenStream函数等等。

  • CUDA2HIP.cpp/.h 定义转码映射关系对象。
    定义了两个std::map<llvm::StringRef, hipCounter>类型的数据对象CUDA_RENAMES_MAP和CUDA_INCLUDE_MAP。在CUDA到HIP转码时,函数名和type名的转码映射关系定义在CUDA_RENAMES_MAP中,它们又由CUDA2HIP_XXX_API_functions.cpp和CUDA2HIP_XXX_API_types.cpp中定义的子类map组合而来。
    头文件名替换映射关系定义在CUDA_INCLUDE_MAP中。

  • HipifyAction.cpp/.h 定义了HipifyAction类。
    HipifyAction类继承了clang::ASTFrontendActionclang::ast_matchers::MatchFinder::MatchCallback接口,实现基于Clang前端解析重命名机制的行为。这里是实现转码的重心之处。函数名和type名转码的重命名操作在RewriteToken函数中完成。HipifyAction的关键函数体结构为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void HipifyAction::ExecuteAction() { //重载ASTFrontendAction的接口函数
while (RawTok.isNot(clang::tok::eof)) {
RewriteToken(RawTok); //调用自定义函数,执行CUDA_RENAMES_MAP替换。
RawLex.LexFromRawLexer(RawTok);
}
// Register yourself as the preprocessor callback, by proxy.
// 自定义预处理阶段的回调函数,跳转调用hipifyAction的InclusionDirective和PragmaDirective函数
// InclusionDirective函数完成CUDA_INCLUDE_MAP替换。
PP.addPPCallbacks(std::unique_ptr<PPCallbackProxy>(new PPCallbackProxy(*this)));
// Now we're done futzing with the lexer, have the subclass proceeed with Sema and AST matching.
clang::ASTFrontendAction::ExecuteAction();//完成基类的操作
}

void HipifyAction::run(const clang::ast_matchers::MatchFinder::MatchResult& Result) {//重载MatchCallback的接口函数
if (cudaLaunchKernel(Result)) return; //调用自定义函数
if (cudaSharedIncompleteArrayVar(Result)) return;//调用自定义函数
}

其中cudaLaunchKernel实现CUDA kernel<<<*>>> 函数的替换。cudaSharedIncompleteArrayVar实现 CUDA __shared__变量定义的重构,即添加HIP_DYNAMIC_SHARED宏包装。

  • Statistics.cpp/.h 定义转码统计类,按子类型计数,便于最后输出统计结果。
  • StringUitils.cpp/.h 定义String辅助操作的类。

另外在HIP项目的tests目录,有hipify-clang的单元测试文件,可以作为hipify-clang和hipify-perl的测试输入文件。如

  • tests/hipify-clang/unit_tests/headers/headers_test_10.cu
  • tests/hipify-clang/unit_tests/headers/headers_test_11.cu
  • tests/hipify-clang/unit_tests/libraries/cuRAND/poisson_api_example.cu

hipify-perl程序简介

hipify-perl是HIP项目提供的一个CUDA到HIP转码的perl脚本,官方代码文件见 https://github.com/ROCm-Developer-Tools/HIP/blob/master/bin/hipify-perl ,本质上是基于文本字符串替换方式进行CUDA到HIP转码的关键字替换,包括类型名和函数名等替换。hipify-perl中的关键字替换的map可以从hipify-clang导出,hipify-perl提供了一个转码的框架。

使用说明

本项目中,主要文件简介:

  • hipify-perl
    基于hipify-clang最新map内容的版本
  • hipify-cmakefile
    处理cmake文件(如CMakeList.txt)转码的脚本
  • cuda2hip.sh
    调用hipify-perl实现文件夹的转码
  • cuda2hip.sed
    供sed调用的脚本文件,补充hipify-perl没有实现的关键字转码
  • cuda2hipsed.sh
    调用hipify-perl和sed脚本实现文件夹的转码

CUDA到HIP转码通常基于hipify-clang或hipify-perl。

  • 直接使用hipify-clang进行代码转换,理论上hipify-clang是最准确的转码方式,但是它基于编译过程,对软件编译头文件有强烈依赖,容易导致编译过程中断,对转码产生一定影响。
  • 还有一种折中的办法,是使用hipify-clang的输出map更新hipify-perl脚本。先用hipify-perl脚本进行主体转换,再用cuda2hip.sed脚本补充转换。应用这两个脚本转换之后,转码成功率相对高些。

hipify-clang

1
2
./hipify-clang --help
./hipify-clang --cuda-path=/usr/local/cuda-10.0 -I /usr/local/cuda-10.0/samples/common/inc lib/*.cu

hipify-clang是基于Clang+LLVM SDK编译的二进制可执行文件。需要在Clang+LLVM的环境下编译获得,这个环境可以是LLVM官方版本,也可以是ROCm下LLVM分支版本(主要使用Clang前端API区别不大)。这里的CUDA头文件版本,需要与编译Clang时的一致,-I指定编译过程中搜索的include头文件目录,可能需要指定多个路径,便于hipify-clang对代码的扫描-编译-转码过程顺利通过。

hipify-perl

1
./hipify-perl <file>

<file>为待转换的CUDA代码文件名。程序在转码之后会检验代码是否还包含cuda、cublas和curand等字眼,如果存在则给出警告(warning)提示,这些警告需要我们确认是否需要转码。

cuda2hip.sh

1
./cuda2hip.sh <dir>

调用hipify-perl脚本进行文件夹内所有代码转换。默认通配*.c**.h**.inl文件(下同)。
<dir>为待转换的CUDA代码所在目录名,可以使用空格隔空,输入多个文件目录名。

cuda2hip.sed

  • 第一种使用方式
1
./cuda2hip.sed <files>

<files>为待转换的CUDA代码文件名,可使用Shell通配符。
结果输出到标准输出端。

  • 第二种使用方式
1
sed -i -f cuda2hip.sed <files>

<files>为待转换的CUDA代码文件名,可使用Shell通配符。-i表示in-place替换。

  • 第三种使用方式
1
find . -type f -name *.c* -o -name *.h* -o -name *.inl |xargs sed -i -f cuda2hip.sed

这里借助find查找C/C++和CUDA代码文件,对每个查找到的文件调用cuda2hip.sed进行转码。

cuda2hipsed.sh

1
./cuda2hipsed.sh <dir>

调用hipify-perl和cuda2hip.sed脚本进行文件夹内所有代码转换。默认通配*.c**.h**.inl文件。<dir>为待转换的CUDA代码所在目录名,可以使用空格输入多个文件目录。

Getting Started with HIP API

HIP API Overview

HIP API包括hipMalloc、hipMemcpy和hipFree等函数。熟悉CUDA的程序员也将能够快速学习并开始使用HIPAPI进行编码。计算内核通过“hipLaunchKernel”宏调用启动。

HIP API Examples

Example 1

下面是一个显示HIP API代码片段的示例:

1
2
3
4
5
6
7
8
9
hipMalloc(&A_d, Nbytes));
hipMalloc(&C_d, Nbytes));
hipMemcpy(A_d, A_h, Nbytes, hipMemcpyHostToDevice);
const unsigned blocks = 512;
const unsigned threadsPerBlock = 256;
hipLaunchKernel(vector_square, /* compute kernel*/
dim3(blocks), dim3(threadsPerBlock), 0/*dynamic shared*/, 0/*stream*/, /*launch config*/
C_d, A_d, N); /* arguments to the compute kernel */
hipMemcpy(C_h, C_d, Nbytes, hipMemcpyDeviceToHost);

HIP内核语言定义了用于确定网格和块坐标、数学函数、短向量、原子和计时器函数的内置函数。它还为函数类型、地址空间和优化控件指定了其他定义和关键字。有关详细说明。

Example 2

下面是一个定义简单“vector_square”内核的示例。

1
2
3
4
5
6
7
8
9
10
template <typename T>
__global__ void
vector_square(T *C_d, const T *A_d, size_t N)
{
size_t offset = (blockIdx.x * blockDim.x + threadIdx.x);
size_t stride = blockDim.x * gridDim.x;
for (size_t i=offset; i<N; i+=stride) {
C_d[i] = A_d[i] * A_d[i];
}
}

HIP运行时API代码和计算内核定义可以存在于同一源文件中——HIP负责适当地生成主机和设备代码。

Introduction to Memory Allocation

Host Memory

hipHostMalloc分配被映射到系统中所有GPU的地址空间的固定主机内存。此主机内存有两种使用情况:

  • 更快的HostToDevice和DeviceToHost数据传输:运行时跟踪hipHostMalloc分配,可以避免常规未固定内存所需的某些设置。要在特定系统上进行精确测量,请尝试使用hipBusBandwidth工具的–unpinted和–pinted开关。
  • 零拷贝GPU访问:GPU可以通过CPU/GPU互连直接访问主机内存,无需复制数据。这避免了复制的需要,但在内核访问期间,每次内存访问都必须遍历互连,这可能比访问GPU的本地设备内存慢几十倍。当内存访问不频繁(可能只有一次)时,零拷贝内存可能是一个不错的选择。零拷贝内存通常是“一致”的,因此不会被GPU缓存,但如果需要,这可以被覆盖。

Memory allocation flags

hipHostMalloc始终设置hipHostMalocPortable和hipHostMallocMapped标志。上述两种使用模型使用相同的分配标志,不同之处在于周围代码如何使用主机内存。

hipHostMallocNumaUser是允许主机内存分配遵循用户设置的NUMA策略的标志。

NUMA-aware host memory allocation

非统一内存体系结构(NUMA)策略确定如何分配内存,并选择最接近每个GPU的CPU。

NUMA还测量GPU和CPU设备之间的距离。默认情况下,每个GPU选择一个Numa CPU节点,该节点之间的Numa距离最小;主机存储器被自动分配为最接近当前GPU设备的NUMA节点的存储器池。

注意,使用不同GPU的hipSetDevice API可以访问主机分配。然而,它可能具有更长的NUMA距离。

Managed memory allocation

HIP现在支持并自动管理异构内存管理(HMM)分配。HIP应用程序在进行托管内存API调用hipMallocManaged之前执行功能检查。

例如

1
2
3
4
5
6
7
8
9
10
int managed_memory = 0;
HIPCHECK(hipDeviceGetAttribute(&managed_memory, hipDeviceAttributeManagedMemory,p_gpuDevice));
if (!managed_memory )` | {
printf ("info: managed memory access not supported on the device %d\n Skipped\n", p_gpuDevice);
}
else {
HIPCHECK(hipSetDevice(p_gpuDevice));
HIPCHECK(hipMallocManaged(&Hmm, N * sizeof(T)));
. . .
}

HIP Stream Memory Operations

HIP支持流内存操作,以实现网络节点和GPU之间的直接同步。添加了以下API:

  • hipStreamWaitValue32
  • hipStreamWaitValue64
  • hipStreamWriteValue32
  • hipStreamWriteValue64

Coherency Controls

ROCm为主机内存定义了两个一致性选项:

  • 一致性内存:支持内核运行时的细粒度同步。例如,内核可以执行主机CPU或其他(对等)GPU可见的原子操作。同步指令包括threadfence_system和C++11风格的原子操作。然而,一致性存储器不能被GPU缓存,因此可能具有较低的性能。
  • 非一致性内存:可由GPU缓存,但无法在内核运行时支持同步。非一致性内存可以选择性地仅在命令(内核结束或复制命令)边界处同步。当不需要细粒度同步时,此内存适用于高性能访问。

HIP为开发人员提供控件,通过传递给hipHostMalloc的分配标志和HIP_HOST_COHERENT环境变量来选择使用哪种类型的内存。默认情况下,环境变量HIP_HOST_CONTENT在HIP中设置为0。HIP当前版本中的控制逻辑如下:

  • 没有传递任何标志:主机内存分配是一致的,HIP_host_coherent环境变量被忽略。
  • hipHostMallocCoherent=1:主机内存分配将是一致的,HIP_host_coherent环境变量将被忽略。
  • hipHostMallocMapped=1:主机内存分配将是一致的,HIP_host_CONTENT环境变量将被忽略。
  • hipHostMallocNonCoherent=1,hipHostMalocCoherent=0,hipHostMallocMapped=0:主机内存将是非一致的,HIP_host_CONTENT环境变量被忽略。
  • hipHostMallocCoherent=0,hipHostMalocNonCoherent=0,hipHostMallocMapped=0,但设置了其他HostMalloc标志之一:
    • 如果HIP_HOST_COHERENT定义为1,则主机内存分配是一致的。
    • 如果未定义HIP_HOST_COHERENT,或定义为0,则主机内存分配是非一致的。
    • hipHostMallocCoherent=1,hipHostMalocNonCoherent=1:非法。

Visibility of Zero-Copy Host Memory

​ 下表描述了一致和非一致主机内存可见性。注意,一致主机内存在同步点自动可见。

HIP API Synchronization Effect Fence Coherent Host Memory Visibility Non-Coherent Host Memory Visibility
hipStreamSynchronize 主机等待指定流中的所有命令完成 system-scope release yes yes
hipDeviceSynchronize 主机等待指定设备上所有流中的所有命令完成 system-scope release yes yes
hipEventSynchronize 主机等待指定的事件完成 device-scope release yes depends - see the description below
hipStreamWaitEvent 流等待指定的事件完成 none yes no

hipEventSynchronize

开发人员可以控制hipEvents的发布范围。默认情况下,GPU对每个记录的事件执行设备范围获取和释放操作。这将使主机和设备内存对在同一设备上执行的其他命令可见。

当使用hipEventCreateWithFlags创建事件时,可以指定更强的系统级围栏。

  • hipEventReleaseToSystem:在记录事件时执行系统范围释放操作。这将使一致和非一致主机内存对系统中的其他代理可见,但可能涉及诸如缓存刷新之类的重量级操作。一致内存通常在内核同步机制中使用较轻的权重,例如原子操作,因此不需要使用hipEventReleaseToSystem。
  • hipEventDisableTiming:使用此标志创建的事件不会记录分析数据,因此,如果用于同步,将提供最佳性能。

注意:对于使用hipExtLaunchKernelGGL/hipExtLaunchKernel的内核调度中的HIP事件,API中传递的事件不会被显式记录,只能用于获取特定启动的经过时间。

例如,如果在多个分派中使用事件,来自不同hipExtLaunchKernelGGL/hipExtLaunchKernel调用的开始和停止事件将被视为无效的未记录事件,并且HIP显示来自hipEventElapsedTime的错误“hipErrorInvalidHandle”。

一致主机内存是默认的,也是最容易使用的,因为CPU在特定的同步点可以看到内存。该内存允许内核内同步命令(如threadfence_system)透明地工作。HIP/ROCm还支持GPU中使用“非一致”主机内存分配的缓存主机内存。这可以提高性能,但必须注意使用正确的同步。

Direct Dispatch

默认情况下,直接调度在HIP运行时启用。利用这一特性,传统的生产者-消费者模型不再适用,其中运行时为每个HIP流创建一个工作线程(消费者),而主机线程(生产者)将命令排入命令队列(每个流)。

对于直接调度,在调度和某些同步的情况下,运行时将直接将数据包排队到AQL队列(用户模式队列到GPU)。这显示了HIP调度API的总延迟和在GPU上启动第一波的延迟。

此外,随着线程调度延迟和原子/锁同步延迟的减少,在运行时消除线程减少了分派数量的差异。

可以通过设置以下环境变量AMD_DIRECT_DISPATCH=0禁用此功能

HIP Runtime Compilation

HIP支持运行时编译(hipRTC),与其他API相比,通过常规离线静态编译,hipRTC的使用将提供优化和性能改进的可能性。

hipRTC API接受字符串格式的HIP源文件作为输入参数,并通过编译HIP源代码文件来创建程序句柄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
#include <test_common.h>

#include <hip/hiprtc.h>
#include <hip/hip_runtime.h>

#include <cassert>
#include <cstddef>
#include <memory>
#include <iostream>
#include <iterator>
#include <vector>

static constexpr auto NUM_THREADS{128};
static constexpr auto NUM_BLOCKS{32};

static constexpr auto saxpy{
R"(
#include "test_header.h"
#include "test_header1.h"
extern "C"
__global__
void saxpy(real a, realptr x, realptr y, realptr out, size_t n)
{
size_t tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid < n) {
out[tid] = a * x[tid] + y[tid] ;
}
}
)"};

int main()
{
using namespace std;

hiprtcProgram prog;
int num_headers = 2;
std::vector<const char*> header_names;
std::vector<const char*> header_sources;
header_names.push_back("test_header.h");
header_names.push_back("test_header1.h");
header_sources.push_back("#ifndef HIPRTC_TEST_HEADER_H\n#define HIPRTC_TEST_HEADER_H\ntypedef float real;\n#endif //HIPRTC_TEST_HEADER_H\n");
header_sources.push_back("#ifndef HIPRTC_TEST_HEADER1_H\n#define HIPRTC_TEST_HEADER1_H\ntypedef float* realptr;\n#endif //HIPRTC_TEST_HEADER1_H\n");
hiprtcCreateProgram(&prog, // prog
saxpy, // buffer
"saxpy.cu", // name
num_headers, // numHeaders
&header_sources[0], // headers
&header_names[0]); // includeNames

hipDeviceProp_t props;
int device = 0;
hipGetDeviceProperties(&props, device);
std::string sarg = std::string("--gpu-architecture=") + props.gcnArchName;
const char* options[] = {
sarg.c_str()
};

hiprtcResult compileResult{hiprtcCompileProgram(prog, 1, options)};

size_t logSize;
hiprtcGetProgramLogSize(prog, &logSize);

if (logSize) {
string log(logSize, '\0');
hiprtcGetProgramLog(prog, &log[0]);

cout << log << '\n';
}

if (compileResult != HIPRTC_SUCCESS) { failed("Compilation failed."); }

size_t codeSize;
hiprtcGetCodeSize(prog, &codeSize);

vector<char> code(codeSize);
hiprtcGetCode(prog, code.data());

hiprtcDestroyProgram(&prog);

hipModule_t module;
hipFunction_t kernel;
hipModuleLoadData(&module, code.data());
hipModuleGetFunction(&kernel, module, "saxpy");

size_t n = NUM_THREADS * NUM_BLOCKS;
size_t bufferSize = n * sizeof(float);

float a = 5.1f;
unique_ptr<float[]> hX{new float[n]};
unique_ptr<float[]> hY{new float[n]};
unique_ptr<float[]> hOut{new float[n]};

for (size_t i = 0; i < n; ++i) {
hX[i] = static_cast<float>(i);
hY[i] = static_cast<float>(i * 2);
}

hipDeviceptr_t dX, dY, dOut;
hipMalloc(&dX, bufferSize);
hipMalloc(&dY, bufferSize);
hipMalloc(&dOut, bufferSize);
hipMemcpyHtoD(dX, hX.get(), bufferSize);
hipMemcpyHtoD(dY, hY.get(), bufferSize);

struct {
float a_;
hipDeviceptr_t b_;
hipDeviceptr_t c_;
hipDeviceptr_t d_;
size_t e_;
} args{a, dX, dY, dOut, n};

auto size = sizeof(args);
void* config[] = {HIP_LAUNCH_PARAM_BUFFER_POINTER, &args,
HIP_LAUNCH_PARAM_BUFFER_SIZE, &size,
HIP_LAUNCH_PARAM_END};

hipModuleLaunchKernel(kernel, NUM_BLOCKS, 1, 1, NUM_THREADS, 1, 1,
0, nullptr, nullptr, config);
hipMemcpyDtoH(hOut.get(), dOut, bufferSize);

for (size_t i = 0; i < n; ++i) {
if (fabs(a * hX[i] + hY[i] - hOut[i]) > fabs(hOut[i])* 1e-6) { failed("Validation failed."); }
}

hipFree(dX);
hipFree(dY);
hipFree(dOut);

hipModuleUnload(module);

passed();
}

该示例显示了如何使用运行时编译机制对HIP应用程序进行编程。

Use of Long Double Type

在HIP-Clang中,长双精度类型是x86_64的80位扩展精度格式,AMD GPU不支持这种格式。HIP-Clang将长双类型视为AMD GPU的IEEE双类型。只要长双类型的数据不在主机和设备之间传输,在HIP源代码中使用长双类型不会导致问题。但是,长双精度类型不应用作内核参数类型。

FMA and Contractions

默认情况下,HIP Clang假设-ffp-contract=fast。对于x86_64,FMA默认关闭,因为通用x86_64目标默认不支持FMA。要在x86_64上打开FMA,请在CPU支持的FMA上使用-mfma或-march=native。当启用收缩且CPU未启用FMA指令时,GPU可以为可收缩的表达式生成与CPU不同的数值结果。

Use of _Float16 Type

如果在x86_64的Clang(或hipcc)和gcc之间使用宿主函数,则其定义由一个编译器编译,但由不同的编译器编译调用方,_Float16或包含Float16的聚合不能用作函数参数或返回类型。这是因为x86_64上的_Float16缺少稳定的ABI。在clang和gcc之间传递_Float16或包含_Float6的聚合可能会导致未定义的行为。

Math Functions with Special Rounding Modes

HIP不支持舍入模式为ru(向上舍入)、rd(向下舍入)和rz(向零舍入)的数学函数。HIP仅支持舍入模式为rn(舍入到最近值)的数学函数。带有后缀_ru_rd_rz的数学函数的实现方式与带有后缀_rn的数学函数相同。它们是一种变通方法,可以让程序使用它们进行编译。

Creating Static Libraries

HIP Clang支持生成两种类型的静态库。

  • 第一类静态库不导出设备功能,仅导出和启动同一库中的主机功能。这种类型的优点是能够与非hipcc编译器(如gcc)链接。
  • 第二种类型导出设备功能,以便由其他代码对象链接。然而,这需要使用hipcc作为链接器。此外,第一类库包含主机对象,其中设备代码嵌入为胖二进制文件。它是使用标志–emit-static lib生成的。第二类库包含可重定位的设备对象,并使用ar生成。

以下是创建和使用静态库的示例:

Type 1 using –emit-static-lib:

1
2
hipcc hipOptLibrary.cpp --emit-static-lib -fPIC -o libHipOptLibrary.a
gcc test.cpp -L. -lhipOptLibrary -L/path/to/hip/lib -lamdhip64 -o test.out

Type 2 using system ar:

1
2
3
hipcc hipDevice.cpp -c -fgpu-rdc -o hipDevice.o
ar rcsD libHipDevice.a hipDevice.o
hipcc libHipDevice.a test.cpp -fgpu-rdc -o test.out

HIP Kernel Language

HIP提供了一种C++语法,适用于编译通常出现在计算内核中的大多数代码,包括类、名称空间、运算符重载、模板等。此外,它还定义了专门针对加速器设计的其他语言功能,例如以下内容:

  • 使用标准C++的内核启动语法,类似于函数调用,可移植到所有HIP目标
  • 可用于主机或设备的短矢量标头
  • 类似于标准C++编译器中包含的“Math.h”标头中的数学函数
  • 用于访问特定GPU硬件功能的内置功能

本节描述了可以从HIP内核访问的内置变量和函数。它面向熟悉CUDA内核语法并希望了解HIP的不同之处的读者。

Function-Type Qualifiers

__device__:在设备上运行,只被设备调用。

__global__:在设备上执行,从主机调用。必须是void返回类型。

__host__:在主机上调用并执行。__host__可以与__device__组合,在这种情况下,函数同时为主机和设备编译。这些函数不能使用HIP网格坐标函数。例如,“threadIdx_x”。一种可能的解决方法是将必要的坐标信息作为参数传递给函数。__host__不能与__global__组合。

HIP解析__noinline____forceinline__关键字,并将它们转换为相应的Clang属性。

Calling global Functions

__global__函数通常称为内核,调用一个函数称为启动内核。这些函数要求调用者指定包含网格和块维度的“执行配置”。执行配置还可以包括用于启动的其他信息,例如要分配的额外共享内存量以及内核应该执行的流。HIP除了Cuda<<<>>>语法之外,还引入了一个标准的C++调用约定,将执行配置传递给内核。

  • 在HIP中,内核使用<<<>>>语法或“hipLaunchKernel”函数启动。
  • hipLaunchKernel的前五个参数如下:
    • symbol kernelName:要启动的内核的名称。要支持包含“,”的模板内核,请使用HIP_KERNEL_NAME宏。hipify工具自动地插入这个宏
    • dim3 gridDim:指定要启动的块数的三维网格尺寸。
    • dim3 blockDim:指定每个块中线程数的3D块尺寸。
    • size_t dynamicShared:启动内核时要分配的额外共享内存量(请参阅shared)
    • hipStream_t:内核应该执行的流。值0对应于NULL流(请参阅同步函数)。
  • 内核参数必须遵循五个参数
1
2
3
4
5
6
7
8
// Example pseudo code introducing hipLaunchKernel:
__global__ MyKernel(hipLaunchParm lp, float *A, float *B, float *C, size_t N)
{
...
}
MyKernel<<<dim3(gridDim), dim3(groupDim), 0, 0>>> (a,b,c,n);
// Alternatively, kernel can be launched by
// hipLaunchKernel(MyKernel, dim3(gridDim), dim3(groupDim), 0/*dynamicShared*/, 0/*stream), a, b, c, n);

hipLaunchKernel宏始终以上面指定的五个参数开头,后跟内核参数。HIPIFY工具可以选择将CUDA启动语法转换为hipLaunchKernel,包括将<<<>>>中的可选参数转换为五个所需的hipLaunchKer参数。dim3构造函数接受零到三个参数,默认情况下将未指定的维度初始化为1。见dim3。内核使用坐标内置(线程、块、网格)来确定当前正在执行的工作项的坐标索引和坐标边界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Example showing device function, __device__ __host__
// <- compile for both device and host
float PlusOne(float x)
{
return x + 1.0;
}
__global__
void MyKernel (const float *a, const float *b, float *c, unsigned N)
{
unsigned gid = threadIdx.x; // <- coordinate index function
if (gid < N) {
c[gid] = a[gid] + PlusOne(b[gid]);
}
}
void callMyKernel()
{
float *a, *b, *c; // initialization not shown...
unsigned N = 1000000;
const unsigned blockSize = 256;
MyKernel<<<dim3(gridDim), dim3(groupDim), 0, 0>>> (a,b,c,n);
// Alternatively, kernel can be launched by
// hipLaunchKernel(MyKernel, dim3(N/blockSize), dim3(blockSize), 0, 0, a,b,c,N);
}

Variable-Type Qualifiers

constant

目前支持__constant__关键字,主机在启动内核之前先写常量内存,在内核运行时这块内存对GPU而言是只读的。获取常量内存的函数主要有hipGetSymbolAddress(), hipGetSymbolSize(),
hipMemcpyToSymbol(), hipMemcpyToSymbolAsync(), hipMemcpyFromSymbol(),
hipMemcpyFromSymbolAsync()。

shared

extern __shared__允许主机动态分配共享内存,并指定为启动参数。

以前,为了准确起见,必须使用HIP_dynamic_shared宏声明动态共享内存,因为在同一内核中使用静态共享内存可能会导致内存范围重叠和数据竞争。

现在,HIPClang编译器支持外部共享声明,不再需要HIP_DYNAMIC_shared选项。

managed

HIP组合主机/设备编译中支持托管内存(__managed__关键字除外)。这个关键字的支持正在开发。

restrict

__restrict__关键字告诉编译器,关联的内存指针不会与内核或函数中的任何其他指针别名。此功能可以帮助编译器生成更好的代码。

在大多数情况下,所有指针参数都必须使用此关键字来实现好处。

Built-In Variables

Coordinate Built-Ins

这些内建的变量表明了运行中的grid的工作线程坐标。在hip_runtime.h中定义,而不是被编译器隐式定义。

HIP Syntax CUDA Syntax
threadIdx.x threadIdx.x
threadIdx.y threadIdx.y
threadIdx.z threadIdx.z
blockIdx.x blockIdx.x
blockIdx.y blockIdx.y
blockIdx.z blockIdx.z
blockDim.x blockDim.x
blockDim.y blockDim.y
blockDim.z blockDim.z
gridDim.x gridDim.x
gridDim.y gridDim.y
gridDim.z gridDim.z

warpSize

warpSize变量的类型为int,包含目标设备的warp大小(以线程为单位)。

注意,所有当前的Nvidia设备返回32作为该变量,所有当前AMD设备返回64。设备代码应使用内置的warpSize来开发便携式波形感知代码。

Vector Types

请注意,这些类型是在hip_runtime.h中定义的,编译器不会自动提供。

Short Vector Types

短向量类型派生自基本整数和浮点类型。它们是在hip_vector_types.h中定义的结构。向量的第一、第二、第三和第四个分量分别通过x、y、z和w字段访问。所有短向量类型都支持make_<type_name>()形式的构造函数。例如,float4 make_float4(float x, float y, float z, float w)创建float4类型和值(x, y, z, w)的向量。

HIP支持以下短矢量格式:

  • Signed Integers
    • char1, char2, char3, char4
    • short1, short2, short3, short4
    • int1, int2, int3, int4
    • long1, long2, long3, long4
    • longlong1, longlong2, longlong3, longlong4
  • Unsigned Integers
    • uchar1, uchar2, uchar3, uchar4
    • ushort1, ushort2, ushort3, ushort4
    • uint1, uint2, uint3, uint4
    • ulong1, ulong2, ulong3, ulong4
    • ulonglong1, ulonglong2, ulonglong3, ulonglong4
  • Floating Points
    • float1, float2, float3, float4
    • double1, double2, double3, double4

dim3

dim3 是一个三维整型数组,用于指定grid和线程组的维度,未指定的维度会被初始化为1。

1
2
3
4
5
6
typedef struct dim3 {
uint32_t x;
uint32_t y;
uint32_t z;
dim3(uint32_t _x=1, uint32_t _y=1, uint32_t _z=1) : x(_x), y(_y), z(_z) {};
};

Memory-Fence Instructions

HIP支持__threadfence()__threadfence_block()。HIP为HIP-Clang路径下的threadfence_system()提供了一种解决方法。要启用此解决方法,应在启用环境变量HIP_COHERENT_HOST_ALLOC的情况下构建HIP 。

使用了__threadfence_system()的内核需要作如下修改:

  • 内核应该只在细粒度系统内存上运行;它应该与hipHostMalloc()一起分配。
  • 删除分配的细粒度系统内存区域的所有内存。

Synchronization Functions

HIP支持__syncthreads() . __syncthreads_count(int)__syncthreads_and(int)__syncthreads_or(int)正在开发中。

Math Functions

HIP-Clang 支持一系列数学操作,能够在设备处调用。

Single Precision Mathematical Functions

Function use Supported on Host Supported on Device
float acosf ( float x ) Calculate the arc cosine of the input argument.
float acoshf ( float x ) Calculate the nonnegative arc hyperbolic cosine of the input argument.
float asinf ( float x ) Calculate the arc sine of the input argument.
float asinhf ( float x ) Calculate the arc hyperbolic sine of the input argument.
float atan2f ( float y, float x ) Calculate the arc tangent of the ratio of first and second input arguments.
float atanf ( float x ) Calculate the arc tangent of the input argument.
float atanhf ( float x ) Calculate the arc hyperbolic tangent of the input argument.
float cbrtf ( float x ) Calculate the cube root of the input argument.
float ceilf ( float x ) Calculate ceiling of the input argument.
float copysignf ( float x, float y ) Create value with given magnitude, copying sign of second value.
float cosf ( float x ) Calculate the cosine of the input argument.
float coshf ( float x ) Calculate the hyperbolic cosine of the input argument.
float erfcf ( float x ) Calculate the complementary error function of the input argument.
float erff ( float x ) Calculate the error function of the input argument.
float exp10f ( float x ) Calculate the base 10 exponential of the input argument.
float exp2f ( float x ) Calculate the base 2 exponential of the input argument.
float expf ( float x ) Calculate the base e exponential of the input argument.
float expm1f ( float x ) Calculate the base e exponential of the input argument, minus 1.
float fabsf ( float x ) Calculate the absolute value of its argument.
float fdimf ( float x, float y ) Compute the positive difference between x and y.
float floorf ( float x ) Calculate the largest integer less than or equal to x.
float fmaf ( float x, float y, float z ) Compute x × y + z as a single operation.
float fmaxf ( float x, float y ) Determine the maximum numeric value of the arguments.
float fminf ( float x, float y ) Determine the minimum numeric value of the arguments.
float fmodf ( float x, float y ) Calculate the floating-point remainder of x / y.
float frexpf ( float x, int* nptr ) Extract mantissa and exponent of a floating-point value. x
float hypotf ( float x, float y ) Calculate the square root of the sum of squares of two arguments.
int ilogbf ( float x ) Compute the unbiased integer exponent of the argument.
__RETURN_TYPE1 isfinite ( float a ) Determine whether the argument is finite.
__RETURN_TYPE1 isinf ( float a ) Determine whether the argument is infinite.
__RETURN_TYPE1 isnan ( float a ) Determine whether the argument is a NaN.
float ldexpf ( float x, int exp ) Calculate the value of x ⋅ 2exp.
float log10f ( float x ) Calculate the base 10 logarithm of the input argument.
float log1pf ( float x ) Calculate the value of loge( 1 + x ).
float logbf ( float x ) Calculate the floating-point representation of the exponent of the input argument.
float log2f ( float x ) Calculate the base 2 logarithm of the input argument.
float logf ( float x ) Calculate the natural logarithm of the input argument.
float modff ( float x, float* iptr ) Break down the input argument into fractional and integral parts. x
float nanf ( const char* tagp ) Returns “Not a Number” value. x
float nearbyintf ( float x ) Round the input argument to the nearest integer.
float powf ( float x, float y ) Calculate the value of the first argument to the power of the second argument.
float remainderf ( float x, float y ) Compute single-precision floating-point remainder.
float remquof ( float x, float y, int* quo ) Compute single-precision floating-point remainder and part of quotient. x
float roundf ( float x ) Round to nearest integer value in floating-point.
float scalbnf ( float x, int n ) Scale floating-point input by an integer power of two.
__RETURN_TYPE1 signbit ( float a ) Return the sign bit of the input.
void sincosf ( float x, float* sptr, float* cptr ) Calculate the sine and cosine of the first input argument. x
float sinf ( float x ) Calculate the sine of the input argument.
float sinhf ( float x ) Calculate the hyperbolic sine of the input argument.
float sqrtf ( float x ) Calculate the square root of the input argument.
float tanf ( float x ) Calculate the tangent of the input argument.
float tanhf ( float x ) Calculate the hyperbolic tangent of the input argument.
float truncf ( float x ) Truncate input argument to an integral part.
float tgammaf ( float x ) Calculate the gamma function of the input argument.
float erfcinvf ( float y ) Calculate the inverse complementary function of the input argument.
float erfcxf ( float x ) Calculate the scaled complementary error function of the input argument.
float erfinvf ( float y ) Calculate the inverse error function of the input argument.
float fdividef ( float x, float y ) Divide two floating-point values.
float frexpf ( float x, int *nptr ) Extract mantissa and exponent of a floating-point value.
float j0f ( float x ) Calculate the value of the Bessel function of the first kind of order 0 for the input argument.
float j1f ( float x ) Calculate the value of the Bessel function of the first kind of order 1 for the input argument.
float jnf ( int n, float x ) Calculate the value of the Bessel function of the first kind of order n for the input argument.
float lgammaf ( float x ) Calculate the natural logarithm of the absolute value of the gamma function of the input argument.
long long int llrintf ( float x ) Round input to nearest integer value.
long long int llroundf ( float x ) Round to nearest integer value.
long int lrintf ( float x ) Round input to the nearest integer value.
long int lroundf ( float x ) Round to nearest integer value.
float modff ( float x, float *iptr ) Break down the input argument into fractional and integral parts.
float nextafterf ( float x, float y ) Returns next representable single-precision floating-point value after an argument.
float norm3df ( float a, float b, float c ) Calculate the square root of the sum of squares of three coordinates of the argument.
float norm4df ( float a, float b, float c, float d ) Calculate the square root of the sum of squares of four coordinates of the argument.
float normcdff ( float y ) Calculate the standard normal cumulative distribution function.
float normcdfinvf ( float y ) Calculate the inverse of the standard normal cumulative distribution function.
float normf ( int dim, const float *a ) Calculate the square root of the sum of squares of any number of coordinates.
float rcbrtf ( float x ) Calculate the reciprocal cube root function.
float remquof ( float x, float y, int *quo ) Compute single-precision floating-point remainder and part of quotient.
float rhypotf ( float x, float y ) Calculate one over the square root of the sum of squares of two arguments.
float rintf ( float x ) Round input to nearest integer value in floating-point.
float rnorm3df ( float a, float b, float c ) Calculate one over the square root of the sum of squares of three coordinates of the argument.
float rnorm4df ( float a, float b, float c, float d ) Calculate one over the square root of the sum of squares of four coordinates of the argument.
float rnormf ( int dim, const float *a ) Calculate the reciprocal of square root of the sum of squares of any number of coordinates.
float scalblnf ( float x, long int n ) Scale floating-point input by an integer power of two.
void sincosf ( float x, float *sptr, float *cptr ) Calculate the sine and cosine of the first input argument.
void sincospif ( float x, float *sptr, float *cptr ) Calculate the sine and cosine of the first input argument multiplied by PI.
float y0f ( float x ) Calculate the value of the Bessel function of the second kind of order 0 for the input argument.
float y1f ( float x ) Calculate the value of the Bessel function of the second kind of order 1 for the input argument.
float ynf ( int n, float x ) Calculate the value of the Bessel function of the second kind of order n for the input argument.

Double Precision Mathematical Functions

Function use Supported on Host Supported on Device
double acos ( double x ) Calculate the arc cosine of the input argument.
double acosh ( double x ) Calculate the nonnegative arc hyperbolic cosine of the input argument.
double asin ( double x ) Calculate the arc sine of the input argument.
double asinh ( double x ) Calculate the arc hyperbolic sine of the input argument.
double atan ( double x ) Calculate the arc tangent of the input argument.
double atan2 ( double y, double x ) Calculate the arc tangent of the ratio of first and second input arguments.
double atanh ( double x ) Calculate the arc hyperbolic tangent of the input argument.
double cbrt ( double x ) Calculate the cube root of the input argument.
double ceil ( double x ) Calculate ceiling of the input argument.
double copysign ( double x, double y ) Create value with given magnitude, copying sign of second value.
double cos ( double x ) Calculate the cosine of the input argument.
double cosh ( double x ) Calculate the hyperbolic cosine of the input argument.
double erf ( double x ) Calculate the error function of the input argument.
double erfc ( double x ) Calculate the complementary error function of the input argument.
double exp ( double x ) Calculate the base e exponential of the input argument.
double exp10 ( double x ) Calculate the base 10 exponential of the input argument.
double exp2 ( double x ) Calculate the base 2 exponential of the input argument.
double expm1 ( double x ) Calculate the base e exponential of the input argument, minus 1.
double fabs ( double x ) Calculate the absolute value of the input argument.
double fdim ( double x, double y ) Compute the positive difference between x and y.
double floor ( double x ) Calculate the largest integer less than or equal to x.
double fma ( double x, double y, double z ) Compute x × y + z as a single operation.
double fmax ( double , double ) Determine the maximum numeric value of the arguments.
double fmin ( double x, double y ) Determine the minimum numeric value of the arguments.
double fmod ( double x, double y ) Calculate the floating-point remainder of x / y.
double frexp ( double x, int* nptr ) Extract mantissa and exponent of a floating-point value. x
double hypot ( double x, double y ) Calculate the square root of the sum of squares of two arguments.
int ilogb ( double x ) Compute the unbiased integer exponent of the argument.
__RETURN_TYPE1 isfinite ( double a ) Determine whether an argument is finite.
__RETURN_TYPE1 isinf ( double a ) Determine whether an argument is infinite.
__RETURN_TYPE1 isnan ( double a ) Determine whether an argument is a NaN.
double ldexp ( double x, int exp ) Calculate the value of x ⋅ 2exp.
double log ( double x ) Calculate the base e logarithm of the input argument.
double log10 ( double x ) Calculate the base 10 logarithm of the input argument.
double log1p ( double x ) Calculate the value of loge( 1 + x ).
double log2 ( double x ) Calculate the base 2 logarithm of the input argument.
double logb ( double x ) Calculate the floating-point representation of the exponent of the input argument.
double modf ( double x, double* iptr ) Break down the input argument into fractional and integral parts. x
double nan ( const char* tagp ) Returns “Not a Number” value. x
double nearbyint ( double x ) Round the input argument to the nearest integer.
double pow ( double x, double y ) Calculate the value of the first argument to the power of the second argument.
double remainder ( double x, double y ) Compute double-precision floating-point remainder.
double remquo ( double x, double y, int* quo ) Compute double-precision floating-point remainder and part of quotient. x
double round ( double x ) Round to nearest integer value in floating-point.
double scalbn ( double x, int n ) Scale floating-point input by an integer power of two.
__RETURN_TYPE1 signbit ( double a ) Return the sign bit of the input.
double sin ( double x ) Calculate the sine of the input argument.
void sincos ( double x, double* sptr, double* cptr ) Calculate the sine and cosine of the first input argument. x
double sinh ( double x ) Calculate the hyperbolic sine of the input argument.
double sqrt ( double x ) Calculate the square root of the input argument.
double tan ( double x ) Calculate the tangent of the input argument.
double tanh ( double x ) Calculate the hyperbolic tangent of the input argument.
double tgamma ( double x ) Calculate the gamma function of the input argument.
double trunc ( double x ) Truncate input argument to an integral part.
double erfcinv ( double y ) Calculate the inverse complementary function of the input argument.
double erfcx ( double x ) Calculate the scaled complementary error function of the input argument.
double erfinv ( double y ) Calculate the inverse error function of the input argument.
double frexp ( float x, int *nptr ) Extract mantissa and exponent of a floating-point value.
double j0 ( double x ) Calculate the value of the Bessel function of the first kind of order 0 for the input argument.
double j1 ( double x ) Calculate the value of the Bessel function of the first kind of order 1 for the input argument.
double jn ( int n, double x ) Calculate the value of the Bessel function of the first kind of order n for the input argument.
double lgamma ( double x ) Calculate the natural logarithm of the absolute value of the gamma function of the input argument.
long long int llrint ( double x ) Round input to a nearest integer value.
long long int llround ( double x ) Round to nearest integer value.
long int lrint ( double x ) Round input to a nearest integer value.
long int lround ( double x ) Round to nearest integer value.
double modf ( double x, double *iptr ) Break down the input argument into fractional and integral parts.
double nextafter ( double x, double y ) Returns next representable single-precision floating-point value after an argument.
double norm3d ( double a, double b, double c ) Calculate the square root of the sum of squares of three coordinates of the argument.
float norm4d ( double a, double b, double c, double d ) Calculate the square root of the sum of squares of four coordinates of the argument.
double normcdf ( double y ) Calculate the standard normal cumulative distribution function.
double normcdfinv ( double y ) Calculate the inverse of the standard normal cumulative distribution function.
double rcbrt ( double x ) Calculate the reciprocal cube root function.
double remquo ( double x, double y, int *quo ) Compute single-precision floating-point remainder and part of quotient.
double rhypot ( double x, double y ) Calculate one over the square root of the sum of squares of two arguments.
double rint ( double x ) Round input to the nearest integer value in floating-point.
double rnorm3d ( double a, double b, double c ) Calculate one over the square root of the sum of squares of three coordinates of the argument.
double rnorm4d ( double a, double b, double c, double d ) Calculate one over the square root of the sum of squares of four coordinates of the argument.
double rnorm ( int dim, const double *a ) Calculate the reciprocal of the square root of the sum of squares of any number of coordinates.
double scalbln ( double x, long int n ) Scale floating-point input by an integer power of two.
void sincos ( double x, double *sptr, double *cptr ) Calculate the sine and cosine of the first input argument.
void sincospi ( double x, double *sptr, double *cptr ) Calculate the sine and cosine of the first input argument multiplied by PI.
double y0f ( double x ) Calculate the value of the Bessel function of the second kind of order 0 for the input argument.
double y1 ( double x ) Calculate the value of the Bessel function of the second kind of order 1 for the input argument.
double yn ( int n, double x ) Calculate the value of the Bessel function of the second kind of order n for the input argument.

__RETURN_TYPE 取决于编译器,通常在C里是int,在C++里是bool。

Integer Intrinsics

下表列出了支持的整数内部函数。注意,内部函数仅在设备上受支持。

Function use
unsigned int __brev ( unsigned int x ) Reverse the bit order of a 32-bit unsigned integer.
unsigned long long int __brevll (unsigned long long int x ) Reverse the bit order of a 64-bit unsigned integer.
int __clz ( int x ) Return the number of consecutive high-order zero bits in a 32-bit integer.
unsigned int __clz(unsigned int x ) Return the number of consecutive high-order zero bits in 32-bit unsigned integer.
int __clzll ( long long int x ) Count the number of consecutive high-order zero bits in a 64-bit integer.
unsigned int __clzll(long long int x ) Return the number of consecutive high-order zero bits in 64-bit signed integer.
unsigned int __ffs(unsigned int x ) Find the position of least significant bit set to 1 in a 32-bit unsigned integer.1
unsigned int __ffs( int x ) Find the position of least significant bit set to 1 in a 32-bit signed integer.
unsigned int __ffsll(unsigned long long int x ) Find the position of least significant bit set to 1 in a 64-bit unsigned integer.1
unsigned int __ffsll(long long int x ) Find the position of least significant bit set to 1 in a 64 bit signed integer.
unsigned int __popc ( unsigned int x ) Count the number of bits that are set to 1 in a 32-bit integer.
int __popcll ( unsigned long long int x ) Count the number of bits that are set to 1 in a 64-bit integer.
int __mul24 ( int x, int y ) Multiply two 24-bit integers.
unsigned int __umul24 ( unsigned int x, unsigned int y ) Multiply two 24-bit unsigned integers.

__ffs()__ffsll()的HIP-Clang实现包含添加constant+1以生成ffs结果格式的代码。对于这种开销是不可接受的,并且程序员愿意专门针对平台的情况优化,HIP-Clang提供__lastbit_u32_u32__lastbit_u32_u64

Floating-point Intrinsics

下表列出了支持的浮点内部函数。注意,内部函数仅在设备上受支持。

Function use
float __cosf ( float x ) Calculate the fast approximate cosine of the input argument.
float __expf ( float x ) Calculate the fast approximate base e exponential of the input argument.
float __frsqrt_rn ( float x ) Compute 1 / √x in round-to-nearest-even mode.
float __fsqrt_rd ( float x ) Compute √x in round-down mode.
float __fsqrt_rn ( float x ) Compute √x in round-to-nearest-even mode.
float __fsqrt_ru ( float x ) Compute √x in round-up mode.
float __fsqrt_rz ( float x ) Compute √x in round-towards-zero mode.
float __log10f ( float x ) Calculate the fast approximate base 10 logarithm of the input argument.
float __log2f ( float x ) Calculate the fast approximate base 2 logarithm of the input argument.
float __logf ( float x ) Calculate the fast approximate base e logarithm of the input argument.
float __powf ( float x, float y ) Calculate the fast approximate of xy.
float __sinf ( float x ) Calculate the fast approximate sine of the input argument.
float __tanf ( float x ) Calculate the fast approximate tangent of the input argument.
double __dsqrt_rd ( double x ) Compute √x in round-down mode.
double __dsqrt_rn ( double x ) Compute √x in round-to-nearest-even mode.
double __dsqrt_ru ( double x ) Compute √x in round-up mode.
double __dsqrt_rz ( double x ) Compute √x in round-towards-zero mode.

Texture Functions

以下头文件中列出了支持的纹理函数:”texture_functions.h”和”texture_indirect_functions.h” 。

Timer Functions

HIP提供以下内置功能,用于从设备读取高分辨率计时器。

  • clock_t clock()
  • long long int clock64()

返回设备上每个时钟周期递增的计数器值。返回值的差异就是计时间隔。

Atomic Functions

原子函数作为驻留在全局或共享内存中的读-修改-写操作执行。在原子操作期间,没有其他设备或线程可以观察或修改内存位置。如果来自不同设备或线程的多条指令以同一内存位置为目标,指令以未定义的顺序序列化。

HIP添加了以_system为后缀的新API,以支持系统范围的原子操作。例如,atomicAnd 专用于GPU设备,atomicAnd_system将允许开发人员将原子操作扩展到系统范围,从GPU设备扩展到系统中的其他CPU和GPU设备。

HIP支持以下原子操作:

Function Supported in HIP Supported in CUDA
int atomicAdd(int* address, int val)
int atomicAdd_system(int* address, int val)
unsigned int atomicAdd(unsigned int* address,unsigned int val)
unsigned int atomicAdd_system(unsigned int* address, unsigned int val)
unsigned long long atomicAdd(unsigned long long* address,unsigned long long val)
unsigned long long atomicAdd_system(unsigned long long* address, unsigned long long val)
float atomicAdd(float* address, float val)
float atomicAdd_system(float* address, float val)
double atomicAdd(double* address, double val)
double atomicAdd_system(double* address, double val)
int atomicSub(int* address, int val)
int atomicSub_system(int* address, int val)
unsigned int atomicSub(unsigned int* address,unsigned int val)
unsigned int atomicSub_system(unsigned int* address, unsigned int val)
int atomicExch(int* address, int val)
int atomicExch_system(int* address, int val)
unsigned int atomicExch(unsigned int* address,unsigned int val)
unsigned int atomicExch_system(unsigned int* address, unsigned int val)
unsigned long long atomicExch(unsigned long long int* address,unsigned long long int val)
unsigned long long atomicExch_system(unsigned long long* address, unsigned long long val)
unsigned long long atomicExch_system(unsigned long long* address, unsigned long long val)
float atomicExch(float* address, float val)
int atomicMin(int* address, int val)
int atomicMin_system(int* address, int val)
unsigned int atomicMin(unsigned int* address,unsigned int val)
unsigned int atomicMin_system(unsigned int* address, unsigned int val)
unsigned long long atomicMin(unsigned long long* address,unsigned long long val)
int atomicMax(int* address, int val)
int atomicMax_system(int* address, int val)
unsigned int atomicMax(unsigned int* address,unsigned int val)
unsigned int atomicMax_system(unsigned int* address, unsigned int val)
unsigned long long atomicMax(unsigned long long* address,unsigned long long val)
unsigned int atomicInc(unsigned int* address)
unsigned int atomicDec(unsigned int* address)
int atomicCAS(int* address, int compare, int val)
int atomicCAS_system(int* address, int compare, int val)
unsigned int atomicCAS(unsigned int* address,unsigned int compare,unsigned int val)
unsigned int atomicCAS_system(unsigned int* address, unsigned int compare, unsigned int val)
unsigned long long atomicCAS(unsigned long long* address,unsigned long long compare,unsigned long long val)
unsigned long long atomicCAS_system(unsigned long long* address, unsigned long long compare, unsigned long long val)
int atomicAnd(int* address, int val)
int atomicAnd_system(int* address, int val)
unsigned int atomicAnd(unsigned int* address,unsigned int val)
unsigned int atomicAnd_system(unsigned int* address, unsigned int val)
unsigned long long atomicAnd(unsigned long long* address,unsigned long long val)
unsigned long long atomicAnd_system(unsigned long long* address, unsigned long long val)
int atomicOr(int* address, int val)
int atomicOr_system(int* address, int val)
unsigned int atomicOr(unsigned int* address,unsigned int val)
unsigned int atomicOr_system(unsigned int* address, unsigned int val)
unsigned int atomicOr_system(unsigned int* address, unsigned int val)
unsigned long long atomicOr(unsigned long long int* address,unsigned long long val)
unsigned long long atomicOr_system(unsigned long long* address, unsigned long long val)
int atomicXor(int* address, int val)
int atomicXor_system(int* address, int val)
unsigned int atomicXor(unsigned int* address,unsigned int val)
unsigned int atomicXor_system(unsigned int* address, unsigned int val)
unsigned long long atomicXor(unsigned long long* address,unsigned long long val))
unsigned long long atomicXor_system(unsigned long long* address, unsigned long long val)

注意:为了保持浮点/双原子加法函数的向后兼容性,CMake文件中引入了一个新的编译标志__HIP_USE_CMPXCHG_FOR_FP_ATOMICS。默认情况下未设置此编译标志(“0”),因此HIP运行时使用当前的float/double atomicAdd函数。如果使用CMake选项将编译标志设置为1,D__HIP_USE_CMPXCHG_FOR_FP_ATOMICS=1,则旧的浮点/双原子加法函数用于与不支持浮点原子的编译器兼容。有关如何构建HIP运行时的详细信息,请参阅本指南中的HIP安装部分。

开发中的注意事项和功能HIP支持32位整数的原子操作。此外,它还支持原子浮点加法运算。

然而,AMD硬件使用CAS循环实现浮点加法,因此此函数可能无法有效执行。

Warp Cross-Lane Functions

在warp中的所有lane上运行。硬件保证所有warp lane将同步执行,因此不需要额外的同步,指令也不使用共享内存。

注意,英伟达和AMD设备具有不同的warp尺寸,因此代码应使用warpSize内置来查询warp尺寸。CUDA路径中的代码需要仔细审查,以确保其不假定warpSize为32。假设warpSize为32的代码在Warp-64机器上运行,它将仅使用一半的机器资源。

WarpSize 内置应该只能使用在设备函数中,它的值仅取决于GPU的架构。主机函数应该使用hipGetDeviceProperties来获取GPU设备的默认warpSize。

1
2
3
4
cudaDeviceProp props;
cudaGetDeviceProperties(&props, deviceID);
int w = props.warpSize;
// implement portable algorithm based on w (rather than assume 32 or 64)

Warp Vote and Ballot Functions

1
2
3
int __all(int predicate)
int __any(int predicate)
uint64_t __ballot(int predicate)

warp中的线程称为lane,编号从0到warpSize-1。对于这些函数,每个warp lane通道贡献1——比特值,它被有效地广播到warp中的所有lane。每个通道中的32位整型减少为1位值:0(predicate=0)或1(predicate!=0)__any__all提供了其他warp lane贡献的参数的概要视图:

  • __any()如果任何warp lane提供非零谓词,则返回1,否则返回0

  • __all()如果所有其他warp lane贡献非零谓词,则返回1,否则返回0

应用程序可以使用hasWarpVote设备属性或HIP_ARCH_AS_WARP_VOTE编译器定义测试目标平台是否支持任意/所有指令。

__ballot提供包含来自每个通道的1位谓词值的位掩码。结果的第n位包含第n个warp lane贡献的1位。请注意,HIP的__ballot函数支持64位返回值(与32位相比)。从CUDA移植的代码应该支持HIP版本的此指令支持的更大的warp大小。应用程序可以使用hasWarpBallot设备属性或HIP_ARCH_AS_WARP_ballot编译器定义测试目标平台是否支持ballot指令。

Cooperative Groups Functions

协作组是以不同于块的粒度在线程之间形成和通信的机制。CUDA 9中引入了此功能。HIP支持以下内核语言协作组类型或函数。

Function HIP CUDA
void thread_group.sync() ;
unsigned thread_group.size();
unsigned thread_group.thread_rank() ;
bool thread_group.is_valid();
grid_group this_grid();
void grid_group.sync() ;
unsigned grid_group.size() ;
unsigned grid_group.thread_rank() ;
bool grid_group.is_valid();
multi_grid_group this_multi_grid() ;
void multi_grid_group.sync();
unsigned multi_grid_group.size() ;
unsigned multi_grid_group.thread_rank() ;
bool multi_grid_group.is_valid() ;
unsigned multi_grid_group.num_grids() ;
unsigned multi_grid_group.grid_rank();
thread_block this_thread_block() ;
multi_grid_group this_multi_grid() ;
void multi_grid_group.sync();
void thread_block.sync() ;
unsigned thread_block.size() ;
unsigned thread_block.thread_rank() ;
bool thread_block.is_valid() ;
dim3 thread_block.group_index() ;
dim3 thread_block.thread_index()

Warp Matrix Functions

warp矩阵函数允许warp在元素以未指定的方式分布在lane上的小矩阵上协同操作。CUDA 9中引入了此功能。

HIP不支持任何内核语言warp矩阵类型或函数。

Function Supported in HIP Supported in CUDA
void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned lda)
void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned lda, layout_t layout)
void store_matrix_sync(T* mptr, fragment<...> &a, unsigned lda, layout_t layout)
void fill_fragment(fragment<...> &a, const T &value)
void mma_sync(fragment<...> &d, const fragment<...> &a, const fragment<...> &b, const fragment<...> &c , bool sat)

Independent Thread Scheduling

在支持CUDA的某些体系结构中引入的对独立线程调度的硬件支持允许线程彼此独立地进行,并启用以前不允许的经内同步。

HIP不支持这种类型的线程调度。

Assert

assert函数正在开发中,HIP不支持abort调用。

Printf

支持printf函数

Device-Side Dynamic Global Memory Allocation

设备端动态全局内存分配正在开发中。

__launch_bounds__

GPU多处理器有一个固定的资源池(主要是寄存器和共享内存),这些资源由主动运行的warp共享。使用更多资源可以增加内核的IPC,但会减少可用于其他warp的资源,并限制可以同时运行的warp的数量。因此,GPU在资源使用和性能之间有着复杂的关系。

__launchbounds__允许应用程序提供影响生成代码所使用的资源(主要是寄存器)的使用提示。它是必须附加到__global__函数的函数属性:

__global__ void __launch_bounds__ (MAX_THREADS_PER_BLOCK, MIN_WARPS_PER_EU) MyKernel(...) ... MyKernel(...)

launch_bounds支持两个参数:

  • MAX_THREADS_PER_BLOCK-程序员保证内核将以少于MAX_THREADS_PER_BLOCK的线程启动。(在NVCC上,这映射到.mantid PTX指令)。如果未指定launch_bounds,则MAX_THREADS_PER_BLOCK是设备支持的最大块大小(通常为1024或更大)。指定MAX_THREADS_PER_BLOCK小于最大值有效地允许编译器使用比默认无约束编译更多的资源,该编译在启动时支持所有可能的块大小。每个块的线程数是(hipBlockDim_x*hipBlockDim_y*hipBlockDim_z)的乘积。

  • MIN_WARPS_PER_EU—指导编译器最小化资源使用,以便在多处理器上同时激活所请求的warp数。由于活动warp会争夺相同的固定资源池,编译器必须减少每个warp所需的资源(主要是寄存器)。MIN_WARPS_PER_EU是可选的,如果未指定,则默认为1。指定大于默认值1的MIN_WARPS_PER_EU有效地限制了编译器的资源使用。

当使用HIPAPI(例如,hipModuleLaunchKernel())启动内核时,HIP将进行验证,以确保输入内核维度大小不大于指定的launch_bounds。如果AMD_LOG_LEVEL设置为正确的值,则如果超过指定的launch_bounds,HIP将返回启动失败。错误详细信息显示在错误日志消息中,包括内核大小、启动边界和出错内核的名称的启动参数。通常有助于识别断层内核。此外,内核dim大小和启动边界值也有助于调试此类故障。

Compiler Impact

编译器使用这些参数如下:

  • 编译器仅使用提示来管理寄存器使用,不会自动减少共享内存或其他资源。
  • 如果编译器无法生成满足指定启动边界要求的内核,则编译失败。
  • 编译器从MAX_THREADS_PER_BLOCK导出启动时可使用的最大warp/块数。MAX_THREADS_PER_BLOCK的值小于默认值允许编译器使用更大的寄存器池:每个warp使用寄存器,此提示包含启动到小于最大值的warp/块大小。
  • 编译器从MIN_WARPS_PER_EU导出内核可使用的最大寄存器数(以满足所需的同时活动块)。如果MIN_WARPS_PER_EU为1,则内核可以使用多处理器支持的所有寄存器。
  • 编译器通过溢出寄存器(到共享或全局内存)或使用更多指令,确保内核中使用的寄存器小于两个允许的最大值。
  • 编译器可以使用启发式方法来增加寄存器使用量,或者可以简单地避免溢出。MAX_THREADS_PER_BLOCK在这种情况下特别有用,因为它允许编译器使用更多寄存器,并避免编译器限制寄存器使用(可能溢出)以满足启动时从未使用过的大数据块大小的要求。

CU and EU Definitions

计算单元(CU)负责执行一个工作组的wave。它由一个或多个负责执行wave的执行单元(EU)组成。一个EU可以有足够的资源来维持不止一个执行wave的状态。这使得EU可以通过以与CPU上的对称多线程类似的方式在wave之间切换来隐藏延迟。为了适应EU的多个wave,一个wave所使用的资源必须受到限制。限制这样的资源可以允许更大的延迟隐藏,但这可能导致不得不将某些寄存器状态泄漏到内存中。该属性允许高级开发人员调整能够适应EU资源的wave数量。它可以用于确保至少有一个特定的数字适合于隐藏延迟,也可以用于确保不超过某个特定的数量适合于限制缓存抖动。

Porting from CUDA __launch_bounds

CUDA 定义了__launch_bounds,用于去控制占用。

__launch_bounds(MAX_THREADS_PER_BLOCK, MIN_BLOCKS_PER_MULTIPROCESSOR)

第二个参数 __launch_bounds必须被转换为__hip_launch_bounds的格式,它使用warps和执行单元EU,而不是blocks 和multiprocessors

MIN_WARPS_PER_EXECUTION_UNIT = (MIN_BLOCKS_PER_MULTIPROCESSOR * MAX_THREADS_PER_BLOCK) / 32

接口的主要区别在于:

  • Warps(而不是块):开发人员试图告诉编译器控制资源利用率,以保证一定数量的活动Warps/EU用于延迟隐藏。以块为单位指定活动warp似乎隐藏了warp大小的微观结构细节,然而,这会使接口更加混乱,因为开发人员最终需要计算warp的数量以获得所需的控制级别。
  • 执行单元(而非多处理器):使用执行单元而不是多处理器为具有多个执行单元/多处理器的体系结构提供支持。例如,AMD GCN架构每个多处理器有4个执行单元。hipDeviceProps有一个字段executionUnitsPerMultiprocessor。如果需要,可以使用平台特定的编码技术(如#ifdef)为NVCC和HIP Clang平台指定不同的launch_bound

Maxregcount

与nvcc不同,HIP Clang不支持--maxregcount选项。相反,我们鼓励用户使用hip_launch_bounds指令,因为这些参数比寄存器等微架构细节更直观和可移植,而且该指令允许每个内核控制,而不是整个文件。hip_launch_bounds同时适用于hip Clang和nvcc

Register Keyword

register关键字在C++中被弃用,nvcc和HIP Clang都会默默忽略。可以将选项“-Wdeprecated register”传递给编译器警告消息。

Pragma Unroll

支持使用编译时已知的绑定展开。例如:

1
2
3
4
5
6
#pragma unroll 16 /* hint to compiler to unroll next loop by 16 */
for (int i=0; i<16; i++) ...
#pragma unroll 1 /* tell compiler to never unroll the loop */
for (int i=0; i<16; i++) ...
#pragma unroll /* hint to compiler to completely unroll next loop. */
for (int i=0; i<16; i++) ...

In-Line Assembly

支持GCN ISA内联汇编。例如:

asm volatile ("v_mac_f32_e32 %0, %2, %3" : "=v" (out[i]) : "0"(out[i]), "v" (a), "v" (in[i]));

HIP编译器使用asm() 语句将GCN插入内核。使用volatile关键字,以便优化器不得改变volatile操作的数量或相对于其他volatile运算改变其执行顺序。v_mac_f32_e32是GCN指令。有关更多信息,请参阅AMD GCN3 ISA体系结构手册。按顺序排列的各个操作数的索引由%提供,后跟操作数列表中的位置“v”是32位VGPR寄存器的约束代码(针对特定于目标的AMDGPU)。有关更多信息,请参阅AMDGPU支持的约束代码列表。输出约束由“=”前缀指定,如上所示(“=v”)。这表示程序集将写入此操作数,然后该操作数将作为asm表达式的返回值可用。输入约束没有前缀-只有约束代码。约束字符串“0”表示将指定的输出寄存器也用作输入(它是第0个约束)。

C++ Support

以下C++特性不支持:

  • Run-time-type information (RTTI)
  • Virtual functions
  • Try/catch

Kernel Compilation

hipcc现在支持将C++/HIP内核编译为二进制代码对象。

二进制文件的文件格式为“.co”,表示代码对象。以下命令使用“hipcc”构建代码对象。

1
2
3
4
`hipcc --genco --offload-arch=[TARGET GPU] [INPUT FILE] -o [OUTPUT FILE]`
[TARGET GPU] = GPU architecture
[INPUT FILE] = Name of the file containing kernels
[OUTPUT FILE] = Name of the generated code object file

ROCm Code Object Tooling

ROCm编译器生成的代码对象(可执行文件、对象文件和共享对象库)可以使用本节中列出的工具进行检查和提取。

roc-obj

Examples

从一系列可执行文件中抽取对象

1
roc-obj <executable>...

从所有可执行文件中抽取ROCm代码对象,并反汇编:

1
2
roc-obj --disassemble <executable>...
roc-obj -d <executable>...

HIP Logging

HIP提供了日志机制来监控HIP代码运行,根据日志级别和掩码,HIP将为不同的函数类别打印出不同的日志信息。

HIP Logging Level

HIP日志默认关闭,可以通过设置AMD_LOG_LEVEL打开,不同的值定义了不同的日志级别。

1
2
3
4
5
6
7
enum LogLevel {
LOG_NONE = 0,
LOG_ERROR = 1,
LOG_WARNING = 2,
LOG_INFO = 3,
LOG_DEBUG = 4
};

HIP Logging Mask

日志掩码在运行时可以被设置为不同的值以输出不同的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
enum LogMask {
LOG_API = 0x00000001, //!< API call
LOG_CMD = 0x00000002, //!< Kernel and Copy Commands and Barriers
LOG_WAIT = 0x00000004, //!< Synchronization and waiting for commands to finish
LOG_AQL = 0x00000008, //!< Decode and display AQL packets
LOG_QUEUE = 0x00000010, //!< Queue commands and queue contents
LOG_SIG = 0x00000020, //!< Signal creation, allocation, pool
LOG_LOCK = 0x00000040, //!< Locks and thread-safety code.
LOG_KERN = 0x00000080, //!< kernel creations and arguments, etc.
LOG_COPY = 0x00000100, //!< Copy debug
LOG_COPY2 = 0x00000200, //!< Detailed copy debug
LOG_RESOURCE = 0x00000400, //!< Resource allocation, performance-impacting events.
LOG_INIT = 0x00000800, //!< Initialization and shutdown
LOG_MISC = 0x00001000, //!< misc debug, not yet classified
LOG_AQL2 = 0x00002000, //!< Show raw bytes of AQL packet
LOG_CODE = 0x00004000, //!< Show code creation debug
LOG_CMD2 = 0x00008000, //!< More detailed command info, including barrier commands
LOG_LOCATION = 0x00010000, //!< Log message location
LOG_ALWAYS = 0xFFFFFFFF, //!< Log always even mask flag is zero
};

一旦AMD_LOG_LEVEL被设置,日志掩码将被设置为默认的0x7FFFFFFF,同样有一个环境变量AMD_LOG_MASK可以被设置。

HIP Logging Command

为了输出HIP日志信息,函数被定义为:

1
2
3
4
5
6
7
8
9
10
11
12
#define ClPrint(level, mask, format, ...)
do {
if (AMD_LOG_LEVEL >= level) {
if (AMD_LOG_MASK & mask || mask == amd::LOG_ALWAYS) {
if (AMD_LOG_MASK & amd::LOG_LOCATION) {
amd::log_printf(level, __FILENAME__, __LINE__, format, ##__VA_ARGS__);
} else {
amd::log_printf(level, "", 0, format, ##__VA_ARGS__);
}
}
}
} while (false)

在HIP代码中,调用ClPrint(),例如:

1
ClPrint(amd::LOG_INFO, amd::LOG_INIT, "Initializing HSA stack.");  

HIP Logging Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
user@user-test:~/hip/bin$ export AMD_LOG_LEVEL=4
user@user-test:~/hip/bin$ ./hipinfo
:3:rocdevice.cpp :453 : 23647210092: Initializing HSA stack.
:3:comgrctx.cpp :33 : 23647639336: Loading COMGR library.
:3:rocdevice.cpp :203 : 23647687108: Numa select cpu
agent[0]=0x13407c0(fine=0x13409a0,coarse=0x1340ad0) for gpu agent=0x1346150
:4:runtime.cpp :82 : 23647698669: init
:3:hip_device_runtime.cpp :473 : 23647698869: 5617 : [7fad295dd840] hipGetDeviceCount: Returned hipSuccess
:3:hip_device_runtime.cpp :502 : 23647698990: 5617 : [7fad295dd840] hipSetDevice ( 0 )
:3:hip_device_runtime.cpp :507 : 23647699042: 5617 : [7fad295dd840] hipSetDevice: Returned hipSuccess
--------------------------------------------------------------------------------
device# 0
:3:hip_device.cpp :150 : 23647699276: 5617 : [7fad295dd840] hipGetDeviceProperties (0x7ffdbe7db730, 0 )
:3:hip_device.cpp :237 : 23647699335: 5617 : [7fad295dd840] hipGetDeviceProperties: Returned hipSuccess
Name: Device 7341
pciBusID: 3
pciDeviceID: 0
pciDomainID: 0
multiProcessorCount: 11
maxThreadsPerMultiProcessor: 2560
isMultiGpuBoard: 0
clockRate: 1900 Mhz
memoryClockRate: 875 Mhz
memoryBusWidth: 0
clockInstructionRate: 1000 Mhz
totalGlobalMem: 7.98 GB
maxSharedMemoryPerMultiProcessor: 64.00 KB
totalConstMem: 8573157376
sharedMemPerBlock: 64.00 KB
canMapHostMemory: 1
regsPerBlock: 0
warpSize: 32
l2CacheSize: 0
computeMode: 0
maxThreadsPerBlock: 1024
maxThreadsDim.x: 1024
maxThreadsDim.y: 1024
maxThreadsDim.z: 1024
maxGridSize.x: 2147483647
maxGridSize.y: 2147483647
maxGridSize.z: 2147483647
major: 10
minor: 12
concurrentKernels: 1
cooperativeLaunch: 0
cooperativeMultiDeviceLaunch: 0
arch.hasGlobalInt32Atomics: 1
arch.hasGlobalFloatAtomicExch: 1
arch.hasSharedInt32Atomics: 1
arch.hasSharedFloatAtomicExch: 1
arch.hasFloatAtomicAdd: 1
arch.hasGlobalInt64Atomics: 1
arch.hasSharedInt64Atomics: 1
arch.hasDoubles: 1
arch.hasWarpVote: 1
arch.hasWarpBallot: 1
arch.hasWarpShuffle: 1
arch.hasFunnelShift: 0
arch.hasThreadFenceSystem: 1
arch.hasSyncThreadsExt: 0
arch.hasSurfaceFuncs: 0
arch.has3dGrid: 1
arch.hasDynamicParallelism: 0
gcnArch: 1012
isIntegrated: 0
maxTexture1D: 65536
maxTexture2D.width: 16384
maxTexture2D.height: 16384
maxTexture3D.width: 2048
maxTexture3D.height: 2048
maxTexture3D.depth: 2048
isLargeBar: 0
:3:hip_device_runtime.cpp :471 : 23647701557: 5617 : [7fad295dd840] hipGetDeviceCount (0x7ffdbe7db714 )
:3:hip_device_runtime.cpp :473 : 23647701608: 5617 : [7fad295dd840] hipGetDeviceCount:Returned hipSuccess
:3:hip_peer.cpp :76 : 23647701731: 5617 : [7fad295dd840] hipDeviceCanAccessPeer (0x7ffdbe7db728, 0, 0 )
:3:hip_peer.cpp :60 : 23647701784: 5617 : [7fad295dd840] canAccessPeer: Returned hipSuccess
:3:hip_peer.cpp :77 : 23647701831: 5617 : [7fad295dd840] hipDeviceCanAccessPeer: Returned hipSuccess
peers:
:3:hip_peer.cpp :76 : 23647701921: 5617 : [7fad295dd840] hipDeviceCanAccessPeer ( 0x7ffdbe7db728, 0, 0 )
:3:hip_peer.cpp :60 : 23647701965: 5617 : [7fad295dd840] canAccessPeer: Returned hipSuccess
:3:hip_peer.cpp :77 : 23647701998: 5617 : [7fad295dd840] hipDeviceCanAccessPeer: Returned hipSuccess
non-peers: device#0
:3:hip_memory.cpp :345 : 23647702191: 5617 : [7fad295dd840] hipMemGetInfo ( 0x7ffdbe7db718, 0x7ffdbe7db720 )
:3:hip_memory.cpp :360 : 23647702243: 5617 : [7fad295dd840] hipMemGetInfo: Returned hipSuccess
memInfo.total: 7.98 GB
memInfo.free: 7.98 GB (100%)

Debugging HIP

Debugging tools

Using ltrace

ltrace是一个标准的linux工具,它在每次动态库调用时都会向stderr提供消息。由于ROCr和ROCt(ROC thunk,是ROC内核驱动程序的用户空间接口)都是动态库,因此这提供了一种简单的方法来跟踪这些库中的活动。在使用命令行调试器深入了解细节之前,跟踪可以是快速观察应用程序流的强大方式。ltrace是可视化整个ROCm软件堆栈的运行时行为的有用工具。跟踪还可以显示与关键路径上对费时API的意外调用相关的性能问题。

跟踪HIP API和输出的命令行:

1
2
3
4
5
6
7
$ ltrace -C -e "hip*" ./hipGetChanDesc
hipGetChanDesc->hipCreateChannelDesc(0x7ffdc4b66860, 32, 0, 0) = 0x7ffdc4b66860
hipGetChanDesc->hipMallocArray(0x7ffdc4b66840, 0x7ffdc4b66860, 8, 8) = 0
hipGetChanDesc->hipGetChannelDesc(0x7ffdc4b66848, 0xa63990, 5, 1) = 0
hipGetChanDesc->hipFreeArray(0xa63990, 0, 0x7f8c7fe13778, 0x7ffdc4b66848) = 0
PASSED!
+++ exited (status 0) +++

命令行仅跟踪API和输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
$ ltrace -C -e "hsa*" ./hipGetChanDesc
libamdhip64.so.4->hsa_init(0, 0x7fff325a69d0, 0x9c80e0, 0 <unfinished ...>
libhsa-runtime64.so.1->hsaKmtOpenKFD(0x7fff325a6590, 0x9c38c0, 0, 1) = 0
libhsa-runtime64.so.1->hsaKmtGetVersion(0x7fff325a6608, 0, 0, 0) = 0
libhsa-runtime64.so.1->hsaKmtReleaseSystemProperties(3, 0x80084b01, 0, 0) = 0
libhsa-runtime64.so.1->hsaKmtAcquireSystemProperties(0x7fff325a6610, 0, 0, 1) = 0
libhsa-runtime64.so.1->hsaKmtGetNodeProperties(0, 0x7fff325a66a0, 0, 0) = 0
libhsa-runtime64.so.1->hsaKmtGetNodeMemoryProperties(0, 1, 0x9c42b0, 0x936012) = 0
...
<... hsaKmtCreateEvent resumed> ) = 0
libhsa-runtime64.so.1->hsaKmtAllocMemory(0, 4096, 64, 0x7fff325a6690) = 0
libhsa-runtime64.so.1->hsaKmtMapMemoryToGPUNodes(0x7f1202749000, 4096, 0x7fff325a6690, 0) = 0
libhsa-runtime64.so.1->hsaKmtCreateEvent(0x7fff325a6700, 0, 0, 0x7fff325a66f0) = 0
libhsa-runtime64.so.1->hsaKmtAllocMemory(1, 0x100000000, 576, 0x7fff325a67d8) = 0
libhsa-runtime64.so.1->hsaKmtAllocMemory(0, 8192, 64, 0x7fff325a6790) = 0
libhsa-runtime64.so.1->hsaKmtMapMemoryToGPUNodes(0x7f120273c000, 8192, 0x7fff325a6790, 0) = 0
libhsa-runtime64.so.1->hsaKmtAllocMemory(0, 4096, 4160, 0x7fff325a6450) = 0
libhsa-runtime64.so.1->hsaKmtMapMemoryToGPUNodes(0x7f120273a000, 4096, 0x7fff325a6450, 0) = 0
libhsa-runtime64.so.1->hsaKmtSetTrapHandler(1, 0x7f120273a000, 4096, 0x7f120273c000) = 0
<... hsa_init resumed> ) = 0
libamdhip64.so.4->hsa_system_get_major_extension_table(513, 1, 24, 0x7f1202597930) = 0
libamdhip64.so.4->hsa_iterate_agents(0x7f120171f050, 0, 0x7fff325a67f8, 0 <unfinished ...>
libamdhip64.so.4->hsa_agent_get_info(0x94f110, 17, 0x7fff325a67e8, 0) = 0
libamdhip64.so.4->hsa_amd_agent_iterate_memory_pools(0x94f110, 0x7f1201722816, 0x7fff325a67f0,
0x7f1201722816 <unfinished ...>
libamdhip64.so.4->hsa_amd_memory_pool_get_info(0x9c7fb0, 0, 0x7fff325a6744, 0x7fff325a67f0) = 0
libamdhip64.so.4->hsa_amd_memory_pool_get_info(0x9c7fb0, 1, 0x7fff325a6748, 0x7f1200d82df4) = 0
...
<... hsa_amd_agent_iterate_memory_pools resumed> ) = 0
libamdhip64.so.4->hsa_agent_get_info(0x9dbf30, 17, 0x7fff325a67e8, 0) = 0
<... hsa_iterate_agents resumed> ) = 0
libamdhip64.so.4->hsa_agent_get_info(0x9dbf30, 0, 0x7fff325a6850, 3) = 0
libamdhip64.so.4->hsa_agent_get_info(0x9dbf30, 0xa000, 0x9e7cd8, 0) = 0
libamdhip64.so.4->hsa_agent_iterate_isas(0x9dbf30, 0x7f1201720411, 0x7fff325a6760,
0x7f1201720411) = 0
libamdhip64.so.4->hsa_isa_get_info_alt(0x94e7c8, 0, 0x7fff325a6728, 1) = 0
libamdhip64.so.4->hsa_isa_get_info_alt(0x94e7c8, 1, 0x9e7f90, 0) = 0
libamdhip64.so.4->hsa_agent_get_info(0x9dbf30, 4, 0x9e7ce8, 0) = 0
...
<... hsa_amd_memory_pool_allocate resumed> ) = 0
libamdhip64.so.4->hsa_ext_image_create(0x9dbf30, 0xa1c4c8, 0x7f10f2800000, 3 <unfinished ...>
libhsa-runtime64.so.1->hsaKmtAllocMemory(0, 4096, 64, 0x7fff325a6740) = 0
libhsa-runtime64.so.1->hsaKmtQueryPointerInfo(0x7f1202736000, 0x7fff325a65e0, 0, 0) = 0
libhsa-runtime64.so.1->hsaKmtMapMemoryToGPUNodes(0x7f1202736000, 4096, 0x7fff325a66e8, 0) = 0
<... hsa_ext_image_create resumed> ) = 0
libamdhip64.so.4->hsa_ext_image_destroy(0x9dbf30, 0x7f1202736000, 0x9dbf30, 0 <unfinished ...>
libhsa-runtime64.so.1->hsaKmtUnmapMemoryToGPU(0x7f1202736000, 0x7f1202736000, 4096, 0x9c8050) =
0
libhsa-runtime64.so.1->hsaKmtFreeMemory(0x7f1202736000, 4096, 0, 0) = 0
<... hsa_ext_image_destroy resumed> ) = 0
libamdhip64.so.4->hsa_amd_memory_pool_free(0x7f10f2800000, 0x7f10f2800000, 256, 0x9e76f0) = 0
PASSED!

Using ROCgdb

ROCm上的HIP开发人员可以使用AMD的ROCgdb进行调试和分析。ROCgdb是Linux的ROCm源代码级调试器,基于GNU源代码级调试程序GDB。它类似于cuda gdb。它可以用于调试器前端,如eclipse、vscode或gdbdashboard。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ export PATH=$PATH:/opt/rocm/bin
$ rocgdb ./hipTexObjPitch
GNU gdb (rocm-dkms-no-npi-hipclang-6549) 10.1
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
...
For bug reporting instructions, please see:
<https://github.com/ROCm-Developer-Tools/ROCgdb/issues>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
...
Reading symbols from ./hipTexObjPitch...
(gdb) break main
Breakpoint 1 at 0x4013d1: file /home/test/hip/tests/src/texture/hipTexObjPitch.cpp, line 98.
(gdb) run
Starting program: /home/test/hip/build/directed_tests/texture/hipTexObjPitch
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, main ()
at /home/test/hip/tests/src/texture/hipTexObjPitch.cpp:98
98 texture2Dtest<float>();
(gdb)c

Debugging HIP Applications

下面的示例显示了如何在运行应用程序时从调试器获取有用的信息,这会导致GPUVM错误问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Memory access fault by GPU node-1 on address 0x5924000. Reason: Page not present or supervisor
privilege.
Program received signal SIGABRT, Aborted.
[Switching to Thread 0x7fffdffb5700 (LWP 14893)]
0x00007ffff2057c37 in __GI_raise (sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:56
56 ../nptl/sysdeps/unix/sysv/linux/raise.c: No such file or directory.
(gdb) bt
#0 0x00007ffff2057c37 in __GI_raise (sig=sig@entry=6) at
../nptl/sysdeps/unix/sysv/linux/raise.c:56
#1 0x00007ffff205b028 in __GI_abort () at abort.c:89
#2 0x00007ffff6f960eb in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#3 0x00007ffff6f99ea5 in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#4 0x00007ffff6f78107 in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#5 0x00007ffff744f184 in start_thread (arg=0x7fffdffb5700) at pthread_create.c:312
#6 0x00007ffff211b37d in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:111
(gdb) info threads
Id Target Id Frame
4 Thread 0x7fffdd521700 (LWP 14895) "caffe" pthread_cond_wait@@GLIBC_2.3.2 () at
../nptl/sysdeps/unix/sysv/linux/x86_64/pthread_cond_wait.S:185
3 Thread 0x7fffddd22700 (LWP 14894) "caffe" pthread_cond_wait@@GLIBC_2.3.2 () at
../nptl/sysdeps/unix/sysv/linux/x86_64/pthread_cond_wait.S:185
* 2 Thread 0x7fffdffb5700 (LWP 14893) "caffe" 0x00007ffff2057c37 in __GI_raise
(sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:56
1 Thread 0x7ffff7fa6ac0 (LWP 14892) "caffe" 0x00007ffff6f934d5 in ?? () from
/opt/rocm/hsa/lib/libhsa-runtime64.so.1
(gdb) thread 1
[Switching to thread 1 (Thread 0x7ffff7fa6ac0 (LWP 14892))]
#0 0x00007ffff6f934d5 in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
(gdb) bt
#0 0x00007ffff6f934d5 in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#1 0x00007ffff6f929ba in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#2 0x00007fffe080beca in HSADispatch::waitComplete() () from /opt/rocm/hcc/lib/libmcwamp_hsa.so
#3 0x00007fffe080415f in HSADispatch::dispatchKernelAsync(Kalmar::HSAQueue*, void const*, int,
bool) () from /opt/rocm/hcc/lib/libmcwamp_hsa.so
#4 0x00007fffe080238e in Kalmar::HSAQueue::dispatch_hsa_kernel(hsa_kernel_dispatch_packet_s
const*, void const*, unsigned long, hc::completion_future*) () from
/opt/rocm/hcc/lib/libmcwamp_hsa.so
#5 0x00007ffff7bb7559 in hipModuleLaunchKernel () from /opt/rocm/hip/lib/libhip_hcc.so
#6 0x00007ffff2e6cd2c in mlopen::HIPOCKernel::run (this=0x7fffffffb5a8, args=0x7fffffffb2a8,
size=80) at /root/MIOpen/src/hipoc/hipoc_kernel.cpp:15
...

Useful Environment Variables

HIP提供了允许HIP、HIP-clang或HSA驱动程序禁用功能或优化的环境变量。这些不适用于生产,但可用于诊断应用程序(或驱动程序)中的同步问题。有关环境变量的描述,请参见以下章节。它们在ROCm路径上受支持。

Kernel Enqueue Serialization 内核排队序列化

开发人员可以使用环境变量从主机控制内核命令序列化,

  • AMD_SERIALIZE_KERNEL,用于序列化内核队列。
  • AMD_SERIALIZE_KERNEL=1,排队前等待完成,
  • AMD_SERIALIZE_KERNEL=2,排队后等待完成,
  • AMD_SERIALIZE_KERNEL=3,两者都有。或AMD_SERIALIZE_COPY,用于序列化副本。
  • AMD_SERIALIZE_COPY=1,排队前等待完成
  • AMD_SERIALIZE_COPY=2,排队后等待完成
  • AMD_SERIALIZE_COPY=3,两者都有。

Making Device Visible

对于具有多个设备的系统,可以通过设置环境变量-HIP_visible_devices使HIP只能看到某些设备。HIP只能看到序列中存在索引的设备。例如:

1
$ HIP_VISIBLE_DEVICES=0,1

或者在应用中:

1
2
3
4
5
if (totalDeviceNum > 2) {
setenv("HIP_VISIBLE_DEVICES", "0,1,2", 1);
assert(getDeviceNumber(false) == 3);
... ...
}

Dump code object

开发人员可以通过设置环境变量GPU_dump_code_object转储代码对象以分析编译器相关问题

HSA提供环境变量帮助分析驱动程序或硬件中的问题。例如

  • HSA_ENABLE_SDMA=0它使主机到设备和设备到主机的副本使用计算着色器blit内核,而不是专用DMA复制引擎。计算着色器副本具有较低的延迟(通常小于5us),可以实现DMA副本引擎大约80%的带宽。此环境变量用于隔离硬件复制引擎的问题。
  • HSA_ENABLE_INTERRUPT=0使用基于内存的轮询而非中断检测完成信号。此环境变量可用于诊断驱动程序中的中断风暴问题。

Summary of Environment Variables in HIP

Environment Variable Default Value Usage
AMD_LOG_LEVEL Enable HIP log on different Levels. 0 0: Disable log. 1: Enable log on error level. 2: Enable log on warning and below levels. 0x3: Enable log on information and below levels. 0x4: Decode and display AQL packets.
AMD_LOG_MASK Enable HIP log on different Levels. 0x7FFFFFFF 0x1: Log API calls. 0x02: Kernel and Copy Commands and Barriers. 0x4: Synchronization and waiting for commands to finish. 0x8: Enable log on information and below levels. 0x20: Queue commands and queue contents. 0x40:Signal creation, allocation, pool. 0x80: Locks and thread-safety code. 0x100: Copy debug. 0x200: Detailed copy debug. 0x400: Resource allocation, performance-impacting events. 0x800: Initialization and shutdown. 0x1000: Misc debug, not yet classified. 0x2000: Show raw bytes of AQL packet. 0x4000: Show code creation debug. 0x8000: More detailed command info, including barrier commands. 0x10000: Log message location. 0xFFFFFFFF: Log always even mask flag is zero.
HIP_VISIBLE_DEVICES Only devices whose index is present in the sequence are visible to HIP. 0,1,2: Depending on the number of devices on the system.
GPU_DUMP_CODE_OBJECT Dump code object. 0 0: Disable. 1: Enable.
AMD_SERIALIZE_KERNEL Serialize kernel enqueue. 0 1: Wait for completion before enqueue. 2: Wait for completion after enqueue. 3: Both.
AMD_SERIALIZE_COPY Serialize copies. 0 1: Wait for completion before enqueue. 2: Wait for completion after enqueue. 3: Both.
HIP_HOST_COHERENT Coherent memory in hipHostMalloc. 0 0: memory is not coherent between host and GPU. 1: memory is coherent with host.
AMD_DIRECT_DISPATCH Enable direct kernel dispatch. 0 0: Disable. 1: Enable

General Debugging Tips

  • “gdb –args”可用于方便地将可执行文件和参数传递给gdb。
  • 从GDB中,您可以设置环境变量“set env”。请注意,该命令不使用“=”符号:(gdb)set env AMD_SERIALIZE_KERNEL 3
  • 故障将由运行时捕获,但实际上是由GPU上运行的异步命令生成的。因此,GDB回溯将在运行时显示路径。
  • 为了确定故障的真实位置,通过查看环境变量AMD_SERIALIZE_KERNEL=3 AMD_SERALIZE_COPY=3,强制内核同步执行。这将迫使HIP运行时在重新调整之前等待内核完成执行。如果错误发生在内核执行过程中,您可以在回溯中看到启动内核的代码。需要进行一些猜测来确定哪个线程实际导致了问题——通常是在libhsaruntime64.so中等待的线程。
  • 内核内部的VM故障可能由以下原因引起:
    • 不正确的代码(即延伸超过阵列边界的循环),
    • 内存问题-无效的内核参数(空指针、未注册的主机指针、坏指针),
    • 同步问题,
    • 编译器问题(编译器生成的代码不正确),
    • 运行时问题。

HIP Version

自ROCm v4.2发布以来,HIP版本定义更新如下:HIP_VERSION=HIP_VERSION_MAJOR * 10000000 + HIP_VERSION_MINOR * 100000 + HIP_VERSION_PATCH),HIP版本可以从以下HIP API调用中查询,hipRuntimeGetVersion(&runtimeVersion);

Transiting from CUDA to HIP

Transition Tool: HIPIFY

Sample and Practice

Add hip/bin path to the PATH.

1
$ export PATH=$PATH:[MYHIP]/bin

Define the environment variable.

1
$ export HIP_PATH=[MYHIP]

Build an executable file.

1
2
3
4
5
6
$ cd ~/hip/samples/0_Intro/square
$ make
/home/user/hip/bin/hipify-perl square.cu > square.cpp
/home/user/hip/bin/hipcc square.cpp -o square.out
/home/user/hip/bin/hipcc -use-staticlib square.cpp -o square.out.static

Execute the file.

1
2
3
4
5
6
7
8
9
$ ./square.out
info: running on device Vega20 [Radeon Pro W5500]
info: allocate host mem ( 7.63 MB)
info: allocate device mem ( 7.63 MB)
info: copy Host2Device
info: launch 'vector_square' kernel
info: copy Device2Host
info: check result
PASSED!

HIP Porting Process

Porting a New CUDA Project

General Tips

  • 在CUDA机器上启动端口通常是最简单的方法,因为您可以将部分代码增量地移植到HIP,而将其余代码留在CUDA中。(回想一下,在CUDA机器上,HIP只是CUDA上的一个薄层,因此这两种代码类型可以在nvcc平台上互操作。)此外,HIP端口可以与原始CUDA代码进行功能和性能比较。
  • CUDA代码移植到HIP并在CUDA机器上运行后,在AMD机器上使用HIP编译器编译HIP代码。
  • HIP端口可以取代CUDA版本:HIP可以提供与本地CUDA实现相同的性能,同时具有对Nvidia和AMD架构的可移植性以及未来C++标准支持的优势。您可以通过条件编译或将其添加到开源HIP基础结构来处理特定于平台的特性。
  • 使用bin/hipconvertinplace-perl.sh发送CUDA源目录中的所有代码文件。

Scanning existing CUDA code to scope the porting effort 扫描现有CUDA代码以确定移植工作的范围

hipinspecte-perl.sh工具将扫描源目录,以确定哪些文件包含CUDA代码,以及其中有多少代码可以自动转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
> cd examples/rodinia_3.0/cuda/kmeans
> $HIP_DIR/bin/hipexamine-perl.sh.
info: hipify ./kmeans.h =====>
info: hipify ./unistd.h =====>
info: hipify ./kmeans.c =====>
info: hipify ./kmeans_cuda_kernel.cu =====>
info: converted 40 CUDA->HIP refs( dev:0 mem:0 kern:0 builtin:37 math:0 stream:0 event:0 err:0
def:0 tex:3 other:0 ) warn:0 LOC:185
info: hipify ./getopt.h =====>
info: hipify ./kmeans_cuda.cu =====>
info: converted 49 CUDA->HIP refs( dev:3 mem:32 kern:2 builtin:0 math:0 stream:0 event:0 err:0
def:0 tex:12 other:0 ) warn:0 LOC:311
info: hipify ./rmse.c =====>
info: hipify ./cluster.c =====>
info: hipify ./getopt.c =====>
info: hipify ./kmeans_clustering.c =====>
info: TOTAL-converted 89 CUDA->HIP refs( dev:3 mem:32 kern:2 builtin:37 math:0 stream:0 event:0
err:0 def:0 tex:15 other:0 ) warn:0 LOC:3607
kernels (1 total) : kmeansPoint(1)

hipinspect-perl扫描指定目录中找到的每个代码文件(cpp、c、h、hpp等):

  • 没有CUDA代码(kmeans.h)的文件只打印一行摘要,列出源文件名。
  • 带有CUDA代码的文件打印找到的内容的摘要-例如,kmeans_CUDA_kernel.cu文件:
1
2
info: hipify ./kmeans_cuda_kernel.cu =====>
info: converted 40 CUDA->HIP refs( dev:0 mem:0 kern:0 builtin:37 math:0 stream:0 event:0
  • kmeans_cuda_kernel.cu中的信息:

    • 有多少CUDA调用转换为HIP(40)
    • 所用CUDA功能的分解(dev:0 mem:0等)。此文件使用了许多CUDA内置(37)和纹理函数(3)。
    • 类似CUDA API但未转换的代码的警告(此文件中为0)。
    • 计算此文件的代码行数(LOC)-185。
  • hipinspect-perl还在流程结束时为所有文件收集的统计数据提供一份摘要。这与每文件报告的格式类似,还包括所有已调用内核的列表。上面的示例:

1
2
3
info: TOTAL-converted 89 CUDA->HIP refs( dev:3 mem:32 kern:2 builtin:37 math:0 stream:0 event:0
err:0 def:0 tex:15 other:0 ) warn:0 LOC:3607
kernels (1 total) : kmeansPoint(1)

Converting a project in-place

1
hipify-perl --inplace  

对于每个输入文件file,此脚本将:

  • 如果file.prehip文件不存在,请将原始代码复制到扩展名为.prehip的新文件中。然后将代码文件发送。
  • 如果“FILE.previip”文件存在,请将FILE.prehip发送并保存到FILE。这对于测试hipify工具集的改进非常有用。

hipconvertinplace-perl.sh脚本将对指定目录中的所有代码文件执行就地转换。这在处理现有CUDA代码库时非常方便,因为脚本保留了现有的目录结构和文件名,并包含了工作。就地转换后,您可以查看代码以向目录名添加其他参数。

1
> hipconvertinplace-perl.sh MY_SRC_DIR

Library Equivalents

CUDA Library ROCm Library Comment
cuBLAS rocBLAS Basic Linear Algebra Subroutines
cuFFT rocFFT Fast Fourier Transfer Library
cuSPARSE rocSPARSE Sparse BLAS + SPMV
cuSolver rocSOLVER Lapack library
AMG-X rocALUTION Sparse iterative solvers and preconditioners with Geometric and Algebraic MultiGrid
Thrust rocThrust C++ parallel algorithms library
CUB rocPRIM Low Level Optimized Parallel Primitives
cuDNN MIOpen Deep learning Solver Library
cuRAND rocRAND Random Number Generator Library
EIGEN EIGEN C++ template library for linear algebra: matrices, vectors, numerical solvers,
NCCL RCCL Communications Primitives Library based on the MPI equivalents

Distinguishing Compiler Modes

Identifying HIP Target Platform

所有HIP项目都以AMD或NVIDIA平台为目标。平台会影响包含的头文件和用于链接的库。

  • 如果HIP平台以AMD为目标,则定义HIP_PLATFORM_AMD。注意,如果HIP平台针对AMD,则先前定义了HIP_PLATFORM_HCC。现在已弃用。
  • 如果HIP平台以NVIDIA为目标,则定义HIP_PLATFORM_NVDIA。注意,如果HIP平台针对NVIDIA,则先前定义了HIP_PLATFORM_NVCC。现在已弃用

Identifying the Compiler: HIP-Clang or NVIDIA

通常,了解底层编译器是HIP Clang还是NVIDIA是很有用的。这些知识可以保护特定于平台的代码或有助于特定于平台性能的调整

1
2
3
4
5
6
7
8
9
#ifdef __HIP_PLATFORM_AMD__
// Compiled with HIP-Clang
#endif
#ifdef __HIP_PLATFORM_NVIDIA__
// Compiled with nvcc
// Could be compiling with CUDA language extensions enabled (for example, a ".cu file)
// Could be in pass-through mode to an underlying host compile OR (for example, a .cpp file)
#ifdef __CUDACC__
// Compiled with nvcc (CUDA language extensions enabled)

HIP Clang直接生成主机代码(使用Clang x86目标),而无需将代码传递给另一个主机编译器。因此,它们没有__CUDACC__定义的等价物。

Identifying Current Compilation Pass: Host or Device 识别当前编译过程:主机或设备

NVCC对代码进行两次传递:一次传递主机代码,一次传递设备代码。HIP Clang将对代码进行多次传递:一次用于主机代码,一次用于设备代码上的每个架构。当编译器(HIP-Clang或nvcc)为__global__内核内的设备或设备函数编译代码时,__HIP_DEVICE_COMPILE__设置为非零值。__HIP_DEVICE_COMPILE__可以替换__CUDA_ARCH__定义上的#ifdef检查。

1
2
//#ifdef__CUDA_ARCH__
#if __HIP_DEVICE_COMPILE__

__CUDA_ARCH__不同,__HIP_DEVICE_COMPILE__值为1或未定义,它不表示目标设备的功能。

Compiler Defines: Summary

Define HIP-Clang nvcc Other (GCC, ICC, Clang, etc.)
HIP-related defines:
__HIP_PLATFORM_AMD__ Defined Undefined Defined if targeting AMD platform; undefined otherwise
__HIP_PLATFORM_NVIDIA__ Undefined Defined Defined if targeting NVIDIA platform; undefined otherwise
__HIP_DEVICE_COMPILE__ 1 if compiling for device; undefined if compiling for host 1 if compiling for device; undefined if compiling for host Undefined
__HIPCC__ Defined Defined Undefined
__HIP_ARCH_* 0 or 1 depending on feature support (see below) 0 or 1 depending on feature support (see below) 0
nvcc-related defines:
__CUDACC__ Defined if source code is compiled by nvcc; undefined otherwise Undefined
__NVCC__ Undefined Defined Undefined
__CUDA_ARCH__ Undefined Unsigned representing compute capability (e.g., “130”) if in device code; 0 if in host code Undefined
hip-clang-related defines:
__HIP__ Defined Undefined Undefined
HIP-Clang common defines:
__clang__ Defined Defined Undefined

Identifying Architecture Features

HIP_ARCH Defines

一些CUDA代码会检查__CUDA_ARCH__是否是特定值来判断设备有无某种特性。

1
2
#if (__CUDA_ARCH__ >= 130)
// doubles are supported

这种类型的代码需要特别注意,因为AMD和CUDA设备具有不同的架构能力。此外,您无法通过与体系结构版本号的简单比较来确定功能的存在。HIP提供一组定义和设备属性,以查询是否支持特定的体系结构特性。

__HIP_ARCH_*定义可以替换__CUDA_ARCH__值的比较:

1
2
3
4
//#if (__CUDA_ARCH__ >= 130) // non-portable
if __HIP_ARCH_HAS_DOUBLES__ { // portable HIP feature query
// doubles are supported
}

对于主机代码,__HIP_ARCH_*定义设置为0。您只应在设备代码中使用HIP_ARCH字段。

Device-Architecture Properties

主机代码应该查询hipGetDeviceProperties返回的设备属性中的体系结构功能标志,而不是直接测试“major”和“minor”字段:

1
2
3
4
5
hipGetDeviceProperties(&deviceProp, device);
//if ((deviceProp.major == 1 && deviceProp.minor < 2)) // non-portable
if (deviceProp.arch.hasSharedInt32Atomics) { // portable HIP feature query
// has shared int32 atomic operations ...
}

Table of Architecture Properties

下表显示了HIP支持的一整套体系结构属性。

Define (use only in device code) Device Property (run time query) Comment
32-bit atomics:
__HIP_ARCH_HAS_GLOBAL_INT32_ATOMICS__ hasGlobalInt32Atomics 32-bit integer atomics for global memory
__HIP_ARCH_HAS_GLOBAL_FLOAT_ATOMIC_EXCH__ hasGlobalFloatAtomicExc h 32-bit float atomic exchange for global memory
__HIP_ARCH_HAS_SHARED_INT32_ATOMICS__ hasSharedInt32Atomics 32-bit integer atomics for shared memory
__HIP_ARCH_HAS_SHARED_FLOAT_ATOMIC_EXCH__ hasSharedFloatAtomicExc h 32-bit float atomic exchange for shared memory
__HIP_ARCH_HAS_FLOAT_ATOMIC_ADD__ hasFloatAtomicAdd 32-bit float atomic add in global and shared memory
64-bit atomics
__HIP_ARCH_HAS_GLOBAL_INT64_ATOMICS__ hasGlobalInt64Atomics 64-bit integer atomics for global memory
__HIP_ARCH_HAS_SHARED_INT64_ATOMICS__ hasSharedInt64Atomics 64-bit integer atomics for shared memory
Doubles
__HIP_ARCH_HAS_DOUBLES__ hasDoubles Double-precision floating point
Warp cross-lane operations:
__HIP_ARCH_HAS_WARP_VOTE__ hasWarpVote Warp vote instructions (any, all)
__HIP_ARCH_HAS_WARP_BALLOT__ hasWarpBallot Warp ballot instructions
__HIP_ARCH_HAS_WARP_SHUFFLE__ hasWarpShuffle Warp shuffle operations (shfl_*)
__HIP_ARCH_HAS_WARP_FUNNEL_SHIFT__ hasFunnelShift Funnel shift two input words into one
Sync
__HIP_ARCH_HAS_THREAD_FENCE_SYSTEM__ hasThreadFenceSystem threadfence_syste m
__HIP_ARCH_HAS_SYNC_THREAD_EXT__ hasSyncThreadsExt syncthreads_count, syncthreads_and, syncthreads_or
Miscellaneous
__HIP_ARCH_HAS_SURFACE_FUNCS__ hasSurfaceFuncs
__HIP_ARCH_HAS_3DGRID__ has3dGrid Grids and groups are 3D
__HIP_ARCH_HAS_DYNAMIC_PARALLEL__ hasDynamicParallelism

Finding HIP

如果不存在默认HIP_PATH,Makefile可以使用以下语法有条件地提供默认HIP_PATH:

1
HIP_PATH ?= $(shell hipconfig --path)

Identifying HIP Runtime

HIP可以依赖于ROCclr或CUDA作为运行时。

AMD平台HIP使用名为ROCclr的Radeon Open Compute公共语言运行时。ROCclr是一个虚拟设备接口,HIP运行时可以与不同的后端交互,允许运行时在Linux和Windows上工作而不需要付出太多努力。

在NVIDIA平台上,HIP只是CUDA之上的一个薄层。在非AMD平台上,HIP运行时确定CUDA是否可用并可以使用。如果可用,HIP_PLATFORM设置为NVIDIA,并使用CUDA路径下面的路径。

hipLaunchKernel

hipLaunchKernel是一个可变的宏,它接受启动配置(网格dims、组dims、流、动态共享大小)和数量可变的内核参数作为参数。然后根据平台的不同,将该序列扩展为适当的内核启动语法。虽然这可能是一种方便的单行内核启动语法,但当嵌套在其他宏中时,宏实现可能会导致问题。例如,考虑以下内容:

1
2
3
4
5
6
7
// Will cause compile error:
#define MY_LAUNCH(command, doTrace) \
{\
if (doTrace) printf ("TRACE: %s\n", #command); \
(command); /* The nested ( ) will cause compile error */\
}
MY_LAUNCH (hipLaunchKernel(vAdd, dim3(1024), dim3(1), 0, 0, Ad), true, "firstCall");

注意:避免在括号内嵌套宏参数-这里有一个可行的替代方案:

1
2
3
4
5
6
#define MY_LAUNCH(command, doTrace) \
{\
if (doTrace) printf ("TRACE: %s\n", #command); \
command;\
}
MY_LAUNCH (hipLaunchKernel(vAdd, dim3(1024), dim3(1), 0, 0, Ad), true, "firstCall");

Compiler Options

HIPcc是一个可移植的编译器驱动程序,它调用nvcc或HIP Clang(取决于目标系统)并附加所有必需的include和library选项。它将选项传递给目标编译器。调用hipcc的工具必须确保编译器选项适合目标编译器。hipconfig脚本可能有助于识别目标平台、编译器和运行时。它还可以帮助适当设置选项。

Compiler Options Supported on AMD Platforms

Option Description
–amdgpu-target= [DEPRECATED] This option is replaced by --offload-arch=<target>. Generate code for the given GPU target. Supported targets are gfx701, gfx801, gfx802, gfx803, gfx900, gfx906, gfx908, gfx1010, gfx1011, gfx1012, gfx1030, gfx1031. This option could appear multiple times on the same command line to generate a fat binary for multiple targets.
–fgpu-rdc Generate relocatable device code, which allows kernels or device functions calling device functions in different translation units.
-ggdb Equivalent to -g plus tuning for GDB. This is recommended when using ROCm’s GDB to debug GPU code.
–gpu-max-threads-per block= Generate code to support up to the specified number of threads per block.
-O Specify the optimization level.
-offload-arch= Specify the AMD GPU [target ID] https://clang.llvm.org/docs/ClangOffloadBundlerFileFormat.html#target-id
-save-temps Save the compiler-generated intermediate files.
-v Show the compilation steps.

Option for specifying GPU processor

–offload-arch=X

Linking Issues

Linking with hipcc

hipcc为HIP以及加速器编译器(nvcc或AMD编译器)添加了必要的库。建议与hipcc链接,因为它会自动将二进制文件链接到必要的HIP运行库。它还支持链接和管理GPU对象。-lm Option

Linking Code with Other Compilers

CUDA代码通常使用nvcc作为加速器代码(定义和启动内核,通常在.cu或.cuh文件中定义)。它还为应用程序的其余部分使用标准编译器(g++)。nvcc是一个使用标准主机编译器(gcc)生成主机代码的预处理器。使用此工具编译的代码只能使用nvcc和宿主编译器支持的语言特性的交集。在某些情况下,您必须注意确保主机编译器的数据类型和对齐方式与设备编译器的相同。仅支持某些主机编译器,例如,最近的nvcc版本缺少Clang主机编译器功能。HIP Clang使用相同的基于Clang的编译器生成设备和主机代码。该代码使用与gcc相同的API,这允许不同的gcc兼容编译器生成的代码链接在一起。例如,使用HIP Clang编译的代码可以与使用“标准”编译器(如gcc、ICC和Clang)编译的代码链接。注意确保所有编译器使用相同的标准C++头和库格式。

libc++ and libstdc++

默认情况下,hipcc链接到libstdc++。这在g++和HIP之间提供了更好的兼容性。

如果将--stdlib=libc++传递给hipcc,hipcc将使用libc++库。通常,libc++提供了一组更广泛的C++特性,而libstdc++是更多编译器(特别是包括g++)的标准。

当交叉链接C++代码时,任何使用C++标准库中类型的C++函数(包括std::string、std::vector和其他容器)都必须使用相同的标准库实现。它们包括以下内容:

  • HIP-Clang中定义的从标准编译器调用的函数或内核
  • 标准编译器中定义的函数从HIP Clang调用。
  • 具有这些接口的应用程序应使用默认的libstdc++链接。

完全使用hipcc编译的应用程序,受益于libstdc++不支持的高级C++功能,并且不需要nvcc的可移植性,可以选择使用libc++。

HIP Headers (hip_runtime.h, hip_runtime_api.h)

hip_runtime.h和hip_runtime_api.h文件定义了编译hip程序所需的类型、函数和枚举:

  • hip_runtime_api.h:定义所有hip运行时api(例如,hipMalloc)以及调用它们所需的类型。仅调用HIPAPI但既不定义也不启动任何内核的源文件都可以包含hip_runtime_api.h。hip_runtime _api.h不使用自定义hc语言特性,可以使用标准C++编译器编译。
  • hip_runtime.h:包含在hip_runtme_api.h中。它还提供了创建和启动内核所需的类型和定义。它可以使用标准C++编译器编译,但将暴露可用函数的子集。

CUDA对这两个文件的内容略有不同。在某些情况下,您可能需要将hipified代码转换为包含更丰富的hip_runtime.h,而不是hip_runtme_api.h。

Using a Standard C++ Compiler

可以使用标准C/C++编译器(gcc或ICC)编译 hip_runtime_api.h。HIP头文件路径和定义(__HIP_PLATFORM_AMD__ 或者 __HIP_PLATFORM_NVIDIA__)必须传给标准编译器,hipconfig会返回必要的选项:

1
2
> hipconfig --cxx_config
-D__HIP_PLATFORM_AMD__ -I/home/user1/hip/include

您可以捕获hipconfig输出并将其传递给标准编译器;下面是makefile语法示例:

1
CPPFLAGS += $(shell $(HIP_PATH)/bin/hipconfig --cpp_config)

默认情况下,nvcc包含一些头文件。然而,HIP不包含默认头文件,而是必须明确包含所有必需的文件。具体来说,调用HIP运行时API或定义HIP内核的文件必须明确包含适当的HIP头。如果编译过程报告找不到必要的api(例如,“错误:标识符’hipSetDevice’未定义”),请确保文件包含hip_runtime.h(或hip_runtme_api.h,如果合适)。hipify-perl脚本会自动将“cudaruntime.h”转换为“hip_runtime.h”,并将“cuda_runtime_api.h”转换成“hip_rountime_api.h”,但可能会丢失嵌套的头或宏。

cuda.h

HIP Clang路径提供了一个空的cuda.h文件。一些现有的CUDA程序包含此文件,但不需要任何功能。

Choosing HIP File Extensions

许多现有CUDA项目使用“.cu”和“.cuh”文件扩展名来指示应该通过nvcc编译器运行的代码。对于快速HIP端口,保持这些文件扩展名不变通常更容易,因为这样可以减少更改目录中的文件名和文件中的#include语句所需的工作量。

对于可以重新分解的新项目或端口,我们建议对源文件使用扩展名“.hip.cpp”,对头文件使用“.hip.h”或“.hip.hpp”。这表明代码是标准的C++代码,但也为make工具在适当时运行hipcc提供了唯一的指示。

Workarounds

memcpyToSymbol

hipMemcpyToSymbol的HIP支持已完成。该特性允许内核定义可以在主机端访问的设备端数据符号。符号可以在__constant或设备空间中。

请注意,符号名称需要封装在HIP_symbol宏中,如下面的代码示例所示。这也适用于hipMemcpyFromSymbolhipGetSymbolAddresshipGetSymbolSize

例如,设备代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include<hip/hip_runtime.h>
#include<hip/hip_runtime_api.h>
#include<iostream>
#define HIP_ASSERT(status) \
assert(status == hipSuccess)
#define LEN 512
#define SIZE 2048
__constant__ int Value[LEN];
__global__ void Get(hipLaunchParm lp, int *Ad)
{
int tid =threadIdx.x + blockIdx.x *blockDim.x;
Ad[tid] = Value[tid];
}
int main()
{
int *A, *B, *Ad;
A = new int[LEN];
B = new int[LEN];
for(unsigned i=0;i<LEN;i++)
{
A[i] = -1*i;
B[i] = 0;
}
HIP_ASSERT(hipMalloc((void**)&Ad, SIZE));
HIP_ASSERT(hipMemcpyToSymbol(HIP_SYMBOL(Value), A, SIZE, 0, hipMemcpyHostToDevice));
hipLaunchKernel(Get, dim3(1,1,1), dim3(LEN,1,1), 0, 0, Ad);
HIP_ASSERT(hipMemcpy(B, Ad, SIZE, hipMemcpyDeviceToHost));
for(unsigned i=0;i<LEN;i++)
{
assert(A[i] == B[i]);
}
std::cout<<"Passed"<<std::endl;
}

CU_POINTER_ATTRIBUTE_MEMORY_TYPE

要在HIP/HIP Clang中获取指针的内存类型,应该使用hipPointerGetAttributes API。API的第一个参数是hipPointerAttribute_t,其成员变量为memoryTypememoryType表示输入指针分配在设备或主机上。

1
2
3
4
5
6
7
8
double * ptr;
hipMalloc(reinterpret_cast<void**>(&ptr), sizeof(double));
hipPointerAttribute_t attr;
hipPointerGetAttributes(&attr, ptr); /*attr.memoryType will have value as hipMemoryTypeDevice*/
double* ptrHost;
hipHostMalloc(&ptrHost, sizeof(double));
hipPointerAttribute_t attr;
hipPointerGetAttributes(&attr, ptrHost); /*attr.memoryType will have value as hipMemoryTypeHost*/

threadfence_system

threadence_system使所有设备内存写入、对映射主机内存的所有写入以及对其他GPU设备内存的写入对其他CPU和GPU可见。一些实现可以通过刷新GPU L2缓存来提供这种行为。HIP/HIP-Clang不提供此功能。作为解决方法,用户可以将环境变量HSA_DISABLE_CACHE=1设置为禁用GPU二级缓存。这将影响所有访问和所有内核,因此可能会影响性能。

Textures and Cache Control

计算程序有时使用纹理来访问专用纹理缓存或使用纹理采样硬件进行插值和夹持。前一种方法使用具有线性插值的简单点采样器,本质上只读取单个点。后一种方法使用采样器硬件对多个样本进行插值和组合。AMD硬件以及最近的竞争硬件都有统一的纹理/L1缓存,因此不再有专用的纹理缓存。但nvcc路径通常将全局加载缓存在二级缓存中,一些程序可能会从一级缓存内容的显式控制中受益。为此,我们建议使用__ldg指令。

AMD编译器目前将所有数据加载到L1和L2缓存中,因此__ldg被视为noop。对于功能可移植性,我们建议如下:

  • 对于仅使用纹理以从改进的缓存中获益的程序,请使用__ldg指令
  • 使用纹理对象和引用API的程序在HIP上运行良好

HIP Porting Driver API

Porting CUDA Driver API

CUDA提供了单独的CUDA驱动程序和运行时API。这两个API在功能上有很大的重叠:

  • 这两个API都支持事件、流、内存管理、内存复制和错误处理。
  • 两种API提供了相似的性能。
  • 驱动程序API调用以前缀cu开头,而运行时API以前缀cuda开头。例如,驱动程序API包含cuEventCreate,而运行时API包含cudaEventCreate,具有类似的功能。
  • 驱动程序API定义的错误代码空间与运行时API使用的编码约定不同,但在很大程度上重叠。例如,驱动程序API定义CUDA_ERROR_INVALID_VALUE,而运行时API定义cudaErrorInvalidValue

注意:驱动程序API提供了运行时API没有提供的两个附加功能:cuModule和cuCtx API。

cuModule API

驱动程序API的模块部分提供了如何以及何时加载加速器代码对象的额外控制。例如,驱动程序API允许从文件或内存指针加载代码对象。可以从加载的代码对象中提取内核或全局数据的符号。相反,运行时API在运行时自动加载并(如果需要)从可执行二进制文件编译所有内核。在此模式下,必须使用NVCC编译内核代码,以便自动加载能够正常运行。

驱动程序和运行时API都定义了一个用于启动内核的函数(称为cuLaunchKernel或cudaLaunchKernel)。内核参数和执行配置(网格维度、组维度、动态共享内存和流)作为参数传递给启动函数。Runtime还提供了用于启动内核的<<<>>>语法,它类似于一个特殊的函数调用,比显式启动API更易于使用(特别是内核参数的处理)。然而,此语法不是标准的C++,只有在使用NVCC编译主机代码时才可用。

模块特性在直接生成代码对象的环境中非常有用,例如新的加速器语言前端。此处不使用NVCC。相反,环境可能具有不同的内核语言或不同的编译流。其他环境有许多内核,不希望它们全部自动加载。Module函数可用于加载生成的代码对象并启动内核。正如我们将在下面看到的,HIP定义了一个模块API,它对代码对象管理提供了类似的显式控制。

cuCtx API

驱动程序API将“上下文”和“设备”定义为单独的实体。上下文包含一个设备,理论上一个设备可以有多个上下文。每个上下文都包含一组特定于上下文的流和事件。历史上,上下文也为GPU定义了唯一的地址空间,但在统一内存平台中可能不再是这种情况(因为CPU和同一进程中的所有设备共享一个统一的地址空间)。上下文API还提供了一种在设备之间切换的机制,允许单个CPU线程向不同的GPU发送命令。HIP以及CUDA运行时的最新版本提供了其他机制来实现这一壮举,例如使用流或cudaSetDevice。

CUDA运行时API将上下文API与设备API统一起来。这简化了API,几乎没有功能损失,因为每个上下文都可以包含一个设备,多个上下文的好处已经被其他接口所取代。HIP提供了一个上下文API,以方便从现有驱动程序代码进行移植。在HIP中,Ctx函数在很大程度上提供了用于更改活动设备的替代语法。大多数新应用程序都倾向于使用hipSetDevice或流API,因此HIP已将hipCtx API标记为已弃用。在未来的版本中可能无法提供对这些API的支持。有关弃用API的详细信息,请参阅HIP弃用API:https://github.com/ROCm-DeveloperTools/HIP/blob/main/docs/markdown/hip_deprecated_api_list.md

HIP Module and Ctx APIs

HIP没有提供两个单独的API,而是用模块和Ctx控件的新API扩展了HIP API。

hipModule API

与CUDA驱动程序API一样,模块API提供了对代码加载方式的额外控制,包括从文件或内存指针加载代码的选项。NVCC和HIP Clang针对不同的体系结构,并使用不同的代码对象格式:NVCC是“cubin”或“ptx”文件,而HIP Clangpath是“hsaco”格式。生成这些代码对象的外部编译器负责为每个平台生成和加载正确的代码对象。值得注意的是,没有可以同时包含NVCC和HIP Clang平台代码的胖二进制格式。下表总结了每个平台上使用的格式:

Format APIs NVCC HIP-CLANG
Code Object hipModuleLoad, hipModuleLoadData .cubin or PTX text .hsaco
Fat Binary hipModuleLoadFatBin .fatbin .hip_fatbin

hipcc使用HIP-Clang或NVCC来编译主机代码。两者都可以将代码对象嵌入到最终的可执行文件中,并且这些代码对象将在应用程序启动时自动加载。hipModule API可用于加载其他代码对象,并以此方式为自动加载的代码对象提供扩展功能。如果需要,HIP-Clang允许两种功能一起使用。可以创建一个没有内核的程序,因此没有自动加载。

hipCtx API

HIP在现有设备功能上提供了一个Ctx API作为薄层。此Ctx API可用于设置当前上下文或查询与上下文关联的设备的属性。当前上下文由其他API(如hipStreamCreate)隐式使用。

hipify translation of CUDA Driver API

HIPIFY工具将用于流、事件、模块、设备、内存管理、上下文、分析器的CUDA驱动程序API转换为等效的HIP驱动程序调用。例如,cuEventCreate将被转换为hipEventCreate。HIPIFY工具还将错误代码从Driver命名空间和编码约定转换为等效的HIP错误代码。因此,HIP统一了这些公共函数的API。内存复制API需要额外的解释。CUDA驱动程序在API的名称中包含内存方向(即cuMemcpyH2D),而CUDA驱动API提供了一个具有指定方向的参数的单一内存复制API,并且还支持运行时自动确定方向的“默认”方向。HIP提供了两种样式的API:例如,hipMemcpyH2D和hipMemcpy。在某些情况下,第一种风格可能更快,因为它们避免了检测不同内存方向的主机开销。

HIP定义单个错误空间,并对所有错误使用驼峰大小写(即hipErrorInvalidValue)

HIP-Clang Implementation Notes

.hip_fatbin

hip clang将来自不同翻译单元的设备代码链接在一起。对于每个设备目标,都会生成一个代码对象。不同设备目标的代码对象由clang卸载绑定器绑定为一个fatbinary,该fatbinary作为全局符号__hip_fatbin嵌入到可执行或共享对象的ELF文件的.hip_fatbin部分中。

Initialization and Termination Functions

HIP-Clang为主机代码编译的每个翻译单元生成初始化和终止函数。初始化函数调用__hipRegisterFatBinary来注册ELF文件中嵌入的fatbinary。它们还调用__hipRegisterFunction__hipRegisterVar来注册内核函数和设备端全局变量。终止函数调用__hipUnregisterFatBinary。HIP Clang发出一个全局变量__HIP_gpubin_handle,类型为void**,带有linkonce链接,每个主机翻译单元的初始值为0。每个初始化函数检查__hip_gpubin_handle,并仅在__hip_gpubin_handle为0时注册fatbinary,并将__hip_gpubin_handle的返回值保存到__hip_gpubin_handle。这是为了保证fatbinary只注册一次。在终端功能中也进行了类似的检查。

Kernel Launching

HIP Clang支持CUDA<<<>>>语法、hipLaunchKernelhipLaunchKernelGGL启动内核。后两个是扩展到CUDA<<<>>>语法的宏。当动态链接器加载可执行或共享库时,将调用初始化函数。在初始化函数中,当调用__hipRegisterFatBinary时,将加载包含所有内核的代码对象;当调用__hipRegisterFunction时,存根函数与代码对象中的相应内核相关联。HIP Clang实现了两组启动API的内核。

默认情况下,在主机代码中,对于<<<>>>语句,hip-clang首先发出hipConfigureCall调用以设置线程和网格,然后发出带有给定参数的存根函数调用。在存根函数中,为每个内核参数调用hipSetupArgument,然后使用指向存根函数的函数指针调用hipLaunchByPtr。在hipLaunchByPtr中,与存根函数关联的真正内核被启动。

如果HIP程序是用-fhip-new-launch-api编译的,在主机代码中,对于<<<>>>语句,HIP-clang首先发出__hipPushCallConfiguration的调用,以将网格维度、块维度、共享内存使用情况和流保存到堆栈中,然后发出带有给定参数的存根函数调用。在存根函数中,调用__hipPopCallConfiguration以获取保存的网格维度、块维度、共享内存使用情况和流,然后hipLaunchKernel被调用,加上指向存根函数的函数指针。在hipLaunchKernel中,与存根函数关联的真实内核被启动。

Address Spaces

HIP Clang定义了一个进程范围的地址空间,其中CPU和所有设备从单个统一池分配地址。因此,地址可以在上下文之间共享,并且与原始CUDA定义不同,新的上下文不会为设备创建新的地址空间。

Using hipModuleLaunchKernel

hipModuleLaunchKernel是HIP世界中的cuLaunchKernel。它采用与cuLaunchKernel相同的参数。

Additional Information

HIP Clang在调用HIP API时创建主上下文。在纯驱动程序API代码中,HIPClang将创建一个主上下文,而HIP/NVCC将有一个空的上下文堆栈。HIP Clang将在主上下文为空时将其推送到上下文堆栈。这可能会在混合运行时和驱动程序API的应用程序中产生细微的差异。

NVCC Implementation Notes

Interoperation between HIP and CUDA Driver

CUDA应用程序可能希望将CUDA驱动程序代码与HIP代码混合。此表显示了启用此交互的类型等效性。

HIP Type CU Driver Type CUDA Runtime Type
hipModule_t CUmodule
hipFunction_t CUfunction
hipCtx_t CUcontext
hipDevice_t CUdevice
hipStream_t CUstream cudaStream_t
hipEvent_t CUevent cudaEvent_t
hipArray CUarray cudaArray

Compilation Options

hipModule_t接口不支持用于控制PTX编译选项的cuModuleLoadDataEx函数。HIP Clang不使用PTX,也不支持这些编译选项。HIP Clang代码对象始终包含完全编译的ISA,并且不需要作为加载步骤的一部分进行额外编译。相应的HIP函数hipModuleLoadDataEx在HIP Clang上表现为hipModuleDoadData(不使用编译选项),在NVCC路径上表现cuModuleLoadData

例如

CUDA

1
2
3
4
5
6
7
8
9
10
11
CUmodule module;
void *imagePtr = ...; // Somehow populate data pointer with code object
const int numOptions = 1;
CUJit_option options[numOptions];
void * optionValues[numOptions];
options[0] = CU_JIT_MAX_REGISTERS;
unsigned maxRegs = 15;
optionValues[0] = (void*)(&maxRegs);
cuModuleLoadDataEx(module, imagePtr, numOptions, options, optionValues);
CUfunction k;
cuModuleGetFunction(&k, module, "myKernel");

HIP

1
2
3
4
5
6
7
8
9
10
11
12
13
hipModule_t module;
void *imagePtr = ...; // Somehow populate data pointer with code object
const int numOptions = 1;
hipJitOption options[numOptions];
void * optionValues[numOptions];
options[0] = hipJitOptionMaxRegisters;
unsigned maxRegs = 15;
optionValues[0] = (void*)(&maxRegs);
// hipModuleLoadData(module, imagePtr) will be called on HIP-Clang path, JIT options will not be used, and
// cupModuleLoadDataEx(module, imagePtr, numOptions, options, optionValues) will be called on NVCC path
hipModuleLoadDataEx(module, imagePtr, numOptions, options, optionValues);
hipFunction_t k;
hipModuleGetFunction(&k, module, "myKernel");

下边的例子展示了如何使用hipModuleGetFunction:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include<hip_runtime.h>
#include<hip_runtime_api.h>
#include<iostream>
#include<fstream>
#include<vector>
#define LEN 64
#define SIZE LEN<<2
#ifdef __HIP_PLATFORM_HCC__
#define fileName "vcpy_isa.co"
#endif
#ifdef __HIP_PLATFORM_NVCC__
#define fileName "vcpy_isa.ptx"
#endif
#define kernel_name "hello_world"
int main(){
float *A, *B;
hipDeviceptr_t Ad, Bd;
A = new float[LEN];
B = new float[LEN];
for(uint32_t i=0;i<LEN;i++){
A[i] = i*1.0f;
B[i] = 0.0f;
std::cout<<A[i] << " "<<B[i]<<std::endl;
}

#ifdef __HIP_PLATFORM_NVCC__
hipInit(0);
hipDevice_t device;
hipCtx_t context;
hipDeviceGet(&device, 0);
hipCtxCreate(&context, 0, device);
#endif
hipMalloc((void**)&Ad, SIZE);
hipMalloc((void**)&Bd, SIZE);
hipMemcpyHtoD(Ad, A, SIZE);
hipMemcpyHtoD(Bd, B, SIZE);
hipModule_t Module;
hipFunction_t Function;
hipModuleLoad(&Module, fileName);
hipModuleGetFunction(&Function, Module, kernel_name);
std::vector<void*>argBuffer(2);
memcpy(&argBuffer[0], &Ad, sizeof(void*));
memcpy(&argBuffer[1], &Bd, sizeof(void*));
size_t size = argBuffer.size()*sizeof(void*);
void *config[] = {
HIP_LAUNCH_PARAM_BUFFER_POINTER, &argBuffer[0],
HIP_LAUNCH_PARAM_BUFFER_SIZE, &size,
HIP_LAUNCH_PARAM_END
};
hipModuleLaunchKernel(Function, 1, 1, 1, LEN, 1, 1, 0, 0, NULL, (void**)&config);
hipMemcpyDtoH(B, Bd, SIZE);
for(uint32_t i=0;i<LEN;i++){
std::cout<<A[i]<<" - "<<B[i]<<std::endl;
}
#ifdef __HIP_PLATFORM_NVCC__
hipCtxDetach(context);
#endif
return 0;
}

HIP Module and Texture Driver API

HIP支持纹理驱动程序API,但纹理引用应在主机范围内声明。以下代码说明了HIP_PLATFORM_HCC平台使用纹理参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Code to generate code object
#include "hip/hip_runtime.h"
extern texture<float, 2, hipReadModeElementType> tex;
__global__ void tex2dKernel(hipLaunchParm lp, float* outputData,
int width, int height)
{
int x = blockIdx.x*blockDim.x + threadIdx.x;
int y = blockIdx.y*blockDim.y + threadIdx.y;
outputData[y*width + x] = tex2D(tex, x, y);
}
// Host code:
texture<float, 2, hipReadModeElementType> tex;
void myFunc ()
{
// ...
textureReference* texref;
hipModuleGetTexRef(&texref, Module1, "tex");
hipTexRefSetAddressMode(texref, 0, hipAddressModeWrap);
hipTexRefSetAddressMode(texref, 1, hipAddressModeWrap);
hipTexRefSetFilterMode(texref, hipFilterModePoint);
hipTexRefSetFlags(texref, 0);
hipTexRefSetFormat(texref, HIP_AD_FORMAT_FLOAT, 1);
hipTexRefSetArray(texref, array, HIP_TRSA_OVERRIDE_FORMAT);
// ...
}

使用hip实现矩阵乘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
#include <stdio.h>
#include <stdlib.h>

#include <hip/hip_runtime.h>
#include <hip/hip_runtime_api.h>

#define M 4
#define K 4
#define N 4

void initial(double* list,int row,int col)
{
double *num = list;
for (int i=0; i<row*col; i++)
{
num[i] = rand()%10;
}
}

void CpuMatrix(double *A,double *B,double *C)
{
int i,j,k;

for( i=0; i<M; i++)
{
for(j=0; j<N; j++)
{
double sum = 0;
for(int k=0; k<K; k++)
{
sum += A[i*K + k] * B[k * N + j];
}
C[i * N + j] = sum;
}
}
}

__global__ void GpuMatrix(double *dev_A,double *dev_B,double *dev_C)
{
int ix = hipBlockIdx_x * hipBlockDim_x + hipThreadIdx_x;
int iy = hipBlockIdx_y * hipBlockDim_y + hipThreadIdx_y;
if(ix<K && iy<M)
{
double sum = 0;
for( int k = 0; k < K;k++)
{
sum += dev_A[iy*K + k] * dev_B[k*N + ix];
}
dev_C[iy * N + ix] = sum;
}
}

void printMatrix(double *list,int row,int col)
{
double *p = list;
for(int i=0; i<row; i++)
{
for(int j=0; j<col; j++)
{
printf("%10lf",p[j]);
}
p = p + col;
printf("\n");
}
}
int main(int argc,char **argv)
{
int Axy = M*K;
int Abytes = Axy * sizeof(double);

int Bxy = K*N;
int Bbytes = Bxy * sizeof(double);

int nxy = M*N;
int nbytes = nxy * sizeof(double);

float time_cpu,time_gpu;

clock_t start_cpu,stop_cpu;

hipEvent_t start_GPU,stop_GPU;

double *host_A, *host_B, *host_C, *c_CPU;
host_A = (double*)malloc(Abytes);
host_B = (double*)malloc(Bbytes);
host_C = (double*)malloc(nbytes);
c_CPU = (double*)malloc(nbytes);


initial(host_A,M,K);

printf("A:(%d,%d):\n",M,K);
printMatrix(host_A,M,K);

initial(host_B,K,N);

printf("B:(%d,%d):\n",K,N);
printMatrix(host_B,K,N);

// start_cpu = clock();
CpuMatrix(host_A,host_B,host_C);
// stop_cpu = clock();

printf("Host_C:(%d,%d):\n",M,N);
// printf("\nCPU time is %f(ms)\n",(float)(stop_cpu-start_cpu)/CLOCKS_PER_SEC);
printMatrix(host_C,M,N);
double *dev_A,*dev_B,*dev_C;
hipMalloc(&dev_A,Axy*sizeof(double));
hipMalloc(&dev_B,Bxy*sizeof(double));
hipMalloc(&dev_C,nxy*sizeof(double));

dim3 block(1024,1);
dim3 grid(64,64);

hipMemcpy(dev_A,host_A,Abytes,hipMemcpyDeviceToHost);
hipMemcpy(dev_B,host_B,Bbytes,hipMemcpyDeviceToHost);

hipEventCreate(&start_GPU);
hipEventCreate(&stop_GPU);
hipEventRecord(start_GPU,0);
hipLaunchKernelGGL(GpuMatrix,grid,block,0,0,dev_A,dev_B,dev_C);
hipEventRecord(stop_GPU,0);
hipEventSynchronize(start_GPU);
hipEventSynchronize(stop_GPU);
hipEventElapsedTime(&time_gpu, start_GPU,stop_GPU);
printf("\nThe time from GPU:\t%f(ms)\n", time_GPU/1000);
hipDeviceSynchronize();
hipEventDestroy(start_GPU);
hipEventDestroy(stop_GPU);

hipMemcpy(c_CPU,dev_C,nbytes,hipMemcpyDeviceToHost);
printf("device_C:(%d,%d):\n",M,N);
printMatrix(c_CPU,M,N);

hipFree(dev_A);
hipFree(dev_B);
hipFree(dev_C);
free(host_A);
free(host_B);
free(host_C);
free(c_CPU);

return 0;
}

结果如下:

img

img

使用结构体实现HIP的矩阵乘

共享内存使用__shared__内存空间说明符来分配。

共享内存应该比全局内存快得多,这在线程结构中有提及并在共享内存中有详细描述。因此,任何可以用

共享内存访问替换全局内存访问的机会都应该被利用,如下面的矩阵乘法示例所示。

下面的示例代码是不利用共享内存的矩阵乘法的简单实现。每个线程读取 A 的一行和 B 的一列,并计算 C 的相应元素,如图 9 所示。因此,A 将从全局内存中被读取 B.width 次,而 B 将被读取 A.height 次。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <hip/hip_runtime.h>
#include <hip/hip_runtime_api.h>

typedef struct{
int width;
int height;
float* elements;
}Matrix;

#define BLOCK_SIZE 4

__global__ void MatMulKernel(const Matrix,const Matrix,Matrix);

void initial(float* A,int N)
{
int i;
for(i = 0;i<N;i++)
{
A[i] = rand()%10;
}
}

void shuchu(Matrix A,int N)
{

int j=0;
for(int i=0; i < N; i++)
{
if( j == A.width)
{
printf("\n");
j = 0;
i--;
}else
{
printf("%15lf",A.elements[i]);
j++;
}
}
}

__global__ void MatMulKernel(Matrix A,Matrix B,Matrix C)
{
float Cvalue = 0;
int row = hipBlockIdx_y * hipBlockDim_y + hipThreadIdx_y;
int col = hipBlockIdx_x * hipBlockDim_x + hipThreadIdx_x;
for(int e = 0; e < A.width; ++e)
{
Cvalue += A.elements[row * A.width + e] * B.elements[e*B.width + col];
}
C.elements[row * C.width + col] = Cvalue;
}

//在CPU上计算矩阵乘
void CpuMatrix(Matrix A,Matrix B,Matrix C)
{
int M,N,K;
M = A.height;
N = B.width;
K = A.width;
int i,j,k;
for(i = 0;i < M;i++)
{
for(j = 0;j<N;j++)
{
float sum = 0.0;
for(k = 0;k<K;k++)
{
sum += A.elements[i * K + k] * B.elements[k * N + j];
}
C.elements[i * N + j] = sum;
}
}
}
void MatMul(Matrix A,Matrix B,Matrix C)
{
Matrix d_A;
Matrix d_B;
Matrix d_C;
d_A.width = A.width;
d_A.height = A.height;
d_B.width = B.width;
d_B.height = B.height;
d_C.width = C.width;
d_C.height = C.height;
size_t size_A = A.width * A.height * sizeof(float);
size_t size_B = B.width * B.height * sizeof(float);
size_t size_C = C.width * C.height * sizeof(float);

hipMalloc(&d_A.elements,size_A);
hipMalloc(&d_B.elements,size_B);
hipMalloc(&d_C.elements,size_C);
dim3 dimBlock(BLOCK_SIZE,BLOCK_SIZE);
dim3 dimGrid(B.width / dimBlock.x,A.height / dimBlock.y);

hipMemcpy(d_A.elements,A.elements,size_A,hipMemcpyHostToDevice);
hipMemcpy(d_B.elements,B.elements,size_B,hipMemcpyHostToDevice);
//测试时间
float gpu_time;
hipEvent_t start_GPU,stop_GPU;
hipEventCreate(&start_GPU);
hipEventCreate(&stop_GPU);
hipEventRecord(start_GPU,0);

hipLaunchKernelGGL(MatMulKernel,dimGrid,dimBlock,0,0,d_A,d_B,d_C);

hipEventRecord(stop_GPU,0);
hipEventSynchronize(start_GPU);
hipEventSynchronize(stop_GPU);
hipEventElapsedTime(&gpu_time,start_GPU,stop_GPU);
hipDeviceSynchronize();
printf("\nGPU spend time is: %lf(ms)\n",gpu_time/1000);
hipEventDestroy(start_GPU);
hipEventDestroy(stop_GPU);
hipMemcpy(C.elements,d_C.elements,size_C,hipMemcpyDeviceToHost);

printf("\nGPU result is :\n");
shuchu(C,C.width*C.height);
printf("\n");
hipFree(d_A.elements);
hipFree(d_B.elements);
hipFree(d_C.elements);
}
int main()
{
Matrix A;
Matrix B;
Matrix C;
A.width = BLOCK_SIZE;
A.height = BLOCK_SIZE;
B.width = BLOCK_SIZE;
B.height = BLOCK_SIZE;
C.width = BLOCK_SIZE;
C.height = BLOCK_SIZE;

int size = BLOCK_SIZE * BLOCK_SIZE;
int size_A = A.width * A.height * sizeof(float);
int size_B = B.width * B.height * sizeof(float);
int size_C = C.width * C.height * sizeof(float);

A.elements = (float *)malloc(size_A);
B.elements = (float *)malloc(size_B);
C.elements = (float *)malloc(size_C);

initial(A.elements,A.height*A.width);
printf("A:\n");
shuchu(A,A.width*A.height);

printf("\nB:\n");
initial(B.elements,B.height*B.width);
shuchu(B,B.width*B.height);

//调用CPU计算
//测试CPU的计算时间
clock_t start_CPU,stop_CPU;
double cpu_time;
start_CPU = clock();

CpuMatrix(A,B,C);
stop_CPU = clock();
//cpu_time = (double)(stop_CPU-start_CPU)/CLOCKS_PER_SEC;
//printf("\nCPU time is %lf(ms)\n",cpu_time);
printf("\nCPU result :\n");
shuchu(C,C.width*C.height);
/ shuchu(C,C.width*C.height);
printf("\n");
MatMul(A,B,C);
return 0;
}

运行结果如下:

img

利用结构体实现HIP的数组相加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <hip/hip_runtime.h>
#include <hip/hip_runtime_api.h>

typedef struct{
int width;
float* elements;
}Matrix;

#define BLOCK_SIZE 4

__global__ void MatMulKernel(const Matrix,const Matrix,Matrix);

void initial(float* A,int N)
{
int i;
for(i = 0;i<N;i++)
{
A[i] = rand()%10;
}
}

void shuchu(Matrix A,int N)
{
for(int i=0; i < N; i++)
{
printf("%10lf",A.elements[i]);
}
printf("\n");
}

__global__ void MatMulKernel(Matrix A,Matrix B,Matrix C)
{
int col = hipBlockIdx_x * hipBlockDim_x + hipThreadIdx_x;
C.elements[col] = A.elements[col]+B.elements[col];
}

void CpuMatrix(Matrix A,Matrix B,Matrix C)
{
int N;
N = B.width;
int i;
for(i=0;i<N;i++)
{
C.elements[i] = A.elements[i] + B.elements[i];
}
}
void MatMul(Matrix A,Matrix B,Matrix C)
{
Matrix d_A;
Matrix d_B;
Matrix d_C;
d_A.width = A.width;
d_B.width = B.width;
d_C.width = C.width;

size_t size_A = A.width * sizeof(float);
size_t size_B = B.width * sizeof(float);
size_t size_C = C.width * sizeof(float);

hipMalloc(&d_A.elements,size_A);
hipMalloc(&d_B.elements,size_B);
hipMalloc(&d_C.elements,size_C);
dim3 dimBlock(BLOCK_SIZE,BLOCK_SIZE);
dim3 dimGrid(1);

hipMemcpy(d_A.elements,A.elements,size_A,hipMemcpyHostToDevice);
hipMemcpy(d_B.elements,B.elements,size_B,hipMemcpyHostToDevice);

float gpu_time;
hipEvent_t start_GPU,stop_GPU;
hipEventCreate(&start_GPU);
hipEventCreate(&stop_GPU);
hipEventRecord(start_GPU,0);

hipLaunchKernelGGL(MatMulKernel,dimGrid,dimBlock,0,0,d_A,d_B,d_C);

hipEventRecord(stop_GPU,0);
hipEventSynchronize(start_GPU);
hipEventSynchronize(stop_GPU);
hipEventElapsedTime(&gpu_time,start_GPU,stop_GPU);
hipDeviceSynchronize();
printf("\nGPU spend time is: %lf(ms)\n",gpu_time/1000);
hipEventDestroy(start_GPU);
hipEventDestroy(stop_GPU);
hipMemcpy(C.elements,d_C.elements,size_C,hipMemcpyDeviceToHost);

printf("\nGPU result is :\n");
shuchu(C,C.width);
printf("\n");
hipFree(d_A.elements);
hipFree(d_B.elements);
hipFree(d_C.elements);
}
int main()
{
Matrix A;
Matrix B;
Matrix C;
A.width = BLOCK_SIZE;

B.width = BLOCK_SIZE;

C.width = BLOCK_SIZE;

int size_A = A.width * sizeof(float);
int size_B = B.width * sizeof(float);
int size_C = C.width * sizeof(float);

A.elements = (float *)malloc(size_A);
B.elements = (float *)malloc(size_B);
C.elements = (float *)malloc(size_C);

initial(A.elements,A.width);
printf("A:\n");
shuchu(A,A.width);

printf("\nB:\n");
initial(B.elements,B.width);
shuchu(B,B.width);

CpuMatrix(A,B,C);

printf("\nCPU result :\n");
shuchu(C,C.width);

printf("\n");
MatMul(A,B,C);
return 0;
}

复制代码

运行结果如下:

img

使用共享内存实现矩阵乘法(利用了结构体)

下面的示例代码是利用共享内存的矩阵乘法的实现.在这个实现中,每个线程块负责计算 C 的一个方形子 矩阵 Csub ,块内的每个线程负责计算 Csub 的一个元素.如图 10 所示,Csub 等于两个矩阵的乘积:维度为 (A.width, block_size)的子矩阵 A 与 Csub 有相同的行索引,维度为(block_size, A.width )的子矩阵 B 与 Csub 有相同的列索引.为了适应设备资源的需求,将这两个矩阵根据需要分为维度为 block_size 的多个 正方形矩阵.计算这些方形矩阵的乘积之和即可得到 Csub .这些乘积中的每一个的计算都是首先将两个 对应的正方形矩阵从全局内存加载到共享内存,一个线程加载一个元素,然后再让每个线程计算一个元 素.每个线程将这些乘积的结果累积到一个寄存器中,完成后再将结果写入全局内存.

通过这种方式分块计算,我们充分利用了快速的共享内存,并节省了大量的全局内存带宽,因为 A 只从全局内存中读取了(B.width / block_size)次,B 只从全局内存中读取了(A.height / block_size)次。

前一段示例代码中的矩阵类型使用了 stride 字段进行扩充,因此可以使用相同类型有效地表示子矩阵。__device__函数用于获取和设置元素并从矩阵中构建任何子矩阵。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#include <stdio.h>
#include <stdlib.h>
#include <hip/hip_runtime.h>
#include <hip/hip_runtime_api.h>

typedef struct{
int width;
int height;
int stride;
float* elements;
}Matrix;

#define BLOCK_SIZE 4

//初始化
void initial(float* A,int N)
{
int i;
for(i = 0;i<N;i++)
{
A[i] = rand()%10;
}
}


__device__ float GetElement(const Matrix A,int row,int col)
{
return A.elements[row*A.stride+col];
}

__device__ void SetElement(Matrix A,int row,int col,float value)
{
A.elements[row*A.stride+col]=value;
}
__device__ Matrix GetSubMatrix(Matrix A,int row,int col)
{
Matrix Asub;
Asub.width = BLOCK_SIZE;
Asub.height = BLOCK_SIZE;
Asub.stride = A.stride;
Asub.elements = &A.elements[A.stride*BLOCK_SIZE*row+BLOCK_SIZE*col];
return Asub;
}

void shuchu(Matrix A,int N)
{

int j=0;
for(int i=0; i < N; i++)
{
if( j == A.width)
{
printf("\n");
j = 0;
i--;
}else
{
printf("%15lf",A.elements[i]);
j++;
}
}
printf("\n");
}
__global__ void MatMulKernel(Matrix A,Matrix B,Matrix C)
{
int blockRow = hipBlockIdx_y;
int blockCol = hipBlockIdx_x;
Matrix Csub = GetSubMatrix(C,blockRow,blockCol);
float Cvalue = 0;
int row = hipThreadIdx_y;
int col = hipThreadIdx_x;
for(int m=0; m<(A.width/BLOCK_SIZE);++m)
{
Matrix Asub = GetSubMatrix(A,blockRow,m);
Matrix Bsub = GetSubMatrix(B,m,blockCol);
__shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
__shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];

As[row][col]=GetElement(Asub,row,col);
Bs[row][col]=GetElement(Bsub,row,col);

__syncthreads();
for(int e = 0;e<BLOCK_SIZE;++e)
{
Cvalue += As[row][e]*Bs[e][col];
}
__syncthreads();
SetElement(Csub,row,col,Cvalue);
}
}

void MatMul(const Matrix A,const Matrix B,Matrix C)
{
Matrix d_A;
d_A.width = d_A.stride = A.width;
d_A.height = A.height;
size_t size = A.width * A.height * sizeof(float);
hipMalloc(&d_A.elements,size);
hipMemcpy(d_A.elements,A.elements,size,hipMemcpyHostToDevice);

Matrix d_B;
d_B.width = d_B.stride=B.width;
d_B.height = B.height;
size = B.width * B.height * sizeof(float);
hipMalloc(&d_B.elements,size);
hipMemcpy(d_B.elements,B.elements,size,hipMemcpyHostToDevice);

Matrix d_C;
d_C.width = d_C.stride = C.width;
d_C.height = C.height;
size = C.width * C.height * sizeof(float);
hipMalloc(&d_C.elements,size);
dim3 dimBlock(BLOCK_SIZE,BLOCK_SIZE);
dim3 dimGrid(B.width / dimBlock.x,A.height / dimBlock.y);

float gpu_time;
hipEvent_t start_GPU,stop_GPU;
hipEventCreate(&start_GPU);
hipEventCreate(&stop_GPU);
hipEventRecord(start_GPU,0);

hipLaunchKernelGGL(MatMulKernel,dimGrid,dimBlock,0,0,d_A,d_B,d_C);

hipEventRecord(stop_GPU,0);
hipEventSynchronize(start_GPU);
hipEventSynchronize(stop_GPU);
hipEventElapsedTime(&gpu_time,start_GPU,stop_GPU);
hipDeviceSynchronize();
printf("\nGPU spend time is: %lf(ms)\n",gpu_time/1000);
hipEventDestroy(start_GPU);
hipEventDestroy(stop_GPU);

hipMemcpy(C.elements,d_C.elements,size,hipMemcpyDeviceToHost);
printf("\nGPU result is:\n");
shuchu(C,C.width*C.height);
hipFree(d_A.elements);
hipFree(d_B.elements);
hipFree(d_C.elements);
}

//使用CPU进行计算
void CpuMatrix(Matrix A,Matrix B,Matrix C)
{
int M,N,K;
M = A.height;
N = B.width;
K = A.width;
int i,j,k;
for(i = 0;i < M;i++)
{
for(j = 0;j<N;j++)
{
float sum = 0.0;
for(k = 0;k<K;k++)
{
sum += A.elements[i * K + k] * B.elements[k * N + j];
}
C.elements[i * N + j] = sum;
}
}
}
int main()
{
Matrix A;
Matrix B;
Matrix C;

A.width = BLOCK_SIZE;
A.height = BLOCK_SIZE;
B.width = BLOCK_SIZE;
B.height = BLOCK_SIZE;
C.width = BLOCK_SIZE;
C.height = BLOCK_SIZE;

int size_A = A.width * A.height * sizeof(float);
int size_B = B.width * B.height * sizeof(float);
int size_C = C.width * C.height * sizeof(float);

A.elements = (float *)malloc(size_A);
B.elements = (float *)malloc(size_B);
C.elements = (float *)malloc(size_C);

initial(A.elements,A.height*A.width);
printf("A:\n");
shuchu(A,A.width*A.height);

printf("\nB:\n");
initial(B.elements,B.height*B.width);
shuchu(B,B.width*B.height);

CpuMatrix(A,B,C);
printf("\nCPU result :\n");
shuchu(C,C.width*C.height);
MatMul(A,B,C);
return 0;
}

运行结果如下:

img

TAU

TAU是一个面向MPI与OpenMP并行程序的profiler,在目前看到的OpenMPI的Profiler中算是比较健全的一个(x)。官网:https://www.cs.uoregon.edu/research/tau/home.php

其实Intel的vtune也不是不能用,但是面向OpenMPI的时候会有些限制。TAU可以根据不同的MPI发行版重新编译,感觉会好一些。

安装

官方的INSTALL中给出的configure建议参数:

1
Copy% ./configure -c++=mpicxx -cc=mpicc -fortran=mpif90 -mpi -ompt -iowrapper -bfd=download -dwarf=download -otf=download -unwind=download -pdt=<path_to_pdt_dir>

pdt不使用,因为是一个挺大的东西,目前还没用过。而且一般profile到第二层就足够了(见使用部分)。ompt也有比较大的概率不支持,可以直接关掉。ompt是貌似是OpenMP提供给外部工具的一个接口,如果没有OpenMP成分的话关掉没什么影响。(https://www.openmp.org/spec-html/5.0/openmpsu15.html)

之后发现BFD也是直接下载不了,关了。没发现有什么用。

最后改成:

1
Copy./configure -c++=mpicxx -cc=mpicc -fortran=mpif90 -mpi -iowrapper -dwarf=download -otf=download -unwind=download -prefix=<prefix>

configure结束之后有提示。根据提示添加一下PATH,然后直接make install即可。TAU的编译过程是内含在install过程中的。

在make install的过程中,大概率是要报错的。这也是TAU比较令我无语的一个地方……它的动态库编译flag基本上都是错的。需要进行一个手动修改。

在主目录的Makefile下,在Line 195左右的位置,把编译命令换成:

(-diag-disable是针对intel编译器的,如果不是intel编译器记得去掉。其实都可以去掉,但是现在用icc不加这个选项就会报出一堆的warning,看着心烦)

1
2
3
Copy@echo "*********** RECURSIVELY MAKING SUBDIRECTORIES ***********"
@for i in ${SUBDIR}; do (echo "*** COMPILING $$i DIRECTORY"; cd $$i;\
$(MAKE) "MAKE=$(MAKE)" "CC=$(CC) -diag-disable=10441 -fPIC" "TAU_F90=$(TAU_F90) -fPIC" "TAU_CC=$(TAU_CC) -fPIC" "CXX=$(CXX) -diag-disable=10441 -fPIC" "AR_SHFLAGS=-shared" "TAU_SHFLAGS=-shared -o" install ) || exit $$?; done

这里其实就是改了一堆flag。这样编译就可以正常通过了。至此完成安装。

使用

TAU提供了三种详细程度不同的profile:

截屏2023-03-24 上午10.09.29

简单来说,三种profile方式详细程度逐渐提高,但最粗略的不需要重新编译,第二种需要重新编译,最详细的一种要求使用PDT。

interposition

这个最简单。从

1
Copympirun <mpi-args> <program>

换成

1
Copympirun <mpi-args> tau_exec <program> 

即可。熟悉vtune的话会觉得差不多。

这样的方式可以得到各个MPI函数的占用的时间,但是没法得到callpath等更细致的信息。

recompile

需要重新编译。

TAU的configure方式比较特殊。如果在配置完一个TAU之后,再在相同的目录下configure一个配置不同的TAU,其内容不会被覆盖,而是两个版本并存。TAU通过一个Makefile来决定使用哪种配置,Makefile在<prefix>/x86_64/lib下。

相应控制TAU的配置的环境变量是TAU_MAKEFILE.

TAU提供的编译器是tau_cc.sh,tau_f90.sh等,其实就是一层wrapper,根据MAKEFILE的内容来指定底层实际的编译器。用这几个命令来编译的时候也可以看到,就是多连接了一些库。

recompile之后不需要额外的运行参数,只需要正常运行就能生成profile文件。不过说实话,如果不开启一些环境变量,感觉和interposition相比也没有丰富多少。

1
CopyPROFILEDIR=$PWD/tau-pro mpirun --host f0104:72,f0105:72 -n 144 <program>

有用的环境变量包括:

TAU_TRACE:开启tracing。生成文件的格式会改变,并且会变得很大。此时文件夹由TRACEDIR决定。

TAU_COMM_MATRIX:为1时启动通讯记录。这样profile中会记录下哪个进程与哪个进程进行了多少通讯,比较直观。

TAU_CALLPATH:为1时追踪函数的调用记录。没有开的时候,只会记录下这个函数总的调用花了多少时间;开了之后会记录从哪个函数中调用这个函数花了多少时间。不过,在recompile等级下好像也没法追踪到自定义的函数体(可能是因为我profile的是fortran的代码?不清楚),并且展开之后会比较乱,也不一定好用。

最后得到的文件使用paraprof工具可以进行可视化。能实现的比较有用的功能:

  • 单个进程的各个MPI函数调用时间排序;
  • 单个MPI函数在各个进程上调用时间分布直方图;
  • Communication Matrix。

测试过程

动态插桩(Dynamic instrumentation)

在mpirun的命令中插入一个tau_exec,实现动态插桩。普通的MPI运行命令。后面是一系列程序运行的参数:

1
mpirun -np 8 ./swap -k 19 -c 5 -i ./data/S.aureus.fasta -o Saur_k19_c5

加了tau_exec之后的运行命令:

1
mpirun -np 8 tau_exec ./swap -k 19 -c 5 -i ./data/S.aureus.fasta -o Saur_k19_c5

接着目录下会多了几个类似于profile.0.0.0的文件。直接在当前目录下执行pprof命令:

1
pprof

显示结果如下图所示:

在这里插入图片描述

这种方法只能够查看到MPI的函数调用情况,并不能看到用户的自定义函数的调用情况。因此不太推荐这种插桩方法。

源码插桩(Source instrumentation)

直接在源码中进行插桩。

首先,要选择我们想要借助TAU获得的信息(e.g. MPI support, tracing, CUDA hardware counters, etc)。我们要将TAU_MAKEFILE变量设置为相应的pdt。因为我们现在使用TAU来测MPI程序的信息,因此将TAU_MAKEFILE变量设为tau-mpi-pdt:

1
export TAU_MAKEFILE=$TAU_HOME/lib/Makefile.tau-mpi-pdt

接着,使用tau_cc.sh或者tau_cxx.sh而不是使用mpicc或者mpicxx来编译cpp文件。以下代码是从别处抄来的,因为我测的这个程序使用MakeFile文件来进行编译的,我就直接在MakeFile文件中进行修改,将mpicxx替换成tau_cxx.sh。

1
tau_cxx.sh wave2d.cpp -o wave2d

编译完成后,还是使用mpirun运行:

1
mpirun -np 4 tau_exec ./swap -k 19 -c 5 -i ./data/S.aureus.fasta -o Saur_k19_c5

接着就是使用各种可视化工具来对性能测试的数据进行可视化。pprof是一个基于文本的可视化工具。先使用pprof试试:

1
pprof

可以看出确实多了很多用户自定义函数的执行情况,而不是只限于MPI函数。但是可能是因为没有解析出来的缘故,很多函数都只是给出了地址,而没有给出函数名字。

基于编译器的插桩(Compiler-based instrumentation)和可选择代码区域的插桩(Selective instrumentation

基于编译器的插桩介于Source和Dynamic之间。而选择代码区域的插桩大致就是在代码中指定一块区域。两个我都没怎么使用过,就不介绍了。文档中还是推荐使用源码(Source)插桩。

GDB

贴原文链接:https://blog.csdn.net/zb872676223/article/details/37906049

原理

在前面几节,我们讲了gdb的命令,以及这些命令在调试时候的作用,并以例子进行了演示。作为C/C++ coder,要知其然,更要知其所以然。所以,借助本节,我们大概讲下GDB调试的原理。

gdb 通过系统调用 ptrace 来接管一个进程的执行。ptrace 系统调用提供了一种方法使得父进程可以观察和控制其它进程的执行,检查和改变其核心映像以及寄存器。它主要用来实现断点调试和系统调用跟踪。

ptrace系统调用定义如下:

1
2
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data)
  • pid_t pid:指示 ptrace 要跟踪的进程
  • void *addr:指示要监控的内存地址
  • enum __ptrace_request request:决定了系统调用的功能,几个主要的选项:
    • PTRACE_TRACEME:表示此进程将被父进程跟踪,任何信号(除了 SIGKILL)都会暂停子进程,接着阻塞于 wait() 等待的父进程被唤醒。子进程内部对 exec() 的调用将发出 SIGTRAP 信号,这可以让父进程在子进程新程序开始运行之前就完全控制它
    • PTRACE_ATTACH:attach 到一个指定的进程,使其成为当前进程跟踪的子进程,而子进程的行为等同于它进行了一次 PTRACE_TRACEME 操作。但需要注意的是,虽然当前进程成为被跟踪进程的父进程,但是子进程使用 getppid() 的到的仍将是其原始父进程的pid
    • PTRACE_CONT:继续运行之前停止的子进程。可同时向子进程交付指定的信号

调试原理

运行并调试新进程,步骤如下:

  • 运行gdb exe
  • 输入run命令,gdb执行以下操作:
  • 通过fork()系统调用创建一个新进程
  • 在新创建的子进程中执行ptrace(PTRACE_TRACEME, 0, 0, 0)操作
  • 在子进程中通过execv()系统调用加载指定的可执行文件

attach运行的进程

可以通过gdb attach pid来调试一个运行的进程,gdb将对指定进程执行ptrace(PTRACE_ATTACH, pid, 0, 0)操作。需要注意的是,当我们attach一个进程id时候,可能会报如下错误:

1
2
Attaching to process 28849
ptrace: Operation not permitted.

这是因为没有权限进行操作,可以根据启动该进程用户下或者root下进行操作。

断点原理

实现原理

当我们通过b或者break设置断点时候,就是在指定位置插入断点指令,当被调试的程序运行到断点的时候,产生SIGTRAP信号。该信号被gdb捕获并 进行断点命中判断。

设置原理

在程序中设置断点,就是先在该位置保存原指令,然后在该位置写入int 3。当执行到int 3时,发生软中断,内核会向子进程发送SIGTRAP信号。当然,这个信号会转发给父进程。然后用保存的指令替换int 3并等待操作恢复。

命中判断

gdb将所有断点位置存储在一个链表中。命中判定将被调试程序的当前停止位置与链表中的断点位置进行比较,以查看断点产生的信号。

条件判断

在断点处恢复指令后,增加了一个条件判断。如果表达式为真,则触发断点。由于需要判断一次,添加条件断点后,是否触发条件断点,都会影响性能。在 x86 平台上,部分硬件支持硬件断点。不是在条件断点处插入 int 3,而是插入另一条指令。当程序到达这个地址时,不是发出int 3信号,而是进行比较。特定寄存器的内容和某个地址,然后决定是否发送int 3。因此,当你的断点位置被程序频繁“通过”时,尽量使用硬件断点,这将有助于提高性能。

单步原理

这个ptrace函数本身就支持,可以通过ptrace(PTRACE_SINGLESTEP, pid,…)调用来实现单步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
printf("attaching to PID %d\n", pid);
if (ptrace(PTRACE_ATTACH, pid, 0, 0) != 0)
{
perror("attach failed");
}
int waitStat = 0;
int waitRes = waitpid(pid, &waitStat, WUNTRACED);
if (waitRes != pid || !WIFSTOPPED(waitStat))
{
printf("unexpected waitpid result!\n");
exit(1);
}

int64_t numSteps = 0;
while (true) {
auto res = ptrace(PTRACE_SINGLESTEP, pid, 0, 0);
}

上述代码,首先接收一个pid,然后对其进行attach,最后调用ptrace进行单步调试。

测试程序

我们先看看我们的测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/* in eg1.c */
int wib(int no1, int no2)
{
int result, diff;
diff = no1 - no2;
result = no1 / diff;
return result;
}

int main()
{
pid_t pid;

pid = fork();
if (pid <0) {
printf("fork err\n");
exit(-1);
} else if (pid == 0) {
/* in child process */
sleep(60); ------------------ (!)

int value = 10;
int div = 6;
int total = 0;
int i = 0;
int result = 0;

for (i = 0; i < 10; i++) {
result = wib(value, div);
total += result;
div++;
value--;
}
printf("%d wibed by %d equals %d\n", value, div, total);
exit(0);
} else {
/* in parent process */
sleep(4);
wait(-1);
exit(0);
}
}

该测试程序中子进程运行过程中会在wib函数中出现一个’除0’异常。现在我们就要调试该子进程。

调试原理

不知道大家发现没有,在(!)处在我们的测试程序在父进程fork后,子进程调用sleep睡了60秒。这就是关键,这个sleep本来是不该存在于子进程代码中的,而是而了使用GDB调试后加入的,它是我们调试的一个关键点。为什么要让子进程刚刚运行就开始sleep呢?因为我们要在子进程睡眠期间,利用 shell命令获取其process id,然后再利用gdb调试外部进程的方法attach到该process id上,调试该进程。
我们现在调试的是mpi程序,intel的mpiexec可以直接-gdb进行调试,但是用gnu的话就不行了,只能gdb attach来调试,下述。

调试过程

GDB 调试程序的前提条件就是你编译程序时必须加入调试符号信息,即使用’-g’编译选项。首先编译我们的源程序gcc -g -o eg1 eg1.c。编译好之后,我们就有了我们的调试目标eg1。由于我们在调试过程中需要多个工具配合,所以你最好多打开几个终端窗口,另外一点需要注意的是最好在eg1的working directory下执行gdb程序,否则gdb回提示’No symbol table is loaded’。你还得手工load symbol table。好了,下面我们就’按部就班’的开始调试我们的eg1。

执行eg1:eg1 & --- 让eg1后台运行

查找进程id:ps -fu YOUR_USER_NAME

或在linux下使用getpid()函数

运行gdb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
gdb
(gdb) attach xxxxx --- xxxxx为利用ps命令获得的子进程process id
(gdb) stop --- 这点很重要,你需要先暂停那个子进程,然后设置一些断点和一些Watch
(gdb) break 37 -- 在result = wib(value, div);这行设置一个断点,可以使用list命令察看源代码
Breakpoint 1 at 0x10808: file eg1.c, line 37.
(gdb) continue
Continuing.

Breakpoint 1, main () at eg1.c:37
37 result = wib(value, div);
(gdb) step
wib (no1=10, no2=6) at eg1.c:13
13 diff = no1 - no2;
(gdb) continue
Continuing.

Breakpoint 1, main () at eg1.c:37
37 result = wib(value, div);
(gdb) step
wib (no1=9, no2=7) at eg1.c:13
13 diff = no1 - no2;
(gdb) continue
Continuing.

Breakpoint 1, main () at eg1.c:37
37 result = wib(value, div);
(gdb) step
wib (no1=8, no2=8) at eg1.c:13
13 diff = no1 - no2;
(gdb) next
14 result = no1 / diff;
(gdb) print diff
$6 = 0 ------- 除数为0,我们找到罪魁祸首了。
(gdb) next
Program received signal SIGFPE, Arithmetic exception.
0xff29d830 in .div () from /usr/lib/libc.so.1

至此,我们调试完毕。

GDB调试精粹

一、列文件清单

list / l
列出产生执行文件的源代码的一部分

1
2
3
4
5
6
7
8
9
10
11
//列出 line1 到 line2 行之间的源代码  
(gdb) list line1, line2

//输出从上次调用list命令开始往后的10行程序代码
(gdb) list

//输出第 n 行附近的10行程序代码
(gdb) list n

//输出函数function前后的10行程序代码
(gdb) list function

二、执行程序

run / r
运行准备调试的程序,在它后面可以跟随发给该程序的任何参数,包括标准输入和标准输出说明符(<和>)和shell通配符(*、?、[、])在内。
如果你使用不带参数的run命令,gdb就再次使用你给予前一条run命令的参数,这是很有用的。

set args
命令就可以修改发送给程序的参数,而使用

show args
命令就可以查看其缺省参数的列表。

1
2
(gdb) set args –b –x  
(gdb) show args

三、显示数据

print / p
查看变量的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//利用print 命令可以检查各个变量的值。  
(gdb) print p (p为变量名)
print 是 gdb 的一个功能很强的命令,利用它可以显示被调试的语言中任何有效的表达式。表达式除了包含你程序中的变量外,还可以包含以下内容:

//对程序中函数的调用
(gdb) print find_entry(1, 0)

//数据结构和其他复杂对象
(gdb) print *table_start
$8={e=reference=’\000’,location=0x0,next=0x0}

//值的历史成分
(gdb)print $1 ($1为历史记录变量,在以后可以直接引用 $1 的值)
whatis

查看变量的类型

//whatis 命令可以显示某个变量的类型
(gdb) whatis p
type = int *

四、设置与清除断点

break / b
可以用来在调试的程序中设置断点,该命令有如下四种形式:

1
2
3
4
5
6
7
8
9
10
11
12
//使程序恰好在执行给定行之前停止  
break line-number

//使程序恰好在进入指定的函数之前停止

break function-name

//如果condition(条件)是真,程序到达指定行或函数时停止
break line-or-function if condition

//在指定例程的入口处设置断点
break routine-name

如果该程序是由很多原文件构成的,你可以在各个原文件中设置断点,而不是在当前的原文件中设置断点,其方法如下:

1
2
3
4
(gdb) break filename:line-number  

(gdb) break filename:function-name
break if

要想设置一个条件断点,可以利用break if命令,如下所示:

1
2
3
4
(gdb) break line-or-function if expr  

(gdb) break 46 if testsize==100
clean number

清除原文件中某一代码行上的所有断点

注:number 为原文件的某个代码行的行号

五、断点的管理

断点是我们在调试中经常用的一个功能,我们在指定位置设置断点之后,程序运行到该位置将会暂停,这个时候我们就可以对程序进行更多的操作,比如查看变量内容,堆栈情况等等,以帮助我们调试程序。

以设置断点的命令分为以下几类:

  • breakpoint
  • watchpoint
  • catchpoint
  1. 显示当前gdb的断点信息
    info break

  2. delete 删除指定的某个断点
    delete breakpoint

1
2
3
4
5
//该命令将会删除编号为1的断点  
(gdb) delete breakpoint 1

//如果不带编号参数,将删除所有的断点
(gdb) delete breakpoint
  1. 禁止、允许使用某个断点
1
2
disable breakpoint 1
enable breakpoint 1

该命令将禁止、允许断点 1,同时断点信息的 (Enb)域将变为 n、y

breakpoint可以根据行号、函数、条件生成断点,下面是相关命令以及对应的作用说明:

命令 作用
break [file]:function 在文件file的function函数入口设置断点
break [file]:line 在文件file的第line行设置断点
info breakpoints 查看断点列表
break [+-]offset 在当前位置偏移量为[+-]offset处设置断点
break *addr 在地址addr处设置断点
break … if expr 设置条件断点,仅仅在条件满足时
ignore n count 接下来对于编号为n的断点忽略count次
clear 删除所有断点
clear function 删除所有位于function内的断点
delete n 删除指定编号的断点
enable n 启用指定编号的断点
disable n 禁用指定编号的断点
save breakpoints file 保存断点信息到指定文件
source file 导入文件中保存的断点信息
break 在下一个指令处设置断点
clear [file:]line 删除第line行的断点

watchpoint是一种特殊类型的断点,类似于正常断点,是要求GDB暂停程序执行的命令。区别在于watchpoint没有驻留某一行源代码中,而是指示GDB每当某个表达式改变了值就暂停执行的命令。

watchpoint分为硬件实现和软件实现两种。前者需要硬件系统的支持;后者的原理就是每步执行后都检查变量的值是否改变。GDB在新建数据断点时会优先尝试硬件方式,如果失败再尝试软件实现。

命令 作用
watch variable 设置变量数据断点
watch var1 + var2 设置表达式数据断点
rwatch variable 设置读断点,仅支持硬件实现
awatch variable 设置读写断点,仅支持硬件实现
info watchpoints 查看数据断点列表
set can-use-hw-watchpoints 0 强制基于软件方式实现

使用数据断点时,需要注意:

  • 当监控变量为局部变量时,一旦局部变量失效,数据断点也会失效
  • 如果监控的是指针变量p,则watch *p监控的是p所指内存数据的变化情况,而watch p监控的是p指针本身有没有改变指向

最常见的数据断点应用场景:「定位堆上的结构体内部成员何时被修改」。由于指针一般为局部变量,为了解决断点失效,一般有两种方法。

命令 作用
print &variable 查看变量的内存地址
watch *(type *)address 通过内存地址间接设置断点
watch -l variable 指定location参数
watch variable thread 1 仅编号为1的线程修改变量var值时会中断

catchpoint从字面意思理解,是捕获断点,其主要监测信号的产生。例如c++的throw,或者加载库的时候,产生断点行为。

命令 含义
catch fork 程序调用fork时中断
tcatch fork 设置的断点只触发一次,之后被自动删除
catch syscall ptrace 为ptrace系统调用设置断点

六、单步执行

next / n
不进入的单步执行

step
进入的单步执行

finish
如果已经进入了某函数,而想退出该函数返回到它的调用函数中,可使用命令finish

until
结束当前循环

七、函数的调用

call name
调用和执行一个函数

1
2
3
(gdb) call gen_and_sork( 1234,1,0 )  
(gdb) call printf(“abcd”)
$1=4

八、 原文件的搜索

1
search text

该命令可显示在当前文件中包含text串的下一行。

1
reverse-search text

该命令可以显示包含text 的前一行。

小结:常用的 gdb 命令

backtrace / bt 显示程序中的当前位置和表示如何到达当前位置的栈跟踪(同义词:where)

breakpoint / b 在程序中设置一个断点

cd 改变当前工作目录

clear 删除刚才停止处的断点

commands 命中断点时,列出将要执行的命令

continue 从断点开始继续执行

delete 删除一个断点或监测点;也可与其他命令一起使用

display 程序停止时显示变量和表达时

down 下移栈帧,使得另一个函数成为当前函数

frame 选择下一条continue命令的帧

info 显示与该程序有关的各种信息

jump 在源程序中的另一点开始运行

kill 异常终止在gdb 控制下运行的程序

list 列出相应于正在执行的程序的原文件内容

next 执行下一个源程序行,从而执行其整体中的一个函数

print 显示变量或表达式的值

pwd 显示当前工作目录

ptype 显示一个数据结构(如一个结构或C++类)的内容

quit 退出gdb

reverse-search 在源文件中反向搜索正规表达式

run 执行该程序

search 在源文件中搜索正规表达式

set variable 给变量赋值

signal 将一个信号发送到正在运行的进程

step 执行下一个源程序行,必要时进入下一个函数

undisplay display 命令的反命令,不要显示表达式

until 结束当前循环

up 上移栈帧,使另一函数成为当前函数

watch 在程序中设置一个监测点(即数据断点)

whatis 显示变量或函数类型

九、查看运行时数据

在你调试程序时,当程序被停住时,你可以使用print命令(简写命令为p),或是同义命令inspect来查看当前程序的运行数据。print命令的格式是:
print

print / 是表达式,是你所调试的程序的语言的表达式(GDB可以调试多种编程语言),是输出的格式,比如,如果要把表达式按16进制的格式输出,那么就是/x。

  1. 表达式
    print和许多GDB的命令一样,可以接受一个表达式,GDB会根据当前的程序运行的数据来计算这个表达式,既然是表达式,那么就可以是当前程序运行中的const常量、变量、函数等内容。可惜的是GDB不能使用你在程序中所定义的宏。表达式的语法应该是当前所调试的语言的语法,由于C/C++是一种大众型的语言,所以,本文中的例子都是关于C/C++的。(而关于用GDB调试其它语言的章节,我将在后面介绍)。在表达式中,有几种GDB所支持的操作符,它们可以用在任何一种语言中。

@是一个和数组有关的操作符,在后面会有更详细的说明。 :: 指定一个在文件或是一个函数中的变量。 {} 表示一个指向内存地址的类型为type的一个对象。

  1. 程序变量
    在GDB中,你可以随时查看以下三种变量的值:
  2. 全局变量(所有文件可见的)
  3. 静态全局变量(当前文件可见的)
  4. 局部变量(当前Scope可见的)

如果你的局部变量和全局变量发生冲突(也就是重名),一般情况下是局部变量会隐藏全局变量,也就是说,如果一个全局变量和一个函数中的局部变量同名时,如果当前停止点在函数中,用print显示出的变量的值会是函数中的局部变量的值。如果此时你想查看全局变量的值时,你可以使用“::”操作符:

1
2
file::variable 
function::variable

可以通过这种形式指定你所想查看的变量,是哪个文件中的或是哪个函数中的。例如,查看文件f2.c中的全局变量x的值:

1
(gdb) p 'f2.c'::x 

当然,“::”操作符会和C++中的发生冲突,GDB能自动识别“::” 是否C++的操作符,所以你不必担心在调试C++程序时会出现异常。 另外,需要注意的是,如果你的程序编译时开启了优化选项,那么在用GDB调试被优化过的程序时,可能会发生某些变量不能访问,或是取值错误码的情况。这个是很正常的,因为优化程序会删改你的程序,整理你程序的语句顺序,剔除一些无意义的变量等,所以在GDB调试这种程序时,运行时的指令和你所编写指令就有不一样,也就会出现你所想象不到的结果。对付这种情况时,需要在编译程序时关闭编译优化。一般来说,几乎所有的编译器都支持编译优化的开关,例如,GNU的C/C++编译器GCC,你可以使用“-gstabs”选项来解决这个问题。关于编译器的参数,还请查看编译器的使用说明文档。

  1. 数组
    有时候,你需要查看一段连续的内存空间的值。比如数组的一段,或是动态分配的数据的大小。你可以使用GDB的“@”操作符,“@”的左边是第一个内存的地址的值,“@”的右边则你你想查看内存的长度。例如,你的程序中有这样的语句:
    int *array = (int *) malloc (len * sizeof (int));
    于是,在GDB调试过程中,你可以以如下命令显示出这个动态数组的取值:
1
p *array@len 

@的左边是数组的首地址的值,也就是变量array所指向的内容,右边则是数据的长度,其保存在变量len中,其输出结果,大约是下面这个样子的:

1
2
(gdb) p *array@len 
$1 = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40}

如果是静态数组的话,可以直接用print数组名,就可以显示数组中所有数据的内容了。

  1. 输出格式
    一般来说,GDB会根据变量的类型输出变量的值。但你也可以自定义GDB的输出的格式。例如,你想输出一个整数的十六进制,或是二进制来查看这个整型变量的中的位的情况。要做到这样,你可以使用GDB的数据显示格式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
x 按十六进制格式显示变量。 
d 按十进制格式显示变量。
u 按十六进制格式显示无符号整型。
o 按八进制格式显示变量。
t 按二进制格式显示变量。
a 按十六进制格式显示变量。
c 按字符格式显示变量。
f 按浮点数格式显示变量。
(gdb) p i
$21 = 101
(gdb) p/a i
$22 = 0x65
(gdb) p/c i
$23 = 101 'e'
(gdb) p/f i
$24 = 1.41531145e-43
(gdb) p/x i
$25 = 0x65
(gdb) p/t i
$26 = 1100101
  1. 查看内存
    你可以使用examine命令(简写是x)来查看内存地址中的值。x命令的语法如下所示:
1
x/ 

n、f、u是可选的参数。
n 是一个正整数,表示显示内存的长度,也就是说从当前地址向后显示几个地址的内容。 f 表示显示的格式,参见上面。如果地址所指的是字符串,那么格式可以是s,如果地址是指令地址,那么格式可以是i。u 表示从当前地址往后请求的字节数,如果不指定的话,GDB默认是4个bytes。u参数可以用下面的字符来代替,b表示单字节,h表示双字节,w表示四字节,g表示八字节。当我们指定了字节长度后,GDB会从指内存定的内存地址开始,读写指定字节,并把其当作一个值取出来。

n/f/u三个参数可以一起使用。例如:

  • 命令:x/3uh 0x54320 表示,从内存地址0x54320读取内容,h表示以双字节为一个单位,3表示三个单位,u表示按十六进制显示。
  1. 自动显示
    你可以设置一些自动显示的变量,当程序停住时,或是在你单步跟踪时,这些变量会自动显示。相关的GDB命令是display。
    display
    格式i和s同样被display支持,一个非常有用的命令是:
    display/i $pc
    $pc是GDB的环境变量,表示着指令的地址,/i则表示输出格式为机器指令码,也就是汇编。于是当程序停下后,就会出现源代码和机器指令码相对应的情形,这是一个很有意思的功能。
    info display
    查看display设置的自动显示的信息。GDB会打出一张表格,向你报告当然调试中设置了多少个自动显示设置,其中包括,设置的编号,表达式,是否enable。

  2. 设置显示选项
    GDB中关于显示的选项比较多,这里我只例举大多数常用的选项。

1
2
set print address 
set print address on

打开地址输出,当程序显示函数信息时,GDB会显出函数的参数地址。系统默认为打开的,
如:

1
2
3
4
5
(gdb) f 
#0 set_quotes (lq=0x34c78 "<<", rq=0x34c88 ">>")
at input.c:530
530 if (lquote != def_lquote)
set print address off

关闭函数的参数地址显示,如:

1
2
3
4
(gdb) set print addr off 
(gdb) f
#0 set_quotes (lq="<<", rq=">>") at input.c:530
530 if (lquote != def_lquote)
1
show print address 

查看当前地址显示选项是否打开。

1
2
set print array 
set print array on

打开数组显示,打开后当数组显示时,每个元素占一行,如果不打开的话,每个元素则以逗号分隔。这个选项默认是关闭的。与之相关的两个命令如下,我就不再多说了。
set print array off
show print array

1
set print elements 

这个选项主要是设置数组的,如果你的数组太大了,那么就可以指定一个来指定数据显示的最大长度,当到达这个长度时,GDB就不再往下显示了。如果设置为0,则表示不限制。

1
show print elements

查看print elements的选项信息。

1
set print null-stop 

如果打开了这个选项,那么当显示字符串时,遇到结束符则停止显示。这个选项默认为off。

1
set print pretty on 

如果打开printf pretty这个选项,那么当GDB显示结构体时会比较漂亮。如:

1
2
3
4
5
6
7
8
$1 = { 
next = 0x0,
flags = {
sweet = 1,
sour = 1
},
meat = 0x54 "Pork"
}
1
set print pretty off 

关闭printf pretty这个选项,GDB显示结构体时会如下显示:

1
$1 = {next = 0x0, flags = {sweet = 1, sour = 1}, meat = 0x54 "Pork"} 

show print pretty 查看GDB是如何显示结构体的。

set print sevenbit-strings
设置字符显示,是否按“/nnn”的格式显示,如果打开,则字符串或字符数据按/nnn显示,
如“/065”。

show print sevenbit-strings
查看字符显示开关是否打开。

set print union
设置显示结构体时,是否显式其内的联合体数据。例如有以下数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef enum {Tree, Bug} Species; 
typedef enum {Big_tree, Acorn, Seedling} Tree_forms;
typedef enum {Caterpillar, Cocoon, Butterfly}
Bug_forms;
struct thing {
Species it;
union {

Tree_forms tree;
Bug_forms bug;
} form;
};
struct thing foo = {Tree, {Acorn}};

当打开这个开关时,执行 p foo 命令后,会如下显示:
$1 = {it = Tree, form = {tree = Acorn, bug = Cocoon}}
当关闭这个开关时,执行 p foo 命令后,会如下显示:
$1 = {it = Tree, form = {...}}

show print union
查看联合体数据的显示方式
set print object
在C++中,如果一个对象指针指向其派生类,如果打开这个选项,GDB会自动按照虚方法调用的规则显示输出,如果关闭这个选项的话,GDB就不管虚函数表了。这个选项默认是off。

show print object
查看对象选项的设置。

set print static-members
这个选项表示,当显示一个C++对象中的内容是,是否显示其中的静态数据成员。默认是on。

show print static-members
查看静态数据成员选项设置。

set print vtbl
当此选项打开时,GDB将用比较规整的格式来显示虚函数表时。其默认是关闭的。

show print vtbl
查看虚函数显示格式的选项。

  1. 历史记录
    当你用GDB的print查看程序运行时的数据时,你每一个print都会被GDB记录下来。GDB会以$1, $2, $3 …..这样的方式为你每一个print命令编上号。于是,你可以使用这个编号访问以前的表达式,如$1。这个功能所带来的好处是,如果你先前输入了一个比较长的表达式,如果你还想查看这个表达式的值,你可以使用历史记录来访问,省去了重复输入。

  2. GDB环境变量
    你可以在GDB的调试环境中定义自己的变量,用来保存一些调试程序中的运行数据。要定义一个GDB的变量很简单只需。使用GDB的set命令。GDB的环境变量和UNIX一样,也是以$起头。如:set $foo = *object_ptr

使用环境变量时,GDB会在你第一次使用时创建这个变量,而在以后的使用中,则直接对其賦值。环境变量没有类型,你可以给环境变量定义任一的类型。包括结构体和数组。

show convenience
该命令查看当前所设置的所有的环境变量。
这是一个比较强大的功能,环境变量和程序变量的交互使用,将使得程序调试更为灵活便捷。
例如:

1
2
set $i = 0 
print bar[$i++]->contents

于是,当你就不必,print bar[0]->contents, print bar[1]->contents地输入命令了。输入这样的命令后,只用敲回车,重复执行上一条语句,环境变量会自动累加,从而完成逐个输出的功能。

  1. 查看寄存器
    要查看寄存器的值,很简单,可以使用如下命令:

info registers
查看寄存器的情况。(除了浮点寄存器)

info all-registers
查看所有寄存器的情况。(包括浮点寄存器)

info registers
查看所指定的寄存器的情况。
寄存器中放置了程序运行时的数据,比如程序当前运行的指令地址(ip),程序的当前堆栈地址(sp)等等。你同样可以使用print命令来访问寄存器的情况,只需要在寄存器名字前加一个$符号就可以了。如:p $eip。

  1. 改变程序的执行

一旦使用GDB挂上被调试程序,当程序运行起来后,你可以根据自己的调试思路来动态地在GDB中更改当前被调试程序的运行线路或是其变量的值,这个强大的功能能够让你更好的调试你的程序,比如,你可以在程序的一次运行中走遍程序的所有分支。

修改变量值
修改被调试程序运行时的变量值,在GDB中很容易实现,使用GDB的print命令即可完成。
如:

1
(gdb) print x=4 

x=4这个表达式是C/C++的语法,意为把变量x的值修改为4,如果你当前调试的语言是Pascal,那么你可以使用Pascal的语法:x:=4。
在某些时候,很有可能你的变量和GDB中的参数冲突,如:

1
2
3
4
5
6
(gdb) whatis width 
type = double
(gdb) p width
$4 = 13
(gdb) set width=47
Invalid syntax in expression.

因为,set width是GDB的命令,所以,出现了“Invalid syntax in expression”的设置错误,此时,你可以使用set var命令来告诉GDB,width不是你GDB的参数,而是程序的变量名,如:
(gdb) set var width=47

另外,还可能有些情况,GDB并不报告这种错误,所以保险起见,在你改变程序变量取值时,最好都使用set var格式的GDB命令。

跳转执行
一般来说,被调试程序会按照程序代码的运行顺序依次执行。GDB提供了乱序执行的功能,也就是说,GDB可以修改程序的执行顺序,可以让程序执行随意跳跃。这个功能可以由GDB的jump命令来完:

1
jump

指定下一条语句的运行点。可以是文件的行号,可以是file:line格式,可以是+num这种偏移量格式。表式着下一条运行语句从哪里开始。
注意,jump命令不会改变当前的程序栈中的内容,所以,当你从一个函数跳到另一个函数时,当函数运行完返回时进行弹栈操作时必然会发生错误,可能结果还是非常奇怪的,甚至于产生程序Core Dump。所以最好是同一个函数中进行跳转。 熟悉汇编的人都知道,程序运行时,有一个寄存器用于保存当前代码所在的内存地址。所以,jump命令也就是改变了这个寄存器中的值。于是,你可以使用“set $pc”来更改跳转执行的地址。如:
set $pc = 0x485

产生信号量
使用singal命令,可以产生一个信号量给被调试的程序。如:中断信号Ctrl+C。这非常方便于程序的调试,可以在程序运行的任意位置设置断点,并在该断点用GDB产生一个信号量,这种精确地在某处产生信号非常有利程序的调试。 语法是:signal ,UNIX的系统信号量通常从1到15。所以取值也在这个范围。
single命令和shell的kill命令不同,系统的kill命令发信号给被调试程序时,是由GDB截获的,而single命令所发出一信号则是直接发给被调试程序的。

强制函数返回
如果你的调试断点在某个函数中,并还有语句没有执行完。你可以使用return命令强制函数忽略还没有执行的语句并返回。
return
使用return命令取消当前函数的执行,并立即返回,如果指定了,那么该表达式的值会被认作函数的返回值。

强制调用函数

call表达式中可以一是函数,以此达到强制调用函数的目的。并显示函数的返回值,如果函数返回值是void,那么就不显示。 另一个相似的命令也可以完成这一功能——print,print后面可以跟表达式,所以也可以用他来调用函数,print和call的不同是,如果函数返回void,call则不显示,print则显示函数返回值,并把该值存入历史数据中。

GDB支持下列语言:C, C++, Fortran, PASCAL, Java, Chill, assembly, 和 Modula-2。一般说来,GDB会根据你所调试的程序来确定当然的调试语言,比如:发现文件名后缀为“.c”的,GDB会认为是C程序。文件名后缀为“.C, .cc, .cp, .cpp, .cxx, .c++”的,GDB会认为是C++程序。而后缀是“.f, .F”的,GDB会认为是Fortran程序,还有,后缀为如果是“.s, .S”的会认为是汇编语言。 也就是说,GDB会根据你所调试的程序的语言,来设置自己的语言环境,并让GDB的命令跟着语言环境的改变而改变。比如一些GDB命令需要用到表达式或变量时,这些表达式或变量的语法,完全是根据当前的语言环境而改变的。例如C/C++中对指针的语法是*p,而在Modula-2中则是p^。并且,如果你当前的程序是由几种不同语言一同编译成的,那到在调试过程中,GDB也能根据不同的语言自动地切换语言环境。这种跟着语言环境而改变的功能,真是体贴开发人员的一种设计。

下面是几个相关于GDB语言环境的命令:
show language
查看当前的语言环境。如果GDB不能识为你所调试的编程语言,那么,C语言被认为是默
认的环境。

info frame
查看当前函数的程序语言。

info source
查看当前文件的程序语言。
如果GDB没有检测出当前的程序语言,那么你也可以手动设置当前的程序语言。使用set language命令即可做到。
当set language命令后什么也不跟的话,你可以查看GDB所支持的语言种类:

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) set language 
The currently understood settings are:
local or auto Automatic setting based on source file
c Use the C language
c++ Use the C++ language
asm Use the Asm language
chill Use the Chill language
fortran Use the Fortran language
java Use the Java language
modula-2 Use the Modula-2 language
pascal Use the Pascal language
scheme Use the Scheme language

于是你可以在set language后跟上被列出来的程序语言名,来设置当前的语言环境。

多进程、多线程

多进程

GDB在调试多进程程序(程序含fork调用)时,默认只追踪父进程。可以通过命令设置,实现只追踪父进程或子进程,或者同时调试父进程和子进程。

命令 作用
info inferiors 查看进程列表
attach pid 绑定进程id
inferior num 切换到指定进程上进行调试
print $_exitcode 显示程序退出时的返回值
set follow-fork-mode child 追踪子进程
set follow-fork-mode parent 追踪父进程
set detach-on-fork on fork调用时只追踪其中一个进程
set detach-on-fork off fork调用时会同时追踪父子进程

在调试多进程程序时候,默认情况下,除了当前调试的进程,其他进程都处于挂起状态,所以,如果需要在调试当前进程的时候,其他进程也能正常执行,那么通过设置set schedule-multiple on即可。

同上面一样,我们仍然以一个例子进行模拟多进程调试,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <unistd.h>

int main()
{
pid_t pid = fork();
if (pid == -1) {
perror("fork error\n");
return -1;
}

if(pid == 0) { // 子进程
int num = 1;
while(num == 1){
sleep(10);
}
printf("this is child,pid = %d\n", getpid());
} else { // 父进程
printf("this is parent,pid = %d\n", getpid());
wait(NULL); // 等待子进程退出
}
return 0;
}

在上面代码中,包含两个进程,一个是父进程(也就是main进程),另外一个是由fork()函数创建的子进程。

在默认情况下,在多进程程序中,GDB只调试main进程,也就是说无论程序调用了多少次fork()函数创建了多少个子进程,GDB在默认情况下,只调试父进程。为了支持多进程调试,从GDB版本7.0开始支持单独调试(调试父进程或者子进程)和同时调试多个进程。

那么,我们该如何调试子进程呢?我们可以使用如下几种方式进行子进程调试。

attach

首先,无论是父进程还是子进程,都可以通过attach命令启动gdb进行调试。我们都知道,对于每个正在运行的程序,操作系统都会为其分配一个唯一ID号,也就是进程ID。如果我们知道了进程ID,就可以使用attach命令对其进行调试了。

在上面代码中,fork()函数创建的子进程内部,首先会进入while循环sleep,然后在while循环之后调用printf函数。这样做的目的有如下:

帮助attach捕获要调试的进程id
在使用gdb进行调试的时候,真正的代码(即print函数)没有被执行,这样就可以从头开始对子进程进行调试

使用如下命令编译生成可执行文件test_process

1
g++ -g test_process.cc -o test_process

现在,我们开始尝试启动调试。

1
2
3
gdb -q ./test_process
Reading symbols from /root/test_process...done.
(gdb)

这里需要说明下,之所以加-q选项,是想去掉其他不必要的输出,q为quite的缩写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(gdb) r
Starting program: /root/./test_process
Detaching after fork from child process 37482.
this is parent,pid = 37478
[Inferior 1 (process 37478) exited normally]
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb) attach 37482
//符号类输出,此处略去
(gdb) n
Single stepping until exit from function __nanosleep_nocancel,
which has no line number information.
0x00007ffff72b3cc4 in sleep () from /lib64/libc.so.6
(gdb)
Single stepping until exit from function sleep,
which has no line number information.
main () at test_process.cc:8
8 while(num==10){
(gdb)

在上述命令中,我们执行了n(next的缩写),使其重新对while循环的判断体进行判断。

1
2
3
4
5
6
7
8
(gdb) set num = 1
(gdb) n
12 printf("this is child,pid = %d\n",getpid());
(gdb) c
Continuing.
this is child,pid = 37482
[Inferior 1 (process 37482) exited normally]
(gdb)

为了退出while循环,我们使用set命令设置了num的值为1,这样条件就会失效退出while循环,进而执行下面的printf()函数;在最后我们执行了c(continue的缩写)命令,支持程序退出。

指定进程

默认情况下,GDB调试多进程程序时候,只调试父进程。GDB提供了两个命令,可以通过follow-fork-mode和detach-on-fork来指定调试父进程还是子进程。

1
follow-fork-mode

该命令的使用方式为:

1
(gdb) set follow-fork-mode mode

其中,mode有以下两个选项:

  • parent:父进程,mode的默认选项
  • child:子进程,其目的是告诉 gdb 在目标应用调用fork之后接着调试子进程而不是父进程,因为在Linux系统中fork()系统调用成功会返回两次,一次在父进程,一次在子进程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(gdb) show follow-fork-mode
Debugger response to a program call of fork or vfork is "parent".
(gdb) set follow-fork-mode child
(gdb) r
Starting program: /root/./test_process
[New process 37830]
this is parent,pid = 37826

^C
Program received signal SIGINT, Interrupt.
[Switching to process 37830]
0x00007ffff72b3e10 in __nanosleep_nocancel () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb) n
Single stepping until exit from function __nanosleep_nocancel,
which has no line number information.
0x00007ffff72b3cc4 in sleep () from /lib64/libc.so.6
(gdb) n
Single stepping until exit from function sleep,
which has no line number information.
main () at test_process.cc:8
8 while(num==10){
(gdb) show follow-fork-mode
Debugger response to a program call of fork or vfork is "child".
(gdb)

在上述命令中,我们做了如下操作:

  • show follow-fork-mode:通过该命令来查看当前处于什么模式下,通过输出可以看出,处于parent即父进程模式
  • set follow-fork-mode child:指定调试子进程模式
  • r:运行程序,直接运行程序,此时会进入子进程,然后执行while循环
  • ctrl + c:通过该命令,可以使得GDB收到SIGINT命令,从而暂停执行while循环
  • n(next):继续执行,进而进入到while循环的条件判断处
  • show follow-fork-mode:再次执行该命令,通过输出可以看出,当前处于child模式下

如果一开始指定要调试子进程还是父进程,那么使用follow-fork-mode命令完全可以满足需求;但是如果想在调试过程中,想根据实际情况在父进程和子进程之间来回切换调试呢?

GDB提供了另外一个命令:

1
(gdb) set detach-on-fork mode

其中mode有如下两个值:

  • on:默认值,即表明只调试一个进程,可以是子进程,也可以是父进程
  • off:程序中的每个进程都会被记录,进而我们可以对所有的进程进行调试

如果选择关闭detach-on-fork模式(mode为off),那么GDB将保留对所有被fork出来的进程控制,即可用调试所有被fork出来的进程。可用 使用info forks命令列出所有的可被GDB调试的fork进程,并可用使用fork命令从一个fork进程切换到另一个fork进程。

  • info forks: 打印DGB控制下的所有被fork出来的进程列表。该列表包括fork id、进程id和当前进程的位置
  • fork fork-id: 参数fork-id是GDB分配的内部fork编号,该编号可用通过上面的命令info forks获取

多线程

多线程开发在日常开发工作中很常见,所以多线程的调试技巧非常有必要掌握。

默认调试多线程时,一旦程序中断,所有线程都将暂停。如果此时再继续执行当前线程,其他线程也会同时执行。

|命令|作用|
|info threads|查看线程列表|
|print $_thread|显示当前正在调试的线程编号|
|set scheduler-locking on|调试一个线程时,其他线程暂停执行|
|set scheduler-locking off|调试一个线程时,其他线程同步执行|
|set scheduler-locking step|仅用step调试线程时其他线程不执行,用其他命令如next调试时仍执行|

如果只关心当前线程,建议临时设置 scheduler-locking 为 on,避免其他线程同时运行,导致命中其他断点分散注意力。

为了方便进行演示,我们创建一个简单的例子,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <chrono>
#include <iostream>
#include <string>
#include <thread>
#include <vector>

int fun_int(int n) {
std::this_thread::sleep_for(std::chrono::seconds(10));
std::cout << "in fun_int n = " << n << std::endl;

return 0;
}

int fun_string(const std::string &s) {
std::this_thread::sleep_for(std::chrono::seconds(10));
std::cout << "in fun_string s = " << s << std::endl;

return 0;
}

int main() {
std::vector<int> v;
v.emplace_back(1);
v.emplace_back(2);
v.emplace_back(3);

std::cout << v.size() << std::endl;

std::thread t1(fun_int, 1);
std::thread t2(fun_string, "test");

std::cout << "after thread create" << std::endl;
t1.join();
t2.join();
return 0;
}

上述代码比较简单:

  • 函数fun_int的功能是休眠10s,然后打印其参数
  • 函数fun_string功能是休眠10s,然后打印其参数
  • main函数中,创建两个线程,分别执行上述两个函数

下面是一个完整的调试过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
(gdb) b 27
Breakpoint 1 at 0x4013d5: file test.cc, line 27.
(gdb) b test.cc:32
Breakpoint 2 at 0x40142d: file test.cc, line 32.
(gdb) info b
Num Type Disp Enb Address What
1 breakpoint keep y 0x00000000004013d5 in main() at test.cc:27
2 breakpoint keep y 0x000000000040142d in main() at test.cc:32
(gdb) r
Starting program: /root/test
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".

Breakpoint 1, main () at test.cc:27
(gdb) c
Continuing.
3
[New Thread 0x7ffff6fd2700 (LWP 44996)]
in fun_int n = 1
[New Thread 0x7ffff67d1700 (LWP 44997)]

Breakpoint 2, main () at test.cc:32
32 std::cout << "after thread create" << std::endl;
(gdb) info threads
Id Target Id Frame
3 Thread 0x7ffff67d1700 (LWP 44997) "test" 0x00007ffff7051fc3 in new_heap () from /lib64/libc.so.6
2 Thread 0x7ffff6fd2700 (LWP 44996) "test" 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
* 1 Thread 0x7ffff7fe7740 (LWP 44987) "test" main () at test.cc:32
(gdb) thread 2
[Switching to thread 2 (Thread 0x7ffff6fd2700 (LWP 44996))]
#0 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
(gdb) bt
#0 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
#1 0x00007ffff7097cc4 in sleep () from /lib64/libc.so.6
#2 0x00007ffff796ceb9 in std::this_thread::__sleep_for(std::chrono::duration<long, std::ratio<1l, 1l> >, std::chrono::duration<long, std::ratio<1l, 1000000000l> >) () from /lib64/libstdc++.so.6
#3 0x00000000004018cc in std::this_thread::sleep_for<long, std::ratio<1l, 1l> > (__rtime=...) at /usr/include/c++/4.8.2/thread:281
#4 0x0000000000401307 in fun_int (n=1) at test.cc:9
#5 0x0000000000404696 in std::_Bind_simple<int (*(int))(int)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (this=0x609080)
at /usr/include/c++/4.8.2/functional:1732
#6 0x000000000040443d in std::_Bind_simple<int (*(int))(int)>::operator()() (this=0x609080) at /usr/include/c++/4.8.2/functional:1720
#7 0x000000000040436e in std::thread::_Impl<std::_Bind_simple<int (*(int))(int)> >::_M_run() (this=0x609068) at /usr/include/c++/4.8.2/thread:115
#8 0x00007ffff796d070 in ?? () from /lib64/libstdc++.so.6
#9 0x00007ffff7bc6dd5 in start_thread () from /lib64/libpthread.so.0
#10 0x00007ffff70d0ead in clone () from /lib64/libc.so.6
(gdb) c
Continuing.
after thread create
in fun_int n = 1
[Thread 0x7ffff6fd2700 (LWP 45234) exited]
in fun_string s = test
[Thread 0x7ffff67d1700 (LWP 45235) exited]
[Inferior 1 (process 45230) exited normally]
(gdb) q

在上述调试过程中:

  • b 27 在第27行加上断点
  • b test.cc:32 在第32行加上断点(效果与b 32一致)
  • info b 输出所有的断点信息
  • r 程序开始运行,并在第一个断点处暂停
  • c 执行c命令,在第二个断点处暂停,在第一个断点和第二个断点之间,创建了两个线程t1和t2
  • info threads 输出所有的线程信息,从输出上可以看出,总共有3个线程,分别为main线程、t1和t2
  • thread 2 切换至线程2
  • bt 输出线程2的堆栈信息
  • c 直至程序结束
  • q 退出gdb

动态链接库中函数的地址确定

有一个问题是我们调用了动态链接库里面的函数,我们怎么知道动态链接库里面的函数的地址呢?事实上,直到我们第一次调用这个函数,我们并不知道这个函数的地址,这个功能要做延迟绑定 lazy bind。 因为程序的分支很多,并不是所有的分支都能跑到,想想我们的异常处理,异常处理分支的动态链接库里面的函数也许永远跑不到,所以,一上来就解析所有出现过的动态库里面的函数是个浪费的办法,降低性能并且没有必要。

下面我们看下延迟绑定的效果。我写了个程序,先睡15s,然后pthread_create 一个线程。我们用LD_DEBUG观察符号的解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include<stdio.h>
#include<stdlib.h>
#include<pthread.h>

void* myfunc()
{
while(1)
{
sleep(10);
}
return NULL;
}

int main()
{
sleep(15);
pthread_t tid = 0;
int ret = pthread_create(&tid,NULL,myfunc,NULL);
if(ret)
{
fprintf(stderr,"pthread create failed %m \n");
return -1;
}

ret = pthread_join(tid,NULL);
if(ret)
{
fprintf(stderr,"pthread join failed %m\n");
return -2;
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
root@libin:~/program/C/plt_got# LD_DEBUG=symbols ./test
2849: symbol=_res; lookup in file=./test [0]
2849: symbol=_res; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=_res; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
2849: symbol=_IO_file_close; lookup in file=./test [0]
2849: symbol=_IO_file_close; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=_IO_file_close; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
2849: symbol=rpc_createerr; lookup in file=./test [0]
2849: symbol=rpc_createerr; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=rpc_createerr; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]

2849: transferring control: ./test
2849:
2849: symbol=sleep; lookup in file=./test [0]
2849: symbol=sleep; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=sleep; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
===================================================================================

然后停了15s,才解析出pthread_create的地址,由此可见,得确是运行时重定位,知道用到这个函数pthread_create才真正去找这个函数的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
2849:    
2849: symbol=sleep; lookup in file=./test [0]
2849: symbol=sleep; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=sleep; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
===================================================================================
2849: symbol=pthread_create; lookup in file=./test [0]
2849: symbol=pthread_create; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=__getpagesize; lookup in file=./test [0]
2849: symbol=__getpagesize; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=__getpagesize; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
2849: symbol=mmap; lookup in file=./test [0]
2849: symbol=mmap; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=mmap; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]

真正动态库中函数地址的解析是第一次调用的时候做的,然后如果再次用到动态库的解析过的函数,就直接用第一次解析的结果。很自然的想法就是,一定有地方存储函数的地址,否则第一次解析出来的结果,第二次调用也没法利用。 这个存储动态库函数的地方就要GOT,Global Offset Table。 OK,我们可以想象,如果我的程序里面用到了6个动态库里面的函数,那个这个GOT里面就应该存有6个条目,每个条目里面存储着对应函数的地址。事实的确是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root@libin:~/program/C/plt_got# readelf -r test

Relocation section '.rel.dyn' at offset 0x394 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
08049ff0 00000206 R_386_GLOB_DAT 00000000 __gmon_start__
0804a020 00000905 R_386_COPY 0804a020 stderr

Relocation section '.rel.plt' at offset 0x3a4 contains 6 entries:
Offset Info Type Sym.Value Sym. Name
0804a000 00000107 R_386_JUMP_SLOT 00000000 pthread_join
0804a004 00000207 R_386_JUMP_SLOT 00000000 __gmon_start__
0804a008 00000407 R_386_JUMP_SLOT 00000000 __libc_start_main
0804a00c 00000507 R_386_JUMP_SLOT 00000000 fprintf
0804a010 00000607 R_386_JUMP_SLOT 00000000 pthread_create
0804a014 00000707 R_386_JUMP_SLOT 00000000 sleep

我们看到了有全局变量stderr和__gmon_start__需要重定位,这些本文并不关心。下面是需要重定位的函数,可以看出,我们调用动态库里面的函数都在这了,fprintf是Glibc库的,pthread_create是pthread库的等等。

.got.plt这个段的起始地址是0x8049ff4。 .got.plt这个section大小为0x24 = 36,可是我们只有6个需要解析地址的function,4*6=24个字节,只需要24个字节就能存放这6个函数指针。多出来的12个字节是dynamic段地址,ModuleID 和 _dl_runtime_resolve的地址,如下图所示

OK 。我们看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) b main
Breakpoint 1 at 0x8048551: file test.c, line 19.
(gdb) r
Starting program: /home/libin/program/C/plt_got/test
[Thread debugging using libthread_db enabled]

Breakpoint 1, main () at test.c:19
19 sleep(15);
(gdb) x/24x 0x8049ff4
0x8049ff4 <_GLOBAL_OFFSET_TABLE_>: 0x08049f18 0x0012c8f8 0x00123270 0x0804841a
0x804a004 <_GLOBAL_OFFSET_TABLE_+16>: 0x0804842a 0x0015daf0 0x0804844a 0x0804845a
0x804a014 <_GLOBAL_OFFSET_TABLE_+32>: 0x0804846a 0x00000000 0x00000000 0x0029c580
0x804a024 : 0x00000000 0x00000000 0x00000000 0x00000000
0x804a034: 0x00000000 0x00000000 0x00000000 0x00000000
0x804a044: 0x00000000 0x00000000 0x00000000 0x00000000

蓝色的0x0849f18是dynamic段的地址

1
[21] .dynamic DYNAMIC 08049f18 000f18 0000d8 08 WA 7 0 4

接下来,我们要分析PLT 和GOT的关系了。

1
2
3
4
5
6
7
(gdb) disas main

0x0804857e <+54>: lea 0x1c(%esp),%eax
0x08048582 <+58>: mov %eax,(%esp)
0x08048585 <+61>: call 0x8048454 <pthread_create@plt>
0x0804858a <+66>: mov %eax,0x18(%esp)
0x0804858e <+70>: cmpl $0x0,0x18(%esp)

要执行pthread_create 函数,跳到PLT部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
libin@libin:~/program/C/plt_got$ objdump -dj .plt test

test: file format elf32-i386

Disassembly of section .plt:

08048404 :
8048404: ff 35 f8 9f 04 08 pushl 0x8049ff8
804840a: ff 25 fc 9f 04 08 jmp *0x8049ffc
8048410: 00 00 add %al,(%eax)

08048414 :
8048414: ff 25 00 a0 04 08 jmp *0x804a000
804841a: 68 00 00 00 00 push $0x0
804841f: e9 e0 ff ff ff jmp 8048404 <_init+0x30>

08048424 <__gmon_start__@plt>:
8048424: ff 25 04 a0 04 08 jmp *0x804a004
804842a: 68 08 00 00 00 push $0x8
804842f: e9 d0 ff ff ff jmp 8048404 <_init+0x30>

08048434 <__libc_start_main@plt>:
8048434: ff 25 08 a0 04 08 jmp *0x804a008
804843a: 68 10 00 00 00 push $0x10
804843f: e9 c0 ff ff ff jmp 8048404 <_init+0x30>

08048444 :
8048444: ff 25 0c a0 04 08 jmp *0x804a00c
804844a: 68 18 00 00 00 push $0x18
804844f: e9 b0 ff ff ff jmp 8048404 <_init+0x30>

08048454 :
8048454: ff 25 10 a0 04 08 jmp *0x804a010
804845a: 68 20 00 00 00 push $0x20
804845f: e9 a0 ff ff ff jmp 8048404 <_init+0x30>

08048464 :
8048464: ff 25 14 a0 04 08 jmp *0x804a014
804846a: 68 28 00 00 00 push $0x28
804846f: e9 90 ff ff ff jmp 8048404 <_init+0x30>

PLT部分认为pthread_create函数存放在GOT,0x804a010是GOT里面的一个条目,这个条目存储着pthread_create函数的地址。当第二次以至于第N次调用pthead_create的时候,的的确确存放着pthread_create的地址,但是第一次不行,第一次这个条目里面还没记录这个地址。那么这个条目记录的是什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) x/10i 0x8048454
0x8048454 : jmp *0x804a010
0x804845a : push $0x20
0x804845f : jmp 0x8048404
0x8048464 : jmp *0x804a014
0x804846a : push $0x28
0x804846f : jmp 0x8048404
0x8048474: add %al,(%eax)
0x8048476: add %al,(%eax)
0x8048478: add %al,(%eax)
0x804847a: add %al,(%eax)
(gdb) x/10x 0x804a010
0x804a010 <_GLOBAL_OFFSET_TABLE_+28>: 0x0804845a 0x0804846a 0x00000000 0x00000000
0x804a020 : 0x0029c580 0x00000000 0x00000000 0x00000000
0x804a030: 0x00000000 0x00000000

0x804a010这个地址最终应该记录的是pthread_create的地址,但是目前还不是,记录的是0x084845a

1
2
3
4
08048454 :
8048454: ff 25 10 a0 04 08 jmp *0x804a010
804845a: 68 20 00 00 00 push $0x20
804845f: e9 a0 ff ff ff jmp 8048404 <_init+0x30>

从PLT跳到GOT 找地址,但是第一次找的时候,并不是pthread_create的地址,而是又跳回来PLT,我们看到push了0x20之后,跳到了0x8048404。 每一个PLT的代码段,都是push了一个值之后,跳到了0x8048404。大家可以去上面的图验证。

接下来,我们看0x8048404存放的是啥指令:

1
2
3
4
5
6
7
8
9
10
11
(gdb) x/10i 0x8048404
0x8048404: pushl 0x8049ff8
0x804840a: jmp *0x8049ffc
0x8048410: add %al,(%eax)
0x8048412: add %al,(%eax)
0x8048414 <</span>pthread_join@plt>: jmp *0x804a000
0x804841a <</span>pthread_join@plt+6>: push $0x0
0x804841f <</span>pthread_join@plt+11>: jmp 0x8048404
0x8048424 <</span>__gmon_start__@plt>: jmp *0x804a004
0x804842a <</span>__gmon_start__@plt+6>: push $0x8
0x804842f <</span>__gmon_start__@plt+11>: jmp 0x8048404
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
(gdb) x/10x 0x8049ffc
0x8049ffc <</span>_GLOBAL_OFFSET_TABLE_+8>: 0x00123270 0x0804841a 0x0804842a 0x0015daf0
0x804a00c <</span>_GLOBAL_OFFSET_TABLE_+24>: 0x0804844a 0x0804845a 0x0804846a 0x00000000
0x804a01c <</span>__dso_handle>: 0x00000000 0x0029c580
(gdb) x/10i 0x00123270
0x123270 <</span>_dl_runtime_resolve>: push %eax
0x123271 <</span>_dl_runtime_resolve+1>: push %ecx
0x123272 <</span>_dl_runtime_resolve+2>: push %edx
0x123273 <</span>_dl_runtime_resolve+3>: mov 0x10(%esp),%edx
0x123277 <</span>_dl_runtime_resolve+7>: mov 0xc(%esp),%eax
0x12327b <</span>_dl_runtime_resolve+11>: call 0x11d5a0 <</span>_dl_fixup>
0x123280 <</span>_dl_runtime_resolve+16>: pop %edx
0x123281 <</span>_dl_runtime_resolve+17>: mov (%esp),%ecx
0x123284 <</span>_dl_runtime_resolve+20>: mov %eax,(%esp)
0x123287 <</span>_dl_runtime_resolve+23>: mov 0x4(%esp),%eax
`````

我们看到0x8049ffc就是GOT的第三项,前文提到的dl_runtime_resolve的地址。这个函数将帮助我们将pthread_create函数地址定位,并且填入GOT表的相应位置 0x804a010。

我们watch下GOT pthread_create对应条目,看下这个条目啥时候变化:

`````
(gdb) b main
Breakpoint 1 at 0x8048551: file test.c, line 19.
(gdb) r
Starting program: /home/libin/program/C/plt_got/test
[Thread debugging using libthread_db enabled]

Breakpoint 1, main () at test.c:19
19 sleep(15);
(gdb) watch *0x804a010
Hardware watchpoint 2: *0x804a010
(gdb) c
Continuing.
Hardware watchpoint 2: *0x804a010

Old value = 134513754
New value = 1260912
_dl_fixup (l=<</span>value optimized out>, reloc_arg=<</span>value optimized out>) at dl-runtime.c:155
155 dl-runtime.c: 没有那个文件或目录.
in dl-runtime.c
(gdb) bt
#0 _dl_fixup (l=<</span>value optimized out>, reloc_arg=<</span>value optimized out>) at dl-runtime.c:155
#1 0x00123280 in _dl_runtime_resolve () at ../sysdeps/i386/dl-trampoline.S:37
#2 0x0804858a in main () at test.c:21
(gdb)
`````

看到了,是_dl_runtime_resolve调用了_dl_fixup修改了GOT的对应条目。

`````
(gdb) x/10i 1260912
0x133d70 <</span>__pthread_create_2_1>: push %ebp
0x133d71 <</span>__pthread_create_2_1+1>: mov %esp,%ebp
0x133d73 <</span>__pthread_create_2_1+3>: push %edi
0x133d74 <</span>__pthread_create_2_1+4>: push %esi
0x133d75 <</span>__pthread_create_2_1+5>: push %ebx
0x133d76 <</span>__pthread_create_2_1+6>: call 0x132340 <</span>__i686.get_pc_thunk.bx>
0x133d7b <</span>__pthread_create_2_1+11>: add $0x10279,%ebx
0x133d81 <</span>__pthread_create_2_1+17>: sub $0x4c,%esp
0x133d84 <</span>__pthread_create_2_1+20>: mov 0xc(%ebp),%edx
0x133d87 <</span>__pthread_create_2_1+23>: test %edx,%edx
`````

这是第一次。第二次就比较简单了,因为GOT里面有一个条目已经有了pthread_create函数的地址。
![](http://blog.chinaunix.net/attachment/201209/16/24774106_13478011589N9A.png)

# Perf
## Perf 简介
Perf 是用来进行软件性能分析的工具。

通过它,应用程序可以利用 PMU,tracepoint 和内核中的特殊计数器来进行性能统计。它不但可以分析指定应用程序的性能问题 (per thread),也可以用来分析内核的性能问题,当然也可以同时分析应用代码和内核,从而全面理解应用程序中的性能瓶颈。

最初的时候,它叫做 Performance counter,在 2.6.31 中第一次亮相。此后他成为内核开发最为活跃的一个领域。在 2.6.32 中它正式改名为 Performance Event,因为 perf 已不再仅仅作为 PMU 的抽象,而是能够处理所有的性能相关的事件。

使用 perf,您可以分析程序运行期间发生的硬件事件,比如 instructions retired ,processor clock cycles 等;您也可以分析软件事件,比如 Page Fault 和进程切换。

这使得 Perf 拥有了众多的性能分析能力,举例来说,使用 Perf 可以计算每个时钟周期内的指令数,称为 IPC,IPC 偏低表明代码没有很好地利用 CPU。Perf 还可以对程序进行函数级别的采样,从而了解程序的性能瓶颈究竟在哪里等等。Perf 还可以替代 strace,可以添加动态内核 probe 点,还可以做 benchmark 衡量调度器的好坏。。。

Perf通过系统调用`sys_perf_event_open` 陷入到内核中,内核根据perf 提供的信息在PMU(Performance Monitoring Unit)上初始化一个硬件性能计数器(PMC: Performance Monitoring Counter)。PMC随着指定硬件事件的发生而自动累加。在PMC 溢出时,PMU 触发一个PMI(Performance Monitoring Interrupt)中断。内核在PMI 中断的处理函数中保存PMC 的计数值,触发中断时的指令地址,当前时间戳以及当前进程的PID,TID,comm 等信息。我们把这些信息统称为一个采样(sample)。内核会将收集到的sample 放入用于跟用户空间通信的Ring Buffer。用户空间里的perf 分析程序采用mmap 机制从ring buffer 中读入采样,并对其解析。

## 背景知识
有些背景知识是分析性能问题时需要了解的。比如硬件 cache;再比如操作系统内核。应用程序的行为细节往往是和这些东西互相牵扯的,这些底层的东西会以意想不到的方式影响应用程序的性能,比如某些程序无法充分利用 cache,从而导致性能下降。比如不必要地调用过多的系统调用,造成频繁的内核 / 用户切换。等等。方方面面,这里只是为本文的后续内容做一些铺垫,关于调优还有很多东西,我所不知道的比知道的要多的多。

当算法已经优化,代码不断精简,人们调到最后,便需要斤斤计较了。cache 啊,流水线啊一类平时不大注意的东西也必须精打细算了。

### 硬件特性之 cache
内存读写是很快的,但还是无法和处理器的指令执行速度相比。为了从内存中读取指令和数据,处理器需要等待,用处理器的时间来衡量,这种等待非常漫长。Cache 是一种 SRAM,它的读写速率非常快,能和处理器处理速度相匹配。因此将常用的数据保存在 cache 中,处理器便无须等待,从而提高性能。Cache 的尺寸一般都很小,充分利用 cache 是软件调优非常重要的部分。

### 硬件特性之流水线,超标量体系结构,乱序执行
提高性能最有效的方式之一就是并行。处理器在硬件设计时也尽可能地并行,比如流水线,超标量体系结构以及乱序执行。

处理器处理一条指令需要分多个步骤完成,比如先取指令,然后完成运算,最后将计算结果输出到总线上。在处理器内部,这可以看作一个三级流水线,如下图所示:

![](/img/20201126102100.gif)
图 1. 处理器流水线

指令从左边进入处理器,上图中的流水线有三级,一个时钟周期内可以同时处理三条指令,分别被流水线的不同部分处理。

超标量(superscalar)指一个时钟周期发射多条指令的流水线机器架构,比如 Intel 的 Pentium 处理器,内部有两个执行单元,在一个时钟周期内允许执行两条指令。

此外,在处理器内部,不同指令所需要的处理步骤和时钟周期是不同的,如果严格按照程序的执行顺序执行,那么就无法充分利用处理器的流水线。因此指令有可能被乱序执行。

上述三种并行技术对所执行的指令有一个基本要求,即相邻的指令相互没有依赖关系。假如某条指令需要依赖前面一条指令的执行结果数据,那么 pipeline 便失去作用,因为第二条指令必须等待第一条指令完成。因此好的软件必须尽量避免这种代码的生成。

### 硬件特性之分支预测
分支指令对软件性能有比较大的影响。尤其是当处理器采用流水线设计之后,假设流水线有三级,当前进入流水的第一条指令为分支指令。假设处理器顺序读取指令,那么如果分支的结果是跳转到其他指令,那么被处理器流水线预取的后续两条指令都将被放弃,从而影响性能。为此,很多处理器都提供了分支预测功能,根据同一条指令的历史执行记录进行预测,读取最可能的下一条指令,而并非顺序读取指令。

分支预测对软件结构有一些要求,对于重复性的分支指令序列,分支预测硬件能得到较好的预测结果,而对于类似 switch case 一类的程序结构,则往往无法得到理想的预测结果。

上面介绍的几种处理器特性对软件的性能有很大的影响,然而依赖时钟进行定期采样的 profiler 模式无法揭示程序对这些处理器硬件特性的使用情况。处理器厂商针对这种情况,在硬件中加入了 PMU 单元,即 performance monitor unit。

PMU 允许软件针对某种硬件事件设置 counter,此后处理器便开始统计该事件的发生次数,当发生的次数超过 counter 内设置的值后,便产生中断。比如 cache miss 达到某个值后,PMU 便能产生相应的中断。

捕获这些中断,便可以考察程序对这些硬件特性的利用效率了。

### Tracepoints
Tracepoint 是散落在内核源代码中的一些 hook,一旦使能,它们便可以在特定的代码被运行到时被触发,这一特性可以被各种 trace/debug 工具所使用。Perf 就是该特性的用户之一。

假如您想知道在应用程序运行期间,内核内存管理模块的行为,便可以利用潜伏在 slab 分配器中的 tracepoint。当内核运行到这些 tracepoint 时,便会通知 perf。

Perf 将 tracepoint 产生的事件记录下来,生成报告,通过分析这些报告,调优人员便可以了解程序运行时期内核的种种细节,对性能症状作出更准确的诊断。

## 性能事件

在程序运行中发生的,可能影响到程序性能的软硬件件事件,使用perf list命令可以显示当前软硬件环境下支持的所有事件,大致可以分为三种:

- Hardware Event由PMU部件产生,在特定的条件下探测性能事件是否发生以及发生的次数。比如CPU周期、分支指令、TLB重填例外、Cache缺失等。
- Software Event是内核产生的事件,分布在各个功能模块中,统计和操作系统相关性能事件。比如系统调用次数、上下文切换次数、任务迁移次数、缺页例外次数等。
- Tracepoint Event是内核中静态tracepoint所触发的事件,这些tracepoint用来判断程序运行期间内核的行为细节,比如slab分配器的分配次数等。基于ftrace框架实现,内核中的所有tracepoint都可以作为perf的性能事件

`cat /sys/kernel/debug/tracing/available_events`,可查看当前系统的所有tracepoint分成了几大类:
- ext4 文件系统的tracepoint events,如果是其它文件系统,比如XFS,也有对应的tracepoint event;
- jbd2 文件日志的tracepoint events;
- skb 内存的tracepoint events;
- net,napi,sock,udp:网络的tracepoint events;
- scsi, block, writeback 磁盘IO
- kmem 内存
- sched 调度
- syscalls 系统调用

## perf 的基本使用
说明一个工具的最佳途径是列举一个例子。

考查下面这个例子程序。其中函数`longa()`是个很长的循环,比较浪费时间。函数`foo1`和`foo2`将分别调用该函数 10 次,以及 100 次。

清单 1. 测试程序 t1
```C
//test.c
void longa()
{
int i,j;
for(i = 0; i < 1000000; i++)
j=i; //am I silly or crazy? I feel boring and desperate.
}

void foo2()
{
int i;
for(i=0 ; i < 10; i++)
longa();
}

void foo1()
{
int i;
for(i = 0; i< 100; i++)
longa();
}

int main(void)
{
foo1();
foo2();
}

找到这个程序的性能瓶颈无需任何工具,肉眼的阅读便可以完成。Longa()是这个程序的关键,只要提高它的速度,就可以极大地提高整个程序的运行效率。

但,因为其简单,却正好可以用来演示 perf 的基本使用。假如 perf 告诉您这个程序的瓶颈在别处,您就不必再浪费宝贵时间阅读本文了。

准备使用 perf

安装 perf 非常简单,只要您有 2.6.31 以上的内核源代码,那么进入 tools/perf 目录然后敲入下面两个命令即可:

1
2
make 
make install

性能调优工具如 perf,Oprofile 等的基本原理都是对被监测对象进行采样,最简单的情形是根据 tick 中断进行采样,即在 tick 中断内触发采样点,在采样点里判断程序当时的上下文。假如一个程序 90% 的时间都花费在函数 foo() 上,那么 90% 的采样点都应该落在函数 foo() 的上下文中。运气不可捉摸,但我想只要采样频率足够高,采样时间足够长,那么以上推论就比较可靠。因此,通过 tick 触发采样,我们便可以了解程序中哪些地方最耗时间,从而重点分析。

稍微扩展一下思路,就可以发现改变采样的触发条件使得我们可以获得不同的统计数据:

以时间点 ( 如 tick) 作为事件触发采样便可以获知程序运行时间的分布。

以 cache miss 事件触发采样便可以知道 cache miss 的分布,即 cache 失效经常发生在哪些程序代码中。如此等等。

因此让我们先来了解一下 perf 中能够触发采样的事件有哪些。

perf –help

先了解一下概貌

perf 命令用法还是挺简单的,根据功能区分了COMMAND,每个COMMAND有各自的用法。

用得比较多的有list, record, report, script, stat, top。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
usage: perf [--version] [--help] [OPTIONS] COMMAND [ARGS]

The most commonly used perf commands are:
annotate Read perf.data (created by perf record) and display annotated code
archive Create archive with object files with build-ids found in perf.data file
bench General framework for benchmark suites
buildid-cache Manage build-id cache.
buildid-list List the buildids in a perf.data file
data Data file related processing
diff Read perf.data files and display the differential profile
evlist List the event names in a perf.data file
inject Filter to augment the events stream with additional information
kmem Tool to trace/measure kernel memory properties
kvm Tool to trace/measure kvm guest os
list List all symbolic event types
lock Analyze lock events
mem Profile memory accesses
record Run a command and record its profile into perf.data
report Read perf.data (created by perf record) and display the profile
sched Tool to trace/measure scheduler properties (latencies)
script Read perf.data (created by perf record) and display trace output
stat Run a command and gather performance counter statistics
test Runs sanity tests.
timechart Tool to visualize total system behavior during a workload
top System profiling tool.
probe Define new dynamic tracepoints
trace strace inspired tool

See 'perf help COMMAND' for more information on a specific command.

Perf list,perf 事件

使用perf list命令可以列出所有能够触发 perf 采样点的事件。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ perf list 
List of pre-defined events (to be used in -e):
cpu-cycles OR cycles [Hardware event]
instructions [Hardware event]

cpu-clock [Software event]
task-clock [Software event]
context-switches OR cs [Software event]

ext4:ext4_allocate_inode [Tracepoint event]
kmem:kmalloc [Tracepoint event]
module:module_load [Tracepoint event]
workqueue:workqueue_execution [Tracepoint event]
sched:sched_{wakeup,switch} [Tracepoint event]
syscalls:sys_{enter,exit}_epoll_wait [Tracepoint event]

不同的系统会列出不同的结果,在 2.6.35 版本的内核中,该列表已经相当的长,但无论有多少,我们可以将它们划分为三类:

  • Hardware Event 是由 PMU 硬件产生的事件,比如 cache 命中,当您需要了解程序对硬件特性的使用情况时,便需要对这些事件进行采样;
  • Software Event 是内核软件产生的事件,比如进程切换,tick 数等 ;
  • Tracepoint event 是内核中的静态 tracepoint 所触发的事件,这些 tracepoint 用来判断程序运行期间内核的行为细节,比如 slab 分配器的分配次数等。

上述每一个事件都可以用于采样,并生成一项统计数据,时至今日,尚没有文档对每一个 event 的含义进行详细解释。我希望能和大家一起努力,以弄明白更多的 event 为目标。。。

Perf stat

做任何事都最好有条有理。老手往往能够做到不慌不忙,循序渐进,而新手则往往东一下,西一下,不知所措。

面对一个问题程序,最好采用自顶向下的策略。先整体看看该程序运行时各种统计事件的大概,再针对某些方向深入细节。而不要一下子扎进琐碎细节,会一叶障目的。

有些程序慢是因为计算量太大,其多数时间都应该在使用 CPU 进行计算,这叫做 CPU bound 型;有些程序慢是因为过多的 IO,这种时候其 CPU 利用率应该不高,这叫做 IO bound 型;对于 CPU bound 程序的调优和 IO bound 的调优是不同的。

如果您认同这些说法的话,Perf stat 应该是您最先使用的一个工具。它通过概括精简的方式提供被调试程序运行的整体情况和汇总数据。

还记得我们前面准备的那个例子程序么?现在将它编译为可执行文件 t1

1
gcc –o t1 – g test.c

下面演示了 perf stat 针对程序 t1 的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$perf stat ./t1 
Performance counter stats for './t1':

262.738415 task-clock-msecs ## 0.991 CPUs
2 context-switches ## 0.000 M/sec
1 CPU-migrations ## 0.000 M/sec
81 page-faults ## 0.000 M/sec
9478851 cycles ## 36.077 M/sec (scaled from 98.24%)
6771 instructions ## 0.001 IPC (scaled from 98.99%)
111114049 branches ## 422.908 M/sec (scaled from 99.37%)
8495 branch-misses ## 0.008 % (scaled from 95.91%)
12152161 cache-references ## 46.252 M/sec (scaled from 96.16%)
7245338 cache-misses ## 27.576 M/sec (scaled from 95.49%)

0.265238069 seconds time elapsed

上面告诉我们,程序 t1 是一个 CPU bound 型,因为 task-clock-msecs 接近 1。
对 t1 进行调优应该要找到热点 ( 即最耗时的代码片段 ),再看看是否能够提高热点代码的效率。

缺省情况下,除了 task-clock-msecs 之外,perf stat 还给出了其他几个最常用的统计信息:

  • Task-clock-msecs:CPU 利用率,该值高,说明程序的多数时间花费在 CPU 计算上而非 IO。
  • Context-switches:进程切换次数,记录了程序运行过程中发生了多少次进程切换,频繁的进程切换是应该避免的。
  • Cache-misses:程序运行过程中总体的 cache 利用情况,如果该值过高,说明程序的 cache 利用不好
  • CPU-migrations:表示进程 t1 运行过程中发生了多少次 CPU 迁移,即被调度器从一个 CPU 转移到另外一个 CPU 上运行。
  • Cycles:处理器时钟,一条机器指令可能需要多个 cycles,
  • Instructions: 机器指令数目。
  • IPC:是 Instructions/Cycles 的比值,该值越大越好,说明程序充分利用了处理器的特性。
  • Cache-references: cache 命中的次数
  • Cache-misses: cache 失效的次数。

通过指定 -e 选项,您可以改变 perf stat 的缺省事件 ( 关于事件,在上一小节已经说明,可以通过 perf list 来查看 )。假如您已经有很多的调优经验,可能会使用 -e 选项来查看您所感兴趣的特殊的事件。

1
2
3
4
5
6
7
8
9
10
11
12
-e <event>:指定性能事件(可以是多个,用,分隔列表)
-p <pid>:指定待分析进程的 pid(可以是多个,用,分隔列表)
-t <tid>;:指定待分析线程的 tid(可以是多个,用,分隔列表)
-a:从所有 CPU 收集系统数据
-d:打印更详细的信息,可重复 3 次
-d:L1 和 LLC data cache
-d -d:dTLB 和 iTLB events
-d -d -d:增加 prefetch events
-r <n>;:重复运行命令 n 次,打印平均值。n 设为 0 时无限循环打印
-c <cpu-list>:只统计指定 CPU 列表的数据,如:0,1,3或1-2
-A:与-a选项联用,不要将 CPU 计数聚合
-I <N msecs>:每隔 N 毫秒打印一次计数器的变化,N 最小值为 100 毫秒

perf top

使用 perf stat 的时候,往往您已经有一个调优的目标。比如我刚才写的那个无聊程序 t1。

也有些时候,您只是发现系统性能无端下降,并不清楚究竟哪个进程成为了贪吃的 hog。

此时需要一个类似 top 的命令,列出所有值得怀疑的进程,从中找到需要进一步审查的家伙。类似法制节目中办案民警常常做的那样,通过查看监控录像从茫茫人海中找到行为古怪的那些人,而不是到大街上抓住每一个人来审问。

Perf top 用于实时显示当前系统的性能统计信息。该命令主要用来观察整个系统当前的状态,比如可以通过查看该命令的输出来查看当前系统最耗时的内核函数或某个用户进程。

让我们再设计一个例子来演示吧。

不知道您怎么想,反正我觉得做一件有益的事情很难,但做点儿坏事儿却非常容易。我很快就想到了如代码清单 2 所示的一个程序:

清单 2. 一个死循环

1
while (1) i++;

我叫他 t2。启动 t2,然后用 perf top 来观察:

下面是 perf top 的可能输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PerfTop: 705 irqs/sec kernel:60.4% [1000Hz cycles] 
--------------------------------------------------
sampl pcnt function DSO
1503.00 49.2% t2
72.00 2.2% pthread_mutex_lock /lib/libpthread-2.12.so
68.00 2.1% delay_tsc [kernel.kallsyms]
55.00 1.7% aes_dec_blk [aes_i586]
55.00 1.7% drm_clflush_pages [drm]
52.00 1.6% system_call [kernel.kallsyms]
49.00 1.5% __memcpy_ssse3 /lib/libc-2.12.so
48.00 1.4% __strstr_ia32 /lib/libc-2.12.so
46.00 1.4% unix_poll [kernel.kallsyms]
42.00 1.3% __ieee754_pow /lib/libm-2.12.so
41.00 1.2% do_select [kernel.kallsyms]
40.00 1.2% pixman_rasterize_edges libpixman-1.so.0.18.0
37.00 1.1% _raw_spin_lock_irqsave [kernel.kallsyms]
36.00 1.1% _int_malloc /lib/libc-2.12.so

很容易便发现 t2 是需要关注的可疑程序。不过其作案手法太简单:肆无忌惮地浪费着 CPU。所以我们不用再做什么其他的事情便可以找到问题所在。但现实生活中,影响性能的程序一般都不会如此愚蠢,所以我们往往还需要使用其他的 perf 工具进一步分析。

通过添加 -e 选项,您可以列出造成其他事件的 TopN 个进程 / 函数。比如 -e cache-miss,用来看看谁造成的 cache miss 最多。

1
2
3
4
5
6
7
-e <event>:指明要分析的性能事件。
-p <pid>:仅分析目标进程及其创建的线程。
-k <path>:带符号表的内核映像所在的路径。
-K:不显示属于内核或模块的符号。
-U:不显示属于用户态程序的符号。
-d <n>:界面的刷新周期,默认为2s。
-g:得到函数的调用关系图。

使用 perf record, 解读 report

使用 top 和 stat 之后,您可能已经大致有数了。要进一步分析,便需要一些粒度更细的信息。比如说您已经断定目标程序计算量较大,也许是因为有些代码写的不够精简。那么面对长长的代码文件,究竟哪几行代码需要进一步修改呢?这便需要使用 perf record 记录单个函数级别的统计信息,并使用 perf report 来显示统计结果。

perf record收集一段时间内的性能事件到文件 perf.data,随后需要用perf report命令分析

1
2
3
4
5
6
7
8
9
10
11
-e <event>:指定性能事件(可以是多个,用,分隔列表)
-p <pid>:指定待分析进程的 pid(可以是多个,用,分隔列表)
-t <tid>:指定待分析线程的 tid(可以是多个,用,分隔列表)
-u <uid>:指定收集的用户数据,uid为名称或数字
-a:从所有 CPU 收集系统数据
-g:开启 call-graph (stack chain/backtrace) 记录
-C <cpu-list>:只统计指定 CPU 列表的数据,如:0,1,3或1-2
-r <RT priority>:perf 程序以SCHED_FIFO实时优先级RT priority运行这里填入的数值越大,进程优先级越高(即 nice 值越小)
-c <count>: 事件每发生 count 次采一次样
-F <n>:每秒采样 n 次
-o <output.data>:指定输出文件output.data,默认输出到perf.data

–call-graph 堆栈展开的方式,perf支持3种方式:perf record -g --call-graph (fp,dwarf,lbr)

  • fp: perf默认采用的方式,需要关闭对堆栈有影响的编译优化(-fno-omit-frame-pointer,-fno-optimize-sibling-calls),否则可能获取不到正确的堆栈信息。
    • 优点:性能消耗小,生成文件小,report速度快。
    • 缺点:不遵守X86Calling convention的函数无法获取堆栈信息,内联函数无法获取堆栈信息。
  • dwarf:
    • 优点:堆栈信息最详细,内联函数也可以获取堆栈信息。
    • 缺点:性能消耗大,生成文件大,report时间长。
  • lbr:
    • 优点:性能消耗极小,堆栈信息非常准确
    • 缺点:需要处理器支持(尝试了阿里云的ECS并不支持,所以实际没有使用过)。

您的调优应该将注意力集中到百分比高的热点代码片段上,假如一段代码只占用整个程序运行时间的 0.1%,即使您将其优化到仅剩一条机器指令,恐怕也只能将整体的程序性能提高 0.1%。俗话说,好钢用在刀刃上,不必我多说了。

仍以 t1 为例。

1
2
perf record – e cpu-clock ./t1 
perf report

结果如下图所示:


图 2. perf report 示例

不出所料,hot spot 是longa()函数。

但,代码是非常复杂难说的,t1 程序中的 foo1() 也是一个潜在的调优对象,为什么要调用 100 次那个无聊的 longa() 函数呢?但我们在上图中无法发现 foo1 和 foo2,更无法了解他们的区别了。

我曾发现自己写的一个程序居然有近一半的时间花费在 string 类的几个方法上,string 是 C++ 标准,我绝不可能写出比 STL 更好的代码了。因此我只有找到自己程序中过多使用 string 的地方。因此我很需要按照调用关系进行显示的统计信息。

使用 perf 的 -g 选项便可以得到需要的信息:

1
2
perf record – e cpu-clock – g ./t1 
perf report

结果如下图所示:


图 3. perf – g report 示例

通过对 calling graph 的分析,能很方便地看到 91% 的时间都花费在 foo1() 函数中,因为它调用了 100 次 longa() 函数,因此假如 longa() 是个无法优化的函数,那么程序员就应该考虑优化 foo1,减少对 longa() 的调用次数。

之前的命令:

1
2
3
4
5
6
7
8
9
sudo perf record -g -a --call-graph dwarf,65000 -p 进程号 -d 1 -b
sudo perf report -i perf.data > perf.txt

火焰图:
perf report -i perf.data > perf.txt
perf script > out.perf

./stackcollapse-perf.pl out.perf > out.folded
./flamegraph.pl out.folded > kernel.svg

Perf Script

读取 Perf Record 结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-i, --input <file>    input file name
-G, --hide-call-graph
When printing symbols do not display call chain
-F, --fields <str> comma separated output fields prepend with 'type:'. Valid types: hw,sw,trace,raw. Fields: comm,tid,pid,time,cpu,event,trace,ip,sym,dso,addr,symoff,period
-a, --all-cpus system-wide collection from all CPUs
-S, --symbols <symbol[,symbol...]>
only consider these symbols
-C, --cpu <cpu> list of cpus to profile
-c, --comms <comm[,comm...]>
only display events for these comms
--pid <pid[,pid...]>
only consider symbols in these pids
--tid <tid[,tid...]>
only consider symbols in these tids
--time <str> Time span of interest (start,stop)
--show-kernel-path
Show the path of [kernel.kallsyms]
--show-task-events
Show the fork/comm/exit events
--show-mmap-events
Show the mmap events
--per-event-dump Dump trace output to files named by the monitored events

使用 PMU 的例子

例子 t1 和 t2 都较简单。所谓魔高一尺,道才能高一丈。要想演示 perf 更加强大的能力,我也必须想出一个高明的影响性能的例子,我自己想不出,只好借助于他人。下面这个例子 t3 参考了文章“Branch and Loop Reorganization to Prevent Mispredicts”

该例子考察程序对奔腾处理器分支预测的利用率,如前所述,分支预测能够显著提高处理器的性能,而分支预测失败则显著降低处理器的性能。首先给出一个存在 BTB 失效的例子:

清单 3. 存在 BTB 失效的例子程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//test.c 
#include <stdio.h>
#include <stdlib.h>

void foo()
{
int i,j;
for(i=0; i< 10; i++)
j+=2;
}
int main(void)
{
int i;
for(i = 0; i< 100000000; i++)
foo();
return 0;
}

用 gcc 编译生成测试程序 t3:

1
gcc – o t3 – O0 test.c

用 perf stat 考察分支预测的使用情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[lm@ovispoly perf]$ ./perf stat ./t3 

Performance counter stats for './t3':

6240.758394 task-clock-msecs ## 0.995 CPUs
126 context-switches ## 0.000 M/sec
12 CPU-migrations ## 0.000 M/sec
80 page-faults ## 0.000 M/sec
17683221 cycles ## 2.834 M/sec (scaled from 99.78%)
10218147 instructions ## 0.578 IPC (scaled from 99.83%)
2491317951 branches ## 399.201 M/sec (scaled from 99.88%)
636140932 branch-misses ## 25.534 % (scaled from 99.63%)
126383570 cache-references ## 20.251 M/sec (scaled from 99.68%)
942937348 cache-misses ## 151.093 M/sec (scaled from 99.58%)

6.271917679 seconds time elapsed

可以看到 branche-misses 的情况比较严重,25% 左右。我测试使用的机器的处理器为 Pentium4,其 BTB 的大小为 16。而 test.c 中的循环迭代为 20 次,BTB 溢出,所以处理器的分支预测将不准确。

对于上面这句话我将简要说明一下,但关于 BTB 的细节,请阅读参考文献。

for 循环编译成为 IA 汇编后如下:

清单 4. 循环的汇编

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// C code 
for ( i=0; i < 20; i++ )
{ … }

//Assembly code;
mov esi, data
mov ecx, 0
ForLoop:
cmp ecx, 20
jge
EndForLoop

add ecx, 1
jmp ForLoop
EndForLoop:

可以看到,每次循环迭代中都有一个分支语句 jge,因此在运行过程中将有 20 次分支判断。每次分支判断都将写入 BTB,但 BTB 是一个 ring buffer,16 个 slot 写满后便开始覆盖。假如迭代次数正好为 16,或者小于 16,则完整的循环将全部写入 BTB,比如循环迭代次数为 4 次,则 BTB 应该如下图所示:


图 4. BTB buffer

这个 buffer 完全精确地描述了整个循环迭代的分支判定情况,因此下次运行同一个循环时,处理器便可以做出完全正确的预测。但假如迭代次数为 20,则该 BTB 随着时间推移而不能完全准确地描述该循环的分支预测执行情况,处理器将做出错误的判断。

我们将测试程序进行少许的修改,将迭代次数从 20 减少到 10,为了让逻辑不变,j++ 变成了 j+=2;

清单 5. 没有 BTB 失效的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h> 
#include <stdlib.h>

void foo()
{
int i,j;
for(i=0; i< 10; i++)
j+=2;
}
int main(void)
{
int i;
for(i = 0; i< 100000000; i++)
foo();
return 0;
}

此时再次用 perf stat 采样得到如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[lm@ovispoly perf]$ ./perf stat ./t3 

Performance counter stats for './t3:

2784.004851 task-clock-msecs ## 0.927 CPUs
90 context-switches ## 0.000 M/sec
8 CPU-migrations ## 0.000 M/sec
81 page-faults ## 0.000 M/sec
33632545 cycles ## 12.081 M/sec (scaled from 99.63%)
42996 instructions ## 0.001 IPC (scaled from 99.71%)
1474321780 branches ## 529.569 M/sec (scaled from 99.78%)
49733 branch-misses ## 0.003 % (scaled from 99.35%)
7073107 cache-references ## 2.541 M/sec (scaled from 99.42%)
47958540 cache-misses ## 17.226 M/sec (scaled from 99.33%)

3.002673524 seconds time elapsed

Branch-misses 减少了。

特殊用法以及内核调优示例

之前介绍了 perf 最常见的一些用法,关注于 Linux 系统上应用程序的调优。现在让我们把目光转移到内核以及其他 perf 命令上面来。

在内核方面,人们的兴趣五花八门,有些内核开发人员热衷于寻找整个内核中的热点代码;另一些则只关注某一个主题,比如 slab 分配器,对于其余部分则不感兴趣。对这些人而言,perf 的一些奇怪用法更受欢迎。当然,诸如 perf top,perf stat, perf record 等也是内核调优的基本手段,但用法和 part1 所描述的一样,无需重述。

此外虽然内核事件对应用程序开发人员而言有些陌生,但一旦了解,对应用程序的调优也很有帮助。我曾经参与开发过一个数据库应用程序,其效率很低。通过常规的热点查询,IO 统计等方法,我们找到了一些可以优化的地方,以至于将程序的效率提高了几倍。可惜对于拥有海量数据的用户,其运行时间依然无法达到要求。进一步调优需要更加详细的统计信息,可惜本人经验有限,实在是无计可施。。。从客户反馈来看,该应用的使用频率很低。作为一个程序员,为此我时常心情沮丧。。。

假如有 perf,那么我想我可以用它来验证自己的一些猜测,比如是否太多的系统调用,或者系统中的进程切换太频繁 ? 针对这些怀疑使用 perf 都可以拿出有用的报告,或许能找到问题吧。但过去的便无可弥补,时光不会倒流,无论我如何伤感,世界绝不会以我的意志为转移。所以我们好好学习 perf,或许可以预防某些遗憾吧。

这里我还要提醒读者注意,讲述 perf 的命令和语法容易,但说明什么时候使用这些命令,或者说明怎样解决实际问题则很困难。就好象说明电子琴上 88 个琴键的唱名很容易,但想说明如何弹奏动听的曲子则很难。

在简述每个命令语法的同时,我试图通过一些示例来说明这些命令的使用场景,但这只能是一种微薄的努力。因此总体说来,本文只能充当那本随同电子琴一起发售的使用说明书。。。

使用 tracepoint

当 perf 根据 tick 时间点进行采样后,人们便能够得到内核代码中的 hot spot。那什么时候需要使用 tracepoint 来采样呢?

我想人们使用 tracepoint 的基本需求是对内核的运行时行为的关心,如前所述,有些内核开发人员需要专注于特定的子系统,比如内存管理模块。这便需要统计相关内核函数的运行情况。另外,内核行为对应用程序性能的影响也是不容忽视的:

以之前的遗憾为例,假如时光倒流,我想我要做的是统计该应用程序运行期间究竟发生了多少次系统调用。在哪里发生的?

下面我用 ls 命令来演示 sys_enter 这个 tracepoint 的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[root@ovispoly /]# perf stat -e raw_syscalls:sys_enter ls 
bin dbg etc lib media opt root selinux sys usr
boot dev home lost+found mnt proc sbin srv tmp var

Performance counter stats for 'ls':

101 raw_syscalls:sys_enter

0.003434730 seconds time elapsed


[root@ovispoly /]# perf record -e raw_syscalls:sys_enter ls

[root@ovispoly /]# perf report
Failed to open .lib/ld-2.12.so, continuing without symbols
# Samples: 70
#
# Overhead Command Shared Object Symbol
# ........ ............... ............... ......
#
97.14% ls ld-2.12.so [.] 0x0000000001629d
2.86% ls [vdso] [.] 0x00000000421424
#
# (For a higher level overview, try: perf report --sort comm,dso)
#

这个报告详细说明了在 ls 运行期间发生了多少次系统调用 ( 上例中有 101 次 ),多数系统调用都发生在哪些地方 (97% 都发生在 ld-2.12.so 中 )。

有了这个报告,或许我能够发现更多可以调优的地方。比如函数 foo() 中发生了过多的系统调用,那么我就可以思考是否有办法减少其中有些不必要的系统调用。

您可能会说 strace 也可以做同样事情啊,的确,统计系统调用这件事完全可以用 strace 完成,但 perf 还可以干些别的,您所需要的就是修改 -e 选项后的字符串。

罗列 tracepoint 实在是不太地道,本文当然不会这么做。但学习每一个 tracepoint 是有意义的,类似背单词之于学习英语一样,是一项缓慢痛苦却不得不做的事情。

Perf probe

tracepoint 是静态检查点,意思是一旦它在哪里,便一直在那里了,您想让它移动一步也是不可能的。内核代码有多少行?我不知道,100 万行是至少的吧,但目前 tracepoint 有多少呢?我最大胆的想象是不超过 1000 个。所以能够动态地在想查看的地方插入动态监测点的意义是不言而喻的。

Perf 并不是第一个提供这个功能的软件,systemTap 早就实现了。但假若您不选择 RedHat 的发行版的话,安装 systemTap 并不是件轻松愉快的事情。perf 是内核代码包的一部分,所以使用和维护都非常方便。

我使用的 Linux 版本为 2.6.33。因此您自己做实验时命令参数有可能不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[root@ovispoly perftest]# perf probe schedule:12 cpu 
Added new event:
probe:schedule (on schedule+52 with cpu)

You can now use it on all perf tools, such as:

perf record -e probe:schedule -a sleep 1

[root@ovispoly perftest]# perf record -e probe:schedule -a sleep 1
Error, output file perf.data exists, use -A to append or -f to overwrite.

[root@ovispoly perftest]# perf record -f -e probe:schedule -a sleep 1
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.270 MB perf.data (~11811 samples) ]
[root@ovispoly perftest]# perf report
# Samples: 40
#
# Overhead Command Shared Object Symbol
# ........ ............... ................. ......
#
57.50% init 0 [k] 0000000000000000
30.00% firefox [vdso] [.] 0x0000000029c424
5.00% sleep [vdso] [.] 0x00000000ca7424
5.00% perf.2.6.33.3-8 [vdso] [.] 0x00000000ca7424
2.50% ksoftirqd/0 [kernel] [k] 0000000000000000
#
# (For a higher level overview, try: perf report --sort comm,dso)
#

上例利用 probe 命令在内核函数 schedule() 的第 12 行处加入了一个动态 probe 点,和 tracepoint 的功能一样,内核一旦运行到该 probe 点时,便会通知 perf。可以理解为动态增加了一个新的 tracepoint。

此后便可以用 record 命令的 -e 选项选择该 probe 点,最后用 perf report 查看报表。如何解读该报表便是见仁见智了,既然您在 shcedule() 的第 12 行加入了 probe 点,想必您知道自己为什么要统计它吧?

比如你想跟踪kernel的某个function, 甚至某一行代码,某些变量的值。或者你想跟踪用户软件的某个function,甚至某一行代码,某些变量的值。首先要添加需要动态跟踪的对象(function, var, …),然后record,和report分析,这和前面的用法是一样的。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
Listing variables available for tcp_sendmsg():

# perf probe -V tcp_sendmsg
Available variables at tcp_sendmsg
@<tcp_sendmsg+0>
size_t size
struct kiocb* iocb
struct msghdr* msg
struct sock* sk
Creating a probe for tcp_sendmsg() with the "size" variable:

# perf probe --add 'tcp_sendmsg size'
Added new event:
probe:tcp_sendmsg (on tcp_sendmsg with size)

You can now use it in all perf tools, such as:

perf record -e probe:tcp_sendmsg -aR sleep 1
Tracing this probe:

# perf record -e probe:tcp_sendmsg -a
^C[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.052 MB perf.data (~2252 samples) ]
# perf script
# ========
# captured on: Fri Jan 31 23:49:55 2014
# hostname : dev1
# os release : 3.13.1-ubuntu-12-opt
# perf version : 3.13.1
# arch : x86_64
# nrcpus online : 2
# nrcpus avail : 2
# cpudesc : Intel(R) Xeon(R) CPU E5645 @ 2.40GHz
# cpuid : GenuineIntel,6,44,2
# total memory : 1796024 kB
# cmdline : /usr/bin/perf record -e probe:tcp_sendmsg -a
# event : name = probe:tcp_sendmsg, type = 2, config = 0x1dd, config1 = 0x0, config2 = ...
# HEADER_CPU_TOPOLOGY info available, use -I to display
# HEADER_NUMA_TOPOLOGY info available, use -I to display
# pmu mappings: software = 1, tracepoint = 2, breakpoint = 5
# ========
#
sshd 1301 [001] 502.424719: probe:tcp_sendmsg: (ffffffff81505d80) size=b0
sshd 1301 [001] 502.424814: probe:tcp_sendmsg: (ffffffff81505d80) size=40
sshd 2371 [000] 502.952590: probe:tcp_sendmsg: (ffffffff81505d80) size=27
sshd 2372 [000] 503.025023: probe:tcp_sendmsg: (ffffffff81505d80) size=3c0
sshd 2372 [001] 503.203776: probe:tcp_sendmsg: (ffffffff81505d80) size=98
sshd 2372 [001] 503.281312: probe:tcp_sendmsg: (ffffffff81505d80) size=2d0
sshd 2372 [001] 503.461358: probe:tcp_sendmsg: (ffffffff81505d80) size=30
sshd 2372 [001] 503.670239: probe:tcp_sendmsg: (ffffffff81505d80) size=40
sshd 2372 [001] 503.742565: probe:tcp_sendmsg: (ffffffff81505d80) size=140
sshd 2372 [001] 503.822005: probe:tcp_sendmsg: (ffffffff81505d80) size=20
sshd 2371 [000] 504.118728: probe:tcp_sendmsg: (ffffffff81505d80) size=30
sshd 2371 [000] 504.192575: probe:tcp_sendmsg: (ffffffff81505d80) size=70
[...]
The size is shown as hexadecimal.

跟踪某行代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# perf probe -L tcp_sendmsg
<tcp_sendmsg@/mnt/src/linux-3.14.5/net/ipv4/tcp.c:0>
0 int tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t size)
2 {
struct iovec *iov;
struct tcp_sock *tp = tcp_sk(sk);
struct sk_buff *skb;
6 int iovlen, flags, err, copied = 0;
7 int mss_now = 0, size_goal, copied_syn = 0, offset = 0;
bool sg;
long timeo;
[...]
79 while (seglen > 0) {
int copy = 0;
81 int max = size_goal;

skb = tcp_write_queue_tail(sk);
84 if (tcp_send_head(sk)) {
85 if (skb->ip_summed == CHECKSUM_NONE)
max = mss_now;
87 copy = max - skb->len;
}

90 if (copy <= 0) {
new_segment:
[...]


# perf probe -V tcp_sendmsg:81
Available variables at tcp_sendmsg:81
@<tcp_sendmsg+537>
bool sg
int copied
int copied_syn
int flags
int mss_now
int offset
int size_goal
long int timeo
size_t seglen
struct iovec* iov
struct sock* sk
unsigned char* from


Now lets trace line 81, with the seglen variable that is checked in the loop:

# perf probe --add 'tcp_sendmsg:81 seglen'
Added new event:
probe:tcp_sendmsg (on tcp_sendmsg:81 with seglen)

You can now use it in all perf tools, such as:

perf record -e probe:tcp_sendmsg -aR sleep 1

# perf record -e probe:tcp_sendmsg -a
^C[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.188 MB perf.data (~8200 samples) ]
# perf script
sshd 4652 [001] 2082360.931086: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x80
app_plugin.pl 2400 [001] 2082360.970489: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x20
postgres 2422 [000] 2082360.970703: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x52
app_plugin.pl 2400 [000] 2082360.970890: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x7b
postgres 2422 [001] 2082360.971099: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0xb
app_plugin.pl 2400 [000] 2082360.971140: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x55
[...]

跟踪用户软件的指定function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# perf probe -x /lib/x86_64-linux-gnu/libc-2.15.so --add malloc
Added new event:
probe_libc:malloc (on 0x82f20)

You can now use it in all perf tools, such as:

perf record -e probe_libc:malloc -aR sleep 1

Tracing it system-wide:

# perf record -e probe_libc:malloc -a
^C[ perf record: Woken up 12 times to write data ]
[ perf record: Captured and wrote 3.522 MB perf.data (~153866 samples) ]
The report:

# perf report -n
[...]
# Samples: 45K of event 'probe_libc:malloc'
# Event count (approx.): 45158
#
# Overhead Samples Command Shared Object Symbol
# ........ ............ ............... ............. ..........
#
42.72% 19292 apt-config libc-2.15.so [.] malloc
19.71% 8902 grep libc-2.15.so [.] malloc
7.88% 3557 sshd libc-2.15.so [.] malloc
6.25% 2824 sed libc-2.15.so [.] malloc
6.06% 2738 which libc-2.15.so [.] malloc
4.12% 1862 update-motd-upd libc-2.15.so [.] malloc
3.72% 1680 stat libc-2.15.so [.] malloc
1.68% 758 login libc-2.15.so [.] malloc
1.21% 546 run-parts libc-2.15.so [.] malloc
1.21% 545 ls libc-2.15.so [.] malloc
0.80% 360 dircolors libc-2.15.so [.] malloc
0.56% 252 tr libc-2.15.so [.] malloc
0.54% 242 top libc-2.15.so [.] malloc
0.49% 222 irqbalance libc-2.15.so [.] malloc
0.44% 200 dpkg libc-2.15.so [.] malloc
0.38% 173 lesspipe libc-2.15.so [.] malloc
0.29% 130 update-motd-fsc libc-2.15.so [.] malloc
0.25% 112 uname libc-2.15.so [.] malloc
0.24% 108 cut libc-2.15.so [.] malloc
0.23% 104 groups libc-2.15.so [.] malloc
0.21% 94 release-upgrade libc-2.15.so [.] malloc
0.18% 82 00-header libc-2.15.so [.] malloc
0.14% 62 mesg libc-2.15.so [.] malloc
0.09% 42 update-motd-reb libc-2.15.so [.] malloc
0.09% 40 date libc-2.15.so [.] malloc
0.08% 35 bash libc-2.15.so [.] malloc
0.08% 35 basename libc-2.15.so [.] malloc
0.08% 34 dirname libc-2.15.so [.] malloc
0.06% 29 sh libc-2.15.so [.] malloc
0.06% 26 99-footer libc-2.15.so [.] malloc
0.05% 24 cat libc-2.15.so [.] malloc
0.04% 18 expr libc-2.15.so [.] malloc
0.04% 17 rsyslogd libc-2.15.so [.] malloc
0.03% 12 stty libc-2.15.so [.] malloc
0.00% 1 cron libc-2.15.so [.] malloc
This shows the most malloc() calls were by apt-config, while I was tracing.

User: malloc() with size

As of the Linux 3.13.1 kernel, this is not supported yet:

# perf probe -x /lib/x86_64-linux-gnu/libc-2.15.so --add 'malloc size'
Debuginfo-analysis is not yet supported with -x/--exec option.
Error: Failed to add events. (-38)
As a workaround, you can access the registers (on Linux 3.7+). For example, on x86_64:

# perf probe -x /lib64/libc-2.17.so '--add=malloc size=%di'
probe_libc:malloc (on 0x800c0 with size=%di)

Perf sched

调度器的好坏直接影响一个系统的整体运行效率。在这个领域,内核黑客们常会发生争执,一个重要原因是对于不同的调度器,每个人给出的评测报告都各不相同,甚至常常有相反的结论。因此一个权威的统一的评测工具将对结束这种争论有益。Perf sched 便是这种尝试。

Perf sched 有五个子命令:

  • perf sched record # low-overhead recording of arbitrary workloads
  • perf sched latency # output per task latency metrics
  • perf sched map # show summary/map of context-switching
  • perf sched trace # output finegrained trace
  • perf sched replay # replay a captured workload using simlated threads

用户一般使用’ perf sched record ’收集调度相关的数据,然后就可以用’ perf sched latency ’查看诸如调度延迟等和调度器相关的统计数据。

其他三个命令也同样读取 record 收集到的数据并从其他不同的角度来展示这些数据。下面一一进行演示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
perf sched record sleep 10     # record full system activity for 10 seconds 
perf sched latency --sort max # report latencies sorted by max

-------------------------------------------------------------------------------------
Task | Runtime ms | Switches | Average delay ms | Maximum delay ms |
-------------------------------------------------------------------------------------
:14086:14086 | 0.095 ms | 2 | avg: 3.445 ms | max: 6.891 ms |
gnome-session:13792 | 31.713 ms | 102 | avg: 0.160 ms | max: 5.992 ms |
metacity:14038 | 49.220 ms | 637 | avg: 0.066 ms | max: 5.942 ms |
gconfd-2:13971 | 48.587 ms | 777 | avg: 0.047 ms | max: 5.793 ms |
gnome-power-man:14050 | 140.601 ms | 434 | avg: 0.097 ms | max: 5.367 ms |
python:14049 | 114.694 ms | 125 | avg: 0.120 ms | max: 5.343 ms |
kblockd/1:236 | 3.458 ms | 498 | avg: 0.179 ms | max: 5.271 ms |
Xorg:3122 | 1073.107 ms | 2920 | avg: 0.030 ms | max: 5.265 ms |
dbus-daemon:2063 | 64.593 ms | 665 | avg: 0.103 ms | max: 4.730 ms |
:14040:14040 | 30.786 ms | 255 | avg: 0.095 ms | max: 4.155 ms |
events/1:8 | 0.105 ms | 13 | avg: 0.598 ms | max: 3.775 ms |
console-kit-dae:2080 | 14.867 ms | 152 | avg: 0.142 ms | max: 3.760 ms |
gnome-settings-:14023 | 572.653 ms | 979 | avg: 0.056 ms | max: 3.627 ms |
...
-----------------------------------------------------------------------------------
TOTAL: | 3144.817 ms | 11654 |
---------------------------------------------------

上面的例子展示了一个 Gnome 启动时的统计信息。各个 column 的含义如下:

  • Task: 进程的名字和 pid
  • Runtime: 实际运行时间
  • Switches: 进程切换的次数
  • Average delay: 平均的调度延迟
  • Maximum delay: 最大延迟

这里最值得人们关注的是 Maximum delay,一般从这里可以看到对交互性影响最大的特性:调度延迟,如果调度延迟比较大,那么用户就会感受到视频或者音频断断续续的。

其他的三个子命令提供了不同的视图,一般是由调度器的开发人员或者对调度器内部实现感兴趣的人们所使用。

首先是 map:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ perf sched map 
...

N1 O1 . . . S1 . . . B0 . *I0 C1 . M1 . 23002.773423 secs
N1 O1 . *Q0 . S1 . . . B0 . I0 C1 . M1 . 23002.773423 secs
N1 O1 . Q0 . S1 . . . B0 . *R1 C1 . M1 . 23002.773485 secs
N1 O1 . Q0 . S1 . *S0 . B0 . R1 C1 . M1 . 23002.773478 secs
*L0 O1 . Q0 . S1 . S0 . B0 . R1 C1 . M1 . 23002.773523 secs
L0 O1 . *. . S1 . S0 . B0 . R1 C1 . M1 . 23002.773531 secs
L0 O1 . . . S1 . S0 . B0 . R1 C1 *T1 M1 . 23002.773547 secs
T1 => irqbalance:2089
L0 O1 . . . S1 . S0 . *P0 . R1 C1 T1 M1 . 23002.773549 secs
*N1 O1 . . . S1 . S0 . P0 . R1 C1 T1 M1 . 23002.773566 secs
N1 O1 . . . *J0 . S0 . P0 . R1 C1 T1 M1 . 23002.773571 secs
N1 O1 . . . J0 . S0 *B0 P0 . R1 C1 T1 M1 . 23002.773592 secs
N1 O1 . . . J0 . *U0 B0 P0 . R1 C1 T1 M1 . 23002.773582 secs
N1 O1 . . . *S1 . U0 B0 P0 . R1 C1 T1 M1 . 23002.773604 secs

星号表示调度事件发生所在的 CPU。

点号表示该 CPU 正在 IDLE。

Map 的好处在于提供了一个的总的视图,将成百上千的调度事件进行总结,显示了系统任务在 CPU 之间的分布,假如有不好的调度迁移,比如一个任务没有被及时迁移到 idle 的 CPU 却被迁移到其他忙碌的 CPU,类似这种调度器的问题可以从 map 的报告中一眼看出来。

如果说 map 提供了高度概括的总体的报告,那么 trace 就提供了最详细,最底层的细节报告。

1
2
3
4
5
6
7
8
9
10
pipe-test-100k-13520 [001]  1254.354513808: sched_stat_wait: 
task: pipe-test-100k:13521 wait: 5362 [ns]
pipe-test-100k-13520 [001] 1254.354514876: sched_switch:
task pipe-test-100k:13520 [120] (S) ==> pipe-test-100k:13521 [120]
:13521-13521 [001] 1254.354517927: sched_stat_runtime:
task: pipe-test-100k:13521 runtime: 5092 [ns], vruntime: 133967391150 [ns]
:13521-13521 [001] 1254.354518984: sched_stat_sleep:
task: pipe-test-100k:13520 sleep: 5092 [ns]
:13521-13521 [001] 1254.354520011: sched_wakeup:
task pipe-test-100k:13520 [120] success=1 [001]

要理解以上的信息,必须对调度器的源代码有一定了解,对一般用户而言,理解他们十分不易。幸好这些信息一般也只有编写调度器的人感兴趣。。。

Perf replay 这个工具更是专门为调度器开发人员所设计,它试图重放 perf.data 文件中所记录的调度场景。很多情况下,一般用户假如发现调度器的奇怪行为,他们也无法准确说明发生该情形的场景,或者一些测试场景不容易再次重现,或者仅仅是出于“偷懒”的目的,使用 perf replay,perf 将模拟 perf.data 中的场景,无需开发人员花费很多的时间去重现过去,这尤其利于调试过程,因为需要一而再,再而三地重复新的修改是否能改善原始的调度场景所发现的问题。

下面是 replay 执行的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ perf sched replay 
run measurement overhead: 3771 nsecs
sleep measurement overhead: 66617 nsecs
the run test took 999708 nsecs
the sleep test took 1097207 nsecs
nr_run_events: 200221
nr_sleep_events: 200235
nr_wakeup_events: 100130
task 0 ( perf: 13519), nr_events: 148
task 1 ( perf: 13520), nr_events: 200037
task 2 ( pipe-test-100k: 13521), nr_events: 300090
task 3 ( ksoftirqd/0: 4), nr_events: 8
task 4 ( swapper: 0), nr_events: 170
task 5 ( gnome-power-man: 3192), nr_events: 3
task 6 ( gdm-simple-gree: 3234), nr_events: 3
task 7 ( Xorg: 3122), nr_events: 5
task 8 ( hald-addon-stor: 2234), nr_events: 27
task 9 ( ata/0: 321), nr_events: 29
task 10 ( scsi_eh_4: 704), nr_events: 37
task 11 ( events/1: 8), nr_events: 3
task 12 ( events/0: 7), nr_events: 6
task 13 ( flush-8:0: 6980), nr_events: 20
------------------------------------------------------------
#1 : 2038.157, ravg: 2038.16, cpu: 0.09 / 0.09
#2 : 2042.153, ravg: 2038.56, cpu: 0.11 / 0.09
^C

perf bench

除了调度器之外,很多时候人们都需要衡量自己的工作对系统性能的影响。benchmark 是衡量性能的标准方法,对于同一个目标,如果能够有一个大家都承认的 benchmark,将非常有助于”提高内核性能”这项工作。

目前,就我所知,perf bench 提供了 3 个 benchmark:

  1. Sched message
1
2
[lm@ovispoly ~]$ perf bench sched messaging
# Running sched/messaging benchmark...# 20 sender and receiver processes per group# 10 groups == 400 processes run Total time: 1.918 [sec]

sched message 是从经典的测试程序 hackbench 移植而来,用来衡量调度器的性能,overhead 以及可扩展性。该 benchmark 启动 N 个 reader/sender 进程或线程对,通过 IPC(socket 或者 pipe) 进行并发的读写。一般人们将 N 不断加大来衡量调度器的可扩展性。Sched message 的用法及用途和 hackbench 一样。

  1. Sched Pipe
1
2
[lm@ovispoly ~]$ perf bench sched pipe
# Running sched/pipe benchmark...# Extecuted 1000000 pipe operations between two tasks Total time: 20.888 [sec] 20.888017 usecs/op 47874 ops/sec

sched pipe 从 Ingo Molnar 的 pipe-test-1m.c 移植而来。当初 Ingo 的原始程序是为了测试不同的调度器的性能和公平性的。其工作原理很简单,两个进程互相通过 pipe 拼命地发 1000000 个整数,进程 A 发给 B,同时 B 发给 A。。。因为 A 和 B 互相依赖,因此假如调度器不公平,对 A 比 B 好,那么 A 和 B 整体所需要的时间就会更长。

  1. Mem memcpy
1
2
[lm@ovispoly ~]$ perf bench mem memcpy
# Running mem/memcpy benchmark...# Copying 1MB Bytes from 0xb75bb008 to 0xb76bc008 ... 364.697301 MB/Sec

这个是 perf bench 的作者 Hitoshi Mitake 自己写的一个执行 memcpy 的 benchmark。该测试衡量一个拷贝 1M 数据的 memcpy() 函数所花费的时间。我尚不明白该 benchmark 的使用场景。。。或许是一个例子,告诉人们如何利用 perf bench 框架开发更多的 benchmark 吧。

这三个 benchmark 给我们展示了一个可能的未来:不同语言,不同肤色,来自不同背景的人们将来会采用同样的 benchmark,只要有一份 Linux 内核代码即可。

perf lock

锁是内核同步的方法,一旦加了锁,其他准备加锁的内核执行路径就必须等待,降低了并行。因此对于锁进行专门分析应该是调优的一项重要工作。

我运行 perf lock 后得到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Name acquired contended total wait (ns) max wait (ns) min 

&md->map_lock 396 0 0 0
&(&mm->page_tabl... 309 0 0 0
&(&tty->buf.lock... 218 0 0 0
&ctx->lock 185 0 0 0
key 178 0 0 0
&ctx->lock 132 0 0 0
&tty->output_loc... 126 0 0 0
。。。
&(&object->lock)... 1 0 0 0
&(&object->lock)... 0 0 0 0
&(&object->lock)... 0 0 0 0
&p->cred_guard_m... 0 0 0 0

=== output for debug===

bad: 28, total: 664
bad rate: 4.216867 %
histogram of events caused bad sequence
acquire: 8
acquired: 0
contended: 0
release: 20

对该报表的一些解释如下:

  • “Name”: 锁的名字,比如 md->map_lock,即定义在 dm.c 结构 mapped_device 中的读写锁。
  • “acquired”: 该锁被直接获得的次数,即没有其他内核路径拥有该锁的情况下得到该锁的次数。
  • “contended”冲突的次数,即在准备获得该锁的时候已经被其他人所拥有的情况的出现次数。
  • “total wait”:为了获得该锁,总共的等待时间。
  • “max wait”:为了获得该锁,最大的等待时间。
  • “min wait”:为了获得该锁,最小的等待时间。

目前 perf lock 还处于比较初级的阶段,我想在后续的内核版本中,还应该会有较大的变化,因此当您开始使用 perf lock 时,恐怕已经和本文这里描述的有所不同了。不过我又一次想说的是,命令语法和输出并不是最重要的,重要的是了解什么时候我们需要用这个工具,以及它能帮我们解决怎样的问题。

perf tracepoint

event中的一种类型,实际上是一些比较常见的系统调用。

不在里面的可以使用前面介绍的动态跟踪的方式进行跟踪。

支持哪些tracepoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
perf list | awk -F: '/Tracepoint event/ { lib[$1]++ } END {for (l in lib) { printf "  %-16s %d\n", l, lib[l] } }' | sort | column

block 18 jbd2 11 kvmmmu 9 napi 1 sched 15 skb 3 timer 12 writeback 16
ext4 46 kmem 42 mce 1 net 4 scsi 5 sock 2 udp 1 xfs 314
irq 5 kvm 21 module 5 power 3 signal 2 syscalls 548 workqueue 4

perf list
......
xfs:xfs_attr_list_sf [Tracepoint event]
xfs:xfs_attr_list_sf_all [Tracepoint event]
xfs:xfs_attr_list_leaf [Tracepoint event]
xfs:xfs_attr_list_leaf_end [Tracepoint event]
xfs:xfs_attr_list_full [Tracepoint event]
xfs:xfs_attr_list_add [Tracepoint event]
......

主要包含以下tracepoint subtype

1
2
3
4
5
6
7
block: block device I/O
ext3, ext4: file system operations
kmem: kernel memory allocation events
random: kernel random number generator events
sched: CPU scheduler events
syscalls: system call enter and exits
task: task events

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
I used perf_events to record the block request (disk I/O) issue and completion static tracepoints:

# perf record -e block:block_rq_issue -e block:block_rq_complete -a sleep 120
[ perf record: Woken up 36 times to write data ]
[ perf record: Captured and wrote 8.885 MB perf.data (~388174 samples) ]
# perf script
[...]
randread.pl 2522 [000] 6011.824759: block:block_rq_issue: 254,16 R 0 () 7322849 + 16 [randread.pl]
randread.pl 2520 [000] 6011.824866: block:block_rq_issue: 254,16 R 0 () 26144801 + 16 [randread.pl]
swapper 0 [000] 6011.828913: block:block_rq_complete: 254,16 R () 31262577 + 16 [0]
randread.pl 2521 [000] 6011.828970: block:block_rq_issue: 254,16 R 0 () 70295937 + 16 [randread.pl]
swapper 0 [000] 6011.835862: block:block_rq_complete: 254,16 R () 26144801 + 16 [0]
randread.pl 2520 [000] 6011.835932: block:block_rq_issue: 254,16 R 0 () 5495681 + 16 [randread.pl]
swapper 0 [000] 6011.837988: block:block_rq_complete: 254,16 R () 7322849 + 16 [0]
randread.pl 2522 [000] 6011.838051: block:block_rq_issue: 254,16 R 0 () 108589633 + 16 [randread.pl]
swapper 0 [000] 6011.850615: block:block_rq_complete: 254,16 R () 108589633 + 16 [0]
[...]

perf Kmem

Perf Kmem 专门收集内核 slab 分配器的相关事件。比如内存分配,释放等。可以用来研究程序在哪里分配了大量内存,或者在什么地方产生碎片之类的和内存管理相关的问题。

Perf kmem 和 perf lock 实际上都是 perf tracepoint 的特例,您也完全可以用 Perf record – e kmem:* 或者 perf record – e lock:* 来完成同样的功能。但重要的是,这些工具在内部对原始数据进行了汇总和分析,因而能够产生信息更加明确更加有用的统计报表。

perf kmem 的输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[root@ovispoly perf]# ./perf kmem --alloc -l 10 --caller stat 
---------------------------------------------------------------------------
Callsite | Total_alloc/Per | Total_req/Per | Hit | Ping-pong| Frag
---------------------------------------------------------------------------
perf_mmap+1a8 | 1024/1024 | 572/572|1 | 0 | 44.141%
seq_open+15| 12384/96 | 8772/68 |129 | 0 | 29.167%
do_maps_open+0| 1008/16 | 756/12 |63 | 0 | 25.000%
...| ... | ...| ... | ... | ...
__split_vma+50| 88/88 | 88/88 | 1 | 0 | 0.000%
---------------------------------------------------------------------------
Alloc Ptr | Total_alloc/Per | Total_req/Per | Hit |Ping-pong| Frag
---------------------------------------------------------------------------
0xd15d4600|64/64 | 33/33 1 | 0 | 48.438%
0xc461e000|1024/1024 | 572/572 |1 | 0 | 44.141%
0xd15d44c0| 64/64 | 38/38 |1 | 0 | 40.625%
... | ... | ... | ... | ... | ...
---------------------------------------------------------------------------

SUMMARY
=======
Total bytes requested: 10487021
Total bytes allocated: 10730448
Total bytes wasted on internal fragmentation: 243427
Internal fragmentation: 2.268563%
Cross CPU allocations: 0/246458

该报告有三个部分:根据 Callsite 显示的部分,所谓 Callsite 即内核代码中调用 kmalloc 和 kfree 的地方。比如上图中的函数 perf_mmap,Hit 栏为 1,表示该函数在 record 期间一共调用了 kmalloc 一次,假如如第三行所示数字为 653,则表示函数 sock_alloc_send_pskb 共有 653 次调用 kmalloc 分配内存。

对于第一行 Total_alloc/Per 显示为 1024/1024,第一个值 1024 表示函数 perf_mmap 总共分配的内存大小,Per 表示平均值。

比较有趣的两个参数是 Ping-pong 和 Frag。Frag 比较容易理解,即内部碎片。虽然相对于 Buddy System,Slab 正是要解决内部碎片问题,但 slab 依然存在内部碎片,比如一个 cache 的大小为 1024,但需要分配的数据结构大小为 1022,那么有 2 个字节成为碎片。Frag 即碎片的比例。

Ping-pong 是一种现象,在多 CPU 系统中,多个 CPU 共享的内存会出现”乒乓现象”。一个 CPU 分配内存,其他 CPU 可能访问该内存对象,也可能最终由另外一个 CPU 释放该内存对象。而在多 CPU 系统中,L1 cache 是 per CPU 的,CPU2 修改了内存,那么其他的 CPU 的 cache 都必须更新,这对于性能是一个损失。Perf kmem 在 kfree 事件中判断 CPU 号,如果和 kmalloc 时的不同,则视为一次 ping-pong,理想的情况下 ping-pone 越小越好。Ibm developerworks 上有一篇讲述 oprofile 的文章,其中关于 cache 的调优可以作为很好的参考资料。

后面则有根据被调用地点的显示方式的部分。

最后一个部分是汇总数据,显示总的分配的内存和碎片情况,Cross CPU allocation 即 ping-pong 的汇总。

Perf timechart

很多 perf 命令都是为调试单个程序或者单个目的而设计。有些时候,性能问题并非由单个原因所引起,需要从各个角度一一查看。为此,人们常需要综合利用各种工具,比如 top,vmstat,oprofile 或者 perf。这非常麻烦。

此外,前面介绍的所有工具都是基于命令行的,报告不够直观。更令人气馁的是,一些报告中的参数令人费解。所以人们更愿意拥有一个“傻瓜式”的工具。

以上种种就是 perf timechart 的梦想,其灵感来源于 bootchart。采用“简单”的图形“一目了然”地揭示问题所在。

加注了引号的原因是,perf timechart 虽然有了美观的图形输出,但对于新手,这个图形就好象高科技节目中播放的 DNA 图像一样,不明白那些坐在屏幕前的人是如何从密密麻麻的点和线中找到有用的信息的。但正如受过训练的科学家一样,经过一定的练习,相信您也一定能从下图中找到您想要的。


图 1. perf timechart

人们说,只有黑白两色是一个人内心压抑的象征,Timechart 用不同的颜色代表不同的含义。上图的最上面一行是图例,告诉人们每种颜色所代表的含义。蓝色表示忙碌,红色表示 idle,灰色表示等待,等等。

接下来是 per-cpu 信息,上图所示的系统中有两个处理器,可以看到在采样期间,两个处理器忙碌程度的概括。蓝色多的地方表示忙碌,因此上图告诉我们,CPU1 很忙,而 CPU2 很闲。

再下面是 per-process 信息,每一个进程有一个 bar。上图中进程 bash 非常忙碌,而其他进程则大多数时间都在等待着什么。Perf 自己在开始的时候很忙,接下来便开始 wait 了。

总之这张图告诉了我们一个系统的概况,但似乎不够详细?

Timechart 可以显示更详细的信息,上图实际上是一个矢量图形 SVG 格式,用 SVG viewer 的放大功能,我们可以将该图的细节部分放大,timechart 的设计理念叫做”infinitely zoomable”。放大之后便可以看到一些更详细的信息,类似网上的 google 地图,找到国家之后,可以放大,看城市的分布,再放大,可以看到某个城市的街道分布,还可以放大以便得到更加详细的信息。

完整的 timechart 图形和颜色解读超出了本文的范围,感兴趣的读者可以到作者 Arjan 的博客上查看。这里仅举一个例子,上图中有一条 bar 对应了 Xorg 进程。多数时候该进程都处于 waiting 状态,只有需要显示什么的时候它才会开始和内核通信,以便进行绘图所需的 IO 操作。

将 Xorg 条目放大的例子图形如下:


图 2. perf timechart detail

上图中需要注意的是几条绿色的短线,表示进程通信,即准备绘图。假如通信的两个进程在图中上下相邻,那么绿线可以连接他们。但如果不相邻,则只会显示如上图所示的被截断的绿色短线。

蓝色部分表示进程忙碌,黄色部分表示该进程的时间片已经用完,但仍处于就绪状态,在等待调度器给予 CPU。

通过这张图,便可以较直观地看到进程在一段时间内的详细行为。

绘制perf火焰图

使用perf report -tui或者-stdio输出的文本不够直观的话,使用火焰图可以很直观的表现出哪些代码是瓶颈所在。

1
2
3
4
5
压测
$pgbench -M prepared -n -r -P 1 -c 32 -j 32 -T 100

收集统计信息
#perf record -a -g -v sleep 30

生成火焰图

1
2
3
4
5
# git clone https://github.com/brendangregg/FlameGraph      # or download it from github
# mv perf.data FlameGraph/
# cd FlameGraph
# perf script | ./stackcollapse-perf.pl > out.perf-folded
# cat out.perf-folded | ./flamegraph.pl > perf-kernel.svg

pic

绘制perf热力图

1
2
3
4
5
压测
$pgbench -M prepared -n -r -P 1 -c 32 -j 32 -T 100

收集统计信息
#perf record -a -g -v sleep 30

生成热力图

1
2
3
4
5
6
7
# git clone https://github.com/brendangregg/HeatMap      # or download it from github
# mv perf.data HeatMap/
# cd HeatMap
# perf script | awk '{ gsub(/:/, "") } $5 ~ /issue/ { ts[$6, $10] = $4 }
$5 ~ /complete/ { if (l = ts[$6, $9]) { printf "%.f %.f\n", $4 * 1000000,
($4 - l) * 1000000; ts[$6, $10] = 0 } }' > out.lat_us
# ./trace2heatmap.pl --unitstime=us --unitslat=us --maxlat=50000 out.lat_us > out.svg

使用 Script 增强 perf 的功能

通常,面对看似复杂,实则较有规律的计算机输出,程序员们总是会用脚本来进行处理:比如给定一个文本文件,想从中找出有多少个数字 0125,人们不会打开文件然后用肉眼去一个一个地数,而是用 grep 命令来进行处理。

perf 的输出虽然是文本格式,但还是不太容易分析和阅读。往往也需要进一步处理,perl 和 python 是目前最强大的两种脚本语言。Tom Zanussi 将 perl 和 python 解析器嵌入到 perf 程序中,从而使得 perf 能够自动执行 perl 或者 python 脚本进一步进行处理,从而为 perf 提供了强大的扩展能力。因为任何人都可以编写新的脚本,对 perf 的原始输出数据进行所需要的进一步处理。这个特性所带来的好处很类似于 plug-in 之于 eclipse。

下面的命令可以查看系统中已经安装的脚本:

1
2
3
4
5
# perf trace -l 
List of available trace scripts:
syscall-counts [comm] system-wide syscall counts
syscall-counts-by-pid [comm] system-wide syscall counts, by pid
failed-syscalls-by-pid [comm] system-wide failed syscalls, by pid

比如 failed-syscalls 脚本,执行的效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# perf trace record failed-syscalls 
^C[ perf record: Woken up 11 times to write data ]
[ perf record: Captured and wrote 1.939 MB perf.data (~84709 samples) ]

perf trace report failed-syscalls
perf trace started with Perl script \
/root/libexec/perf-core/scripts/perl/failed-syscalls.pl

failed syscalls, by comm:

comm # errors
-------------------- ----------
firefox 1721
claws-mail 149
konsole 99
X 77
emacs 56
[...]

failed syscalls, by syscall:

syscall # errors
------------------------------ ----------
sys_read 2042
sys_futex 130
sys_mmap_pgoff 71
sys_access 33
sys_stat64 5
sys_inotify_add_watch 4
[...]

该报表分别按进程和按系统调用显示失败的次数。非常简单明了,而如果通过普通的 perf record 加 perf report 命令,则需要自己手工或者编写脚本来统计这些数字。

遇到的其它问题

非root用户运行perf时出现的警告

1
2
3
4
5
6
7
8
9
10
11
user# perf record  --call-graph dwarf -e task-clock,cpu-clock  -p pid
WARNING: Kernel address maps (/proc/{kallsyms,modules}) are restricted,
check /proc/sys/kernel/kptr_restrict.

Samples in kernel functions may not be resolved if a suitable vmlinux
file is not found in the buildid cache or in the vmlinux path.

Samples in kernel modules won't be resolved at all.

If some relocation was applied (e.g. kexec) symbols may be misresolved
even with a suitable vmlinux or kallsyms file.

原因是perf只能采集所允许用户空间下的事件,可使用:u指定

1
user# perf record  --call-graph dwarf -e task-clock:u,cpu-clock:u  -p pid

非root用户运行perf时使用-a选项出现的警告

1
2
Warning:
PID/TID switch overriding SYSTEM

原因是非root用户不能使用-a来对所有内核事件进行采样

1
2
3
4
5
6
mapping pages error
user# perf record --call-graph dwarf -e task-clock:u,cpu-clock:u -p pid -m 256
Permission error mapping pages.
Consider increasing /proc/sys/kernel/perf_event_mlock_kb,
or try again with a smaller value of -m/--mmap_pages.
(current value: 256,0)

原因是-m 选项指定的mmap data pages的大小超过perf系统设置中限定的最大值,该最大值可通过以下方式查看:

1
2
user# cat /proc/sys/kernel/perf_event_mlock_kb     
516

如果不填-m选项,默认使用最大值。

另外要注意的是perf_event_mlock_kb是该用户可使用的最大值,如果用户同时运行了多个perf,则多个perf使用共享该值。例如,perf_event_mlock_kb设置512,有2个perf正在运行,则每个perf可使用512/2即256.

另外,如果-m所填参数不是2的幂次方,perf会帮你向上扩充到2的幂次方,例如,你输入-m 12,perf会帮你扩展到16.

qsub教程

PBS作业管理,即以qsub、qstat、qdel命令为核心的集群作业管理系统,且它是开源的。

在此环境下运行,用户不需要指定程序在哪些节点上运行,程序所需的硬件资源由PBS管理和分配。

qsub、qstat、qdel的功能分别为“提交作业”、“查看作业状态”、“删除作业”。

非PBS下mpi计算

通常来说,使用mpirun即可,例如mpirun -np 16 ./pom.kii2b.exe < /dev/null > pom.log,意为在一个节点上使用16核执行pom.kii2b.exe,stdin重定向至空,stdout重定向至pom.log。

之后可能会再执行几个mv命令之类的,例如把pom.log文件移动至别处,防止多次调用时覆盖log。

PBS作业提交

直接执行qsub会提示输入PBS作业脚本,这样很不方便,因此通常来说都是把qsub脚本写到文件里,重定向输入来进行作业提交,例如qsub < cess.sbpom.qsub(其实不加<也可以),提交一个写在cess.sbpom.qsub文件里的PBS脚本。

PBS脚本形如下面那段代码从#!/bin/shmv pom.log ../$TIME.pom.log的部分。

由于存在需要多次提交相似作业的可能,例如我有20个文件夹的数据,每个文件夹里数据的处理方式相同,调用同一个程序,总不能写20个PBS脚本。因此更为通常的做法是,使用shell脚本进行qsub脚本输出再提交,举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    PNAME=fz_letkf
NODE=1
NP=16
QSUBTIME="24:00:00"
NOWDIR=`pwd`
QSUB=cess.letkf.qsub
LETKF=letkf020.m01
cat <<EOF >$QSUB
#!/bin/sh
#PBS -q hpca
#PBS -V
#PBS -N $PNAME
#PBS -l nodes=$NODE:ppn=$NP
#PBS -l walltime="$QSUBTIME"
#PBS -o /home/xfh_stu/WORK3/qsublog
#PBS -j oe
cd $NOWDIR
mpirun ./$LETKF < /dev/null
mv pom.log ../$TIME.pom.log
EOF
qsub $QSUB >cessrunid

调用此脚本就会自动将PBS脚本输出至$QSUB文件中,并提交此作业。通常在这段代码外面会套上循环,每次修改相应变量,从而实现一次提交多个相似作业。

在这里cat命令使用了一种叫做heredoc的写法,用于输出大段文字,同时还要替换其中的变量。界定符EOF是可以自定义的,不过通常来说都使用EOF。另外,用于结束的界定符必须顶格写(想不顶格也是可以的,但是有其他限制,且不方便)。

命令格式:

1
2
3
4
5
6
qsub [-a date_time] [-c interval] [-C directive_prefix]
[-e path] [-I] [-j join] [-k keep] [-l resource_list] [-m mail_options]
[-M user_list][-N name] [-o path] [-p priority] [-q destination] [-r c]
[-S path_list] [-u user_list][-v variable_list] [-V]
[-W additional_attributes] [-z]
[script]

下面解释一下参数的意思。

  • -q:指定使用的队列。可使用qstat -q查看队列信息,包括队列名、资源限制、正在运行的任务数等。
  • -V:将执行qsub命令时拥有的环境变量都export该作业。
  • -N:指定作业名。
  • -l:指定作业使用的节点数与核数、时间限制等。
  • -o:重定向此作业的stdout至指定的文件夹中,名为作业名.o作业ID。
  • -j oe:合并此作业的stdout与stderr。

qsub成功提交作业后,会在stdout输出Job ID,形如作业ID.主机名,例如15252.manager。在上面的例子中,我将qsub的结果重定向至cessrunid文件中,用于存储作业ID,以便后续处理。

查看作业状态

执行qstat即可查看当前正在执行的作业以及刚刚完成的作业。在S那一列,C表示已完成,R表示正在执行,Q表示正在等待,还有一些其他不常见的状态,可以man qstat并以job state为关键词查询即可。

命令格式:qatat [-f][-a][-i] [-n][-s] [-R] [-Q][-q][-B][-u]
参数说明:
-f jobid 列出指定作业的信息
-a 列出系统所有作业
-i 列出不在运行的作业
-n 列出分配给此作业的结点
-s 列出队列管理员与scheduler所提供的建议
-R 列出磁盘预留信息
-Q 操作符是destination id,指明请求的是队列状态
-q 列出队列状态,并以alternative形式显示
-au userid 列出指定用户的所有作业
-B 列出PBS Server信息
-r 列出所有正在运行的作业
-Qf queue 列出指定队列的信息
-u 若操作符为作业号,则列出其状态。
若操作符为destination id,则列出运行在其上的属于user_list中用户的作业状态。

现在还有个问题,即如何在脚本中判断作业完成与否呢?一个很实际的例子是,我在脚本中一次提交完多个作业后,后续的脚本必须在完成这些作业后才能继续执行,那么就需要知道这些作业有没有完成。

通常有两种思路:

一是查看程序本应输出的文件有没有正常输出,即判断输出文件是否存在。或者在程序中写一些日志输出语句,脚本就可以通过查找日志文件某关键的一句话有没有输出从而知道程序运行有没有正常完成。

二是查看上文中通过-o参数指定的作业stdout输出文件,文件名为作业名.o作业ID。这也就是为什么我要把qsub提交信息保存到cessrunid这个文件里。通常来说,只要作业正常完成了,就会生成此文件。

对于第一种思路,就要根据程序具体情况来编写了。

对于第二种思路,一个典型的判断脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
while :
do

temp=`cat cessrunid`
runid=${PNAME}.o${temp%.manager}
runfilename='/home/xfh_stu/WORK3/qsublog/'$runid
if [ -f "$runfilename" ]; then
break
fi

sleep 60

done

大意为:

提取出cessrunid这个文件里的qsub提交信息,并去掉后面的.manager主机名(CESS集群主机名为manager),之后改写成作业名.o作业ID的形式,并加上路径,判断该文件是否存在。

如果文件存在,则说明作业已完成,即可break掉这个无限循环,继续后面的操作了。

这里需要注意的一个地方就是,在无限循环里的每次判断中间要加上一个sleep语句,比如我设置的是每分钟跑一次循环,这样机器就不会由于每时每刻都在执行判断而耗尽资源。

删除作业

执行qdel -W 15 15303即可在15秒后停止并删除Job ID为15303的作业。

管理作业

qmgr 命令—用于队列管理

1
2
3
4
5
6
qmgr -c “create queue batch queue_type=execution”
qmgr -c “set queue batch started=true
qmgr -c “set queue batch enabled=true
qmgr -c “set queue batch resources_default.nodes=1″
qmgr -c “set queue batch resources_default.walltime=3600″
qmgr -c “set server default_queue=batch”

脚本的正确使用方法

通常来说,我们都是在shell脚本中进行qsub脚本输出再提交该qsub脚本。

这里存在一个问题,即shell脚本自身需要后台执行。如果执行前台执行脚本,就会导致断开SSH连接后,脚本就会停止执行。

因此,需要使用nohup ./your.script.name.sh &命令,它可以脚本在后台执行且将stdout重定向至nohup.out文件中。要注意命令最后的&是不可缺少的,如果不写,脚本虽然也会在后台执行,但是在关闭SSH后就会停止。

另外,在执行完这个命令之后要按一下回车,使其回到shell上来。

当我们解决脚本后台执行的问题后,又出现了新问题,即如何停止该脚本?

通过脚本提交的PBS作业可以通过qdel命令结束掉,而脚本本身停止就需要kill掉该脚本的进程了。

首先,我们使用ps -ef | grep your.script.name.sh查询到脚本的进程PID,之后执行kill xxxxx即可停止PID为xxxxx的进程了。

tmux使用备忘

简介

tmux is a terminal multiplexer. It lets you switch easily between several programs in one terminal, detach them (they keep running in the background) and reattach them to a different terminal.

快捷键

操作 快捷键
启动 tmux
退出 ctrl + d 或者exit
前缀键 ctrl + b
查看帮助 ctrl + b ?
新建会话 tmux new -s blog
分离会话 ctrl + b d tmux detach
查看会话 tmux ls
接入会话 tmux attach -t 0
tmux attach -t blog
杀死会话 tmux kill-session -t 0
tmux kill-session -t blog
切换会话 tmux switch -t 0
tmux switch -t blog
重命名窗口 tmux rename-window <new-name>
ctrl + b ,
创建一个新窗口 ctrl + b c
切换上一个窗口 ctrl + b n
切换到指定编号窗口 ctrl + b number
从列表中选择窗口 ctrl + b w
列出所有快捷键 tmux list-keys
列出所有命令及参数 tmux list-commands
列出所有会话信息 tmux info
wwtmux配置 tmux source-file ~/.tmux.conf
左右分屏 ctrl + b %
上下分屏 ctrl + b "
切换分屏窗口 ctrl + b 方向键

切换pane

ctrl+b o 依次切换当前窗口下的各个pane。

ctrl+b Up|Down|Left|Right 根据按箭方向选择切换到某个pane。

ctrl+b Space (空格键) 对当前窗口下的所有pane重新排列布局,每按一次,换一种样式。

ctrl+b z 最大化当前pane。再按一次后恢复。

关闭pane

ctrl+b x 关闭当前使用中的pane,操作之后会给出是否关闭的提示,按y确认即关闭。

tmux window中的历史输出查看

在tmux里面,因为每个窗口(tmux window)的历史内容已经被tmux接管了,当我们在每个tmux的window之间进行来回切换,来回操作,那么我们没有办法看到一个window里面屏幕上的历史输出。没办法使用鼠标滚动(例如在SecureCRT中)查看之前的内容,在SecureCRT中通过鼠标滚动看到的输出一定是各个tmux的window的输出混乱夹杂在一起的,如果要看当前窗口的历史内容,那么应该怎么办呢,通过在当前的tmux window 按 ctrl-b 进入copy mode,然后就可以用PgUp/PgDn来浏览历史输出了,按q退出。

使用mpiP工具分析AMG程序

下载mpiP

mpiP需要依赖几个库:

  • libunwind : 用来收集调用栈信息
  • binutils : 用来解析程序地址到源代码行的信息

安装libunwind

安装的时候会在autoreconf -i这一步就报错,导致没有生成Makefile文件,应该先安装libtool这个工具

执行libtool中带的libtoolize,发现报错

1
2
3
4
5
6
7
8
9
10
11
12
[yuhao@localhost libunwind]$ ../libtool/bin/libtoolize 
libtoolize: putting auxiliary files in AC_CONFIG_AUX_DIR, 'config'.
libtoolize: linking file 'config/ltmain.sh'
libtoolize: You should add the contents of the following files to 'aclocal.m4':
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/libtool.m4'
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/ltoptions.m4'
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/ltsugar.m4'
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/ltversion.m4'
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/lt~obsolete.m4'
libtoolize: Consider adding 'AC_CONFIG_MACRO_DIRS([m4])' to configure.ac,
libtoolize: and rerunning libtoolize and aclocal.
libtoolize: Consider adding '-I m4' to ACLOCAL_AMFLAGS in Makefile.am.

先尝试执行下边的命令,会报错:

1
2
3
4
5
6
7
8
[yuhao@localhost libunwind]$ aclocal -I /home/yuhao/tool/libtool/share/aclocal/
[yuhao@localhost libunwind]$ autoreconf -i
src/Makefile.am:10: error: Libtool library used but 'LIBTOOL' is undefined
src/Makefile.am:10: The usual way to define 'LIBTOOL' is to add 'LT_INIT'
src/Makefile.am:10: to 'configure.ac' and run 'aclocal' and 'autoconf' again.
src/Makefile.am:10: If 'LT_INIT' is in 'configure.ac', make sure
src/Makefile.am:10: its definition is in aclocal's search path.
autoreconf: automake failed with exit status: 1

尝试按照上边的提示,在configure.ac中加入AC_CONFIG_MACRO_DIRS([m4]),其中m4替换成libtool中的aclocal路径。再执行一遍libtoolize

1
2
[yuhao@localhost libunwind]$ ../libtool/bin/libtoolize 
libtoolize: Consider adding '-I /home/yuhao/tool/libtool/share/aclocal/' to ACLOCAL_AMFLAGS in Makefile.am.

又让把一个路径加到Makefile.am中

再从头开始执行一遍,总算好了。

1
2
3
4
5
[yuhao@localhost libunwind]$ ../libtool/bin/libtoolize 
[yuhao@localhost libunwind]$ aclocal
ac[yuhao@localhost libunwind]$ autoheader
a[yuhao@localhost libunwind]$ autoreconf
[yuhao@localhost libunwind]$ ./configure CC=icc CFLAGS="-std=c11 -g -O3 -ip" CXX=icpc CCASFLAGS=-g --prefix=/home/yuhao/tool/libunwind

编译的时候又报错了,最后使用了2019的intel编译器才能编译:

1
2
3
4
5
6
7
8
9
libtool: compile:  icc -DHAVE_CONFIG_H -I. -I../include -I../include -I../include/tdep-x86_64 -I. -D_GNU_SOURCE -DNDEBUG -g -std=c++11 -O3 -ip -D__EXTENSIONS__ -MT os-linux.lo -MD -MP -MF .deps/os-linux.Tpo -c os-linux.c  -fPIC -DPIC -o .libs/os-linux.o
icc: command line warning #10370: option '-std=c++11' is not valid for C compilations
In file included from ../include/tdep-x86_64/libunwind_i.h(41),
from ../include/tdep/libunwind_i.h(25),
from ../include/libunwind_i.h(356),
from os-linux.c(33):
../include/dwarf.h(355): error: identifier "_Atomic" is undefined
_Atomic uint32_t generation; /* generation number */

安装好依赖库后,相应配置mpiP的安装选项(依赖库路径,编译器,编译选项等),再按照提示安装即可。

mpiP生成一个动态库libmpiP.so,使用起来比较简单,不需要用它来重编程序。但为了得到正确的源文件和行号信息,最好用-g选项重编一下。两种使用方式

  • 将mpiP库与可执行文件链接起来。如果链接命令包含MPI库,要将mpiP库排序到MPI库之前,如 -l mpiP -lmpi
  • 在运行时设置LD_PRELOAD环境变量来加载mpiP库

mpiP提供一系列运行时选项给用户,通过打开它们来采集对应的信息。主要用到的一些列举如下:

  • -k n设置调用栈回溯的深度(默认1)
  • -o 在初始化时关掉profiling,在希望profiling的特定代码段通过MPI_Pcontrol()打开
  • -p 报告包含点对点通信的消息大小、通信子
  • -y 报告包含集合通信的消息大小、通信子

这些选项通过环境变量MPIP的设置来生效,如:export MPIP=”-t 10.0 -k 2” (bash)。

AMG编译和运行过程

选择候选程序中的AMG(https://github.com/LLNL/AMG)进行测试。clone下来之后,按照README进行编译。注意几个编译选项,开启了`-DHYPRE_USING_PERSISTENT_COMM`来使用MPI的重复非阻塞通信来提高性能,开启了`-DHYPRE_HOPSCOTCH`来提高OpenMP的优化。它还提供`-DHYPRE_PROFILE`来做一些很简单的计时,但这里没有用。

编译需要修改Makefile.include中的INCLUDE_CFLAGS,加上-g选项;同时修改INCLUDE_LFLAGS,加上-lmpip选项。

编译过程:依次进入子目录utilities,krylov,IJ_mv,parcsr_ls,parcsr_mv,seq_mv,编译各个源文件,并生成一个对应的静态库*.a,最后进入test目录编译和链接测试程序。

mpiP对AMG的分析结果

设置环境变量MPIP=”-k5,-e,-y,-p”,程序结束运行时输出一个mpiP的记录文件。

打开可以看到几部分。首先是记录了程序运行的信息,每个MPI进程的分布:

第二部分记录了每个进程花在MPI相关的函数上的时间,占总的时间比例:

可以看到通信负载并不是很均衡,最小通信耗时的进程只是最大的一半左右。

第三部分展示了各个MPI相关函数调用的信息,每一个ID对应一个位置的调用(同样的MPI函数出现在不同地方的调用,有不同的ID,如下图的Waitall),这里设置了调用栈回溯为5层,所以每个ID重复出现5次,Lev较大的是调用者,同时有每一个函数所在的源文件和行数。

如果程序所使用的进程很多,且通信模式复杂的话,这一部分将会非常庞大。这一次的运行,该部分共有47733条记录。

第四部分记录了总耗时在前20个的MPI函数调用。这里的site就对应上一部分的ID,可以据此判定这一项对应哪个位置的调用。

类似的记录还有发的消息总大小、次数、平均大小。按照消息总大小展示前20名。

由于打开了-y选项,下一部分展示了集合通信的总耗时、通信子大小、数据大小。可见程序中涉及的集合通信只有四个地方。但不知道为什么,这里没有site,不知道怎么去找对应的调用位置。

由于打开了-p选项,下一部分展示了点对点通信的通信子大小、消息大小等,MPI_Sent %似乎表示的是这一个调用占的发送消息的总比例,但也没有显示site。

最后一部分是每个调用位置的统计信息。该部分首先是时间信息,包括了操作时间的最大最小值和平均值,以及该调用位置耗时在它进程的MPI总时间和整个程序时间中的占比(Rank列中的星号代表该调用位置的累加的信息)。这部分的排序是按字母序的,a开头的Allreduce在前。

该部分然后是通信的消息大小的统计。

分析结果的简单分析和理解

mpiP只能简单测量通信特征,从以上结果,大致可以有几点判断:

  • 程序中的通信以点对点通信为主
    • ISend在发送消息的量上占比很高,各个位置的调用次数都很多,且通信消息的大小较大;对应使用的异步接收的Waiall说明是重复的非阻塞通信,且耗时占比很高
    • 集合通信的类型较少,耗时占比小,消息大小也很小
  • 最耗时的点对点通信(即程序中的update halo操作),消息大小都在5.5 KB左右
  • 通信负载很不均衡,MPI时间的min/max只有50%左右
  • 程序的通信热点所在的路径包含了函数hypre_ParCSRMatrixMatvecOutOfPlace,因为在调用位置的栈回溯中大量出现该函数(但仅通过通信特征来明确程序热点似乎有点不足,虽然它确实就是程序最耗时的部分)

小结

综合来看,要对一个程序进行性能分析,包括全面的计算、访存和通信,得需要多种的分析工具。光靠一种有点难以全面评估。而mpiP给出的信息只能覆盖通信特征,且缺乏直观,尤其在大规模进程数时,得到的callsite statistics非常庞大,需要用户自己通过site ID去找最耗时的通信调用对应在哪个地方。虽然通过mpiP的MPI_Pcontrol()可以实现任意具体代码段的分析,能减小输出的文件的规模,便于针对性的分析,但这就需要用户对程序代码本身有理解了才能在代码里添加MPI_Pcontrol()的控制。而且配合MPI_Pcontrol()启用-o选项后,不知道为什么,输出的文件里就没有具体通信调用处的通信子大小、消息大小等信息了。

Git的奇技淫巧

Git是一个 “分布式版本管理工具”,简单的理解版本管理工具:大家在写东西的时候都用过 “回撤” 这个功能,但是回撤只能回撤几步,假如想要找回我三天之前的修改,光用 “回撤” 是找不回来的。而 “版本管理工具” 能记录每次的修改,只要提交到版本仓库,你就可以找到之前任何时刻的状态(文本状态)。

下面的内容就是列举了常用的 Git 命令和一些小技巧,可以通过 “页面内查找” 的方式进行快速查询:Ctrl/Command+f

开卷必读

如果之前未使用过 Git,可以学习 Git 小白教程入门

  1. 一定要先测试命令的效果后,再用于工作环境中,以防造成不能弥补的后果!到时候别拿着砍刀来找我
  2. 所有的命令都在git version 2.7.4 (Apple Git-66)下测试通过
  3. 统一概念:
    • 工作区:改动(增删文件和内容)
    • 暂存区:输入命令:git add 改动的文件名,此次改动就放到了 ‘暂存区’
    • 本地仓库(简称:本地):输入命令:git commit 此次修改的描述,此次改动就放到了 ’本地仓库’,每个 commit,我叫它为一个 ‘版本’。
    • 远程仓库(简称:远程):输入命令:git push 远程仓库,此次改动就放到了 ‘远程仓库’(GitHub 等)
    • commit-id:输出命令:git log,最上面那行 commit xxxxxx,后面的字符串就是 commit-id

展示帮助信息

1
git help -g

The command output as below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
The common Git guides are:
attributes Defining attributes per path
cli Git command-line interface and conventions
core-tutorial A Git core tutorial for developers
cvs-migration Git for CVS users
diffcore Tweaking diff output
everyday A useful minimum set of commands for Everyday Git
glossary A Git Glossary
hooks Hooks used by Git
ignore Specifies intentionally untracked files to ignore
modules Defining submodule properties
namespaces Git namespaces
repository-layout Git Repository Layout
revisions Specifying revisions and ranges for Git
tutorial A tutorial introduction to Git
tutorial-2 A tutorial introduction to Git: part two
workflows An overview of recommended workflows with Git

'git help -a' and 'git help -g' list available subcommands and some concept guides. See 'git help <command>' or 'git help <concept>' to read about a specific subcommand or concept.

回到远程仓库的状态

抛弃本地所有的修改,回到远程仓库的状态。

1
git fetch --all && git reset --hard origin/master

重设第一个 commit

也就是把所有的改动都重新放回工作区,并清空所有的 commit,这样就可以重新提交第一个 commit 了

1
git update-ref -d HEAD

展示工作区和暂存区的不同

输出工作区暂存区的 different (不同)。

1
git diff

还可以展示本地仓库中任意两个 commit 之间的文件变动:

1
git diff <commit-id> <commit-id>

展示暂存区和最近版本的不同

输出暂存区和本地最近的版本 (commit) 的 different (不同)。

1
git diff --cached

展示暂存区、工作区和最近版本的不同

输出工作区暂存区 和本地最近的版本 (commit) 的 different (不同)。

1
git diff HEAD

快速切换到上一个分支

1
git checkout -

删除已经合并到 master 的分支

1
git branch --merged master | grep -v '^\*\|  master' | xargs -n 1 git branch -d

展示本地分支关联远程仓库的情况

1
git branch -vv

关联远程分支

关联之后,git branch -vv 就可以展示关联的远程分支名了,同时推送到远程仓库直接:git push,不需要指定远程仓库了。

1
git branch -u origin/mybranch

或者在 push 时加上 -u 参数

1
git push origin/mybranch -u

列出所有远程分支

-r 参数相当于:remote

1
git branch -r

列出本地和远程分支

-a 参数相当于:all

1
git branch -a

创建并切换到本地分支

1
git checkout -b <branch-name>

从远程分支中创建并切换到本地分支

1
git checkout -b <branch-name> origin/<branch-name>

删除本地分支

1
git branch -d <local-branchname>

删除远程分支

1
git push origin --delete <remote-branchname>

或者

1
git push origin :<remote-branchname>

重命名本地分支

1
git branch -m <new-branch-name>

查看标签

1
git tag

展示当前分支的最近的 tag

1
git describe --tags --abbrev=0

查看标签详细信息

1
git tag -ln

本地创建标签

1
git tag <version-number>

默认 tag 是打在最近的一次 commit 上,如果需要指定 commit 打 tag:

1
$ git tag -a <version-number> -m "v1.0 发布(描述)" <commit-id>

推送标签到远程仓库

首先要保证本地创建好了标签才可以推送标签到远程仓库:

1
git push origin <local-version-number>

一次性推送所有标签,同步到远程仓库:

1
git push origin --tags

删除本地标签

1
git tag -d <tag-name>

删除远程标签

删除远程标签需要先删除本地标签,再执行下面的命令:

1
git push origin :refs/tags/<tag-name>

切回到某个标签

一般上线之前都会打 tag,就是为了防止上线后出现问题,方便快速回退到上一版本。下面的命令是回到某一标签下的状态:

1
git checkout -b branch_name tag_name

放弃工作区的修改

1
git checkout <file-name>

放弃所有修改:

1
git checkout .

恢复删除的文件

1
2
3
git rev-list -n 1 HEAD -- <file_path> #得到 deleting_commit

git checkout <deleting_commit>^ -- <file_path> #回到删除文件 deleting_commit 之前的状态

以新增一个 commit 的方式还原某一个 commit 的修改

1
git revert <commit-id>

回到某个 commit 的状态,并删除后面的 commit

和 revert 的区别:reset 命令会抹去某个 commit id 之后的所有 commit

1
2
3
4
5
6
7
git reset <commit-id>  #默认就是-mixed参数。

git reset –mixed HEAD^ #回退至上个版本,它将重置HEAD到另外一个commit,并且重置暂存区以便和HEAD相匹配,但是也到此为止。工作区不会被更改。

git reset –soft HEAD~3 #回退至三个版本之前,只回退了commit的信息,暂存区和工作区与回退之前保持一致。如果还要提交,直接commit即可

git reset –hard <commit-id> #彻底回退到指定commit-id的状态,暂存区和工作区也会变为指定commit-id版本的内容

修改上一个 commit 的描述

如果暂存区有改动,同时也会将暂存区的改动提交到上一个 commit

1
git commit --amend

查看 commit 历史

1
git log

查看某段代码是谁写的

blame 的意思为‘责怪’,你懂的。

1
git blame <file-name>

显示本地更新过 HEAD 的 git 命令记录

每次更新了 HEAD 的 git 命令比如 commint、amend、cherry-pick、reset、revert 等都会被记录下来(不限分支),就像 shell 的 history 一样。
这样你可以 reset 到任何一次更新了 HEAD 的操作之后,而不仅仅是回到当前分支下的某个 commit 之后的状态。

1
git reflog

修改作者名

1
git commit --amend --author='Author Name <email@address.com>'

修改远程仓库的 url

1
git remote set-url origin <URL>

增加远程仓库

1
git remote add origin <remote-url>

列出所有远程仓库

1
git remote

查看两个星期内的改动

1
git whatchanged --since='2 weeks ago'

把 A 分支的某一个 commit,放到 B 分支上

这个过程需要 cherry-pick 命令,参考

1
git checkout <branch-name> && git cherry-pick <commit-id>

给 git 命令起别名

简化命令

1
2
3
4
5
git config --global alias.<handle> <command>

比如:git status 改成 git st,这样可以简化命令

git config --global alias.st status

存储当前的修改,但不用提交 commit

详解可以参考廖雪峰老师的 git 教程

1
git stash

保存当前状态,包括 untracked 的文件

untracked 文件:新建的文件

1
git stash -u

展示所有 stashes

1
git stash list

回到某个 stash 的状态

1
git stash apply <stash@{n}>

回到最后一个 stash 的状态,并删除这个 stash

1
git stash pop

删除所有的 stash

1
git stash clear

从 stash 中拿出某个文件的修改

1
git checkout <stash@{n}> -- <file-path>

展示所有 tracked 的文件

1
git ls-files -t

展示所有 untracked 的文件

1
git ls-files --others

展示所有忽略的文件

1
git ls-files --others -i --exclude-standard

强制删除 untracked 的文件

可以用来删除新建的文件。如果不指定文件文件名,则清空所有工作的 untracked 文件。clean 命令,注意两点

  1. clean 后,删除的文件无法找回
  2. 不会影响 tracked 的文件的改动,只会删除 untracked 的文件
1
git clean <file-name> -f

强制删除 untracked 的目录

可以用来删除新建的目录,注意:这个命令也可以用来删除 untracked 的文件。详情见上一条

1
git clean <directory-name> -df

展示简化的 commit 历史

1
git log --pretty=oneline --graph --decorate --all

把某一个分支到导出成一个文件

1
git bundle create <file> <branch-name>

从包中导入分支

新建一个分支,分支内容就是上面 git bundle create 命令导出的内容

1
git clone repo.bundle <repo-dir> -b <branch-name>

执行 rebase 之前自动 stash

1
git rebase --autostash

从远程仓库根据 ID,拉下某一状态,到本地分支

1
git fetch origin pull/<id>/head:<branch-name>

详细展示一行中的修改

1
git diff --word-diff

清除 gitignore 文件中记录的文件

1
git clean -X -f

展示所有 alias 和 configs

注意: config 分为:当前目录(local)和全局(golbal)的 config,默认为当前目录的 config

1
2
git config --local --list (当前目录)
git config --global --list (全局)

展示忽略的文件

1
git status --ignored

commit 历史中显示 Branch1 有的,但是 Branch2 没有 commit

1
git log Branch1 ^Branch2

在 commit log 中显示 GPG 签名

1
git log --show-signature

删除全局设置

1
git config --global --unset <entry-name>

新建并切换到新分支上,同时这个分支没有任何 commit

相当于保存修改,但是重写 commit 历史

1
git checkout --orphan <branch-name>

展示任意分支某一文件的内容

1
git show <branch-name>:<file-name>

clone 下来指定的单一分支

1
git clone -b <branch-name> --single-branch https://github.com/user/repo.git

忽略某个文件的改动

关闭 track 指定文件的改动,也就是 Git 将不会在记录这个文件的改动

1
git update-index --assume-unchanged path/to/file

恢复 track 指定文件的改动

1
git update-index --no-assume-unchanged path/to/file

忽略文件的权限变化

不再将文件的权限变化视作改动

1
git config core.fileMode false

以最后提交的顺序列出所有 Git 分支

最新的放在最上面

1
git for-each-ref --sort=-committerdate --format='%(refname:short)' refs/heads/

在 commit log 中查找相关内容

通过 grep 查找,given-text:所需要查找的字段

1
git log --all --grep='<given-text>'

把暂存区的指定 file 放到工作区中

不添加参数,默认是 -mixed

1
git reset <file-name>

强制推送

1
git push -f <remote-name> <branch-name>

一图详解

优雅的提交Commit信息

使用Angular团队提交规范

主要有以下组成

  • 标题行: 必填, 描述主要修改类型和内容
  • 主题内容: 描述为什么修改, 做了什么样的修改, 以及开发的思路等等
  • 页脚注释: 放 Breaking Changes 或 Closed Issues

常用的修改项

  • type: commit 的类型
  • feat: 新特性
  • fix: 修改问题
  • refactor: 代码重构
  • docs: 文档修改
  • style: 代码格式修改, 注意不是 css 修改
  • test: 测试用例修改
  • chore: 其他修改, 比如构建流程, 依赖管理.
  • scope: commit 影响的范围, 比如: route, component, utils, build…
  • subject: commit 的概述
  • body: commit 具体修改内容, 可以分为多行
  • footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.

memcached完全剖析

memcached是什么?

memcached是以LiveJournal旗下Danga Interactive 公司的Brad Fitzpatric为首开发的一款软件。现在已成为mixi、hatena、 Facebook、Vox、LiveJournal等众多服务中提高Web应用扩展性的重要因素。

许多Web应用都将数据保存到RDBMS中,应用服务器从中读取数据并在浏览器中显示。 但随着数据量的增大、访问的集中,就会出现RDBMS的负担加重、数据库响应恶化、 网站显示延迟等重大影响。

这时就该memcached大显身手了。memcached是高性能的分布式内存缓存服务器。 一般的使用目的是,通过缓存数据库查询结果,减少数据库访问次数,以提高动态Web应用的速度、 提高可扩展性。

图1 一般情况下memcached的用途

memcached的特征

memcached作为高速运行的分布式缓存服务器,具有以下的特点。

  • 协议简单
  • 基于libevent的事件处理
  • 内置内存存储方式
  • memcached不互相通信的分布式

协议简单

memcached的服务器客户端通信并不使用复杂的XML等格式,而使用简单的基于文本行的协议。因此,通过telnet 也能在memcached上保存数据、取得数据。下面是例子。

1
2
3
4
5
6
7
8
9
10
$ telnet localhost 11211
Trying 127.0.0.1...
Connected to localhost.localdomain (127.0.0.1).
Escape character is '^]'.
set foo 0 0 3 (保存命令)
bar (数据)
STORED (结果)
get foo (取得命令)
VALUE foo 0 3 (数据)
bar (数据)

协议文档位于memcached的源代码内,也可以参考以下的URL。

http://code.sixapart.com/svn/memcached/trunk/server/doc/protocol.txt

基于libevent的事件处理

libevent是个程序库,它将Linux的epoll、BSD类操作系统的kqueue等事件处理功能 封装成统一的接口。即使对服务器的连接数增加,也能发挥O(1)的性能。 memcached使用这个libevent库,因此能在Linux、BSD、Solaris等操作系统上发挥其高性能。 关于事件处理这里就不再详细介绍,可以参考Dan Kegel的The C10K Problem。

libevent: http://www.monkey.org/~provos/libevent/
The C10K Problem: http://www.kegel.com/c10k.html

内置内存存储方式

为了提高性能,memcached中保存的数据都存储在memcached内置的内存存储空间中。 由于数据仅存在于内存中,因此重启memcached、重启操作系统会导致全部数据消失。 另外,内容容量达到指定值之后,就基于LRU(Least Recently Used)算法自动删除不使用的缓存。 memcached本身是为缓存而设计的服务器,因此并没有过多考虑数据的永久性问题。 关于内存存储的详细信息,本连载的第二讲以后前坂会进行介绍,请届时参考。

memcached不互相通信的分布式

memcached尽管是“分布式”缓存服务器,但服务器端并没有分布式功能。 各个memcached不会互相通信以共享信息。那么,怎样进行分布式呢? 这完全取决于客户端的实现。本连载也将介绍memcached的分布式。

图2 memcached的分布式

接下来简单介绍一下memcached的使用方法。

memcached的安装

运行memcached需要本文开头介绍的libevent库。Fedora 8中有现成的rpm包, 通过yum命令安装即可。

1
$ sudo yum install libevent libevent-devel

memcached的源代码可以从memcached网站上下载。本文执笔时的最新版本为1.2.5。 Fedora 8虽然也包含了memcached的rpm,但版本比较老。因为源代码安装并不困难, 这里就不使用rpm了。

下载memcached:http://www.danga.com/memcached/download.bml
memcached安装与一般应用程序相同,configure、make、make install就行了。

1
2
3
4
5
6
$ wget http://www.danga.com/memcached/dist/memcached-1.2.5.tar.gz
$ tar zxf memcached-1.2.5.tar.gz
$ cd memcached-1.2.5
$ ./configure
$ make
$ sudo make install

默认情况下memcached安装到/usr/local/bin下。

memcached的启动

从终端输入以下命令,启动memcached。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ /usr/local/bin/memcached -p 11211 -m 64m -vv
slab class 1: chunk size 88 perslab 11915
slab class 2: chunk size 112 perslab 9362
slab class 3: chunk size 144 perslab 7281
中间省略
slab class 38: chunk size 391224 perslab 2
slab class 39: chunk size 489032 perslab 2
<23 server listening
<24 send buffer was 110592, now 268435456
<24 server listening (udp)
<24 server listening (udp)
<24 server listening (udp)
<24 server listening (udp)

这里显示了调试信息。这样就在前台启动了memcached,监听TCP端口11211 最大内存使用量为64M。调试信息的内容大部分是关于存储的信息, 下次连载时具体说明。

作为daemon后台启动时,只需

1
$ /usr/local/bin/memcached -p 11211 -m 64m -d

这里使用的memcached启动选项的内容如下。

  • -p使用的TCP端口。默认为11211
  • -m最大内存大小。默认为64M
  • -vv用very vrebose模式启动,调试信息和错误输出到控制台
  • -d作为daemon在后台启动

上面四个是常用的启动选项,其他还有很多,通过

1
$ /usr/local/bin/memcached -h

命令可以显示。许多选项可以改变memcached的各种行为, 推荐读一读。

使用Cache::Memcached

Perl的memcached客户端有

  • Cache::Memcached
  • Cache::Memcached::Fast
  • Cache::Memcached::libmemcached

等几个CPAN模块。这里介绍的Cache::Memcached是memcached的作者Brad Fitzpatric的作品, 应该算是memcached的客户端中应用最为广泛的模块了。

使用Cache::Memcached连接memcached

下面的源代码为通过Cache::Memcached连接刚才启动的memcached的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/perl

use strict;
use warnings;
use Cache::Memcached;

my $key = "foo";
my $value = "bar";
my $expires = 3600; # 1 hour
my $memcached = Cache::Memcached->new({
servers => ["127.0.0.1:11211"],
compress_threshold => 10_000
});

$memcached->add($key, $value, $expires);
my $ret = $memcached->get($key);
print "$ret\n";

在这里,为Cache::Memcached指定了memcached服务器的IP地址和一个选项,以生成实例。 Cache::Memcached常用的选项如下所示。

  • servers 用数组指定memcached服务器和端口
  • compress_threshold 数据压缩时使用的值
  • namespace 指定添加到键的前缀

另外,Cache::Memcached通过Storable模块可以将Perl的复杂数据序列化之后再保存, 因此散列、数组、对象等都可以直接保存到memcached中。

保存数据

向memcached保存数据的方法有

  • add
  • replace
  • set

它们的使用方法都相同:

1
2
3
my $add = $memcached->add( '键', '值', '期限' );
my $replace = $memcached->replace( '键', '值', '期限' );
my $set = $memcached->set( '键', '值', '期限' );

向memcached保存数据时可以指定期限(秒)。不指定期限时,memcached按照LRU算法保存数据。 这三个方法的区别如下:

  • add 仅当存储空间中不存在键相同的数据时才保存
  • replace 仅当存储空间中存在键相同的数据时才保存
  • set 与add和replace不同,无论何时都保存

获取数据

获取数据可以使用get和get_multi方法。

1
2
my $val = $memcached->get('键');
my $val = $memcached->get_multi('键1', '键2', '键3', '键4', '键5');

一次取得多条数据时使用get_multi。get_multi可以非同步地同时取得多个键值, 其速度要比循环调用get快数十倍。

删除数据

删除数据使用delete方法,不过它有个独特的功能。

1
$memcached->delete('键', '阻塞时间(秒)');

删除第一个参数指定的键的数据。第二个参数指定一个时间值,可以禁止使用同样的键保存新数据。 此功能可以用于防止缓存数据的不完整。但是要注意,set函数忽视该阻塞,照常保存数据

增一和减一操作

可以将memcached上特定的键值作为计数器使用。

1
2
my $ret = $memcached->incr('键');
$memcached->add('键', 0) unless defined $ret;

增一和减一是原子操作,但未设置初始值时,不会自动赋成0。因此, 应当进行错误检查,必要时加入初始化操作。而且,服务器端也不会对 超过2 SUP(32)时的行为进行检查。

Slab Allocation机制:整理内存以便重复使用

最近的memcached默认情况下采用了名为Slab Allocator的机制分配、管理内存。 在该机制出现以前,内存的分配是通过对所有记录简单地进行malloc和free来进行的。 但是,这种方式会导致内存碎片,加重操作系统内存管理器的负担,最坏的情况下, 会导致操作系统比memcached进程本身还慢。Slab Allocator就是为解决该问题而诞生的。

下面来看看Slab Allocator的原理。下面是memcached文档中的slab allocator的目标:

the primary goal of the slabs subsystem in memcached was to eliminate memory fragmentation issues totally by using fixed-size memory chunks coming from a few predetermined size classes.

也就是说,Slab Allocator的基本原理是按照预先规定的大小,将分配的内存分割成特定长度的块, 以完全解决内存碎片问题。

Slab Allocation的原理相当简单。 将分配的内存分割成各种尺寸的块(chunk), 并把尺寸相同的块分成组(chunk的集合)(图1)。
图1 Slab Allocation的构造图

而且,slab allocator还有重复使用已分配的内存的目的。 也就是说,分配到的内存不会释放,而是重复利用。

Slab Allocation的主要术语

Page

分配给Slab的内存空间,默认是1MB。分配给Slab之后根据slab的大小切分成chunk。

Chunk

用于缓存记录的内存空间。

Slab Class

特定大小的chunk的组。

在Slab中缓存记录的原理

下面说明memcached如何针对客户端发送的数据选择slab并缓存到chunk中。

memcached根据收到的数据的大小,选择最适合数据大小的slab(图2)。 memcached中保存着slab内空闲chunk的列表,根据该列表选择chunk, 然后将数据缓存于其中。

图2 选择存储记录的组的方法

实际上,Slab Allocator也是有利也有弊。下面介绍一下它的缺点。

Slab Allocator的缺点

Slab Allocator解决了当初的内存碎片问题,但新的机制也给memcached带来了新的问题。

这个问题就是,由于分配的是特定长度的内存,因此无法有效利用分配的内存。 例如,将100字节的数据缓存到128字节的chunk中,剩余的28字节就浪费了(图3)。
图3 chunk空间的使用

对于该问题目前还没有完美的解决方案,但在文档中记载了比较有效的解决方案。

The most efficient way to reduce the waste is to use a list of size classes that closely matches (if that’s at all possible) common sizes of objects that the clients of this particular installation of memcached are likely to store.

就是说,如果预先知道客户端发送的数据的公用大小,或者仅缓存大小相同的数据的情况下, 只要使用适合数据大小的组的列表,就可以减少浪费。

但是很遗憾,现在还不能进行任何调优,只能期待以后的版本了。 但是,我们可以调节slab class的大小的差别。 接下来说明growth factor选项。

使用Growth Factor进行调优

memcached在启动时指定 Growth Factor因子(通过-f选项), 就可以在某种程度上控制slab之间的差异。默认值为1.25。 但是,在该选项出现之前,这个因子曾经固定为2,称为“powers of 2”策略。

让我们用以前的设置,以verbose模式启动memcached试试看:

1
$ memcached -f 2 -vv

下面是启动后的verbose输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
slab class   1: chunk size    128 perslab  8192
slab class 2: chunk size 256 perslab 4096
slab class 3: chunk size 512 perslab 2048
slab class 4: chunk size 1024 perslab 1024
slab class 5: chunk size 2048 perslab 512
slab class 6: chunk size 4096 perslab 256
slab class 7: chunk size 8192 perslab 128
slab class 8: chunk size 16384 perslab 64
slab class 9: chunk size 32768 perslab 32
slab class 10: chunk size 65536 perslab 16
slab class 11: chunk size 131072 perslab 8
slab class 12: chunk size 262144 perslab 4
slab class 13: chunk size 524288 perslab 2

可见,从128字节的组开始,组的大小依次增大为原来的2倍。 这样设置的问题是,slab之间的差别比较大,有些情况下就相当浪费内存。 因此,为尽量减少内存浪费,两年前追加了growth factor这个选项。

来看看现在的默认设置(f=1.25)时的输出(篇幅所限,这里只写到第10组):

1
2
3
4
5
6
7
8
9
10
slab class   1: chunk size     88 perslab 11915
slab class 2: chunk size 112 perslab 9362
slab class 3: chunk size 144 perslab 7281
slab class 4: chunk size 184 perslab 5698
slab class 5: chunk size 232 perslab 4519
slab class 6: chunk size 296 perslab 3542
slab class 7: chunk size 376 perslab 2788
slab class 8: chunk size 472 perslab 2221
slab class 9: chunk size 592 perslab 1771
slab class 10: chunk size 744 perslab 1409

可见,组间差距比因子为2时小得多,更适合缓存几百字节的记录。 从上面的输出结果来看,可能会觉得有些计算误差, 这些误差是为了保持字节数的对齐而故意设置的。

将memcached引入产品,或是直接使用默认值进行部署时, 最好是重新计算一下数据的预期平均长度,调整growth factor, 以获得最恰当的设置。内存是珍贵的资源,浪费就太可惜了。

接下来介绍一下如何使用memcached的stats命令查看slabs的利用率等各种各样的信息。

查看memcached的内部状态

memcached有个名为stats的命令,使用它可以获得各种各样的信息。 执行命令的方法很多,用telnet最为简单:

1
$ telnet 主机名 端口号

连接到memcached之后,输入stats再按回车,即可获得包括资源利用率在内的各种信息。 此外,输入”stats slabs”或”stats items”还可以获得关于缓存记录的信息。 结束程序请输入quit。

这些命令的详细信息可以参考memcached软件包内的protocol.txt文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$ telnet localhost 11211
Trying ::1...
Connected to localhost.
Escape character is '^]'.
stats
STAT pid 481
STAT uptime 16574
STAT time 1213687612
STAT version 1.2.5
STAT pointer_size 32
STAT rusage_user 0.102297
STAT rusage_system 0.214317
STAT curr_items 0
STAT total_items 0
STAT bytes 0
STAT curr_connections 6
STAT total_connections 8
STAT connection_structures 7
STAT cmd_get 0
STAT cmd_set 0
STAT get_hits 0
STAT get_misses 0
STAT evictions 0
STAT bytes_read 20
STAT bytes_written 465
STAT limit_maxbytes 67108864
STAT threads 4
END
quit

另外,如果安装了libmemcached这个面向C/C++语言的客户端库,就会安装 memstat 这个命令。 使用方法很简单,可以用更少的步骤获得与telnet相同的信息,还能一次性从多台服务器获得信息。

1
$ memstat --servers=server1,server2,server3,...

libmemcached可以从下面的地址获得:http://tangent.org/552/libmemcached.html

查看slabs的使用状况

使用memcached的创造者Brad写的名为memcached-tool的Perl脚本,可以方便地获得slab的使用情况 (它将memcached的返回值整理成容易阅读的格式)。可以从下面的地址获得脚本:

http://code.sixapart.com/svn/memcached/trunk/server/scripts/memcached-tool
使用方法也极其简单:

1
$ memcached-tool 主机名:端口 选项

查看slabs使用状况时无需指定选项,因此用下面的命令即可:

1
$ memcached-tool 主机名:端口

获得的信息如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 ##  Item_Size   Max_age  1MB_pages Count   Full?
1 104 B 1394292 s 1215 12249628 yes
2 136 B 1456795 s 52 400919 yes
3 176 B 1339587 s 33 196567 yes
4 224 B 1360926 s 109 510221 yes
5 280 B 1570071 s 49 183452 yes
6 352 B 1592051 s 77 229197 yes
7 440 B 1517732 s 66 157183 yes
8 552 B 1460821 s 62 117697 yes
9 696 B 1521917 s 143 215308 yes
10 872 B 1695035 s 205 246162 yes
11 1.1 kB 1681650 s 233 221968 yes
12 1.3 kB 1603363 s 241 183621 yes
13 1.7 kB 1634218 s 94 57197 yes
14 2.1 kB 1695038 s 75 36488 yes
15 2.6 kB 1747075 s 65 25203 yes
16 3.3 kB 1760661 s 78 24167 yes

各列的含义为:

  • ‘#’ slab class编号
  • Item_Size Chunk大小
  • Max_age LRU内最旧的记录的生存时间
  • 1MB_pages 分配给Slab的页数
  • Count Slab内的记录数
  • Full? Slab内是否含有空闲chunk

从这个脚本获得的信息对于调优非常方便,强烈推荐使用。

memcached在数据删除方面有效利用资源

数据不会真正从memcached中消失
上次介绍过, memcached不会释放已分配的内存。记录超时后,客户端就无法再看见该记录(invisible,透明), 其存储空间即可重复使用。

Lazy Expiration

memcached内部不会监视记录是否过期,而是在get时查看记录的时间戳,检查记录是否过期。 这种技术被称为lazy(惰性)expiration。因此,memcached不会在过期监视上耗费CPU时间。

LRU:从缓存中有效删除数据的原理

memcached会优先使用已超时的记录的空间,但即使如此,也会发生追加新记录时空间不足的情况, 此时就要使用名为 Least Recently Used(LRU)机制来分配空间。 顾名思义,这是删除“最近最少使用”的记录的机制。 因此,当memcached的内存空间不足时(无法从slab class 获取到新的空间时),就从最近未被使用的记录中搜索,并将其空间分配给新的记录。 从缓存的实用角度来看,该模型十分理想。

不过,有些情况下LRU机制反倒会造成麻烦。memcached启动时通过“-M”参数可以禁止LRU,如下所示:

1
$ memcached -M -m 1024

启动时必须注意的是,小写的“-m”选项是用来指定最大内存大小的。不指定具体数值则使用默认值64MB。

指定“-M”参数启动后,内存用尽时memcached会返回错误。 话说回来,memcached毕竟不是存储器,而是缓存,所以推荐使用LRU。

memcached的最新发展方向

memcached的roadmap上有两个大的目标。一个是二进制协议的策划和实现,另一个是外部引擎的加载功能。

关于二进制协议

使用二进制协议的理由是它不需要文本协议的解析处理,使得原本高速的memcached的性能更上一层楼, 还能减少文本协议的漏洞。目前已大部分实现,开发用的代码库中已包含了该功能。 memcached的下载页面上有代码库的链接。

http://danga.com/memcached/download.bml

二进制协议的格式

协议的包为24字节的帧,其后面是键和无结构数据(Unstructured Data)。 实际的格式如下(引自协议文档):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Byte/     0       |       1       |       2       |       3       |   
/ | | | |
|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|
+---------------+---------------+---------------+---------------+
0/ HEADER /
/ /
/ /
/ /
+---------------+---------------+---------------+---------------+
24/ COMMAND-SPECIFIC EXTRAS (as needed) /
+/ (note length in th extras length header field) /
+---------------+---------------+---------------+---------------+
m/ Key (as needed) /
+/ (note length in key length header field) /
+---------------+---------------+---------------+---------------+
n/ Value (as needed) /
+/ (note length is total body length header field, minus /
+/ sum of the extras and key length body fields) /
+---------------+---------------+---------------+---------------+
Total 24 bytes

如上所示,包格式十分简单。需要注意的是,占据了16字节的头部(HEADER)分为 请求头(Request Header)和响应头(Response Header)两种。 头部中包含了表示包的有效性的Magic字节、命令种类、键长度、值长度等信息,格式如下:

Request Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Byte/     0       |       1       |       2       |       3       |
/ | | | |
|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|
+---------------+---------------+---------------+---------------+
0| Magic | Opcode | Key length |
+---------------+---------------+---------------+---------------+
4| Extras length | Data type | Reserved |
+---------------+---------------+---------------+---------------+
8| Total body length |
+---------------+---------------+---------------+---------------+
12| Opaque |
+---------------+---------------+---------------+---------------+
16| CAS |
| |
+---------------+---------------+---------------+---------------+

Response Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Byte/     0       |       1       |       2       |       3       |
/ | | | |
|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|
+---------------+---------------+---------------+---------------+
0| Magic | Opcode | Key Length |
+---------------+---------------+---------------+---------------+
4| Extras length | Data type | Status |
+---------------+---------------+---------------+---------------+
8| Total body length |
+---------------+---------------+---------------+---------------+
12| Opaque |
+---------------+---------------+---------------+---------------+
16| CAS |
| |
+---------------+---------------+---------------+---------------+

如希望了解各个部分的详细内容,可以checkout出memcached的二进制协议的代码树, 参考其中的docs文件夹中的protocol_binary.txt文档。

HEADER中引人注目的地方

看到HEADER格式后我的感想是,键的上限太大了!现在的memcached规格中,键长度最大为250字节, 但二进制协议中键的大小用2字节表示。因此,理论上最大可使用65536字节(216)长的键。 尽管250字节以上的键并不会太常用,二进制协议发布之后就可以使用巨大的键了。

二进制协议从下一版本1.3系列开始支持。

外部引擎支持

我去年曾经试验性地将memcached的存储层改造成了可扩展的(pluggable)。

http://alpha.mixi.co.jp/blog/?p=129
MySQL的Brian Aker看到这个改造之后,就将代码发到了memcached的邮件列表。 memcached的开发者也十分感兴趣,就放到了roadmap中。现在由我和 memcached的开发者Trond Norbye协同开发(规格设计、实现和测试)。 和国外协同开发时时差是个大问题,但抱着相同的愿景, 最后终于可以将可扩展架构的原型公布了。 代码库可以从memcached的下载页面 上访问。

外部引擎支持的必要性

世界上有许多memcached的派生软件,其理由是希望永久保存数据、实现数据冗余等, 即使牺牲一些性能也在所不惜。我在开发memcached之前,在mixi的研发部也曾经 考虑过重新发明memcached。

外部引擎的加载机制能封装memcached的网络功能、事件处理等复杂的处理。 因此,现阶段通过强制手段或重新设计等方式使memcached和存储引擎合作的困难 就会烟消云散,尝试各种引擎就会变得轻而易举了。

简单API设计的成功的关键

该项目中我们最重视的是API设计。函数过多,会使引擎开发者感到麻烦; 过于复杂,实现引擎的门槛就会过高。因此,最初版本的接口函数只有13个。 具体内容限于篇幅,这里就省略了,仅说明一下引擎应当完成的操作:

  • 引擎信息(版本等)
  • 引擎初始化
  • 引擎关闭
  • 引擎的统计信息
  • 在容量方面,测试给定记录能否保存
  • 为item(记录)结构分配内存
  • 释放item(记录)的内存
  • 删除记录
  • 保存记录
  • 回收记录
  • 更新记录的时间戳
  • 数学运算处理
  • 数据的flush

对详细规格有兴趣的读者,可以checkout engine项目的代码,阅读器中的engine.h。

重新审视现在的体系

memcached支持外部存储的难点是,网络和事件处理相关的代码(核心服务器)与 内存存储的代码紧密关联。这种现象也称为tightly coupled(紧密耦合)。 必须将内存存储的代码从核心服务器中独立出来,才能灵活地支持外部引擎。 因此,基于我们设计的API,memcached被重构成下面的样子:

重构之后,我们与1.2.5版、二进制协议支持版等进行了性能对比,证实了它不会造成性能影响。

在考虑如何支持外部引擎加载时,让memcached进行并行控制(concurrency control)的方案是最为容易的, 但是对于引擎而言,并行控制正是性能的真谛,因此我们采用了将多线程支持完全交给引擎的设计方案。

memcached的分布式

正如第1次中介绍的那样,memcached虽然称为“分布式”缓存服务器,但服务器端并没有“分布式”功能。 服务器端仅包括第2次、第3次前坂介绍的内存存储功能,其实现非常简单。至于memcached的分布式,则是完全由客户端程序库实现的。这种分布式是memcached的最大特点。

memcached的分布式是什么意思?
这里多次使用了“分布式”这个词,但并未做详细解释。 现在开始简单地介绍一下其原理,各个客户端的实现基本相同。

下面假设memcached服务器有node1~node3三台, 应用程序要保存键名为“tokyo”“kanagawa”“chiba”“saitama”“gunma” 的数据。
图1 分布式简介:准备

首先向memcached中添加“tokyo”。将“tokyo”传给客户端程序库后, 客户端实现的算法就会根据“键”来决定保存数据的memcached服务器。 服务器选定后,即命令它保存“tokyo”及其值。

图2 分布式简介:添加时

同样,“kanagawa”“chiba”“saitama”“gunma”都是先选择服务器再保存。

接下来获取保存的数据。获取时也要将要获取的键“tokyo”传递给函数库。 函数库通过与数据保存时相同的算法,根据“键”选择服务器。 使用的算法相同,就能选中与保存时相同的服务器,然后发送get命令。 只要数据没有因为某些原因被删除,就能获得保存的值。
图3 分布式简介:获取时

这样,将不同的键保存到不同的服务器上,就实现了memcached的分布式。 memcached服务器增多后,键就会分散,即使一台memcached服务器发生故障 无法连接,也不会影响其他的缓存,系统依然能继续运行。

接下来介绍第1次中提到的Perl客户端函数库Cache::Memcached实现的分布式方法。

Cache::Memcached的分布式方法

Perl的memcached客户端函数库Cache::Memcached是 memcached的作者Brad Fitzpatrick的作品,可以说是原装的函数库了。

Cache::Memcached - search.cpan.org
该函数库实现了分布式功能,是memcached标准的分布式方法。

根据余数计算分散

Cache::Memcached的分布式方法简单来说,就是“根据服务器台数的余数进行分散”。 求得键的整数哈希值,再除以服务器台数,根据其余数来选择服务器。

下面将Cache::Memcached简化成以下的Perl脚本来进行说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
use strict;
use warnings;
use String::CRC32;

my @nodes = ('node1','node2','node3');
my @keys = ('tokyo', 'kanagawa', 'chiba', 'saitama', 'gunma');

foreach my $key (@keys) {
my $crc = crc32($key); ## CRC値
my $mod = $crc % ( $#nodes + 1 );
my $server = $nodes[ $mod ]; ## 根据余数选择服务器
printf "%s => %s\n", $key, $server;
}

Cache::Memcached在求哈希值时使用了CRC。

String::CRC32 - search.cpan.org
首先求得字符串的CRC值,根据该值除以服务器节点数目得到的余数决定服务器。 上面的代码执行后输入以下结果:

1
2
3
4
5
tokyo       => node2
kanagawa => node3
chiba => node2
saitama => node1
gunma => node1

根据该结果,“tokyo”分散到node2,“kanagawa”分散到node3等。 多说一句,当选择的服务器无法连接时,Cache::Memcached会将连接次数 添加到键之后,再次计算哈希值并尝试连接。这个动作称为rehash。 不希望rehash时可以在生成Cache::Memcached对象时指定“rehash => 0”选项。

根据余数计算分散的缺点

余数计算的方法简单,数据的分散性也相当优秀,但也有其缺点。 那就是当添加或移除服务器时,缓存重组的代价相当巨大。 添加服务器后,余数就会产生巨变,这样就无法获取与保存时相同的服务器, 从而影响缓存的命中率。用Perl写段代码来验证其代价。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use strict;
use warnings;
use String::CRC32;

my @nodes = @ARGV;
my @keys = ('a'..'z');
my %nodes;

foreach my $key ( @keys ) {
my $hash = crc32($key);
my $mod = $hash % ( $#nodes + 1 );
my $server = $nodes[ $mod ];
push @{ $nodes{ $server } }, $key;
}

foreach my $node ( sort keys %nodes ) {
printf "%s: %s\n", $node, join ",", @{ $nodes{$node} };
}

这段Perl脚本演示了将“a”到“z”的键保存到memcached并访问的情况。 将其保存为mod.pl并执行。

首先,当服务器只有三台时:

1
2
3
4
$ mod.pl node1 node2 nod3
node1: a,c,d,e,h,j,n,u,w,x
node2: g,i,k,l,p,r,s,y
node3: b,f,m,o,q,t,v,z

结果如上,node1保存a、c、d、e……,node2保存g、i、k……, 每台服务器都保存了8个到10个数据。

接下来增加一台memcached服务器。

1
2
3
4
5
$ mod.pl node1 node2 node3 node4
node1: d,f,m,o,t,v
node2: b,i,k,p,r,y
node3: e,g,l,n,u,w
node4: a,c,h,j,q,s,x,z

添加了node4。可见,只有d、i、k、p、r、y命中了。像这样,添加节点后 键分散到的服务器会发生巨大变化。26个键中只有六个在访问原来的服务器, 其他的全都移到了其他服务器。命中率降低到23%。在Web应用程序中使用memcached时, 在添加memcached服务器的瞬间缓存效率会大幅度下降,负载会集中到数据库服务器上, 有可能会发生无法提供正常服务的情况。

mixi的Web应用程序运用中也有这个问题,导致无法添加memcached服务器。 但由于使用了新的分布式方法,现在可以轻而易举地添加memcached服务器了。 这种分布式方法称为 Consistent Hashing。

Consistent Hashing

Consistent Hashing的简单说明

Consistent Hashing如下所示:首先求出memcached服务器(节点)的哈希值, 并将其配置到0~2SUP(32)的圆(continuum)上。 然后用同样的方法求出存储数据的键的哈希值,并映射到圆上。 然后从数据映射到的位置开始顺时针查找,将数据保存到找到的第一个服务器上。 如果超过2SUP(32)仍然找不到服务器,就会保存到第一台memcached服务器上。

图4 Consistent Hashing:基本原理

从上图的状态中添加一台memcached服务器。余数分布式算法由于保存键的服务器会发生巨大变化 而影响缓存的命中率,但Consistent Hashing中,只有在continuum上增加服务器的地点逆时针方向的 第一台服务器上的键会受到影响。

图5 Consistent Hashing:添加服务器

因此,Consistent Hashing最大限度地抑制了键的重新分布。 而且,有的Consistent Hashing的实现方法还采用了虚拟节点的思想。 使用一般的hash函数的话,服务器的映射地点的分布非常不均匀。 因此,使用虚拟节点的思想,为每个物理节点(服务器) 在continuum上分配100~200个点。这样就能抑制分布不均匀, 最大限度地减小服务器增减时的缓存重新分布。

通过下文中介绍的使用Consistent Hashing算法的memcached客户端函数库进行测试的结果是, 由服务器台数(n)和增加的服务器台数(m)计算增加服务器后的命中率计算公式如下:

1
(1 - n/(n+m)) * 100

支持Consistent Hashing的函数库

本连载中多次介绍的Cache::Memcached虽然不支持Consistent Hashing, 但已有几个客户端函数库支持了这种新的分布式算法。 第一个支持Consistent Hashing和虚拟节点的memcached客户端函数库是 名为libketama的PHP库,由last.fm开发。
至于Perl客户端,连载的第1次中介绍过的Cache::Memcached::Fast和Cache::Memcached::libmemcached支持 Consistent Hashing。

两者的接口都与Cache::Memcached几乎相同,如果正在使用Cache::Memcached, 那么就可以方便地替换过来。Cache::Memcached::Fast重新实现了libketama, 使用Consistent Hashing创建对象时可以指定ketama_points选项。

1
2
3
4
my $memcached = Cache::Memcached::Fast->new({
servers => ["192.168.0.1:11211","192.168.0.2:11211"],
ketama_points => 150
});

另外,Cache::Memcached::libmemcached 是一个使用了Brain Aker开发的C函数库libmemcached的Perl模块。 libmemcached本身支持几种分布式算法,也支持Consistent Hashing, 其Perl绑定也支持Consistent Hashing。

CPU优化

为什么需要高性能编程

简单谈一下我们为什么需要高性能编程。其实原因很简单,那就是我们希望计算尽量快。

在机器学习领域,模型训练和推理过程中都有大量的计算,尤其是大量的向量和矩阵计算。比如矩阵乘法(GEMM),卷积(Conv),类似Polling/Filter的算子等,这些计算有一个共同的特点就是可以利用并行编程加块计算的速度。

img

机器学习中常见的计算,都可以利用并行计算加速

举一个最简单的例子。如果我们要对一张图片(简化为一个矩阵)进行模糊处理(Blur),最简单的一种算法就是均值模糊,又叫标准盒式过滤器(Normalized Box Filter),其方法就是对每一个元素和围绕它周围的元素求均值,周围的元素可以组成一个“盒子”或者叫“核”。

这个“盒子”的选择方法一般有两种,要么当前元素作为盒子的左上角元素(如图中浅蓝色的盒子);要么作为盒子的中间元素(如图中灰色的盒子)。在实际编程中,还要考虑处理边界的情况(如同中深蓝色的盒子)。选择哪一种方法这里并不重要,为了简化编程,我们在接下来的编程中选择第一种。

其实,均值模糊在实现中还可以利用深度神经网络中很常用的卷积(Conv)操作,即如果我们用一个值全都是1的卷积核对每个元素进行卷积操作,可以得到相同的结果。或者从另外一个角度,卷积就是一种Filter。本文为了简单,我们接下来还是用最朴素的求和方法。

img

对图片进行均值模糊

算法的朴素实现

对于上面这个问题,最简单的实现就是一个两层嵌套的循环,对每一个元素分别进行计算。这个代码相信计算机专业大学一年级的同学就能很快的写出来,这里不多解释了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void blur_mat_original(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
int right, right_right, below, below_below;
for (int x = 0; x < width; ++x) {
right = x + 1 >= width ? width - 1 : x + 1;
right_right = x + 2 >= width ? width - 1 : x + 2;
for (int y = 0; y < height; ++y) {
below = y + 1 >= height ? height - 1 : y + 1;
below_below = y + 2 >= height ? height - 1 : y + 2;
output[y][x] =
((input[y][x] + input[y][right] + input[y][right_right]) +
(input[below][x] + input[below][right] + input[below][right_right]) +
(input[below_below][x] + input[below_below][right] +
input[below_below][right_right])) / 9;
}
}
}

我们选择的测试矩阵大小为 81924096,核大小为33,数据类型为float32。 使用gcc编译并运行,这段代码在我的机器上的执行时间是3914ms

1
$ g++ cpu.cpp -O0 -o cpu -std=c++11 && ./cpu

消除重复计算

如果这是一道程序员面试题,上面的这个答案显然不能拿到高分,因为这里面有肉眼可见的重复计算。由于我们是遍历每一个元素,对其“盒子”内的元素求均值时,我们计算了9个元素的和,在遍历过程中,由于盒子间会有重叠,因此有些元素的和被重复计算了。如下图中浅绿色和深绿色的两个元素的计算,橙色部分的计算就是重复的。

img

深绿色和浅绿色两个元素的计算过程中存在重复计算

如何优化呢?

我们可以把这个计算分成两个阶段,两次遍历。第一次遍历,如左图,对每一个元素和其右边两个元素求均值并保存下来,如左图的黄色部分。等所有元素都变成黄色之后,我们进行第二次遍历,对每一个元素和其下方的两个元素求和并求均值,从而得到最终的解。

img

两阶段计算,消除重复计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void blur_mat_redup(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
int right, right_right, below, below_below;
for (int x = 0; x < width; ++x) {
for (int y = 0; y < height; ++y) {
right = x + 1 >= width ? width - 1 : x + 1;
right_right = x + 2 >= width ? width - 1 : x + 2;
output[y][x] =
(input[y][x] + input[y][right] + input[y][right_right]) / 3;
}
}

for (int x = 0; x < width; ++x) {
for (int y = 0; y < height; ++y) {
below = y + 1 >= height ? height - 1 : y + 1;
below_below = y + 2 >= height ? height - 1 : y + 2;
output[y][x] =
(output[y][x] + output[below][x] + output[below_below][x]) / 3;
}
}
}

上面是具体的代码实现,就是把原来的一个循环改成了两个循环,并充分利用了output变量的空间缓存中间结果,是一种典型的“空间换时间”的方法。再使用同样的参数和硬件配置,编译并运行后,性能果然有所提升,总时间从3914ms下降到3281ms

如果有应届生同学在面试中写出了上面的答案,我觉得有比较大的概率会通过第一轮面试:)

内存优化

上面的优化我们通过减少重复计算从而提升了代码的性能。但是,了解计算机组成原理和体系结构的同学会知道,如今的CPU的主频非常高,制约计算机性能的主要方面往往是IO的性能,尤其是内存的性能。大多数时候CPU都在等内存的数据,因此我们才会有L1缓存,L2缓存等硬件和非阻塞机制来减少CPU等待IO的时间。下图展示了从CPU到寄存器、缓存、物理内存、固态硬盘、机械硬盘等不同硬件的速度。基本上每差一级,速度都有数量级上的降低。

img

那么从内存的角度,我们能优化哪些?首先我们总结几个知识点:

  • IO远远慢于计算,数量级上的慢
  • 内存的随机读写远远慢于顺序读写,即便是RAM
  • 在大多数编程语言中,都是“行优先”。也就是说我们代码中如果用了二维数组,其在内存中的真实分布是一行一行顺序拼起来的

这三个知识点跟上面的代码有什么关系?如果你观察的足够仔细就会发现,由于我们编程的习惯问题,我们的两层循环中,外层循环是X,内层循环是Y。虽然这样写代码看起来“很舒服”,但是我们的内存就“很不舒服”了,这会导致比较严重的内存随机读写的问题,如下图左边所示。

img

其优化方法也非常简单,我们只需要把两层循环交换一下位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void blur_mat_locality(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
int right, right_right, below, below_below;
// 交换位置
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
right = x + 1 >= width ? width - 1 : x + 1;
right_right = x + 2 >= width ? width - 1 : x + 2;
output[y][x] =
(input[y][x] + input[y][right] + input[y][right_right]) / 3;
}
}
// 交换位置
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
below = y + 1 >= height ? height - 1 : y + 1;
below_below = y + 2 >= height ? height - 1 : y + 2;
output[y][x] =
(output[y][x] + output[below][x] + output[below_below][x]) / 3;
}
}
}

这么一个简单的操作,会有多大的性能提升呢?这次的运行时间从3281ms大幅下降到2057ms, 性能提升了37%,相较于最原始的做法,几乎提升了一倍!

如果一个应届生同学的面试能优化到这个程度,应该可以拿到Offer了吧:)

注意:为了简化编程,代码中用vector<vector<float>>表示矩阵,因此,矩阵的两行数据之前,在内存中可能并不是顺序排列,这更加剧了内存随机访问的开销

CPU并行

其实到上面,我们还没进入并行编程的部分。那么如何利用并行编程对上面的代码继续优化?我们在遍历和计算每个元素时,他们之间是“独立”的,也就是说我们可以利用CPU的多个核心,同时对多个元素进行计算,这就是并行编程最朴素思想。

img

利用多线程同时处理

实际的编程中,我们可以利用多线程机制。如果CPU有16个核,那么我们可以同时启动16个线程,每个线程负责矩阵中的一列或者一行数据的计算(应该优先用列还是行?大家可以思考一下)。多线程编程不是本文的重点,我们直接介绍一种更简单的方法:OpenMP。

OpenMP是一个开源的并行计算模型和接口,通过提供一系列的编译级别的指令,大大简化了CPU上并行编程的难度。关于OpenMP的详细介绍,大家可以关注官网或者其他资料,这里不再赘述。回到我们的问题本身,我们只需要在代码中增加一行,就可以利用CPU的多核实现并行计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void blur_mat_parallel(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
#pragma omp parallel for
for (int y = 0; y < height; ++y) {
int below = y + 1 >= height ? height - 1 : y + 1;
int below_below = y + 2 >= height ? height - 1 : y + 2;
for (int x = 0; x < width; ++x) {
int right = x + 1 >= width ? width - 1 : x + 1;
int right_right = x + 2 >= width ? width - 1 : x + 2;
output[y][x] =
((input[y][x] + input[y][right] + input[y][right_right]) +
(input[below][x] + input[below][right] + input[below][right_right]) +
(input[below_below][x] + input[below_below][right] +
input[below_below][right_right])) / 9;
}
}
}

这里我们使用了OpenMP中的指令#pragma omp parallel for,这条指令告诉编译器,接下来的这个for循环,请帮我使用多线程进行并行计算。由于使用了编译指令,因此gcc在编译这段代码的时候,对会循环中y的每一个值在一个线程中运行从而实现并行加速。当然,OpenMP假设接下来的这些循环之间是“独立”的,且不会保证多个循环之间的执行顺序问题,这些都需要程序员自己去保证。

为了能成功编译,需要额外添加一个编译选项-fopenmp。

1
$ g++ cpu.cpp -O0 -o cpu -std=c++11 -fopenmp && ./cpu

经过这样的优化,我们这段代码的运行时间是多少呢?182ms!相较于最朴素的实现,我们整整提升了21.5倍,这就是并行编程的威力。

细心的同学可能发现了,上面的这段代码中仍然有重复计算的部分,我们为什么没有把第三章中的优化方法也用上呢?我们试试看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void blur_mat_parallel_redup(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
#pragma omp parallel for
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
int right = x + 1 >= width ? width - 1 : x + 1;
int right_right = x + 2 >= width ? width - 1 : x + 2;
output[y][x] =
(input[y][x] + input[y][right] + input[y][right_right]) / 3;
}
}
// can not parallel here !!!
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
int below = y + 1 >= height ? height - 1 : y + 1;
int below_below = y + 2 >= height ? height - 1 : y + 2;
output[y][x] =
(output[y][x] + output[below][x] + output[below_below][x]) / 3;
}
}
}

由于把一个循环变成两个循环,我们需要用两次OpenMP的编译指令。但是仔细观察就会发现,第二个循环体在计算过程中,需要同时读output和写output矩阵且多个线程之间的读写操作存在交集。当多个线程同时且随机地对output进行操作时,由于这里没有锁机制,会出现严重的不一致问题,从而影响计算结果的正确性。那上面的这种写法实际效率如何?大约在1115ms左右,远远慢于刚刚的182ms。

如果一个应届生同学能回答到这个程度,应该会拿到一个不错的 Offer了:)

分片执行

那有没有什么方法,可以同时利用多线程加速并且消除重复计算呢?肯定有,那就是Tiling分片计算方法。上一章节的实现之所以会出现错误主要有两个因素。一是因为存在了两个循环,因此不得不两次使用OpenMP的编译指令;二则是因为output变量在多个线程之前存在了同时读写的问题。Tilling的中文含义是“瓷砖”,也就是说,我们把原始数据像铺瓷砖一样“一大片一大片”的处理。

img

分片计算Tiling

如上图所示,我们可以把原始数据分片成四份,四个分片可以并行,分片内部也可以按照之前的方法并行。看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void blur_mat_tiling_parallel(const vector<vector<float>> &input,
vector<vector<float>> &output, int tile_width, int tile_height) {
int height = input.size();
int width = input[0].size();
#pragma omp parallel for
for (int tile_y = 0; tile_y < height / tile_height; ++tile_y) {
int t_y = tile_y * tile_height;
for (int tile_x = 0; tile_x < width / tile_width; ++tile_x) {
int t_x = tile_x * tile_width;
vector<vector<float>> tile_tmp(tile_height, vector<float>(tile_width, 0));
for (int y = 0; y < tile_height; ++y) {
int target_y = t_y + y;
for (int x = 0; x < tile_width; ++x) {
int target_x = t_x + x;
int right = target_x + 1 >= width ? width - 1 : target_x + 1;
int right_right = target_x + 2 >= width ? width - 1 : target_x + 2;
tile_tmp[y][x] = (input[target_y][target_x] + input[target_y][right] +
input[target_y][right_right]) / 3;
}
}

for (int y = 0; y < tile_height; ++y) {
int target_y = t_y + y;
int below = y + 1 >= tile_height ? tile_height - 1 : y + 1;
int below_below = y + 2 >= tile_height ? tile_height - 1 : y + 2;
for (int x = 0; x < tile_width; ++x) {
int target_x = t_x + x;
output[target_y][target_x] =
(tile_tmp[y][x] + tile_tmp[below][x] + tile_tmp[below_below][x]) / 3;
}
}
}
}
}

现在,代码貌似变得复杂了一点,别慌。改动就两点:

  1. 把原来的两次循环改成了四层循环,从而可以在分片维度实现并行,比如在第5行,我们可以在最外层的循环使用OpenMP的编译指令
  2. 在第10行,我们为每个分片增加了一个临时存储空间,在减少重复计算的同时,避免上一章中提到的多线程同步的问题

经过这样的优化,性能是多少呢?同样的参数和硬件配置下,这个实现是231ms,貌似并没有比之前的更好。至于为什么,我没做深入的profile所以不好确认。我猜测是更复杂的循环和临时空间的使用,都影响了实际执行的性能。这也告诉我们性能优化在很多时候并不是把“十八般武艺”都用上就会更好。

img

不同优化方法的性能对比

到这里,我们从消除重复计算;内存连续性和并行计算三个方面对代码进行了优化,执行时间从3914ms大幅下降到200ms一下,提升了近20倍。

指令集优化SIMD

除了上面提到的三种优化,还有没有其他手段?该轮到大杀器“指令集优化”出场了。

我们知道,CPU的计算都被抽象成了一系列的CPU指令集,有些指令集负责从内存加载数据到寄存器,有些指令集则从寄存器读取数据执行具体的计算操作,然后再由另外一些指令集把寄存器中的数据更新回内存。不同的CPU架构和指令集能操作的数据大小是不同的,从16bit,32bit到64bit。在同样的计算精度下,能操作的数据量越大就代表了计算的“吞吐”越大,也就表示更快的计算速度。这正是SIMD的初衷。

Single Instruction Multiple Data指的就是CPU在硬件上,支持一个指令读写一个向量(128bit),更重要的是,可以对两个向量同时执行计算且计算是可分割的。也就是说可以把128bit看成4个32bit的float分别对4个float执行同样的计算。举个例子,如果我们要计算四对数的和:

img

单指令集和SIMD的区别

在没有用SIMD时,我们首先需要八次LD操作用来从内存把数据加载到寄存器;然后用四次ADD操作执行加法计算;最后再用四次ST操作,把计算结果存储到内存中,故共需要16次CPU指令操作。

如果用SIMD,我们首先需要一次LD操作一次性把四个数据加载到寄存器;然后执行一次ADD操作完成加法计算;最后用一次ST操作把数据更新回内存,这样只需要3次CPU指令操作。相较于第一种方法性能提升了5倍之多,下图则从另外两个角度展示了SIMD的优势。

img

SIMD可同时操作一个向量,从而大大减少指令数量

目前绝大多数的CPU都支持SIMD,那如何使用SIMD能力?不同的CPU架构和厂商提供了不同的SIMD指令集来支持,以我们常用的x86架构来说,我们可以通过SSE指令集来使用x86架构下的SIMD能力。

SSE优化

SSE指令集介绍

目前,绝大多数的CPU都支持SIMD,不同的CPU架构和厂商提供了不同的SIMD指令集来支持,以常用的x86架构来说,我们可以通过SSE指令集来使用x86架构下的SIMD能力。

img

以SSE为代表的多种SIMD指令集

SSE指令集是对普通指令集的扩充,其使用方法可以归纳为:“接-化-发”,即:

  1. 使用SSE专门的LOAD指令从内存加载一个向量到寄存器。
  2. 使用SSE专门的OP指令对两个向量进行某种计算
  3. 使用SSE专门的STORE指令把计算结果从寄存机写回到内存

在实际写代码中,我们可以直接使用汇编调用SSE相关的指令,但是更常见的方式还是用Intel提供的C/C++的指令集内联函数intrinsics,详细的文档见:Intel® Intrinsics Guide。比如跟LOAD指令相关的内联函数就有这么多,主要功能是从内存地址mem_addr处,加载128bit的数据到寄存器。

img

Intel intrinsics中与load相关的指令集函数

之所以有这么多种,首先由于支持的数据类型不同。其中__m128表示128bit的单精度浮点数;__m128h表示半精度;__128d表示128bit的双精度浮点数;__m128i表示128bit的整数型。

其次,函数签名不同。SSE指令的函数从命名上,主要分成三部分,以_mm_loadu_pd为例:

  1. 第一部分均以_mm开头,表示属于SSE指令集;
  2. 第二部分表明操作类型,比如load,add,store等。但部分指令后面跟有[l|h|u|r]等字母,比如u表示mem_addr不需要内存对齐,r表示反向读取等;
  3. 第三部分主要包括两个字母,大部分以[p|s]开头,p表示packed即对128bits的数据全部执行相同的操作,s表示scalar,只对128bit中的第一组数据执行操作,如下图所示。而第二个字母往往和数据类型相关,比如[d|h|s]等,分别表示双精度、半精度、单精度等。

img

由于SSE指令集发展多年,有SSE、SSE2、SSE3、SSEE3、SSE4.1、AVX等众多版本,但是命名上主要遵循上面的规则。在真正使用时可以查阅Intel® Intrinsics Guide 了解细节。

SSE指令集优化

接下来尝试使用SSE指令集对原来的代码进行优化。由于SSE指令集操作的单位都是128bit,即可同时操作四个32bit的单精度float数据,为了编程方便,我们修改blur操作的kernel大小,从33修改为34,代码执行的流程如下图:

img

利用SSE指令集修改计算流程

  1. 通过三次_mm_loadu_ps操作,分别内存加载3*4个元素到寄存器。
  2. 通过两次_mm_add_ps操作,对寄存器中的数据执行packed加法操作,执行完成后寄存器中每一小部分都累积了结果。(类似于Ring AllReduce中的ScatterReduce操作)
  3. 接下来通过两次_mm_hadd_ps操作把集群器中的四个结果再次累加,_mm_hadd_ps操作比较特别,它会把相邻的两个数相加,然后把结果写到最高两位。(类似于Tree Reduce操作)
  4. 最后通过一次_mm_store_ss操作,把结果写回内存。由于寄存器中的四个元素的值都是最终的结果,因此只需要执行一次scalar操作即可。

此外,我们仍然可以用openmp,在循环的最外层进行并行计算加速,详细代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 导入SSE指令集的头文件
#include <pmmintrin.h>

void blur_mat_sse(const vector<vector<float>> &input,
vector<vector<float>> &output) {
int height = input.size();
int width = input[0].size();
#pragma omp parallel for
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
int below = y + 1 >= height ? height - 1 : y + 1;
int below_below = y + 2 >= height ? height - 1 : y + 2;
// 三次数据加载
__m128 vdata_1 = _mm_loadu_ps(&input[y][x]);
__m128 vdata_2 = _mm_loadu_ps(&input[below][x]);
__m128 vdata_3 = _mm_loadu_ps(&input[below_below][x]);
// 两次逐元素相加,packed操作
__m128 vres = _mm_add_ps(vdata_1, vdata_2);
vres = _mm_add_ps(vres, vdata_3);
// 两次相邻元素相加,packed操作
vres = _mm_hadd_ps(vres, vres);
vres = _mm_hadd_ps(vres, vres);
// 写回内存,scalar操作
_mm_store_ss(&output[y][x], vres);
output[y][x] /= 12;
}
}
}

看一下动图:

动图封面

实际编译运行看一下效果。由于使用了SSE指令集,在编译时需要显示指定编译选项-msse3:

1
$ g++ cpu.cpp -O0 -o cpu -std=c++11 -fopenmp -msse3 && ./cpu

经过SSE指令集的优化,我们代码的性能进一步提升到了130ms,相较于最原始的版本,加速了30倍。

img

小结

这篇文章里,我们通过实现一个blur滤波器,把原来需要执行3.9秒的一段代码逐步优化到只需要0.13秒,加速比超过了30倍。其背后的原理并不复杂,主要是

  • 消除重复计算
  • 考虑内存/缓存的本地性
  • 利用多核CPU并行计算
  • 利用Tiling机制
  • 使用SIMD SSE指令

等几个技术。其中加速效果最好,显而易见的,就是多核并行计算和SIMD机制。

在机器学习、深度学习应用中,主要的计算类型包括GEMM(通用矩阵乘法)、Conv(卷积)、Pooling、Activation等,这些计算本质上都满足并行计算和SIMD的前提,因此基于并行计算的优化方法被大量应用到机器学习领域中。

另一方面,如今的通用CPU发展越来越受制于物理定律的限制,即CPU的核数、L1/L2的缓存大小都难以有数量级上的增长,而计算的需求却在指数级增长,单纯靠CPU已经难以满足计算的需求。此时,以GPGPU为代表的专用加速器以拯救者的身姿出现在我们面前,成为推动深度学习发展的功臣。如何利用GPGPU继续优化我们的代码,我们放到下一篇中介绍。

GPU和CUDA优化

GPGPU简介

在上两篇中,我们使用多种技术提高了Blur算子的速度,效果最好的技术是利用多核和SIMD指令集,通过并行计算来提升速度。这给我们一个很大的启发,如果我们的CPU能扩展到数百数千甚至数万的核,不就能进一步加速了吗?事实上,传统超算就是这么干的。但是传统超算的做法是一种分布式计算的思路,即用数十个机柜,每个机柜内放置数百个计算单元(看成一台服务器),每个计算单元有几个CPU,以此组成一个巨大的CPU集群。集群间通过高速网络,配合上分布式的编程来使用整个集群的数以万记的CPU执行计算任务。

之所以用分布式计算的思路提高整个系统的并行计算的能力,主要原因是CPU的发展已面临物理定律的限制,人们无法在有限的成本下创造出核心数更多的CPU。此时人们发现,原本用来做图形渲染的专用硬件,GPU(Graphic Processing Unit),非常适合用于并行计算。

我个人认为原因有三点:首先,GPU在渲染画面时需要同时渲染数以百万记的顶点或三角形,因此GPU硬件设计时就利用多种技术优化并行计算/渲染的效率;第二,GPU作为专用的硬件,只负责并行计算/并行渲染这一类任务,不承担复杂的逻辑控制和分时切换等任务,可更专注于计算本身,通过专用指令集和流水线等技术,优化计算效率和吞吐;最后,GPU作为一类外插设备,在尺寸、功率、散热、兼容性等方面的限制远远小于CPU,让GPU可以有更大的缓存、显存和带宽,可以放置更多的计算核心,而这进一步体现到成本上的优势(成本对于技术的普及非常重要)。

img

CPU和GPU对比示意图

上图是CPU和GPU的一个对比示意图。由于CPU除了计算任务外,还需要负责大量的逻辑控制,分时调度,兼容多种指令集等等“包袱”,真正用来计算的部分其实很少。而GPU则不然,GPU可以在更大的自由度下,放置更多的计算核心。

其实从最本质的角度来讲,GPU之所以适合并行计算场景,是因为GPU使用的是SIMT(Single Instruction Multiple Threads)模型。SIMT可以看成是SIMD(Single Instruction Multiple Data)模型的一种增强。

Nvidia公司在2000年推出第一款GPU,但是直到2006年才把GPU用于通用计算任务,其转折点就是CUDA(Compute Unified Device Architecture)的出现。CUDA是一整套软件技术,把Nvidia GPU的计算能力封装成一套编程规范和编程语言接口,同时提供相应的编译器、调试器等工具,这使得程序员能方便的使用GPU并行计算能力,执行通用的计算任务,因此又被称为GPGPU(General-purpose computing on graphics processing units)。在CUDA出现6年之后,Alex Krizhevsky和其老师Geoffrey Hinton在CUDA的帮助下成功地实现了AlexNet深度神经网络,一举打开了此轮人工智能浪潮的大门。AlexNet论文中提到:模型的深度对于提高算法性能至关重要,但是其计算成本很高,GPU的使用让深度神经网络的训练具有可行性。

甚至可以武断地说,如果没有GPU和CUDA,尤其是后者,以深度神经网络为主要代表的人工智能算法很可能是另外一种命运。当然,Nvidia并不是唯一一家生产GPU和提供上层编程框架的公司,除了Nvidia GPU和CUDA之外,Intel、AMD和最近几年爆火的如寒武纪、海思等公司,也推出了不同的硬件和软件平台,但是无论完善度、丰富性尤其是生态上,Nvidia仍然是毫无疑问的王者。因此这篇文章介绍的还是Nvidia的GPU,下文中提到的GPU,没有特殊说明都代指Nvidia GPU。

img

Nvidia的软硬件生态

上图是Nvidia提供的一整套软硬件平台。Nvidia针对嵌入式端,桌面级消费卡、专业工作站和数据中心提供了不同的硬件产品,但是在软件层,通过CUDA抽象成了统一的编程接口并提供C/C++/Python/Java等多种编程语言的支持。CUDA的这一层抽象非常重要,因为有了这层抽象,向下开发者的代码可在不同的硬件平台上快速迁移;向上,在CUDA基础上封装了用于科学计算的cuBLAS(兼容BLAS接口),用于深度学习的cuDNN等中间件和代码库。这些中间件对于Tensorflow,PyTorch这一类的深度学习框架非常重要,可以更容易地使用CUDA和底层硬件进行机器学习的计算任务。

GPU硬件架构

对于一般开发者而言,大多数情况下接触到的都是CUDA层。但是由于GPU的特殊性,为了能正确高效使用CUDA,非常有必要学习一下GPU的硬件架构。由于Nvidia的GPU发展非常迅速,平均1-2年就会推出一款新GPU或核心架构,因此这里简单介绍下,不涉及硬件细节(主要是我也不太懂硬件)。

img

Nvidia A100 GPU的硬件架构

我们以Nvidia 2020年推出的Ampere架构和A100 GPU为例(A10、A30、RTX3090等型号也是Ampere架构)为例。上图是整个A100的硬件架构,从上到下,其主要组成部分:

  • PCIe接口层,目前大部分GPU都是通过PCIe以外部设备的方式集成到服务器上;
  • GigaThread Engine with MIG是GPU用于调度资源的引擎,Ampere架构还支持了MIG(Multiple Instance GPU)控制即允许对GPU进行硬件隔离切分;
  • 中间占据面积最大的绿色部分是GPU的核心计算单元,共有6912个CUDA Cores,这是重点部分,我们在后面详细介绍;
  • 中间蓝色部分是L2缓存,在GPU层面提供大于40MB的共享缓存;
  • 最下层的NVLink是用于多个GPU间快速通信的模块(在分布式训练中非常重要);
  • 两侧的HBM2(High Bandwidth Memory)即我们通常说的显存。A100提供了高达1.56TB带宽,40GB/80GB的显存;

其中最重要的绿色部分是计算核心,在A100中这些计算单元又进一步被“分组”成了GPC(Graphic Processing Cluster)- TPC(Texture Processing Cluster) - SM(Streaming Multiprocessor)几个概念。最需要了解的是SM的架构,把其中一个SM放大:

img

Nvidia A100的SM硬件架构

SM可以看成是GPU计算调度的一个基本单位,只有对其架构和硬件规格有一定认识,才能更高效地利用GPU的硬件能力。SM中自上而下分别有:

  • L1 Shared Memory,在SM层面提供192KB的共享缓存
  • 接下来是四个PB(Process Block),每个PB内部分别有
    • L0缓存
      • Warp Scheduler用于计算任务调度
      • Dispatch Unit用于计算任务发射
      • Register File寄存器
      • 16个专用于INT32计算的核心
      • 16个专用于FP32计算的核心
      • 8个专用于FP64计算的核心
      • 4个Tensor Core,这是受Google TPU的影响,专门设计用于执行A*B+C这类矩阵计算的硬件单元,甚至还支持稀疏矩阵计算,Tensor Core对深度学习的加速非常明显
      • 8个用于LOAD/STORE数据的硬件单元
      • 4个SFU(Special Function Unit)用于执行超越函数、插值等特殊计算

因此一个SM内就有64个用于计算的核心,Nvidia称之为CUDA Core(但是我没搞明白64是怎么算出来的)。而一整块A100 GPU则密密麻麻地放置了108个SM,总计6912个CUDA Core,可以简单理解成这是一个拥有6912个核心的巨型CPU!

至此,我们应该能搞懂为什么GPU比CPU更适合执行并行计算了,就是“专业人干专业事,大力就会出奇迹“嘛。

CUDA基本概念

为了方便地使用GPU的硬件能力,Nvidia在软件层做了抽象,形成软件上的一系列逻辑概念,这些逻辑概念跟GPU的硬件有一定的对应关系。

img

GPU软件和硬件的对应关系

如上图,CUDA编程中的最小单元称之为Thread,可以简单认为一个软件Thread会在一个硬件CUDA Core中执行,而Thread中执行的内容或函数称之为Kernel。多个相同的Thread组成一个Thread Block,软件Thread Block会被调度到一个硬件SM上执行,同一个Thread Block内的多个Thread执行相同的kernel并共享SM内的硬件资源。而多个Thread Block又可以进一步组成一个Grid,一个软件Grid可以看成一次GPU的计算任务,被提交到一整个GPU硬件中执行。这几个概念非常重要,简单总结下:

  • kernel:Thread执行的内容/代码/函数
  • Thread:执行kernel的最小单元,被SM调度到CUDA Core中执行(其实还有一个Warp的概念,为了简单,这里先略过)
  • Thread Block:多个Thread组合,GPU任务调度的最小单元(这个描述不太准确,应该是Warp,为了简单暂时先不细究),被调度到SM中执行。一个SM可以同时执行多个Thread Block,但是一个Thread Block只能被调度到一个SM上。
  • Grid:多个Thread Block的组合,被调度到整个GPU中执行

同时,Thread、Thread Block和Grid由于所处层次不同,他们可以访问的存储资源也不同。如Thread只能访问自身的寄存器,Thread Block可以访问SM中的L1缓存,而Grid则可以访问L2缓存和更大的HBM显存。在第一篇文章中我们就介绍过不同层次的存储其访问速度往往是数量级的差别,GPU也不例外,在后续的文章中我们会看到,针对CUDA的优化很大一部分就是如何正确高效的使用GPU中的多级存储来提高GPU的方寸比,从而进一步提高GPU的计算效率。

img

Nvidia GPU的内存模型

到底如何使用CUDA?由于GPU是作为一类外挂设备,通过PCIe之类的接口插到服务器上,因此在CUDA编程中,称GPU为Device,而服务器上的CPU和内存统称为Host。在编写CUDA代码时,最主要的工作就是编写kernel函数,然后利用CUDA提供的接口,把kernel函数从Host发射到Device中执行,Device则从 Grid → Thread Block → Warp → Thread 一层层的调度下最终完成kernel的计算。

在计算开始前,还需要用CUDA接口把计算要用到的数据从Host拷贝到Device中。Device完成计算后,则需要利用CUDA接口把计算结果从Device拷贝回Host中,因此总结下来,CUDA编程分为三步:

  1. 从Host拷贝数据到Device
  2. 把需要Device执行的kernel函数发射给Device
  3. 从Device拷贝计算结果到Host

如果有点抽象,我们看个例子。

CUDA HelloWorld

CUDA HelloWorld代码很简单,求两个数组A和B的和。代码分两部分,首先实现上面介绍的三步流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <cuda.h>

// Host code
int main() {
int N = 1024;
size_t size = N * sizeof(float);

// Host端申请内存
float* h_A = (float*)malloc(size);
float* h_B = (float*)malloc(size);
float* h_C = (float*)malloc(size);

// Device端申请显存,分别用来存放A、B和结果C
float* d_A, d_B, d_C;
cudaMalloc(&d_A, size);
cudaMalloc(&d_B, size);
cudaMalloc(&d_C, size);

// 把需要计算的结果从Host拷贝到Device
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);

// 执行kernel,需要指定两个参数
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
VecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

// 把计算结果拷贝回Host
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);

// 释放Device中的显存
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);

// 释放Host的内存
...
}

代码比较直白,其中cuda开头的函数都是CUDA库中提供的,比如cudaMalloc会在Device中申请一段显存,cudaMemcpy则可以在Host和Device之间进行数据拷贝等,不再赘述。

核心是kernel函数部分,先看看kernel函数的实现:

1
2
3
4
__global__ void VecAdd(float* A, float* B, float* C, int N) {
int i = blockDim.x * blockIdx.x + threadIdx.x;
if (i < N) C[i] = A[i] + B[i];
}

Kernel函数只有两行并不复杂,但是需要好好解释。

这个kernel函数把数组A和数组B逐元素相加,结果写到数组C中。即C[i] = A[i] + B[i]。如果在CPU上编程,一个循环遍历就搞定了。但是当用CUDA编程时,我们希望利用GPU中数千个计算核心同时计算,每个核心只负责A和B的一个元素。但是,如何分配任务?

如果GPU中的每个核心都有唯一的ID,比如0号核心执行C[0] = A[0] + B[0],127号核心执行C[127] = A[127] + B[127]就好了。但是如果我们要操作的数据大于GPU的核心数怎么办?因此CUDA做了一层逻辑抽象。当一个计算任务在GPU中执行时,这个任务相关的Grid、Thread Block和Thread都有一系列的身份标识,即几个全局变量:

  • blockDim:表明kernel所在的Thread Block的尺寸,包含x, y, z三个维度
  • blockIdx:表明kernel所在的Thread Block的index,包含x, y, z三个维度
  • threadIdx:表明kernel在在的Thead在Thread Block内的idx,包括x, y, z三个维度

只要引入了cuda.h的头文件,在代码中可以直接使用上面几个全局变量,这几个全局变量的值会在执行时由CUDA自动维护更新。因此,kernel函数 i 的计算方法就是用blockDim.x乘以blockIdx.x再加上threadIdx.x,就可以算出当前一个唯一的、逻辑ID被当前的thread使用。那为什么只用到了x维度,没有用到y和z维度?

答案在于Host端代码中,在启动kernel时,代码中指定了两个模板类参数:blocksPerGrid和threadsPerBlock。其含义通过名字就能看懂。这两个参数被我们指定成两个int型数字。

1
2
3
4
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;

VecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

其实这两个参数还可以是dim2或dim3类型。如果是dim2类型,那么逻辑ID可以通过逻辑x和逻辑y唯一确定,其计算方法如下。

1
2
3
int y = blockIdx.y * blockDim.y + threadIdx.y;
int x = blockIdx.x * blockDim.x + threadIdx.x;
int i = blockDim.y * y + x;

img

原理可以参考上图。这与二维数组索引转换成一维数组索引的过程是类似的,相信有基本编程经验的同学很快就能搞懂。

Preface

在进入具体学习前,文中先提到了APOD(Assess, Parallelize, Optimize, Deploy,即评估、并行、优化、部署)这样一种思路去帮助开发人员快速识别它们的代码中哪些部分可以从GPU加速中受益,并尽快开发获得这种好处,最终尽早将其运用到产品中。

APOD是这样一种循环处理(像是一种迭代开发方式):得到最初的加速效果,测试,加入最小的优化,识别更多的可优化的地方,观察更多的加速效果,将更快的应用部署到产品中。

下面先分别介绍了A、P、O、D。

img

Assess

对于已经存在的项目,首先要评估应用代码的哪些部分在运行时消耗时间最长,这样开发者才能进一步考虑需要用并行化和GPU加速解决哪些现存的瓶颈。

通过理解终端用户的需求和客观限制(这里提到了Amdahl(阿姆达尔定律)和 Gustafson定律(古斯塔夫森定律) ,这两个定律从不同的角度诠释了加速比与系统串行化程度、cpu核心数之间的关系,它们是我们在做高并发程序设计时的理论依据。),开发者可以确定通过加速代码中的瓶颈部分可以得到的性能提升上限。

Parallelize

在识别瓶颈和设置优化目标之后,开发者接下来需要把代码并行化。根据现有代码的情况,这一步可以简单的调用现有的GPU优化库(比如cuBLAS, cuFFT, or Thrust),或者只是简单地加一些预处理指令让编译器去做并行化处理。

一些应用程序需要一定程度的重构才能做并行化。就像运行在CPU架构上的程序设计时需要考虑并行化以提升串行的应用程序的性能那样,CUDA并行编程家族(比如CUDA C++, CUDA Fortran)在让支持CUDA的GPU能尽量做到最大并行运算量的同时,也在尝试使并行化的表达尽量简单。

Optimize

在每一轮的应用程序并行化完成后,开发者需要进一步的优化程序的执行以提升性能。因为有很多潜在的优化方法,更好的了解应用程序的需求(以选择合适的方法)可以让这一步更顺利。但是,因为APOD是一个整体,所以程序的优化是一个迭代的过程(找到可优化的点,优化并测试,验证性能的提升,然后再重复),这意味着程序员不需要提前记住所有可能的优化策略,而是可以边学边用。

在很多层面可以做优化,比如使数据的传输和计算同时进行、调优浮点操作顺序等。Profiling工具在这个过程中作用相当大,它们能告知开发者下一步应该优化哪里,并且给本指南提供了一些参考。

Deploy

在使用GPU加速了应用程序的某些组件/部分后,可以去和原有的应用比较一把。想一想当初做第一步(access)的时候,预估的性能提升上限,看是否达到了目标。

在处理器其他瓶颈以进一步提升总体速率之前,开发者需要考虑先把部分并行化的执行放入产品中。这样做是有原因的:比如用户可以从中尽早获利,小步迭代比革命性的更改风险更小等。

在进入真正的优化细节之前,作者给出了一个建议:把所有可以做的优化点分优先级,对性能有较大影响或影响范围较广的为高优先级,先花时间去做高优先级的工作。

1 评估你的应用程序(Assessing Your Application)

无论是在超级计算机还是在手机上,处理器都越来越依赖并行化以提供更高性能,其提供了多份核心计算单元(包括控制、计算、寄存器、cache,其实就是多核的意思)。这些核都通过一个总线和内存相连。要充分利用这些计算能力,就需要代码能在不同核上并行执行。

随着处理器不断向软件开发人员开放更细颗粒度的并行机制(fine-grained parallelism),现存的一些代码就变得太串行化或太粗颗粒度的并行了(就是在并行度上没跟上处理器的发展)。

为了更好的利用现代处理器架构(包括GPU),我们需要做的第一步就是评估应用程序,找到关键瓶颈,看它们能否并行化,理解现在和将来的相关工作量

2 异构计算(Heterogeneous Computing)

CUDA编程会将代码同时跑在两个不同的平台上:一个带一个或多个CPUs的host系统(服务器/电脑主机)和一个或多个支持CUDA的NVIDIA GPUs。

NVIDIA的GPUs不仅可以做图像相关的工作,还可以支持大规模的并行数学计算。这使得它们特别适合于并行运算。

但是由于设备是独立于host系统的,为了更有效的使用CUDA,所以需要理解这种异构架构以及这种架构是如何影响CUDA应用程序的性能的。

2.1 主机和设备的不同(Differences between Host and Device)

最主要的不同是线程模型和分离的物理内存。

线程资源(这里的线程资源在CPU上指的是硬件的软核,不是操作系统线程的概念)

主机系统的执行流水线可以支持有限数量的并发线程。比如说,一个由32个处理器核(这里指硬核)的服务器只可以同时并发64个线程(因为一般一个硬核有2个软核,这里的意思应该是CPU上只有64个软核)。相比之下,CUDA设备上一个最小的并行执行单元就可能含32个线程。现代NVIDIA GPUs上能有80个并行执行单元(多处理器),每个多处理器上能支持2048个并发线程,也就是总共可以支持16万个线程并发。

线程(这里指操作系统级的软件线程)

CPU上的线程是重量级的实体。操作系统为了提供多线程的能力必须把线程在CPU执行通路上不停的切入切出。当两个线程的上下文在一个核上做切换时,会比较耗时。相比之下,GPUs上的线程是非常轻量级的。在一个典型的系统中,可能有几千个线程在排队(在32个并行执行单元上,指GPU上)。如果GPU必须等待一个执行单元,它可以简单的直接使用另外一个去运行。因为每个线程的寄存器都是单独分配的,GPU线程上做任务更换不需要切换上下文(寄存器或其他状态)。每个线程在完成执行前会独占资源。简而言之,CPU核是为小批量的线程低延时运行设计的,GPU是为处理大量并行、轻量级线程以获取最大吞吐量而设计的(这么说来,分配给GPU的任务并发量越大越好,它的优势不是单个运算执行速度,而是并发量)。

内存

主机和GPU设备都有自己独立的物理内存。它们之间需要通讯来交互数据。

关于并行编程,主机端的CPU和GPU设备主要的不同在硬件上。应用程序的开发者需要有这种意识在异构系统上去处理这些不同点,让每个处理单元去做它们最擅长的工作:主机CPU做串行工作,设备做并行工作。

2.2 使能了CUDA的设备上在运行什么?(What Runs on a CUDA-Enabled Device?)

在决定让应用程序的哪些部分跑在设备上时,需要考虑下面几个问题:

设备上最适合的是那种可以同时对很多数据元素并行进行计算的场合。比较典型的是对一个大数据集(比如矩阵)的子元素同时进行那种几千到几百万个相同类型的数学计算。这需要用好CUDA以提供高性能:软件必须使用大量的并行线程。设备中使用的是上面描述的那种轻量级的线程(就是最多可达16万个的那种)。

要使用CUDA,需要从主机向设备(指GPU)传输数据,需要考虑如何把这种传输操作消耗的时间最小化以避免影响性能。

计算的复杂度(其实是复杂度对应的计算时间)决定了把数据在主机和设备间搬来搬去是否值得。如果只使用GPU上小量的线程去做简单计算,是得不到什么好处的(指的是还不如在CPU上算)。理想的场景是需要GPU上的很多线程执行大量的计算工作。

比如说,传输两个矩阵到设备上去执行矩阵加法操作然后把结果再传回主机就得不到太多好处。问题在于计算操作的数量和数据元素的传输的数量的比值。在前面说的矩阵加法这个例子中,假设矩阵的大小为NxN,就会有N2加法操作和3N2个(传两次源数据再传回一次结果数据)元素的传输,所以操作和元素的比为1:3或O(1)。当这个比例比较大时,使用GPU运算才能得到好处。比如相同大小的矩阵乘法需要N3次(乘-加)计算,所以计算操作和元素的比为O(N),矩阵越大获得的好处也越多。总之需要考虑数据在主机和设备间传输的消耗来决定一个操作是在主机执行还是在设备执行。

数据应该在设备上维持尽可能长时间。因为要尽可能降低数据传输的时间消耗,在多个kernel上(应该是指GPU计算单元)基于相同数据做运算的程序应该在kernel调用间充分利用设备上的数据,而不是先把结果传回主机然后再做后面的运算时又把数据传到设备。还是拿之前的例子来说, 如果两个矩阵相加后的结果将要用于随后的计算,那这个加法结果就应该留在设备上。即使某一步的计算在主机上执行的更快,也应该使用这个方法,因为避免了一步或多步数据传输可以达到更好的总体性能。“主机和设备间的数据传输”这一节提供了更多细节,包括主机和设备间的带宽计算,以及和把数据维持在设备内的对比。

为了获取更好的性能,需要考虑设备上相邻线程间的内存访问的连贯性(应该指数据的地址尽可能连在一起,比如充分利用cache的预取功能)。某些内存访问方式使得硬件可以把一组对多个数据的读/写操作合并成一个操作执行。数据的排列如果无法使能这种合并操作,或者不能有效利用cache,将会降低GPU运算速度。一个值得注意的例外是完全随机的内存访问方式。一般情况下需要避免这种情况,因为通常处理这种模式效率比较低。但是和基于cache的架构相比(比如CPU),延迟隐藏架构(latency hiding architectures,比如GPU)更擅长处理这种完全随机内存访问模式。

3 应用程序剖析(Application Profiling)

3.1 剖析(Profile)

很多程序中完成重要工作的代码只占它所有代码的一小部分(意思是如果能优化这一小部分,就能实现整体性能的大幅改善)。使用性能剖析器,开发者可以定位这部分热点代码,并以此为基础做下一步的并行优化。

3.1.1 创建剖析(Creating the Profile)

有很多方法去剖析代码,但最终目标都是相同的:找到程序中消耗执行时间最长的一个或多个函数。

注:高优先级:剖析应用程序找到关键点和瓶颈,最大化开发者的生产力。

剖析行为最重要的是先确保(识别出的关键点的)工作负载的真实性,比如说从测试和相关分析中得到的信息和真实情况是相关的。使用不真实的工作负载会误导开发者去优化没有实际用途的size问题或错误的函数,从而得到次优结果并浪费人力。

剖析工具有很多。下面的例子使用了gprof,一个Linux上的开源剖析器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ gcc -O2 -g -pg myprog.c
$ gprof ./a.out > profile.txt
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
33.34 0.02 0.02 7208 0.00 0.00 genTimeStep
16.67 0.03 0.01 240 0.04 0.12 calcStats
16.67 0.04 0.01 8 1.25 1.25 calcSummaryData
16.67 0.05 0.01 7 1.43 1.43 write
16.67 0.06 0.01 mcount
0.00 0.06 0.00 236 0.00 0.00 tzset
0.00 0.06 0.00 192 0.00 0.00 tolower
0.00 0.06 0.00 47 0.00 0.00 strlen
0.00 0.06 0.00 45 0.00 0.00 strchr
0.00 0.06 0.00 1 0.00 50.00 main
0.00 0.06 0.00 1 0.00 0.00 memcpy
0.00 0.06 0.00 1 0.00 10.11 print
0.00 0.06 0.00 1 0.00 0.00 profil
0.00 0.06 0.00 1 0.00 50.00 report

3.1.2 识别关键点(Identifying Hotspots)

在上面的例子中,我们可以很清楚地看到genTimeStep()这个函数消耗了1/3的运行时间。这是我们第一个可以去优化的备选函数。下面的“理解加速比”一节讨论了我们期望从这种并行化中得到的性能提升。

值得注意的是,上面例子中的其他几个函数也占用了相当比例的运行时间,比如说calcStats()和calcSummaryData()。并行化这些函数也可以获得潜在的速度提升。但是,因为APOD是一个循环处理(是指一轮一轮不停的进行),我们可以在随后的APOD过程中去并行化这几个函数。

3.1.3.理解可扩展性( Understanding Scaling)

应用程序通过在CUDA上运行而获得的性能优势完全取决于它可以并行化的程度。无法充分并行化的代码应该在主机上运行,除非这样做会导致主机和设备之间的过度传输。

注意:高优先级:要从CUDA中获得最大的好处,请首先关注如何并行化顺序代码。

通过了解应用程序如何扩展,可以设置期望值并规划增量并行化策略。

3.1.3 理解加速比(Understanding Scaling)

一个应用程序使用CUDA可以获得的性能提升完全取决于它被并行化的程度。那些不能被充分并行化的代码应该跑在主机上,除非这样做会导致主机和设备间多余的数据交换。

注:高优先级:为了从CUDA上获取最大的好处,首先集中精力去把顺序执行的代码并行化。

下面提到了高并发程序设计中有两个非常重要的定律(Amdahl’s Law 和Gustafson’s Law),为便于理解,从网上找到下面这段描述:

在高并发程序设计中有两个非常重要的定律:

  • Amdahl(阿姆达尔定律)
  • Gustafson定律(古斯塔夫森定律)

这两个定律从不同的角度诠释了加速比与系统串行化程度、cpu核心数之间的关系,它们是我们在做高并发程序设计时的理论依据。

  • 加速比

“加速比”是个什么鬼?先来看张图:

img

串行程序为什么需要并行化,显然是为了提升系统的处理能力,即性能。并行化的过程,也可以称作系统优化的过程。上图中,在优化前,系统是完全串行的,步骤1至步骤5依次执行,共花费了500ms的时间;我们将步骤2与步骤5进行优化,使其分别用两个线程执行,每个线程各花费50ms,这样步骤2与5的执行时间就由优化前的100ms变为了优化后的50ms,那么整个程序在优化后的执行时间就缩短至400毫秒了,相当于系统的性能提升了20%。这个性能的提升可以用“加速比”来反应:

加速比=优化前系统耗时/优化后系统耗时

在上面的例子中,加速比=500/400=1.25,它是衡量系统优化程度的一个指标。

那么什么是阿姆达尔定律呢?

Amdahl(阿姆达尔定律)

阿姆达尔定律定义了串行系统并行化后加速比的计算公式与理论上限。

先来看优化后耗时与优化前耗时之间的关系,其公式为:

img

其中定义n为处理器个数,T1为单核处理器时系统耗时即优化前系统耗时,Tn为n核心处理器系统时系统耗时即优化后系统耗时,F为串行比例,那么1-F就是并行比例了。

由前面的介绍可知:加速比=优化前系统耗时/优化后系统耗时

用T1与Tn来表示,“加速比”的计算公式可变为:加速比=T1/Tn

将前面Tn的计算公式代入:

img

这就是加速比的计算公式,从公式可以看出增加处理器的数量(提升n的值)并不一定能有效地提高加速比,如果系统的并行化程序不高,即F的值接近100%,就算n无穷大,加速比也是趋近于1的,并不会对系统的性能优化起到什么作用,而成本却无限增加了。

所以,我们可以从“加速比”的公式中看出,单纯地增加cup处理器的数量并不一定可以有效地提高系统的性能,只有在提高系统内并行化模块比重的前提下,同时合理增加处理器的数量,才能以最小的投入得到最大的加速比,这就是阿姆达尔定律要告诉我们的核心思想,它很直观地反应了加速比与处理器个数、系统串行比例之间的关系。

使用加速比的公式,我们同样可以计算出前方例子中的加速比是1.25,如下:

img

Gustafson定律(古斯塔夫森定律)

Gustafson定律也是说明处理器个数、串行比例和加速比之前的关系,只不过它的侧重角度有所不同。

我们定义a为系统串行执行时间,b为系统并行执行时间,n为处理器个数,F为串行比例,那么系统执行时间(串行时间+并行时间)可以表示为a+ba+b,系统总执行时间(串行时间)可以表示为a+nba+nb,所以有如下公式推演:

执行时间=a+b

总执行时间=a+nb

img

其中,串行比例 F=a/(a+b),将其代入上面的公司,可得到:

img

最终的公式为:加速比= n−F(n−1);

从公式中可以看出,F(串行化程度)足够小,也即并行化足够高,那么加速比和cpu个数成正比。

3.1.3.1. Strong Scaling and Amdahl’s Law

强大的扩展性是衡量在固定的总体问题大小下,随着系统中添加更多处理器,解决问题的时间如何减少的一个指标。呈现线性强扩展性的应用程序的加速比等于使用的处理器数量。

强可扩展性通常等同于Amdahl定律,该定律规定了串行程序部分并行化所能预期的最大加速。本质上,它指出程序的最大加速比S为:

img

这里P是可并行化的代码部分所花费的总串行执行时间的一部分,N是运行代码并行部分的处理器数量。

N越大(即处理器数量越大),P/N分数越小。将N视为一个非常大的数字可能更简单,这基本上将方程转换为S=1/(1−P)。现在,如果序列程序运行时间的3/4被并行化,则串行代码的最大加速比为1(1-3/4)=4。

实际上,大多数应用程序并没有表现出完美的线性强可扩展,即使它们确实表现出某种程度的强可扩展性。对于大多数目的来说,关键点是可并行化部分P越大,潜在的加速能力就越大。相反,如果P是一个小数字(意味着应用程序基本上不可并行化),那么增加处理器数量N对提高性能几乎没有作用。因此,为了在固定的问题大小下获得最大的加速,有必要努力增加P,最大化可并行化的代码量。

3.1.3.1 强加速比和阿姆达尔定律(Strong Scaling and Amdahl’s Law)

强加速比是这样一种度量方式:对于一个总size固定的模型,使用更多的处理器可以多大程度的降低计算时间。一个有着强加速比的应用程序可提升的速度倍数与使用的处理器数量相等。

强加速比经常被等同于阿姆达尔定律,它指出了通过并行化一个串行执行程序的某些部分所能得到的最大速度提升,即最大加速比。它的公式如下(这个公式和上面网上找到的那个看起来不同,是因为这里的P等于上面那个的1-F,上面那个F指串行比例,所以这里的P指并行比例):

img

P是指代码中可以被并行化的部分(占全部串行运行时的时间)的比例,N是并行部分代码可以运行的处理器个数。N越大,即处理器越多,P/N越小。如果把N看做一个很大的值,公式可以被简化为S=1/(1−P)。那么,如果一个串行运行程序的3/4可以被并行化,最大的加速比可以达1 (1 - 3/4) = 4。

实际上,大多数应用程序不能呈现出完美线性的强加速比,即使它们看起来有某种程度的强加速比。对大多数实际场合来说,关键点是并行比例P越大,潜在的加速比越高。相反,如果P比较小,增加处理器的数量N几乎不会提升性能。因此,要为一个固定size的模型获取更大的加速比,需要花时间去提升P,最大化的使代码并行执行。

3.1.3.2 弱加速比和古斯塔夫森定律(Weak Scaling and Gustafson’s Law)

弱加速比是这样一种度量方式:假设每个处理器上运行的模型的size是固定的,加入更多的处理器对总体时间有什么影响。

弱加速比经常被等同于古斯塔夫森定律,运算模型size的大小和处理器的数量成正比。因此,一个程序的最大加速比S为:

S=N+(1−P)(1−N)

其中P是并行比例,N是并行部分代码运行的处理器数量(也代表了总体运算规模,因为这里假设每个处理器上处理的数据size固定)。

古斯塔夫森定律假设并行比例为恒定,反应的是当处理更大规模计算的时候所增加的消耗。

3.1.3.3 强弱加速比的应用(Applying Strong and Weak Scaling)

理解哪种加速比对一个应用程序更实用是性能评估很重要的一部分。

对一些应用程序来说总数据size是恒定的,因此强加速比更适合。一个例子是,当分子的size是固定的时候,这两个分子是如何互相影响的。

对其它一些应用程序,问题size是随着处理器的数量而增加的。比如将流体或结构建模为网格,以及一些蒙特卡罗模拟,其中增加问题大小可提高精度。

剖析应用程序后,开发者需要理解数据size是如何随着计算性能的改变而改变的,然后从阿姆达尔定律和古斯塔夫森定律中挑选一个来确定加速的上限。

4 并行化你的应用程序(Parallelizing Your Application)

在识别了关键点和设置优化目标后,开发者可以去并行化代码了。基于原始代码的情况,这一步可以简单的调用已有的GPU优化库,比如cuBLAS、cuFFT或Thrust,也可以加一些预处理执行给并行化编译器。

另一方面,一些应用程序需要一定程度的重构才能做被并行化。就像运行在CPU架构上的程序设计时需要考虑并行化以提升串行的应用程序的性能那样,CUDA并行编程家族(比如CUDA C++, CUDA Fortran)在让支持CUDA的GPU能尽量做到最大并行运算量的同时,也在尝试使并行化的表达尽量简单。

5 开始优化(Getting Started)

并行化串行代码有几个关键策略。如何在某个应用程序上使用这些策略是一个复杂和专门的课题,这里列出的主题并不局限于把并行化后的代码运行在哪里(多CPUs或CUDA GPUs都可以)。

5.1 并行库(Parallel Libraries)

让一个应用程序并行化的最直接的方法是已有的利用了并行架构的库。CUDA工具包包含了很多这样的已经为NVIDIA CUDA GPUs做了优化的库,比如cuBLAS、cuFFT。

这里的关键是库要和应用程序的需求相匹配。已经使用了其它BLAS库的应用程序一般可以比较容易的切换到cuBLAS。例如,如果应用程序几乎不做线性代数,那么cuBLAS就没有什么用处。其他CUDA工具包库也是如此:比如cuFFT有一个类似于FFTW的接口。

还有Thrust库,这是一个类似于C++标准模板库的并行C++模板库。Thrust提供了丰富的数据并行原语,比如扫描、排序和归集,这些原语可以组合在一起,用简洁易读的源代码实现复杂的算法。通过用这些高级抽象描述你的计算,Thrust可以自由地帮你自动选择最有效的实现。因此,Thrust可用于CUDA应用程序的快速原型设计,它提高了程序员的生产力,也保证了代码的鲁棒性和绝对性能。

5.2 并行化编译器(Parallelizing Compilers)

另一个并行化的方式是利用并行化编译器。通常这意味着使用基于指令的方法,程序员不是调整已有的代码本身,而是使用注释pragma或标记等让编译器知道哪里可以被并行化。随后编译器自己会把计算映射到并行架构上。

OpenACC标准提供了一组编译指令,可用于指明C、C++和Fortran代码中的哪些片段可以从主机CPU上移到CUDA GPU上执行。具体对设备的操作细节由使能了OpenACC的编译器管理和执行。

5.3 编码使并行化(Coding to Expose Parallelism)

如果现有的库和并行化编译器都搞不定,应用程序还需要另外的功能或性能提升,就需要使用并行编程语言,比如CUDA C++,并无缝衔接现有的串行代码。

在使用剖析器得到关键点和确定需要重写代码后,我们可以使用CUDA C++,把代码中的可以并行的部分当做一个CUDA kernel。我们可以在GPU上运行kernel并获取结果,而不用大幅重写代码的其它部分。

当我们程序的运行时间主要消耗在一些相对隔离的部分的时候,这种方法(即直接重写那部分耗时的代码)是最直接的。

比较难并行化的是那种非常扁平的应用程序,即时间广泛地消耗在代码的很多部分。对于这种情况,就需要进行某种程度的重构,把可以被并行化的地方暴露出来。将来的架构都将在这种重构中获利,所以这么做是值得的。

6 保证结果正确(Getting the Right Answer)

获取到正确的结果是所有计算的原则。在并行系统上,可能会遇到在传统的串行导向的编程中不常见的困难。这些问题包括线程问题、浮点值计算方式导致的意外值,以及CPU和GPU处理器操作方式差异带来的挑战。本章将分析可能影响返回数据正确性的一些问题,并给出适当的解决方案。

6.1 验证(Verification)

6.1.1 参考比较(Reference Comparison)

对任何现有程序进行修改,并验证其正确性的一个关键是建立某种机制:对某些有代表性的输入,用以前(修改前)良好的输出结果与新结果进行比较。每次更改后,确保无论对当前算法使用任何标准,结果都是匹配的。某些人会期望得到所有位都相同的结果,这并不总是可行的,特别是在涉及浮点运算的情况下;有关数值精度,请参见“数值精度和精确”一节。对于其他算法,如果运算结果与参考目标只有很小的差异(比如小于某个很小的数的范围),则可以认为是正确的。

上面提到的用于验证数值结果的方法可以很容易地扩展到验证性能结果。我们想要保证做过的每一项改变都是正确的并且可以(在预期程度上)提升性能。作为周期性APOD过程的一个组成部分,经常检查这些事情将有助于确保我们尽快达到预期的结果。

6.1.2. Unit Testing

6.1.2 单元测试(Unit Testing)

与上面描述的“参考比较”策略对应的一个有用的方法就是在把代码做成单元级可验证的方式。比如我们可以在写CUDA kernel时多用短的__device__函数,而不是大的__global__函数。所有设备函数在被连接在一起之前都可以单独测试。

例如,许多内核除了实际的计算之外,还有复杂的寻址逻辑来访问内存。如果我们在引入大量计算之前单独验证寻址逻辑,就将简化以后的调试工作。(请注意,CUDA编译器将任何不向全局内存写入数据的设备代码视为需要消除的死代码,因此我们必须向写入全局内存写点东西当做寻址逻辑的结果,以便成功应用此策略。)

更进一步地说,如果大多数函数被定义为__host____device__函数而不仅仅是__device__ 函数,那么这些函数可以在CPU和GPU上都进行测试,从而增加我们对函数正确性和结果不会有任何意外差异的信心。如果存在差异,那么这些差异将在早期被发现,并且可以在简单函数的上下文中被理解(指不用以后再到复杂逻辑中去排查)。

还有一个有用的副作用,如果我们希望在应用程序中同时包含CPU和GPU执行路径,此策略将允许我们减少代码重复:如果我们的CUDA内核的大部分工作是在 __host____device__函数中完成的,我们可以轻松地从主机代码和设备代码调用这些函数,而无需重复写这些函数。

6.2. 调试

CUDA-GDB是在Linux和Mac上运行的GNU调试器的一个端口。https://developer.nvidia.com/cuda-gdb

还有几个第三方的调试器支持CUDA调试。可参考https://developer.nvidia.com/debugging-solutionshttps://developer.nvidia.com/nsight-visual-studio-edition。

6.3 数值精度和精确(Numerical Accuracy and Precision)

不正确或意外的结果主要由浮点值的计算和存储方式引起的浮点精度问题引发。以下各节解释了需要主要关注的地方。

6.3.1 单精度和双精度(Single vs. Double Precision)

计算能力1.3(NVIDIA对自己GPU的计算能力的一种打分)和以上的设备支持双精度浮点运算(即64位宽的值)。由于双精度算法的精度更高以及四舍五入问题,即使执行相同的操作,使用双精度算法通常与使用单精度算法获得的结果不同。因此,重要的是对类似精度的值做比较,并在一定公差范围内考虑结果是否正确,而不是期望它们是完全精确的。

6.3.2 浮点计算不符合结合律(Floating Point Math Is not Associative)

每个浮点算术运算操作都会有一定程度的四舍五入。因此执行算术运算的顺序很重要。比如A、B、C都是浮点数,(A+B)+C不能像符号数学中那样保证等于A+(B+C)。并行计算时,可能会更改操作顺序,因此并行计算的结果可能与顺序计算的结果不匹配。这一限制并不是CUDA特有的,而是浮点数并行计算的固有特点。

6.3.3 IEEE 754规范(IEEE 754 Compliance)

除了一些小的例外,所有CUDA计算设备都遵循IEEE 754二进制浮点表示标准。这些例外,在CUDA C++编程指南的特征和技术规范中有详细说明,可以导致与在主机系统上计算的结果不同。

其中一个关键区别是fused multiply add(FMA)指令,它将乘加(multiply-add)操作组合到单个指令执行中。其结果通常与分别执行这两个操作所获得的结果略有不同。

6.3.4 x86 80位 计算

x86在做浮点运算时可以使用80位双扩展精度计算。其计算结果经常与在CUDA设备上执行纯64位计算不同。如果想让结果尽可能相近,需要去设置x86处理器,使其使用常规的双单精度(分别为64位和32位)。这是通过FLDCW x86汇编指令或等效的操作系统API完成的。

7 优化CUDA应用程序

在每一轮应用程序并行化完成后,开发人员可以着手优化具体实现以提高性能。由于可以考虑的优化方法很多,充分了解应用程序的需求有助于使优化过程尽可能顺利。但是,像APOD是一个整体那样,程序优化是一个迭代过程(确定优化机会,应用并测试优化,验证实现的加速效果,重复),这意味着在获得良好的加速成果之前,程序员不必花费大量时间来记忆所有可能的优化策略。相反,策略可以边学边用。

优化可以应用到各个层次,从“计算和数据传输并行”到“微调浮点操作顺序”。可用的剖析工具在此过程中是非常珍贵的,它们可以为开发人员的优化工作提供下一个最佳的行动方案的建议,并为本指南优化部分的相关内容提供参考。

8 性能指标(Performance Metrics)

在尝试优化CUDA代码时,先了解如何准确测量性能以及理解带宽在性能测量中的作用是值得的。本章讨论如何使用CPU计时器和CUDA事件正确测量性能。然后探讨带宽如何影响性能指标,以及如何缓解带宽带来的一些挑战。

8.1 计时(Timing)

CUDA调用和内核执行可以使用CPU或GPU定时器进行计时。本节将介绍这两种方法的功能、优点和缺点。

8.1.1 使用CPU定时器(Using CPU Timers)

任何CPU时钟都可以用来测量CUDA调用和kernel执行消耗的时间。CPU计时方法的细节不在本文讨论范围内,但是开发者需要有计时精度的意识。

使用CPU计时器时,很关键的一点是要记住很多CUDA API函数是异步的,也就是它们在完成工作之间就返回调用它们的CPU线程了。所有kernel启动函数都是异步的,名称上带有Async后缀的内存复制函数也是如此。因此,要精确的测量某一调用的时间消耗,必须在开始和停止CPU定时器时立即调用cudaDeviceSynchronize(),同步CPU线程和GPU。cudaDeviceSynchronize()会阻塞调用它的CPU线程,直到这个线程之前发起的CUDA调用全部执行完成。

虽然也可以将CPU线程与GPU上的特定流或事件进行同步,但这些同步函数不适用于对默认流以外的流中的代码进行计时。cudaStreamSynchronize()将阻塞CPU线程,直到之前向给特定流发出的所有CUDA调用完成。cudaEventSynchronize()也会阻塞CPU线程,直到GPU记录了特定流中的给定事件。因为驱动可以交错执行来自其他非默认流的CUDA调用,所以计时中可能包括了其他流中的调用。

由于默认流(流0)显示设备上工作的串行行为(默认流中的操作只能在其他任意流中的所有之前的调用全部完成后开始;任何流中的后续操作在完成之前都不能开始),因此这些函数可以可靠地用于在默认流中计时。

请注意,CPU到GPU的同步点(如本节中提到的同步点)意味着GPU处理流水线中的暂停,因此应谨慎使用,以将其性能影响降至最低。

8.1.2 使用CUDA GPU定时器(Using CUDA GPU Timers)

CUDA事件API提供了用于创建和销毁事件、记录事件(包括时间戳)和将时间戳差异转换为浮点值(以毫秒为单位)的调用。下面这一小节阐明了它们的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cudaEvent_t start, stop;
float time;

cudaEventCreate(&start);
cudaEventCreate(&stop);

cudaEventRecord( start, 0 );
kernel<<<grid,threads>>> ( d_odata, d_idata, size_x, size_y,
NUM_REPS);
cudaEventRecord( stop, 0 );
cudaEventSynchronize( stop );

cudaEventElapsedTime( &time, start, stop );
cudaEventDestroy( start );
cudaEventDestroy( stop );

这里,cudaEventRecord()用于将开始和停止事件放入默认流(即流0)中。当设备在流中到达该事件时,将记录该事件的时间戳。cudaEventElapsedTime() 函数的作用是:返回开始记录和停止记录事件之间经过的时间。该值以毫秒为单位,分辨率约为半微秒。与本代码段中的其他调用一样,CUDA工具包参考手册中描述了它们的具体操作、参数和返回值。请注意,计时是在GPU时钟上测量的,因此计时分辨率与操作系统无关。

8.2 带宽(Bandwidth)

带宽——数据传输的速率——是性能最重要的关键因素之一。几乎对代码的所有更改都应该考虑它们如何影响带宽。如本指南的“内存优化”一节中所述,带宽可能会受到存储数据的内存选择、数据布局和访问顺序以及其他因素的显著影响。

为了准确测量性能,需要计算理论带宽和有效带宽。当后者比前者低得多时,设计或实现细节可能降低了带宽,增加带宽应该是后续优化工作的主要目标。

注意:高优先级:在评估性能和优化效果时,使用计算的有效带宽(只考虑了有效数据和时间)作为衡量标准。

8.2.1 理论带宽的计算(Theoretical Bandwidth Calculation)

理论带宽可以使用产品文献中提供的硬件规格进行计算。例如,NVIDIA TESLA V100使用HBM2(双数据速率)RAM,存储器时钟速率为877 MHz和4096位宽的存储器接口。

使用这些数据项,NVIDIA TESLA V100的峰值理论存储器带宽为898 Gb/s:

(0.877×109×(4096/8)×2)÷109=898GB/s⁡

在该计算中,内存时钟速率转换为Hz,乘以接口宽度(除以8,将位转换为字节),再乘以2(由于数据速率加倍)。最后,将该乘积除以109,将结果转换为GB/s。

注:某些计算使用10243而不是109进行最终计算(这里应该是指公式里后面那个109)。在这种情况下,带宽将为836.4 GiB/s。在计算理论带宽和有效带宽时,使用相同的除数很重要,这样比较才有效。

注:在启用ECC的GDDR内存的GPU上,可用DRAM减少6.25%,以允许存储ECC位。与禁用ECC的相同GPU相比,为每次内存传输获取ECC位也会将有效带宽减少约20%,尽管ECC对带宽的确切影响可能更高,并且取决于内存访问模式。另一方面,HBM2存储器提供专用ECC资源,允许无开销ECC保护(即不会影响有效带宽)。

8.2.2 有效带宽的计算(Effective Bandwidth Calculation)

有效带宽是通过为特定的程序活动计时和了解程序如何访问数据来计算的。需使用以下等式:

有效带宽=((Br+Bw)÷109)÷时间

这里,有效带宽以GB/s为单位,Br为每个kernel读取的字节数,Bw为每个kernel写入的字节数,时间以秒为单位。

例如,要计算2048 x 2048矩阵拷贝的有效带宽,可以使用以下公式:

有效带宽=((20482×4×2)÷109)÷时间

元素数乘以每个元素的大小(浮点为4字节),再乘以2(读和写为两步),再除以109(或10243),即可获得所传输的GB内存。此数字除以以秒为单位的时间,得到GB/s。

8.2.3 Visual Profiler记录的吞吐量(Throughput Reported by Visual Profiler)

对于计算能力为2.0或更高的设备,可以用Visual Profiler收集几种不同的内存吞吐量。以下吞吐量指标可显示在详细信息或细节视图中:

  • (程序主动)请求的全局加载吞吐量
  • (程序主动)请求的全局保存吞吐量
  • (系统实际发生的)全局加载吞吐量
  • (系统实际发生的)全局保存吞吐量
  • DRAM读吞吐量
  • DRAM写吞吐量

请求的全局加载吞吐量和请求的全局保存吞吐量值指的是kernel请求的全局内存吞吐量,因此对应于“有效带宽计算”那一小节提到的方法获得的有效带宽。

由于最小内存访问的size大于大多数的字的size(这里指的是当访问某些以字为单位的数据时,由于总线的宽度一般大于一个字,每次访问的size又必须是一个总线宽度,所以经常会有无效的数据被访问。另外还有内存访问合并的考虑。),因此kernel所需的实际内存吞吐量可能包括内核未使用的数据传输。对于全局内存访问,实际吞吐量由全局加载吞吐量和全局保存吞吐量体现。

需要注意的是,这两个数字都很有用。实际内存吞吐量显示代码与硬件限制的接近程度,将有效或请求的带宽与实际带宽进行比较,可以很好地估计内存访问的次优合并会浪费多少带宽(请参阅对全局内存的合并访问一节)。对于全局内存访问,请求的内存带宽与实际内存带宽的比较由“全局内存加载效率”和“全局内存保存效率”来衡量。

9 内存优化(Memory Optimizations)

内存优化对性能来说是最重要的。其目标是通过带宽最大化来最大程度的使用硬件的能力。通过使用尽可能多的快速内存访问和尽可能少的慢速内存访问,得到最大的带宽。本章将讨论主机和设备上的各种内存,以及如何最好地设置数据项以有效使用内存。

9.1 主机和设备间的数据传输(Data Transfer Between Host and Device)

设备内存和GPU之间的峰值理论带宽(比如英伟达TESLA V100的898 Gb/s)要远高于主机内存和设备内存之间的峰值理论带宽(比如PCIe X16 GE3上的16 Gb/s)。因此,为了获得最佳的应用程序整体性能,将主机和设备之间的数据传输降至最低是很重要的,即使这意味着在GPU上运行kernel与在主机CPU上运行相同逻辑相比不会表现出任何速度优势。

注意:高优先级:尽量减少主机和设备之间的数据传输,即使这意味着在设备上运行一些kernel,与在主机CPU上运行这些kernel相比,它们的性能并没有提高。

中间(即非结果的)数据结构应在设备内存中创建,由设备操作,并在未映射或未复制到主机内存的情况下销毁。

此外,由于与每次传输相关的开销,将许多小的传输批处理成一个大的传输要比单独进行每次传输的性能好得多,即使这样做需要将数据从非连续的内存区域打包到连续的buffer中,然后在传输后解包。

最后,当使用页面锁定(或pinned)的内存时(一般意味着数据在这端内存中常驻,不会被swap到磁盘中),主机和设备之间的带宽更高,如CUDA C++编程指南和本文档的“ Pinned Memory ”(就是下面这里)部分中所介绍的那样。

9.1.1 Pinned内存

页锁定或pinned(它的一个重要特点是操作系统将不会对这块内存分页并交换到磁盘上,从而保证了内存始终驻留在物理内存中)内存传输可在主机和设备之间获得最高带宽。例如,在PCIe x16 Gen3卡上,pinned内存可以达到大约12 GB/s的传输速率。

pinned内存是使用运行时API中的cudaHostAlloc()函数分配的。bandwidthTest CUDA示例演示了如何使用这些函数以及如何测量内存传输性能。

对于已经提前分配好的系统内存区域,可以使用cudahostergister()去动态pin内存,而无需再分配新的缓冲区并将数据复制到里面。

不应过度使用pinned内存。过度使用会降低总体系统性能,因为pinned内存是一种稀缺资源,但是多少算多呢?很难事先知道。此外,与大多数正常的系统内存分配相比,系统内存的pin是一项重量级(指更耗时的)操作,因此需要像其他所有优化那样,测试应用程序及其运行的系统以获得最佳性能(意思是否则还不如不pin)。

9.1.2 数据传输和计算的异步和同时进行(Asynchronous and Overlapping Transfers with Computation)

使用cudaMemcpy()进行的主机和设备之间的数据传输是阻塞式的;也就是说,只有在数据传输完成后,控制才会返回到主机线程。cudamemcpysync()函数是cudaMemcpy()的一个非阻塞变体,其中控制权立即返回到主机线程。与cudaMemcpy()相反,异步传输版本需要pinned主机内存(否则啥时候去pin呢…),并且它包含一个附加参数,即流ID。流是在设备上按顺序执行的一系列操作。不同流中的操作可以交错执行,在某些情况下可以重叠(即同时执行)-这是一个可用于隐藏主机和设备之间的数据传输的属性(这里的意思是如果运算和数据传输同时执行,那就相当于数据传输没有占用计算时间了)。

异步传输以两种不同的方式实现数据传输与计算的同时执行。在所有支持CUDA的设备上,主机计算可能与异步数据传输和设备计算(三者)同时执行。例如,下面这部分描述演示了在将数据传输到设备并执行使用该设备的kernel时,如何在主机上执行cpuFunction()的计算。

同时进行计算和数据传输(Overlapping computation and data transfers)

1
2
3
cudaMemcpyAsync(a_d, a_h, size, cudaMemcpyHostToDevice, 0);
kernel<<<grid, block>>>(a_d);
cpuFunction();

cudaMemcpyAsync()函数的最后一个参数是流ID,在本例中,它使用默认的0号ID。kernel也使用默认流,在内存拷贝完成之前,它不会开始执行;因此,不需要显式同步。因为内存拷贝和kernel都会立即将控制权返回给主机,所以主机函数cpuFunction()的执行会和前两步(即内存拷贝和kernel执行,注:前两步自身不重叠)同时进行。

在上面这个例子中,内存拷贝和kernel执行是顺序进行的。在能够并发数据拷贝和计算的设备上,可以同时进行设备上的内核执行与主机和设备之间的数据传输。设备是否具有此功能由cudaDeviceProp结构的asyncEngineCount字段指示(或在deviceQuery CUDA示例的输出中列出)。在具有此功能的设备上,要做到同时执行,还是需要pinned主机内存,此外,数据传输和kernel必须使用不同的非默认流(具有非0流ID的流)。此重叠需要非默认流,因为使用默认流的内存复制、内存设置函数和kernel调用只有在设备(在任何流中)上的所有先前调用完成后才开始,并且设备(在任何流中)上的任何操作在完成之前也都不会开始。

下一小节对此做了基本演示。

并发拷贝和执行(Concurrent copy and execute)

1
2
3
4
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
cudaMemcpyAsync(a_d, a_h, size, cudaMemcpyHostToDevice, stream1);
kernel<<<grid, block, 0, stream2>>>(otherData_d);

在这段代码中,创建了两个流,并分别用于数据传输和kernel执行,正如cudaMemcpyAsync调用的最后一个参数和kernel的执行配置中指定的那样。

并发复制和执行演示了如何将内核执行与异步数据传输重叠。当数据依赖性不太强,使得数据可以被分割成多个块并在多个阶段中传输时,可以使用该技术,在每个块到达时启动多个kernel对其进行操作。顺序复制和执行以及分阶段并发复制和执行演示了这一点。它们产生了相同的结果。第一段显示了引用顺序实现,它在N个浮点数组上传输和操作(其中N被假设为可被nThreads整除)。

下面两小段描述即“顺序拷贝和执行”(Sequential copy and execute)以及“分段并发拷贝和执行”(Staged concurrent copy and execute)演示了这一点。它们产生了相同的结果。

第一段展示了参考顺序实现,其在N个浮点数组上传输和操作(其中N被假定为可被N个线程平均整除)。

1
2
cudaMemcpy(a_d, a_h, N*sizeof(float), dir);
kernel<<<N/nThreads, nThreads>>>(a_d);

“分段并发拷贝和执行”(Staged concurrent copy and execute),即下一小段,描述了数据的传输和kernel的执行可以被分成多个流的阶段。这种方法使得数据传输和kernel执行可以并行。

1
2
3
4
5
6
7
size=N*sizeof(float)/nStreams;
for (i=0; i<nStreams; i++) {
offset = i*N/nStreams;
cudaMemcpyAsync(a_d+offset, a_h+offset, size, dir, stream[i]);
kernel<<<N/(nThreads*nStreams), nThreads, 0,
stream[i]>>>(a_d+offset);
}

(在上面这段代码中,假设N可被n个线程*n个流整除。)因为流中的执行是顺序进行的,所以在各自流中的数据传输完成之前,不会启动任何kernel。当前的GPU可以同时处理异步数据传输和执行kernel。具有单个复制引擎的GPU可以执行一次异步数据传输并执行kernel,而具有两个复制引擎的GPU可以同时执行一次从主机到设备的异步数据传输、一次从设备到主机的异步数据传输以及执行kernel。GPU上复制引擎的数量由cudaDeviceProp结构的asyncEngineCount字段给出,该字段也可以在deviceQuery CUDA示例的输出中找到。(应该提到的是,不可能将阻塞传输与异步传输重叠,因为阻塞传输发生在默认流中,因此在所有以前的CUDA调用完成之前,它不会开始。在它自己完成之前,它也不会允许任何其他CUDA调用开始。)

9.1.3 零拷贝(Zero Copy)

零拷贝是CUDA工具包2.2版中添加的一项功能。它使GPU线程能够直接访问主机内存。为此,它需要映射的pinned(不可分页,即物理地址连续的)内存。在集成GPU(即CUDA设备属性结构的集成字段设置为1的GPU,这里可以理解为使用主机内存的集成显卡)上,映射固定内存始终可以获得性能增益,因为它避免了多余的拷贝,因为集成GPU和CPU内存在物理上是相同的。在和主机分离的GPU(即独立显卡)上,映射固定内存仅在某些情况下具有优势。由于数据未缓存在GPU上,映射的pinned内存应该只读取或写入一次,读取和写入内存的全局加载和保存应该合并。零拷贝可以用来代替流,因为源于kernel的数据传输会自动与kernel同时执行,而无需花费时间在设置和确定最佳流数上。

注意:低优先级:在CUDA Toolkit 2.2版及更高版本的集成GPU(集成显卡)上使用零拷贝操作(因为集成显卡和CPU共享主机物理内存)。

1
2
3
4
5
6
7
8
9
float *a_h, *a_map;
...
cudaGetDeviceProperties(&prop, 0);
if (!prop.canMapHostMemory)
exit(0);
cudaSetDeviceFlags(cudaDeviceMapHost);
cudaHostAlloc(&a_h, nBytes, cudaHostAllocMapped);
cudaHostGetDevicePointer(&a_map, a_h, 0);
kernel<<<gridSize, blockSize>>>(a_map);

在此代码中,cudaGetDeviceProperties()返回的结构的canMapHostMemory字段用于检查设备是否支持将主机内存映射到设备的地址空间。通过调用cudaSetDeviceFlags(cudaDeviceMapHost)使能页锁定的内存映射。请注意,必须在设置设备或做CUDA调用获取状态之前(本质上是在创建上下文之前)调用CUDASETDEVICELAGS()。使用cudaHostAlloc()分配页锁定的主机内存并映射,然后通过函数cudaHostGetDevicePointer()获取指向映射设备地址空间的指针。在上面的代码中,kernel()可以使用指针a_map来引用映射的pinned主机内存,就好像a_map指向的是设备内存一样。

注意:映射pinned主机内存允许CPU-GPU内存传输与计算同时进行,同时避免使用CUDA流。但是,由于对这种内存区域的任何重复访问将导致重复的CPU-GPU间传输,所以可以考虑在设备内存中创建第二区域以手动缓存先前从主机内存读取到的数据。

9.1.4 统一虚拟寻址(Unified Virtual Addressing)

计算能力2.0及更高版本的设备在使用TCC驱动模式时,在64位Linux、Mac OS和Windows上支持称为统一虚拟寻址(UVA)的特殊寻址模式。使用UVA,所有已安装的受支持设备的主机内存和设备内存共享一个虚拟地址空间。

在UVA之前,应用程序必须跟踪哪些指针指向设备内存(以及哪个设备),哪些指针指向主机内存(使用一个bit作为标记,或在程序中硬编码实现)。另一方面,使用UVA,指针指向的物理内存空间(是属于设备还是主机)可以通过使用CUDAPointerGetAttributes()检查指针的值来确定。

在UVA下,使用cudaHostAlloc()分配的pinned主机内存将具有相同的主机和设备指针,因此无需为其调用cudaHostGetDevicePointer()(去获取设备地址在主机端的对应地址)。但是,通过cudaHostRegister()分配然后又pinned的主机内存将继续具有与其主机指针不同的设备指针,因此在这种情况下,cudaHostGetDevicePointer()仍然是必需的。

UVA也是支持相关配置的GPU互相直接通过PCIe总线或NVLink(绕过主机内存)进行对等(P2P)数据传输的必要先决条件。

9.2 设备地址空间(Device Memory Spaces)

CUDA设备使用多个内存空间,这些内存空间具有不同的特性,反映了它们在CUDA应用程序中的不同用途。这些内存空间包括全局、局部、共享、纹理(Texture)和寄存器。

纹理内存是计算机内存的一个只读区域,专门用于快速访问计算机图形学中用作纹理表面的图像,通常是用于三维(3D)渲染。最有效的纹理存储器存在于视频卡上的专用存储芯片中,这种视频卡上的处理器独立于计算机中的主处理器。有时图形卡内存不足。在这些情况下,计算机的RAM甚至硬盘上的空间都可以用作虚拟纹理存储器,尽管在这些情况下性能会受到负面影响。可用的纹理内存量越大,存储在其中的图像就越大、越详细,从而提供更逼真的图形渲染。

在计算机屏幕上渲染三维图像的过程需要几个步骤。最后一个步骤是将纹理应用于正在被渲染的对象的几何体。此纹理是存储在内存中的二维(2D)图像,用于提供3D多边形对象表面的颜色、抛光和细节。将2D图像保留在纹理内存中可以快速访问,这有助于提高场景渲染的速度,从而实现平滑运动和动画。

如果只是用于GPU的本职工作——图像渲染,这段话的理解应该够了,把它理解成一段只读内存就行了,之所以起这个名字是因为它的用途——存储纹理数据。

但我们的工作并不仅限于图像渲染,还要用GPU来计算,于是又在网上找到这么一句话和下图。

纹理内存是DRAM上的内存,可以申请很大的空间,相比常量内存只能申请64kb来说,是一种很大空间的常量内存,而常量内存的好处是可以广播,当多个swap访问同一位置时,广播机制可以减少全局内存的访问,来提速。

综合来看纹理内存应该是有这样的特点:空间大、用于快速访问的只读(常量)数据。但下面又提到纹理内存访问延迟较大,上面也提到RAM甚至硬盘上的空间都可以用作虚拟纹理存储器,看来纹理内存只是一种组织结构,目的很好,但实际是否真的快还取决于具体情况(比如取决于纹理内存实际使用的物理内存在哪里)。

在这些不同的内存空间中,全局内存是最丰富的(应该是用的最多空间也最大的意思);CUDA C++编程指南的特征和技术规范这个文档中可以找到每个计算能力级别的GPU上在每种内存空间中可用的内存量。全局、本地和纹理(Texture)内存的访问延迟最大,其次是常量内存、共享内存和寄存器文件。

内存类型的各种主要特征如表1所示。

Memory Location on/off chip Cached Access Scope Lifetime
Register On n/a R/W 1 thread Thread
Local Off Yes†† R/W 1 thread Thread
Shared On n/a R/W All threads in block Block
Global Off R/W All threads + host Host allocation
Constant Off Yes R All threads + host Host allocation
Texture Off Yes R All threads + host Host allocation

† Cached in L1 and L2 by default on devices of compute capability 6.0 and 7.x; cached only in L2 by default on devices of lower compute capabilities, though some allow opt-in to caching in L1 as well via compilation flags.

†† Cached in L1 and L2 by default except on devices of compute capability 5.x; devices of compute capability 5.x cache locals only in L2.

在访问纹理(Texture)内存时,如果纹理引用绑定到全局内存中的线性数组,则设备代码可以写入底层数组。绑定到CUDA阵列的纹理引用可以通过表面写入(surface-write)操作写入,方法是将surface绑定到相同的底层CUDA阵列存储。应避免在同一kernel启动中写入其底层全局内存数组时读取纹理,因为纹理缓存是只读的,并且在修改关联的全局内存时不会被无效(意思是读取时不会和实际的底层数据同步)。

9.2.1 合并访问全局内存(Coalesced Access to Global Memory)

在为支持CUDA的GPU体系结构编程时,一个非常重要的性能考虑因素是合并全局内存访问。一个warp中的线程的全局内存加载和保存请求,由设备合并成尽可能少的(读写)事务。

注意:高优先级:确保尽可能合并全局内存访问。

合并访问请求依赖于设备的计算能力。

对于compute capability 6.0或更高版本的设备,可以很容易地总结出:一个warp中的线程的并发访问将合并成一系列事务,这些事务的数量等于为这个warp中的线程提供服务所需的32字节事务的数量。

对于计算能力为3.5、3.7和5.2的某些设备,可以选择启用全局内存访问的一级缓存。如果在这些设备上启用了L1缓存,则所需事务的数量等于所需的128字节对齐的段的数量。

注意:在compute capability 6.0或更高版本的设备上,L1缓存是默认使能的,但是无论全局负载是否缓存在L1中,数据访问事务的基本单位都是32字节。

在具有GDDR内存的设备上,当ECC打开时,以合并方式访问内存更为重要。分散访问会增加ECC内存传输开销,尤其是在将数据写入全局内存时。

以下简单示例说明了合并概念。这些示例假设计算能力为6.0或更高版本,除非另有说明,否则访问是针对4字节字的。

9.2.1.1 一个简单的访问模式(A Simple Access Pattern)

访问合并的第一个也是最简单的情况可以通过任何支持CUDA的计算能力为6.0或更高的设备实现:第k个线程访问32字节对齐数组中的第k个字。并非所有线程都需要参与。

例如,如果一个warp中的线程访问相邻的4字节字(例如,相邻的浮点值)(注:这里的一个warp中有32个线程,每个线程访问4字节字,总地址可达128字节),则四个合并的32字节事务将为该内存访问提供服务。

img

此访问模式产生四个32字节的事务,由红色矩形表示。

如果某次操作只想从四个32字节段中的任何一个中,请求获取它的一个或几个字(例如,如果多个线程访问了同一个字,或者如果一些线程没有参与访问),则无论如何都会获取整个段。此外,如果warp中的线程的访问已在段内或跨段进行了重新排序,则具有6.0或更高计算能力的设备仍将仅执行四个32字节的事务。

9.2.1.2 一种顺序但未对齐的访问模式(A Sequential but Misaligned Access Pattern)

如果一个warp中的线程按地址顺序访问内存,但地址并不是32字节对齐,则将请求5个32字节段,如图4所示。

img

通过CUDA运行时API(例如cudaMalloc())分配的内存保证至少256字节对齐(所以看来不需要程序员再主动对齐)。所以,选择合理的线程块大小,例如warp size(比如当前GPU上的32,意思是一个warp线程组中有32个线程)的倍数,有助于正确对齐的批量访问内存。(可以考虑这样一种情况,如果线程块大小不是warp size倍数,第二个、第三个和后续线程块访问的内存地址会发生什么情况?就对不齐了,会影响内存访问效率)

9.2.1.3 未对齐访问的影响(Effects of Misaligned Accesses)

使用一个简单的复制kernel(是说这个kernel要做数据复制,例如下面这段)来探索未对齐访问的后果是很容易的,而且获得的信息很有用。

1
2
3
4
5
__global__ void offsetCopy(float *odata, float* idata, int offset)
{
int xid = blockIdx.x * blockDim.x + threadIdx.x + offset;
odata[xid] = idata[xid];
}

在上面这段代码,数据从输入数组idata复制到输出数组,这两个数组都存在于全局内存中。做这个复制工作的kernel在主机代码中的循环执行,每次循环将参数offset从0到32之间做更改(图4的横轴对应于该offset的值)。NVIDIA Tesla V100(计算能力7.0)上对应各种offset的有效带宽如图5所示。

img

对于NVIDIA Tesla V100,地址没有偏移或为8的倍数偏移的全局内存访问引起的是4个32字节的事务。实现的带宽约为790 GB/s。否则,每个warp将加载5个32字节的段,这样简单算的话,和没有偏移的情况相比,有偏移的情况将获得大约4/5的内存吞吐量。

然而,在这个示例中,有偏移的内存吞吐量约为无偏移的9/10,这是因为相邻的warps(线程)会重用其邻居(线程)获取的缓存线。因此,尽管影响仍然明显,但并不像我们预期的那么大。如果相邻的warp(线程)没有高度重用cache line,情况会更糟。

这一小节告诉我们数据至少要8字节对齐。

9.2.1.4 跨步访问(Strided Accesses)

如上所述,在顺序访问未对齐的情况下,缓存有助于减轻其对性能的影响。但是,非单元跨步访问(non-unit-strided accesse,就是不连续地址访问,而是按某个步长跳着访问)的情况就不一样了,这是一种在处理多维数据或矩阵时经常出现的模式。因此,确保实际使用的每个缓存线中存在尽可能多的数据是内存访问性能优化的一个重要部分。

要说明跨步访问对有效带宽的影响,请参考下面这个例子——kernel strideCopy() 。它在线程之间从idata到odata以参数stride为步幅复制数据。

1
2
3
4
5
__global__ void strideCopy(float *odata, float* idata, int stride)
{
int xid = (blockIdx.x*blockDim.x + threadIdx.x)*stride;
odata[xid] = idata[xid];
}

图6说明了这种情况:一个warp中的线程以2为步长访问内存中的字。此操作将导致Tesla V100(计算能力7.0)上每个warp加载8个二级缓存段。

img

Figure 6. Adjacent threads accessing memory with a stride of 2

以2为步幅进行访问会导致50%的加载/存储效率,因为事务中有一半的元素未被使用,意味着带宽被浪费掉了。随着步幅的增加,有效带宽会降低,直到为warp中的32个线程加载32个32字节段为止(意思是随着步长越来越大,每个warp中的线程在访问数据时用的事务越来越多,直到32个。因为再多的话一个事务就不会再覆盖两次访问,每次访问都会对应一个事务,就不会再有性能降低了),如图7所示。

img

Figure 7. Performance of strideCopy kernel

如图7所示,应尽可能避免非单位跨步全局内存访问。一种方法是利用共享内存,将在下一节中讨论。

9.2.2. L2 Cache

从CUDA 11.0开始,计算能力为8.0及以上的设备能够影响二级缓存中数据的持久性。因为二级缓存是片上的,所以它有可能提供更高的带宽和更低的全局内存访问延迟。

9.2.2.1 L2 Cache 访问窗口(L2 Cache Access Window)

当CUDA内核重复访问全局内存中的数据区域时,可以认为这种数据访问是持续性的。另一方面,如果数据仅被访问一次,则此类数据访问可被视为流式访问。可以把二级缓存的一部分预留出来,用于对全局内存中的数据区域进行持续性访问。如果此预留部分没有被持续性访问使用,则流式或正常数据访问可以使用它。

下面的代码可在一定限制范围内调整二级缓存为持续性访问预留的缓存大小。

1
2
cudaGetDeviceProperties(&prop, device_id);
cudaDeviceSetLimit(cudaLimitPersistingL2CacheSize, prop.persistingL2CacheMaxSize); /* Set aside max possible size of L2 cache for persisting accesses */

用户数据到L2预留部分的映射可以使用CUDA流或CUDA图形kernel节点上的访问策略窗口进行控制。下面的示例显示如何在CUDA流上使用访问策略窗口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cudaStreamAttrValue stream_attribute;                                         
// Stream level attributes data structure
stream_attribute.accessPolicyWindow.base_ptr = reinterpret_cast<void*>(ptr);
// Global Memory data pointer
stream_attribute.accessPolicyWindow.num_bytes = num_bytes;
// Number of bytes for persisting accesses.
// (Must be less than cudaDeviceProp::accessPolicyMaxWindowSize)
stream_attribute.accessPolicyWindow.hitRatio = 1.0;
// Hint for L2 cache hit ratio for persisting accesses in the num_bytes region
stream_attribute.accessPolicyWindow.hitProp = cudaAccessPropertyPersisting;
// Type of access property on cache hit
stream_attribute.accessPolicyWindow.missProp = cudaAccessPropertyStreaming;
// Type of access property on cache miss.
//Set the attributes to a CUDA stream of type cudaStream_t
cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &stream_attribute);

访问策略窗口需要hitRatio和num_bytes的值。根据num_bytes参数的值和二级缓存的大小,可能需要调整hitRatio的值以避免L2 cache lines的抖动。

9.2.2.2 调整访问窗口的Hit率(Tuning the Access Window Hit-Ratio)

hitRatio参数可用于指定接收hitProp属性的访问的比例。例如,如果hitRatio值为0.6,则全局内存区域[ptr..ptr+num_bytes)中60%的内存访问具有持续性属性,40%的内存访问具有流式属性。为了了解hitRatio和num_bytes的影响,我们使用了滑动窗口微基准测试。

此微基准使用GPU全局内存中的1024 MB。首先,(像上一节描述的那样)我们使用CudDeviceSetLimit()预留出30 MB的二级缓存用于持续性访问。然后,如下图所示,我们指定对第一个freqSize*sizeof(int)字节数大小的内存区域的访问是持续性的,这个数据将使用L2预留的那部分(30 MB)。在我们的实验中,我们将这个持续性数据区域的大小从10 MB改变到60 MB,以模拟数据适合或超过可用L2预留部分(30 MB)的各种场景。NVIDIA TESLA A100 GPU拥有40 MB的L2高速缓存容量。对其他内存区域数据(即流数据)的访问被视为正常访问或流访问,因此其将使用未预留L2部分的剩余10 MB(除非L2预留部分的一部分未使用)。

img

Figure 8. Mapping Persistent data accesses to set-aside L2 in sliding window experiment

下面的kernel代码和访问窗口参数,就是本滑动窗口实验的实现方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__global__ void kernel(int *data_persistent, int *data_streaming, int dataSize, int freqSize) { 
int tid = blockIdx.x * blockDim.x + threadIdx.x;
/*Each CUDA thread accesses one element in the persistent data section
and one element in the streaming data section.
Because the size of the persistent memory region (freqSize * sizeof(int) bytes) is much
smaller than the size of the streaming memory region (dataSize * sizeof(int) bytes), data
in the persistent region is accessed more frequently*/

data_persistent[tid % freqSize] = 2 * data_persistent[tid % freqSize];
data_streaming[tid % dataSize] = 2 * data_streaming[tid % dataSize];
}
stream_attribute.accessPolicyWindow.base_ptr = reinterpret_cast<void*>(data_persistent);
stream_attribute.accessPolicyWindow.num_bytes = freqSize * sizeof(int);
//Number of bytes for persisting accesses in range 10-60 MB
stream_attribute.accessPolicyWindow.hitRatio = 1.0;
//Hint for cache hit ratio. Fixed value 1.0

下面的图表显示了上述kernel的性能。当持续性数据区域与L2 cache的30 MB预留部分很好地匹配时(其实就是持续性数据区域的size小于/等于为其预留的L2 cache,并把Hint率设置为1),可以观察到的性能提高达50%。但是,一旦持续性数据区域的大小超过L2 cache预留部分的大小,就可以看到由于L2 cache lines的抖动而导致了大约10%的性能下降。

img

Figure 9. The performance of the sliding-window benchmark with fixed hit-ratio of 1.0

如下所示,为了优化性能,当持续性数据的大小大于预留二级缓存部分的大小时,我们在访问窗口中调整num_bytes和hitRatio参数。

1
2
3
stream_attribute.accessPolicyWindow.base_ptr  = reinterpret_cast<void*>(data_persistent); 
stream_attribute.accessPolicyWindow.num_bytes = 20*1024*1024; //20 MB
stream_attribute.accessPolicyWindow.hitRatio = (20*1024*1024)/((float)freqSize*sizeof(int)); //Such that up to 20MB of data is resident.

在上面这段代码中,我们将访问窗口中的num_bytes固定为20MB,并调整hitRatio,以使持续性数据中随机的20MB驻留在二级缓存的预留部分。这意味着将使用流属性访问此持续性数据的其余部分。这有助于减少缓存抖动。结果如下图所示,无论持续性数据是否适合二级缓存,我们都可以看到良好的性能。(我的理解是这样的:这其实是在让持续性内存空间灵活的共享预留的L2 cache,也就是说当持续性内存较大时,不要让其一部分固定使用一块预留的L2 cache,这将导致其它部分不得不使用非预留的L2 cache,从而引起cache line抖动影响性能。而是要设定一个比例,让所有的持续性内存以随机的方式共享这块预留的L2 cache)

img

Figure 10. The performance of the sliding-window benchmark with tuned hit-ratio

9.2.3 共享内存(Shared Memory)

因为共享内存是片上的,所以共享内存比本地和全局内存具有更高的带宽和更低的延迟,前提是线程之间没有bank冲突(即多线程没有同时使用内存上的同一个bank,bank的定义后面有,这玩意和主机DDR的bank很像,应该都是物理通路引出的概念)。

9.2.3.1 共享内存和内存banks(Shared Memory and Memory Banks

为了实现并发访问的高内存带宽,共享内存被划分为大小相等的内存模块(称为banks),这些banks可以被同时访问。因此,任何“跨越n个不同banks”的“n个地址”的内存加载或保存操作都可以同时进行,产生的有效带宽是单个内存bank带宽的n倍。

但是,如果一个内存请求的多个地址映射到了同一个内存bank,则访问会被序列化(指需要排队)。硬件需要将具有bank冲突的内存请求拆分为多个独立的无冲突请求,这种行为将降低有效带宽(降低的程度取决于拆分出来的独立内存请求的数量)。这里有一个例外,当一个warp中的多个线程访问共享内存中的相同地址时,会引发广播。在这种情况下,来自不同bank的多个广播会合并成一个(从目标共享内存到多线程的)多播。

为了最大限度地减少bank冲突,了解内存地址如何映射到bank以及如何最佳地调度内存请求非常重要。

在计算能力为5.x或更高版本的设备上,每个bank每个时钟周期的带宽为32位,连续的32位字分配给连续的banks。warp size为32个线程,banks数量也为32,因此warp中的任何线程之间都可能发生bank冲突。参见CUDA C++编程指南中的Compute Capability 5.x一节。

在计算能力为3.x的设备上,每个bank每个时钟周期的带宽为64位。有两种不同的bank模式:将连续的32位字(即32位模式)或连续的64位字(64位模式)分配给连续的bank。warp size为32个线程,而bank数也为32,因此warp中的任何线程之间都可能发生bank冲突。参见CUDA C++编程指南中的Compute Capability 3.x一节。

9.2.3.2 矩阵乘法中使用共享内存(C=AB)(Shared Memory in Matrix Multiplication (C=AB))

共享内存支持一个块中多线程之间的协作。当一个块中的多个线程使用全局内存中的相同数据时,共享内存只访问全局内存中的数据一次。共享内存还可以通过从全局内存以合并模式加载和保存数据,然后在共享内存中对其重新排序,来避免未合并的内存访问(这里有点像CPU中cache的行为)。除了bank冲突之外,一个warp中的线程们不会在使用共享内存时遇到非顺序或未对齐访问引起的问题。

下面通过矩阵乘法C=AB的简单示例说明共享内存的使用,其中A的维数为Mxw,B的维数为wxN,C的维数为MxN。为了使kernel简单,M和N是32的倍数,因为当前设备的warp size(w)是32。(这样一个warp中的线程就可以计算出C中一个tile中的一行,每个线程计算这一行的一个元素)

问题的自然分解是使用一个线程块(含wxw个线程,每个线程计算tile中的一个元素)计算每个size为wxw的tile (tile指图11中灰色的小格子)(它的意思是把源矩阵和结果矩阵像下图那样都分解成wxw大小的块即tile,然后使用wxw个线程组成一个线程组或线程块,每个线程组计算一个tile)。因此,就wxw维度的tiles而言,A是列矩阵,B是行矩阵,C是它们的外积;可参见图11。启动一个由N/w×M/w个块组成的网格,其中每个线程块(含wxw个线程)根据A中的单个 tile和B中的单个 tile计算C中相应的tile。

img

Figure 11. Block-column matrix multiplied by block-row matrix. Block-column matrix (A) multiplied by block-row matrix (B) with resulting product matrix (C).

下图这个名为simpleMultiply的kernel(未优化的矩阵乘法)计算了矩阵C中的一个tile。

1
2
3
4
5
6
7
8
9
10
11
__global__ void simpleMultiply(float *a, float* b, float *c,
int N)
{
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
for (int i = 0; i < TILE_DIM; i++) {
sum += a[row*TILE_DIM+i] * b[i*N+col];
}
c[row*N+col] = sum;
}

(注:由于内存中保存矩阵时是按一行一行顺序进行的,所以按行索引来计算某个元素的位置和按列索引的方法是不同的,这也是a[]和b[]中的算法不同的原因)

在上面这段代码中,a、b和c分别是指向矩阵A、B和C的全局内存的指针;blockDim.x、blockDim.y和TILE_DIM都等于w(加注:blockIdx.y是指当前的tile是矩阵A中的第几个,blockIdx.x是指当前的tile是矩阵B中的第几个,threadIdx.y是当前thread在wxw线程块中的行编号,threadIdx.x是当前thread在wxw线程块中的列编号)。wxw线程块中的每个线程计算C中一个tile中的一个元素。row和col是由特定线程计算的C中元素的行和列(注:其实也对应着A中元素的行,和B中元素的列)。基于i的for循环每次将A的一行乘以B的一列,然后将结果写入C。

在NVIDIA Tesla V100上,该kernel的有效带宽为119.9 GB/s。为了分析性能,有必要考虑这组warps的线程如何在for循环中访问全局内存。每个warp的线程计算C中一个tile的一行(这么算来,上面提到的wxw线程块包含了w个warp线程组,每个warp线程组又含w个线程),这会用到A中的一个行和B中的一个tile,如图12所示。

img

Figure 12. Computing a row of a tile. Computing a row of a tile in C using one row of A and an entire tile of B.

对于For循环的每次迭代i,一个warp中的所有线程(一个warp线程组处理一个行)都会读取B中一个tile的一个行(注:每个线程每次迭代都只读取tile中的一个元素,但某次迭代中所有线程合起来就是读了一行,又因为这一个warp中的所有线程是同时执行的,所以GPU可以把这些访问组合成一个业务去内存读取数据),所有计算能力的GPU都有这种顺序合并访问功能。

但是,对于每次迭代i,一个warp中的所有线程从全局内存中读取矩阵A的相同值,因为索引“row*TILE_DIM+i”在一个warp中是常量(因为这个warp中的所有线程是要计算矩阵C中的某一行数据的,这样对应矩阵A中的数据源也是同一行,也就是这个warp中的所有线程都会从矩阵A中读取同一行数据,每个迭代读取的位置也一样)。即使这样的访问只需要计算能力为2.0或更高的设备上的一个事务,事务中也会浪费带宽,因为32字节缓存段中8个字中只有一个4字节字(这里假设float为4字节,利用率为1/8)被使用。理论上我们可以在循环的后续迭代中重用这个cache line,最终我们将利用所有8个字;然而,当许多warps同时在同一个多核处理器上执行时,通常情况下,在迭代i和i+1之间,这个cache line可能很容易从缓存中被移出。

任何计算能力的设备上的性能都可以通过把A中的一个tile读取到共享内存中来提高,如下面的代码段所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__global__ void coalescedMultiply(float *a, float* b, float *c,
int N)
{
__shared__ float aTile[TILE_DIM][TILE_DIM];
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
aTile[threadIdx.y][threadIdx.x] = a[row*TILE_DIM+threadIdx.x];
__syncwarp();
for (int i = 0; i < TILE_DIM; i++) {
sum += aTile[threadIdx.y][i]* b[i*N+col];
}
c[row*N+col] = sum;
}

在上面这段代码中,矩阵A中一个tile中的每个元素只从全局内存以完全合并的方式(没有浪费带宽)读取一次到共享内存。在for循环的每次迭代中,共享内存中的一个值将广播给warp中的所有线程。与_syncthreads()同步屏障调用不同,在将A中的tile读入共享内存后,_syncwarp()就足够了(应该是只warp内线程同步的意思),因为只有将数据写入共享内存的warp内的线程才会读取此数据。该kernel在NVIDIA Tesla V100上的有效带宽为144.4 GB/s。这说明了当硬件L1 cache逐出策略与应用程序的需要不匹配时,或者当L1 cache不用于从全局内存读取数据时,应考虑将共享内存用作用户管理的缓存。

以上面的代码为基础,在处理矩阵B时,可以做更进一步的改进。在计算矩阵C的一个tile的每一行时,读取B的整个tile。通过将B的tile读入共享内存一次,可以避免对它的重复读取(见下面的代码)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__global__ void sharedABMultiply(float *a, float* b, float *c,
int N)
{
__shared__ float aTile[TILE_DIM][TILE_DIM],
bTile[TILE_DIM][TILE_DIM];
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
aTile[threadIdx.y][threadIdx.x] = a[row*TILE_DIM+threadIdx.x];
bTile[threadIdx.y][threadIdx.x] = b[threadIdx.y*N+col];
__syncthreads();
for (int i = 0; i < TILE_DIM; i++) {
sum += aTile[threadIdx.y][i]* bTile[i][threadIdx.x];
}
c[row*N+col] = sum;
}

在上面的代码中,在读取B的tile后需要调用_syncthreads(),因为一个warp中的线程会从共享内存中读取由不同warps的线程写入共享内存的数据(同A的tile中的行数据不同,B的tile中的列数据有可能是由别的warp中的线程读取出来的,因为一个warp对应着tile中的一行)。在NVIDIA Tesla V100上,此例程的有效带宽为195.5 GB/s。请注意,性能的提高并不是因为在这两种情况下都改进了数据访问合并,而是因为避免了对全局内存的冗余访问。

下表列出了上面几个不同优化的例子对应的性能测试结果。

Optimization NVIDIA Tesla V100
No optimization 119.9 GB/s
Coalesced using shared memory to store a tile of A 144.4 GB/s
Using shared memory to eliminate redundant reads of a tile of B 195.5 GB/s

9.2.3.3 矩阵乘法中使用共享内存(C = AAT)(Shared Memory in Matrix Multiplication (C = AAT))

矩阵乘法的一个变体可用于说明如何处理对全局内存的跨步访问和共享内存的bank冲突。这个变体只是使用A的转置来代替B,所以C=AAT。下面的代码是一个对其简单的实现。

1
2
3
4
5
6
7
8
9
10
__global__ void simpleMultiply(float *a, float *c, int M)
{
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
for (int i = 0; i < TILE_DIM; i++) {
sum += a[row*TILE_DIM+i] * a[col*TILE_DIM+i];
}
c[row*M+col] = sum;
}

在上面的代码中,C的第row行、第col列元素是A的第row行和第col行的点积。在NVIDIA Tesla V100上,该kernel的有效带宽为12.8 GB/s。这个性能结果大大低于C=AB kernel的相应测量结果。不同之处在于,对于每个迭代i,一半(为啥是一半warp线程呢?应该是一半读取操作)warp中的线程如何访问第二项中A的元素a[col*TILE_DIM+i]。对于一个warp中的线程(对应C中某个tile的一个行),col表示A的转置的连续列,因此col*TILE_DIM表示以w(32)为跨步访问全局内存,导致大量带宽浪费。

注:这里不太好理解,其实关键点在于a[row*TILE_DIM+i] * a[col*TILE_DIM+i];中的第二步a[col*TILE_DIM+i]。本来如果是两个独立的矩阵做乘法,这一步是b[i*N+col],这意味着warp中的每个kernel此时会分别读取矩阵B中不同列但同一行的元素,即它们要读取的数据是连续的,可以做合并访问。但由于现在B矩阵变成了AT,数据的存放不是连续的了,而是有了步长w,所以极大影响了带宽利用率,因为这里w=32,这相当于每读取一个float(4字节字)都需要一个事务。

避免跨步访问的方法是像之前一样使用共享内存,比如在这种情况下,一个warp将A中的一行读入共享内存作为一个tile的一列,下面的代码就展示了这样的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__global__ void coalescedMultiply(float *a, float *c, int M)
{
__shared__ float aTile[TILE_DIM][TILE_DIM],
transposedTile[TILE_DIM][TILE_DIM];
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
aTile[threadIdx.y][threadIdx.x] = a[row*TILE_DIM+threadIdx.x];
transposedTile[threadIdx.x][threadIdx.y] =
a[(blockIdx.x*blockDim.x + threadIdx.y)*TILE_DIM +
threadIdx.x];
__syncthreads();
for (int i = 0; i < TILE_DIM; i++) {
sum += aTile[threadIdx.y][i]* transposedTile[i][threadIdx.x];
}
c[row*M+col] = sum;
}

上面的代码使用 transposedTile 来避免点积第二项中的非合并访问(由跨步访问引起),并使用上一示例中的共享aTile技术来避免第一项中的非合并访问(由字节数占事务比例过低并且cache长时间后失效引起)。在NVIDIA Tesla V100上,此kernel的有效带宽为140.2 GB/s。这些结果低于C=AB的最终kernel所获得的结果。造成差异的原因是共享内存bank冲突。

for循环中transposeSedTile中元素的读取不会发生冲突,因为每半个warp的线程都会跨tile的行读取(为啥是半个warp???本例中tile为wxw大小,w=32,每个warp的线程数也为32,毕竟w就是从warp size来的,这样每个warp跨一行才合理),从而导致跨banks的单位(应该是指单个bank,即4字节)跨步访问。问题发生在for循环之前,当将tile从全局内存复制到共享内存时,会发生bank冲突。为了能够合并对全局内存的加载操作,会按顺序从全局内存读取数据。但是,这需要以列的形式(应该指transposedTile[threadIdx.x][threadIdx.y]=XXX这句,横向读,竖向写,才能转置)写入共享内存,并且由于在共享内存中使用了wxw tiles,这会导致线程之间存在一个w banks的跨步——warp的每个线程都会命中同一bank(记住w为32)(另外我的理解是系统中有32个banks,随着地址的增加,每经过32个banks,就又会使用到同一个bank,每个bank为32 bits,正好是一个4字节字)。这些多路的bank冲突代价高昂。简单的补救方法是填充共享内存数组,使其具有一个额外的列,如下面的代码行所示。

1
__shared__ float transposedTile[TILE_DIM][TILE_DIM+1];

这种填充完全消除了冲突,因为现在线程之间的跨步是w+1个banks(对于当前设备为33个),由于使用了“取模运算”(modulo arithmetic)用于计算bank索引,这相当于一个单位跨步(相当于每个线程访问数据时都错开了一个bank)。经过此更改后,NVIDIA Tesla V100上的有效带宽为199.4 GB/s,与上一个C=AB kernel的结果相当。

表3总结了这些优化的结果。

Optimization NVIDIA Tesla V100
No optimization 12.8 GB/s
Using shared memory to coalesce global reads 140.2 GB/s
Removing bank conflicts 199.4 GB/s

这些结果应与表2中的结果进行比较。从这些表中可以看出,明智地使用共享内存可以显著提高性能。

本节中的示例说明了使用共享内存的三个原因:

  • 支持对全局内存的联合访问,特别是避免大的跨步(对于一般矩阵,跨步远大于32)
  • 从全局内存中消除(或减少)冗余加载操作
  • 避免浪费带宽

9.2.3.4 从全局内存到共享内存的异步复制(Asynchronous Copy from Global Memory to Shared Memory)

CUDA 11.0引入了异步复制功能,可在设备代码中使用该功能显式管理数据从全局内存到共享内存的异步复制。此功能使CUDA kernel能够将数据从全局内存复制到共享内存的操作与计算同时进行。它还避免了传统上存在于全局内存读取和共享内存写入之间的中间寄存器文件访问。

有关更多细节,请参见CUDA C++编程指南中的memcopy_async部分。

为了理解从全局内存到共享内存的数据同步复制和异步复制的性能差异,下面的微基准CUDA kernel代码用于演示同步和异步方法。对于NVIDIA A100 GPU,异步拷贝是硬件加速的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template <typename T>
__global__ void pipeline_kernel_sync(T *global, uint64_t *clock, size_t copy_count) {
extern __shared__ char s[];
T *shared = reinterpret_cast<T *>(s);

uint64_t clock_start = clock64();

for (size_t i = 0; i < copy_count; ++i) {
shared[blockDim.x * i + threadIdx.x] = global[blockDim.x * i + threadIdx.x];
}

uint64_t clock_end = clock64();

atomicAdd(reinterpret_cast<unsigned long long *>(clock),
clock_end - clock_start);
}

template <typename T>
__global__ void pipeline_kernel_async(T *global, uint64_t *clock, size_t copy_count) {
extern __shared__ char s[];
T *shared = reinterpret_cast<T *>(s);

uint64_t clock_start = clock64();

//pipeline pipe;
for (size_t i = 0; i < copy_count; ++i) {
__pipeline_memcpy_async(&shared[blockDim.x * i + threadIdx.x],
&global[blockDim.x * i + threadIdx.x], sizeof(T));
}
__pipeline_commit();
__pipeline_wait_prior(0);

uint64_t clock_end = clock64();

atomicAdd(reinterpret_cast<unsigned long long *>(clock),
clock_end - clock_start);
}

kernel的同步版本将元素从全局内存加载到中间寄存器,然后将中间寄存器值存储到共享内存。在kernel的异步版本中,只要调用_pipeline_memcpy_async()函数,就会发出从全局内存加载并直接存储到共享内存的指令。异步拷贝过程中使用的__pipeline_wait_prior(0)将一直等到指令流水线中的所有指令都执行完成。使用异步复制不使用任何中间寄存器,这有助于降低寄存器压力并增加kernel占用率。使用异步复制指令从全局内存复制到共享内存的数据可以缓存在L1 cache中,也可以选择绕过L1 cache。如果单个CUDA线程正在复制16字节的元素,则可以绕过L1 cache。这一差异如图13所示。

img

Figure 13. Comparing Synchronous vs Asynchronous Copy from Global Memory to Shared Memory

我们评估这两个(就是上面的同步和异步)kernel的性能时使用大小为4B、8B和16B的元素,比如可以使用int、int2和int4作为(C++)模板参数。我们调整kernel中的copy_count,使每个线程拷贝的数据从512字节到48MB变化。kernel的性能如图14所示。

image-20221224174833976

Figure 14. Comparing Performance of Synchronous vs Asynchronous Copy from Global Memory to Shared Memory

从上图中,我们可以观察到以下几点:

  1. 同步复制的性能,对于所有三种元素size,当copy_count参数是4的倍数时,达到最佳。编译器可以优化4组加载和保存指令。这从锯齿曲线中可以明显看出。
  2. 异步复制几乎在所有情况下都能实现(比同步复制)更好的性能。
  3. 异步复制不要求copy_count参数为4的倍数,以便通过编译器优化使性能最大化。
  4. 总的来说,使用大小为8或16字节的元素的异步拷贝可以获得最佳性能。

9.2.4 本地内存(Local Memory)

本地内存之所以如此命名,是因为它的作用域在线程本地(有点C语言中函数内局部变量的意思,后面说的原因也很像——寄存器不够保存函数内临时变量),而不是因为它的物理位置。实际上,本地内存是片外的。因此,访问本地内存与访问全局内存一样耗时。换句话说,local并不意味着更快的访问。

本地内存仅用于保存自动变量。这是由nvcc编译器在确定没有足够的寄存器空间来保存变量时才使用的。可能放在本地内存中的自动变量是大型结构或数组(会被动态索引),它们会占用太多的寄存器空间。

检查PTX汇编代码(通过nvcc编译时使用-ptx或-keep命令行选项获得)可以发现在第一个编译阶段是否在本地内存中放置了变量。如果有,编译器将使用.local助记符声明它,并使用ld.local和st.local助记符访问它。如果没有,后续的编译阶段可能仍然会做出相反的决定(也就是仍然会决定使用本地内存),如果他们发现变量在目标架构上占用了太多的寄存器空间。对于特定的变量,无法对此进行检查,但是当使用--ptxas options=-v选项运行时,编译器会报告每个kernel的总本地内存使用量(lmem)。

9.2.5 纹理内存

只读纹理内存(为啥叫纹理内存前面有描述)空间是被缓存的。因此,只有在缓存未命中时文本内存的获取才需要消耗一个设备内存的读取操作;否则,只需从纹理缓存读取就可以了。纹理缓存针对二维空间局部性进行了优化,因此读取相邻纹理地址的同一个warp中的线程将获得最佳性能。纹理内存也被设计用于具有恒定延迟的流式读取;也就是说,缓存命中可以减少DRAM带宽需求,但不会减少读取延迟(因为延时被设计成)。

在某些寻址情况下,通过纹理抓取(texture fetching)读取设备内存可能是从全局或常量内存读取设备内存的更好替代方法。

9.2.5.1 附加纹理功能(Additional Texture Capabilities)

如果使用tex1D()tex2D()tex3D()而不是tex1Dfetch()获取纹理,则硬件提供的其他功能可能对某些应用程序(如图像处理)有用,如表4所示。

Feature Use Caveat
Filtering Fast, low-precision interpolation between texels Valid only if the texture reference returns floating-point data
Normalized texture coordinates Resolution-independent coding None
Addressing modes Automatic handling of boundary cases1 Can be used only with normalized texture coordinates

1表4底行中边界情况的自动处理是指当纹理坐标超出有效寻址范围时,如何解析纹理坐标。有两种选择:夹紧和包裹。如果x是坐标,N是一维纹理的纹素数,则使用钳位,如果x<0,x将替换为0,如果1<x,则替换为1-1/N。使用wrap时,x被frac(x)替换,其中frac(x)=x-floor(x)。Floor返回小于或等于x的最大整数。因此,在N=1的钳位模式下,1.3的x被钳位为1.0;而在包裹模式下,它被转换为0.3

在kernel调用中,纹理缓存与全局内存写入并不保持一致,因此如果纹理读取操作从同一kernel写入过的全局地址获取数据,其返回的数据是未定义的。也就是说,如果某个内存位置已由以前的kernel调用或内存复制更新,则线程可以通过纹理安全地读取该内存位置,但如果该位置已被同一线程或同一kernel调用中的另一个线程更新,则该线程不能通过纹理安全地读取该内存位置。

9.2.6 常量内存(Constant Memory)

一个设备上总共有64 KB的常量内存。其内存空间是被缓存的。因此,只有在发生cache miss时,从常量内存读取数据才会消耗从设备内存读取的一个读取操作;否则,只需从常量缓存读取。一个warp内的线程对不同地址的访问是序列化的,因此其时间消耗与一个warp内所有线程读取的唯一地址数成线性关系。因此,当同一warp中的线程仅访问少量几个不同的位置时,最好使用常量缓存。如果一个warp的所有线程访问同一个位置,那么常量内存的访问速度可以与寄存器的访问速度一样快。

9.2.7 寄存器(Registers)

通常,指令访问寄存器不会消耗额外的时钟周期,但由于寄存器read-after-write依赖性和寄存器内存bank冲突,可能会出现延迟。

编译器和硬件线程调度器将尽可能优化地指令的调度,以避免寄存器内存bank冲突。应用程序无法直接控制这些bank冲突。

9.2.7.1 寄存器压力(Register Pressure)

当给定任务没有足够的寄存器可用时,就会出现寄存器压力。尽管每个多核处理器都包含数千个32位寄存器(参见CUDA C++编程指南的特性和技术规范部分),但这些寄存器是并发的线程共享的。为了防止编译器分配太多寄存器,使用maxrregcount=Nn编译器命令行选项或启动边界kernel定义限定符(参见CUDA C++编程指南的执行配置部分)来控制每个线程分配的最大寄存器数。

9.3 内存分配(Allocation)

通过cudaMalloc()cudaFree()分配和取消分配设备内存是很耗时的操作,因此应用程序应尽可能重用和子分配(应该指内部管理已经分配的)设备内存,以尽量减少分配操作对整体性能的影响。

9.4 NUMA最佳实践(NUMA Best Practices)

一些最新的Linux发行版默认启用自动NUMA平衡机制(或“AutoNUMA”,指的是自动的在不同的NUMA node上分配内存)。在某些情况下,由自动NUMA平衡机制执行的操作可能会降低在NVIDIA GPU上运行的应用程序的性能。为了获得最佳性能,用户应该手动调整其应用程序的NUMA特性。

最佳NUMA调整将取决于每个应用程序和节点的特性和所需的硬件亲和力,但在NVIDIA GPU上执行计算的一般应用程序中,建议选择禁用自动NUMA平衡的策略。例如,在IBM Newell POWER9节点(其中CPU对应于NUMA节点0和8)上,使用:numactl --membind=0,8将内存分配绑定到CPU。

我的理解是这样的:这段是在写分配内存时,选择的内存地址所在的物理内存芯片应该属于执行程序的CPU所在的NUMA node。如果为GPU分配主机内存也应当如此,因为GPU一般使用PCIE和主机CPU/内存相连,GPU本身也会属于某个NUMA node。所以最佳组合应该是CPU、内存、GPU都属于同一个NUMA node。

10 执行配置优化(Execution Configuration Optimizations)

良好性能的关键之一是使设备上的多处理器尽可能繁忙。如果多处理器之间的工作不均衡(那有些处理器就不忙),就得不到最优性能。因此,很重要的一点是,应用程序设计中使用线程和块(这里指线程块)时需要最大化地利用硬件,并尽量自由分配工作。其中的一个关键概念是占有率,将在以下章节中解释。

在某些情况下,通过设计应用程序,使多个独立内核可以同时执行,也可以提高硬件利用率。多个kernels同时执行称为并发kernels执行。并发内核执行将在下一小节介绍。

另一个重要概念是管理分配给特定任务的系统资源。本章最后几节将讨论如何管理资源利用率。

10. 1 占有率(Occupancy)

线程指令在CUDA中是顺序执行的,因此,在一个warp暂停或阻塞时执行其他warp是隐藏延迟和保持硬件繁忙的唯一方法。因此,与多处理器上活动warp数量相关的一些指标对于确定硬件是否繁忙非常重要。这个指标是占有率。

占有率是每个多处理器上的活动warp数与可能的最大活动warp数之比。(如果想确定后面的数字,请参阅deviceQuery CUDA示例或参考CUDA C++编程指南中的计算能力部分)另一种查看占有率的方法是正在使用的warps占硬件能力的百分比。

占有率越高并不总是意味着性能越高。额外(应该是无效的)占有率并不能提高性能。但是,占有率低总是会影响隐藏内存延迟的能力,从而导致性能下降。

CUDA内核所需的每个线程资源可能会以不必要的方式限制最大块的大小。为了保持与未来硬件和工具包的前向兼容性,并确保至少有一个线程块可以在SM上运行,开发人员需要在代码中包含单参数__launch_bounds__(maxThreadsPerBlock) ,该参数指定kernel将使用的最大块大小。否则可能导致“为启动请求的资源过多”错误(我之前在TensorFlow上运行时经常遇到这种问题)。在某些情况下,提供双参数版本的__launch_bounds__(maxThreadsPerBlock,minBlocksPerMultiprocessor)可以提高性能。minBlocksPerMultiprocessor的正确值应该在详细分析每个kernel后确定。

10.1.1 计算占有率(Calculating Occupancy)

决定占有率的几个因素之一是可用的寄存器资源。寄存器存储使线程能够将本地变量保留在其中,以便进行低延迟访问。但是,寄存器集(称为寄存器文件)是一种有限的资源,被多处理器上的所有线程所共享。寄存器一次被分配给整个(线程)块。因此,如果每个线程块使用许多寄存器,(由于寄存器资源有限)那么可以驻留在多处理器上的线程块的数量就会减少,从而降低多处理器的占有率。每个线程的最大寄存器数可以在编译时使用-maxrregcount选项在每个文件中手动设置,也可以使用__launch_bounds__ 限定符在每个kernel中手动设置(请参阅寄存器压力一节,在第9章)。

为了计算占有率,每个线程使用的寄存器数量是关键因素之一。例如,在计算能力为7.0的设备上,每个多处理器有65536个32位寄存器,最多可驻留2048个并发线程(64个warp * 每个warp中32个线程)。这意味着在其中一个设备中,要使多处理器具有100%的占有率,每个线程最多可以使用32个寄存器。然而,这种评估寄存器数量如何影响占有率的方法没有考虑寄存器分配粒度(从后面的举例看,这句话的意思是由于寄存器是按块分配的,而设备的线程块数是有限制的,即使给每个块分配了足够的寄存器,总共使用的线程数即活动的线程块数*每块中的线程数并不一定达到最大并发线程数)。例如,在compute capability 7.0的设备上,一个kernel有128个线程块,每个线程使用37个寄存器,导致占有率为75%(注:12*128/2048=0.75),每个多处理器(最多)有12个活动的128个线程块。而另一个kernel有320个线程块,每个线程使用相同的37个寄存器,结果占用率为63%(注:4*320/2048=0.625),因为一个多处理器上只能驻留4个320的线程块。此外,在计算能力为7.0的设备上,四舍五入后每个块分配到的寄存器接近256个。

可用寄存器的数量、驻留在每个多处理器上的并发线程的最大数量以及寄存器分配粒度因不同的计算能力而异。由于寄存器分配中存在这些细微差别,而且多处理器的共享内存也在驻留的线程块之间进行划分,因此很难确定寄存器使用和占有率之间的确切关系。nvcc的--ptxas options=v选项详细说明了每个kernel每个线程使用的寄存器数量。在CUDA C++程序指南的硬件多线程部分,有用于计算各种能力的设备的寄存器分配公式。在CUDA C++编程指南的特性和技术规范中,有这些设备上可用的寄存器总数。另外,NVIDIA以Excel电子表格的形式提供了占有率计算器,使开发人员能够推敲最佳平衡,并更轻松地测试不同的可能场景。该电子表格如图15所示,称为CUDA_Occupancy_Calculator.xls,位于CUDA Toolkit安装目录的tools子目录中。

Figure 15. Using the CUDA Occupancy Calculator to project GPU multiprocessor occupancy

除了电子表格计算器,使用NVIDIA Nsight Compute Profiler可以也确定占用率。占用率的详细信息显示在其占用率部分。

应用程序还可以使用CUDA运行时的占有率API,例如cudaOccupancyMaxActiveBlocksPerMultiprocessor,根据运行时参数动态选择启动配置。

10.2 隐藏寄存器依赖(Hiding Register Dependencies)

注意:中优先级:为了隐藏由寄存器依赖引起的延迟,请为每个多处理器保持足够数量的活动线程(即,足够的占有率)。

当指令使用前一条指令写入的寄存器中存储的结果时,就会产生寄存器依赖。在计算能力为7.0的设备上,大多数算术指令的延迟通常为4个周期。所以线程在使用算术结果之前必须等待大约4个周期。但是,(在这段时间内)通过执行其他warp中的线程,可以完全隐藏此延迟。有关详细信息,请参阅寄存器一节。

10.3 线程和块启发式(Thread and Block Heuristics)

When choosing the block size, it is important to remember that multiple concurrent blocks can reside on a multiprocessor, so occupancy is not determined by block size alone. In particular, a larger block size does not imply a higher occupancy.

注意:中优先级:每个线程块的线程数应该是32的倍数,因为这样可以提供最佳的计算效率并促进内存访问合并。

每个grid的块的维度和size以及每个块的线程的维度和size都是重要的因素。这些参数的多维度的那一方面使得将多维问题映射到CUDA更容易,并且不影响性能。因此,本节将讨论的是size,而不是维度。

延迟隐藏和占有率的情况取决于每个多处理器的活动warp数,它由执行参数以及资源(寄存器和共享内存)的限制隐式确定。选择执行参数是在延迟隐藏(占有率)和资源利用率之间做平衡的问题。

选择执行配置参数时应同时进行;但是,有一些特定的启发式方法可以分别应用于每个参数。选择第一个执行配置参数——每个grid的块数或grid size——主要考虑的是保持整个GPU繁忙。grid中的块数应大于多处理器的数量,以便所有多核处理器至少有一个块去执行。此外,每个多核处理器应该有多个活动块,这样没有等待_syncthreads()的块就可以让硬件保持忙碌。此建议依赖于有多少资源可用;因此,它还取决于第二个执行参数——每个块的线程数或块大小——和共享内存使用情况。考虑到向未来的设备扩展,每个kernel启动的块数应该是数千。

在选择块大小时,要谨记多处理器上可以驻留多个并发块,因此占有率不仅仅由块size决定。特别是,更大的块size并不意味着更高的占有率。

如占有率那一节中所述,占用率越高并不总是意味着性能越好。例如,将占有率从66%提高到100%通常不会转化为类似的性能提升。占有率较低的内核比占用率较高的内核在每个线程上有更多的可用寄存器,这可能会导致溢出到本地内存的寄存器更少;特别是,在某些情况下,使用高度的公开指令级并行(ILP)(应该也意味着占用更多寄存器),可以以较低的占用率完全覆盖延迟。

在选择块大小时有许多需要考虑的因素,不可避免地需要进行一些实验。但是,应遵循下面这样一些经验法则:

  • 每个块的线程数应为warp大小的倍数,以避免在未充分使用的warp上浪费计算,并便于访问合并。

  • 每个块至少应使用64个线程,并且仅当每个多处理器有多个并发块时(否则应该用更多线程?)。

  • 如果用实验来判断使用哪个块size更好,每个块128到256个线程是一个很好的初始设定范围。

  • 如果延迟影响性能,则每个多处理器使用几个较小的线程块,而不是一个较大的线程块。这对于经常调用__syncthreads()的内核尤其有益。

请注意,当线程块分配的寄存器多于多处理器上可用的寄存器时,kernel启动会失败,因为请求的共享内存或线程太多。

10.4 共享内存的影响(Effects of Shared Memory)

共享内存在几种情况下很有用,例如帮助合并或消除对全局内存的冗余访问。但是,它也一定程度上限制了占有率。在许多情况下,kernel所需的共享内存量与所选的块大小有关,但是线程到共享内存元素的映射不需要是一对一的(可以一对多)。例如,kernel中可能需要使用64x64元素的共享内存阵列,但由于每个块的最大线程数为1024,因此无法启动每个块具有64x64线程的kernel。在这种情况下,可以启动线程数为32x32或64x16的kernel,每个线程处理共享内存阵列的四个元素。即使没有每个块的线程数等限制问题,使用单个线程处理共享内存阵列的多个元素的方法也是有好处的。这是因为每个元素的一些公共操作可以由线程执行一次,从而将成本分摊到线程处理的共享内存元素的数量上(我的理解是一般一次性处理很多数据比多次处理这些数据花费的时间更少,比如减少了读取需要的某些公共数据的时间)。

确定性能对占有率的敏感度的一种有用技术是通过实验动态分配共享内存的量,如执行配置的第三个参数所指定的那样。通过简单地增加这个参数(不修改kernel),就可以有效地降低kernel的占有率并测量其对性能的影响。

10. 5 并发执行内核(Concurrent Kernel Execution)

如前文所述,CUDA流可用于将内核执行与数据传输重叠(同时执行)。在有能力并发kernel的设备上,还可以使用流同时执行多个kernel,以更充分地利用设备的多处理器。设备是否具有此功能可以去看cudaDeviceProp结构的concurrentKernels字段(或在deviceQuery CUDA示例的输出中有列出)。并发执行必须使用非默认流(0号流以外的流),因为使用默认流的kernel调用只有在设备(在任何流中)上的所有先前调用完成后才能开始,并且(意思是反之也是成立的)在设备(在任何流中)上的任何操作直到(默认流中的kernle调用)完成后才开始。

下面是一个基本示例。由于kernel1和kernel12在不同的非默认流中执行,因此一个有能力的设备可以同时执行这两个kernel。

1
2
3
4
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
kernel1<<<grid, block, 0, stream1>>>(data_1);
kernel2<<<grid, block, 0, stream2>>>(data_2);

10.6 多上下文(Multiple contexts)

CUDA工作在特定GPU的进程空间中,我们称之为上下文。上下文封装了该GPU的kernel启动和内存分配,以及其支持的结构,如页表。上下文在CUDA驱动API中是显式的,但在CUDA运行时API中是完全隐式的,CUDA运行时API自动创建和管理上下文。

使用CUDA驱动API,CUDA应用程序进程可能会为给定GPU创建多个上下文。如果多个CUDA应用程序进程同时访问同一GPU,这几乎总是意味着多个上下文,因为除非使用多进程服务,否则上下文与特定主机进程绑定。

虽然可以在给定GPU上同时分配多个上下文(及其相关资源,如全局内存分配),但在任何给定时刻,该GPU上只有一个上下文可以执行工作;共享同一GPU的上下文是采用的是时间片的方式。创建额外的上下文会导致每个上下文数据的内存开销和上下文切换的时间开销。此外,当多个上下文的工作可以并发执行时,上下文切换会降低利用率(见并发内核执行一节)。

因此,最好避免在同一CUDA应用程序中(每个GPU上)使用多个上下文。为了帮助实现这一点,CUDA驱动API提供了访问和管理每个GPU上称为“主上下文”的特殊上下文的方法。这些(驱动API指定的)上下文与CUDA运行时线程还没有当前上下文时隐式使用的上下文相同。

1
2
3
4
5
6
7
8
9
10
11
// When initializing the program/library
CUcontext ctx;
cuDevicePrimaryCtxRetain(&ctx, dev);

// When the program/library launches work
cuCtxPushCurrent(ctx);
kernel<<<...>>>(...);
cuCtxPopCurrent(&ctx);

// When the program/library is finished with the context
cuDevicePrimaryCtxRelease(dev);

注意:NVIDIA-SMI可用于将GPU配置为独占进程模式,这将把每个GPU的上下文数限制为一个。在创建过程中,可以根据需要将此上下文更新到任意多个线程,如果设备上已存在使用CUDA驱动API创建的非主上下文,则cuDevicePrimaryCtxRetain将失败。

11. 指令优化(Instruction Optimization)

了解指令的执行方式可以让我们对代码进行非常有用的底层优化,特别是在频繁运行的代码(程序中的所谓热点)中。本文建议在完成所有高层优化之后再做此类底层优化。

11.1 算术指令(Arithmetic Instructions)

单精度浮点数提供最佳性能,强烈建议使用。在CUDA C++编程指南中详细描述了单个算术运算的吞吐量。

11.1.1 除模运算(Division Modulo Operations)

注:低优先级:使用移位操作以避免耗时的除法和模计算。

整数除法和模运算特别耗时,应尽可能避免或用位运算代替:如果n是2的幂,那(i/n)就等于i≫log2(n),(i%n)就等同于(i&(n−1))

如果n是字面意义的(应该指不是算出来的,而是编译器可以直接理解n是2的幂),编译器将执行这些转换。

11.1.2 有符号vs无符号的循环计数器(Loop Counters Signed vs. Unsigned)

注意:中低优先级:使用有符号整数而不是无符号整数作为循环计数器。

在C语言标准中,无符号整数溢出语义定义良好,而有符号整数溢出会导致未定义的结果。因此,编译器在使用有符号算术时,和使用无符号算术比,可以进行更积极的优化(意思是语言标准的宽松使得编译器在优化时的操作余地增大)。对于循环计数器,这一点尤其值得注意:因为循环计数器的值通常都是正数,所以程序员可能很容易将计数器声明为无符号。但是,为了获得更好的性能,应该将它们声明为有符号数。

例如下面的代码:

1
2
3
for (i = 0; i < n; i++) {
out[i] = in[offset + stride*i];
}

在这里,中括号中的子表达式stride*i可能会使32位整数溢出,因此如果i被声明为无符号,溢出语义会阻止编译器使用一些可能的优化,例如强度降低(strength reduction)。相反,如果i被声明为signed,其溢出语义未被语法定义,那么编译器有更多的余地来使用这些优化。

这里插入网上找到的一段话,描述下什么是强度降低(strength reduction)。我的理解是:下面的代码中,第一段中在每次迭代中都要进行一个乘法操作。在计算结果不变的情况下,第二段中把乘法运算变成了加法运算,加法运算相比乘法运算指令花费的时间更少,可以看作是计算强度降低。

强度降低寻找包含循环不变量和归纳变量的表达式。其中一些表达式可以简化。例如,循环不变量c和归纳变量i的乘法

1
2
c = 8; 
for (i = 0; i < N; i++) { y[i] = c * i; }

可以用连续的较弱的加替换

1
2
c = 8; k = 0; 
for (i = 0; i < N; i++) { y[i] = k; k = k + c; }

11.1.3 倒数平方根(Reciprocal Square Root)

对于单精度,应始终以rsqrtf()显式调用倒数平方根,对于双精度,应以rsqrt()显式调用倒数平方根。只有在不违反IEEE-754语义的情况下,编译器才会将1.0f/sqrtf(x)优化为rsqrtf()

11.1.4 其他算数指令

注意:低优先级:避免自动将双精度转换为浮点。

编译器有时必须插入转换指令,导致引入额外的执行周期。以下两种情况就是这样:

  1. 操作char或short的函数,其操作数通常需要转换为int

  2. 双精度浮点常量(定义时没有任何类型后缀),用作单精度浮点计算的输入

后一种情况可以通过使用单精度浮点常量来避免,该常量由f后缀定义,如3.141592653589793f、1.0f、0.5f。

对于单精度代码,强烈建议使用浮点类型和单精度数学函数。

还应注意,CUDA数学库中的互补误差函数erfcf(),使用完整的单精度时速度特别快。

11.1.5 带小分数参数的幂运算(Exponentiation With Small Fractional Arguments)

对于某些以分数为指数的幂运算,与使用pow()相比,通过使用平方根、立方根及其逆(应该指反函数),可以显著加快指数运算。对于那些指数不能精确表示为浮点数的指数,例如1/3,这也可以提供更精确的结果,因为pow()的使用会放大初始表示错误。

下表中的公式适用于x>=0x!=-0,即signbit(x) == 0的情况。

Computation Formula
x^1/9 r = rcbrt(rcbrt(x))
x^-1/9 r = cbrt(rcbrt(x))
x^1/6 r = rcbrt(rsqrt(x))
x^-1/6 r = rcbrt(sqrt(x))
x^1/4 r = rsqrt(rsqrt(x))
x^-1/4 r = sqrt(rsqrt(x))
x^1/3 r = cbrt(x)
x^-1/3 r = rcbrt(x)
x^1/2 r = sqrt(x)
x^-1/2 r = rsqrt(x)
x^2/3 r = cbrt(x); r = r*r
x^-2/3 r = rcbrt(x); r = r*r
x^3/4 r = sqrt(x); r = r*sqrt(r)
x^-3/4 r = rsqrt(x); r = r*sqrt(r)
x^7/6 r = x*rcbrt(rsqrt(x))
x^-7/6 r = (1/x) * rcbrt(sqrt(x))
x^5/4 r = x*rsqrt(rsqrt(x))
x^-5/4 r = (1/x)*sqrt(rsqrt(x))
x^4/3 r = x*cbrt(x)
x^-4/3 r = (1/x)*rcbrt(x)
x^3/2 r = x*sqrt(x)
x^-3/2 r = (1/x)*rsqrt(x)

11.1.6 数学计算库(Math Libraries)

注意:中优先级:只要对速度的要求超过精度,就使用快速数学库。

CUDA支持两种类型的运行时数学操作。它们可以通过名称来区分:一些名称中有带前缀的下划线,而其他名称则没有(例如,__functionName()functionName())。遵循__functionName()命名约定的函数直接映射到硬件级别。它们速度更快,但精度稍低(例如__sinf(x)__expf(x))。遵循functionName()命名约定的函数速度较慢,但精度较高(例如sinf(x)expf(x))。__sinf(x)__cosf(x)__expf(x)的吞吐量远远大于sinf(x)cosf(x)expf(x)。如果需要减小参数x的大小,则后者会变得更加耗时(大约慢一个数量级)。此外,在这种情况下,参数缩减代码使用本地内存,这可能会因为本地内存的高延迟而对性能产生更大的影响。更多的细节可在CUDA C++编程指南中获得。

还请注意,每当计算同一参数的正弦和余弦时,应使用sincos系列指令来优化性能:

  • __sincosf()用于单精度快速数学(见下一段)
  • sincosf()正则单精度运算
  • sincos()用于双精度运算

nvcc的-use_fast_math编译选项将每个functionName()调用强制改为等效的__functionName()调用。它还禁用单精度非规范化支持,通常会降低单精度除法的精度。这是一个比较激进的优化,它会降低数值精度,也可能改变某些特殊情况下的处理。一种更稳健的方法是,只有在性能提高更有价值并且可以容忍被改变行为的情况下,才有选择地引入对快速内在函数的调用。注意:此开关仅对单精度浮点有效。

注:中优先级:尽可能选择更快、更专业的数学函数,而不是更慢、更通用的函数。

对于小整数幂(例如,x^2或x^3),显式乘法几乎肯定比使用pow()等常规求幂例程快。虽然编译器优化改进不断寻求缩小这一差距,但显式乘法(或使用等效的专门构建的内联函数或宏)仍可能具有显著优势。当需要对相同的基求几个不同的幂时(例如,x^2和x^5的计算是紧密相邻的,x^2本身就是x^5的一个计算中间数),这种优势会增加,因为这有助于编译器进行公共子表达式消除(CSE)优化。

对于使用基数2或10的求幂运算,请使用函数exp2()expf2()exp10()expf10(),而不是函数pow()powf()pow()powf()在寄存器压力和指令计数方面都是重量级函数,这是它需要处理在一般的求幂运算中出现的许多特殊情况,并且很难在整个基数和指数范围内实现良好的精度。另一方面,函数exp2()exp2f()exp10()exp10f()在性能方面与exp()expf()类似,可以比pow()/powf()快十倍。

对于指数为1/3的求幂,请使用cbrt()cbrtf()函数,而不是通用的求幂函数pow()powf(),因为前者比后者快得多。同样,对于指数为-1/3的指数,请使用rcbrt()rcbrtf()

sinpi(<expr>)替换sinsin(π*<expr>),用cospi(<expr>)替换cos(π*<expr>),用sincospi(<expr>)替换sincos(π*<expr>)。这在准确性和性能方面都是有好处的。举个例子,要以度数而不是弧度计算正弦函数,即使用sinpi(x/180.0)。类似地,当函数参数的形式为π*<expr>时,应选择单精度函数sinpif()cospif()sincospif(),而不是sinf()cosf()sincosf()。(与sin()相比,sinpi()的性能优势在于简化了参数缩减(argument reduction);精度优势在于sinpi()仅隐式地乘以π,有效地使用了无限精确的数学π,而不是其单精度或双精度近似值。)

默认情况下,nvcc编译器生成符合IEEE标准的代码,但它也提供了一些选项来生成精度稍低但速度更快的代码:

  • -ftz=true(非规范化数字刷新为零)
  • -prec-div=false (精度较低的除法)
  • -prec-sqrt=false(精度较低的平方根)

另一个更激进的选项是-use_fast_math,它将每个functionName()强制为等效的__functionName()。这使得代码运行得更快,但代价是精度和准确性降低。

11.2 内存指令(Memory Instructions)

注意:高优先级:尽量减少全局内存的使用,尽可能访问共享内存。

内存指令包括读取或写入共享、本地或全局内存的所有指令。当访问未缓存的本地或全局内存时,会有有数百个时钟周期的内存访问延迟。

例如,以下示例代码中的赋值运算符具有高吞吐量,但关键是,从全局内存读取数据时存在数百个时钟周期的延迟:

1
2
3
__shared__ float shared[32];
__device__ float device[32];
shared[threadIdx.x] = device[threadIdx.x];

如果在等待全局内存访问完成时可以发出足够的独立的算术指令,那么线程调度器可以隐藏大部分全局内存延迟。但是,最好尽可能避免访问全局内存。

12. 流控(Control Flow)

12.1 跳转与分叉(Branching and Divergence)

注意:高优先级:在同一个warp中避免不同的执行路径。

流控指令(if、switch、do、for、while)会导致同一warp中的线程分叉(即不同的执行路径),从而显著影响指令吞吐量。在这种情况下,不同的线程必须分别执行不同的执行路径;这会增加此warp执行的指令总数。

为了在控制流依赖于线程ID的情况下获得最佳性能,应编写控制条件,以最小化发散warps的数量。

这是可能的,因为跨块的warps的分布是确定的,正如CUDA C++编程指南的SIMT架构部分所提到的。一个简单的例子是,控制条件仅依赖于threadIdx/WSIZE,其中WSIZE是warp大小。在这种情况下,没有warp分叉,因为控制条件与warps完全对齐。

对于只包含少量指令的分支,warp分叉通常会导致边际性能损失。例如,编译器可以使用预测来避免实际的分支。相反,所有指令都可以被调度,但每个线程的条件代码或预测控制哪些线程执行这些指令。带有错误预测的线程不会写入结果,也不会计算地址或读取操作数。

从Volta体系结构开始,独立线程调度允许warp在依赖于数据的条件块(conditional block)之外保持分叉。可以使用显式的 __syncwarp()来确保warp已重新聚合以用于后续指令。

12.2 分支预测(Branch Predication)

注意:低优先级:使编译器易于使用分支预测代替循环或控制语句。

有时,编译器可能通过使用分支预测展开循环或优化if或switch语句。在这种情况下,任何warp都不会分叉。

程序员还可以使用“#pragma unroll”,更多信息请参考CUDA C++编程指南。

使用分支预测时,不会跳过执行取决于控制条件的任何指令。相反,每个这样的指令都与根据控制条件设置为true或false的每线程条件代码或谓词相关联。尽管这些指令中的每一条都计划执行,但实际上只执行具有true预测的指令。带有false预测的指令不会写入结果,也不会计算地址或读取操作数。

仅当分支条件控制的指令数小于或等于某个阈值时,编译器才使用预测指令替换分支指令。

13 部署CUDA应用(Deploying CUDA Applications)

完成应用程序的一个或多个组件的GPU加速优化后,可以将结果与最开始的预期进行比较。最初的评估使得开发人员可以确定通过加速某些关键点可以实现的潜在加速的上限。

在解决其他热点以提高总性能之前,开发人员应考虑先把已实现的部分优化应用到实际的生产中。这一点很重要,原因有很多;例如,它使得用户尽早从他们的投资(劳动)中获利(虽然只是部分优化,但仍然是有价值的),并且它通过为应用程序提供一组渐进的而非革命性的更改,将开发人员和用户的风险降至最低。

14. 理解编程环境(Understanding the Programming Environment)

随着每一代新的NVIDIA处理器的出现,CUDA可以利用的GPU都增加了新功能。因此,了解体系结构的特征非常重要。

程序员应该知道两个版本号。第一个是计算能力,第二个是CUDA运行时和CUDA驱动API的版本号。

14.1 CUDA计算能力(CUDA Compute Capability)

计算能力描述了硬件的功能,并反映了设备支持的指令集以及其他规范,例如每个块的最大线程数和每个多处理器的寄存器数。较高的计算能力版本是较低(即较早)版本的超集,因此它们是向后兼容的。

可以通过编程方式查询设备中GPU的计算能力,如deviceQuery CUDA示例所示。该程序的输出如图16所示。通过调用cudaGetDeviceProperties()并访问它返回的结构中的信息,可以获得此信息。

img

Figure 16. Sample CUDA configuration data reported by deviceQuery

计算能力的主要和次要版本号如上图的第七行所示。图中该系统的设备0具有7.0的计算能力。

关于各种GPU的计算能力的更多细节可以参考CUDA C++编程指南。

开发人员应该特别注意设备上的多处理器数量、寄存器数量和可用内存量,以及设备的任何特殊功能。

14.2 附加硬件数据(Additional Hardware Data)

计算能力不描述某些硬件功能。例如,无论计算能力如何,在大多数但并非所有GPU上都可以使用主机和设备之间的异步数据传输来重叠(同时进行)kernel执行。在这种情况下,调用cudaGetDeviceProperties()以确定设备是否能够使用特定功能。例如,设备属性结构的asyncEngineCount字段指示是否可以同时进行kernel执行和数据传输(如果可以,可以进行多少并发传输);同样,canMapHostMemory字段指示是否可以执行零拷贝数据传输。

14.3 目标设备的有啥计算能力(Which Compute Capability Target)

要针对特定版本的NVIDIA硬件和CUDA软件做开发,请使用nvcc的-arch、-code和-gencode选项。例如,使用“warp shuffle”操作的代码编译时必须使用-arch=sm_30(或更高的计算能力)选项。

14.4 CUDA运行时组件(CUDA Runtime)

CUDA软件环境的主机运行时组件只能由主机功能使用。它提供了以下各项功能:

  • 设备管理
  • 上下文管理
  • 内存管理
  • 代码模块管理
  • 执行控制
  • 纹理参考管理
  • 与OpenGL和Direct3D的交互能力

与更底层的CUDA驱动API相比,CUDA运行时组件通过提供隐式初始化、上下文管理和设备代码模块管理,大大简化了设备管理。由nvcc生成的C++主机代码使用CUDA运行时组件,因此链接到该代码的应用程序将依赖于CUDA运行时组件;类似地,任何使用cuBLAS、cuFFT和其他CUDA工具包库的代码也将依赖于这些库内部使用的CUDA运行时组件。

CUDA工具包参考手册中解释了构成CUDA运行时API的函数。

CUDA运行时组件在kernel启动之前处理kernel加载、设置kernel参数和启动配置。隐式驱动版本检查、代码初始化、CUDA上下文管理、CUDA模块管理(cubin到函数映射)、kernel配置和参数传递都由CUDA运行时组件执行。

它包括两个主要部分:

  • 一个C风格的函数接口(cuda_runtime_api.h)。
  • C++风格的便利封装(cuda_runtime.h),但构建在C风格函数之上。

有关运行时API的更多信息,请参见CUDA C++编程指南的CUDA运行时组件部分。

15. CUDA兼容性开发指南(CUDA Compatibility Developer’s Guide)

CUDA工具包每月发布一次,以提供新功能、性能改进和关键缺陷修复。CUDA的兼容能力允许用户更新最新的CUDA工具包软件(包括编译器、库和工具),而无需更新整个驱动程序栈(这里应该是指CUDA驱动和显卡驱动)。

CUDA软件环境由三部分组成:

  1. CUDA工具包(库、CUDA运行时组件和开发者工具),即开发人员用于编译CUDA应用程序的SDK。

  2. CUDA驱动程序,用于运行CUDA应用程序的用户态驱动程序组件(例如Linux系统上的libcuda.so)。

  3. NVIDIA GPU设备驱动程序,即NVIDIA GPU的内核态驱动程序组件。

在Linux系统上,CUDA驱动程序和内核态组件被一起放在了NVIDIA显卡驱动程序包中。

CUDA编译器(nvcc)提供了一种处理CUDA和非CUDA代码的方法(通过拆分和控制编译),以及CUDA运行时组件(是CUDA编译器工具链的一部分)。CUDA运行时API为开发人员提供了用于管理设备、kernel执行等功能的高层C++接口,而CUDA驱动API为应用程序提供对NVIDIA硬件的底层编程接口。

在这些技术的基础上构建了CUDA库,其中一些库包含在CUDA工具包中,而cuDNN等其他库可能独立于CUDA工具包发布。

15.1 CUDA工具包版本(CUDA Toolkit Versioning)

从CUDA 11开始,工具包版本的定义基于行业标准语义版本控制方案:.X.Y.Z,其中:

  • .X代表主要版本-API已更改,二进制兼容性已中断。(需要使用新API重新编写、编译代码)

  • .Y代表次要版本-新API的引入、旧API的弃用和源代码兼容性可能会被破坏,但保持二进制兼容性。(新代码需要使用新API,但以前编译过的应该还能运行)

  • .Z代表发行版/修补程序版本-新的更新和修补程序将增加该版本。(基本不需要重新编写、编译代码)

CUDA平台的兼容能力旨在解决以下几种场景:

如果在企业或数据中心正在使用的GPU系统上升级驱动程序,可能很复杂,并且需要提前规划。延迟推出新的NVIDIA驱动程序可能意味着此类系统的用户可能无法获取CUDA新版本中提供的新功能。如果新CUDA版本不需要更新驱动程序,就意味着新版本的软件可以更快地提供给用户。

许多基于CUDA编译的软件库和应用程序(例如数学库或深度学习框架)并不直接依赖于CUDA运行时组件、编译器或驱动程序。在这种情况下,用户或开发人员仍然可以从使用这些库或框架中获益,而不用升级整个CUDA工具包或驱动程序。

升级依赖项容易出错且耗时,在某些极端情况下,甚至会更改程序的语义。不断地使用最新的CUDA工具包重新编译意味着对应用程序产品的最终客户强制要求升级。包管理器为这个过程提供了便利,但是意外的问题仍然会出现,如果发现错误,就需要重复上面的升级过程。

CUDA支持多种兼容性选择:

  • 首先在CUDA 10中引入CUDA正向兼容升级,旨在允许用户在使用旧的NVIDIA驱动的情况下,用新的CUDA版本编译和运行应用程序,以使用新的CUDA特征。

  • CUDA 11.1中首次引入了CUDA增强兼容性,它有两个好处:

    • 通过利用CUDA工具包中跨组件的语义版本控制,只要为一个CUDA小版本(例如11.1)构建应用程序,就可以跨大版本(即11.x)中的所有未来小版本工作。
    • CUDA运行时放宽了最低驱动程序版本检查,因此在迁移到新的小版本时不再需要升级驱动程序。
  • CUDA驱动程序确保编译后的CUDA应用程序保持向后二进制兼容。使用CUDA工具包(最老可到3.2版)编译的应用程序可以在更新的驱动程序上运行。

总结起来就是:老驱动可以支持新应用;新驱动可以支持老应用;只要CUDA工具包的大版本不变,相同代码的编译和运行就不会有问题。

15.2 源码兼容性(Compatibility)

源码兼容性就是库提供的一套保证,当安装了较新版本的SDK时,基于库的特定版本(使用SDK)编译的格式良好的应用程序将可以继续编译并运行,不会出现错误。

CUDA驱动和CUDA运行时组件(和库不一样)在跨不同的SDK版本时,并不是源码兼容。API可以被弃用和删除。因此,在较旧版本的工具包上成功编译的应用程序可能需要更改,以便针对较新版本的工具包进行编译。

开发人员将通过弃用和文档机制收到关于任何当前或即将发生的更改的通知。这并不意味着不再支持使用旧工具包编译的应用程序二进制文件。应用程序二进制依赖于CUDA驱动API接口,即使CUDA驱动程序API本身在不同的工具包版本中发生变化,CUDA也保证CUDA驱动API接口的二进制兼容性。(意思是想用新版本直接编老代码是不行的,但以前编好的老代码还能继续运行)

15.3 二进制兼容(Binary Compatibility)

我们将二进制兼容性定义为库提供的一套保证,即针对某个版本的库的应用程序在动态链接到库的不同版本时将继续工作。

CUDA驱动程序API有一个版本化的C风格ABI,它保证了针对旧驱动程序(例如CUDA 3.2)运行的应用程序仍然可以针对现代驱动程序(例如CUDA 11.0附带的驱动程序)正常运行。这意味着,即使应用程序源代码必须根据较新的CUDA工具包重新编译(甚至修改代码)才能使用较新的功能,但较新版本的驱动程序将始终支持现存的(以前编译好的)应用程序及其功能。

因此,CUDA驱动程序API是二进制兼容的(操作系统加载程序可以选择较新版本的驱动,应用程序可以继续工作),但不兼容源代码(用较新的SDK重编应用程序可能需要更改源代码)。

在继续讨论这个主题之前,开发人员必须了解最低驱动程序版本的概念以及这可能对他们产生的影响。

CUDA工具包(和运行时组件)的每个版本都有NVIDIA驱动程序的最低版本的要求。根据CUDA工具包版本编译的应用程序将仅在具有该工具包版本的指定最低驱动程序版本(当然还包括它以后更高的版本)上运行。在CUDA 11.0之前,工具包的最低驱动程序版本与CUDA工具包随附的驱动程序版本相同。

因此,当使用CUDA 11.0编译应用程序时,它只能在具有R450或更高版本驱动程序的系统上运行。如果此类应用程序在安装了R418驱动程序的系统上运行,CUDA初始化将返回一个错误。

15.3.1 CUDA二进制兼容性(CUDA Binary (cubin) Compatibility)

一个稍微相关但重要的主题是CUDA中GPU架构之间的应用程序二进制兼容性。

CUDA C++为熟悉C++编程语言的用户提供了一个简单的路径,以方便地编写程序在设备上执行。Kernel可以使用CUDA指令集体系结构(称为PTX)编写,该体系结构在PTX参考手册中有描述。然而,通常使用C++等高级编程语言效率更高。在这两种情况下,kernel必须由nvcc编译成二进制代码(称为cubins)才能在设备上执行。

cubins是特定架构相关的。cubins的二进制兼容性从一个计算能力小版本到下一个(新的)版本都有保证,但从一个计算能力小版本到上一个版本或跨计算能力主版本都不能保证兼容性。换句话说,为计算能力X.y生成的cubin对象将仅能在计算能力X.z(其中z≥y)的设备上执行。

要在具有特定计算能力的设备上执行代码,应用程序必须加载与此计算能力兼容的二进制或PTX代码。对于可移植性,即能够在具有更高计算能力的未来GPU架构上执行代码(现在还不能生成针对这种未来架构的二进制代码),应用程序必须加载英伟达驱动程序(当有了这些设备的时候)为这些未来设备编译的PTX代码(注:我认为这个步骤一般是将来使用新版本的CUDA工具包中的编译器编译代码的时候由编译器来做的)。

更多的关于cubins、PTX和应用兼容性的信息可以在CUDA C++编程指南中找到。

15.4 跨小版本的CUDA兼容性(CUDA Compatibility Across Minor Releases)

通过利用语义版本控制,从CUDA 11开始,CUDA工具包中的组件将在工具包跨小版本时保持二进制兼容。为了保持跨小版本的二进制兼容性,CUDA运行时组件不再增加每个小版本所需的最低驱动程序版本——仅在大版本发布时才会这样做。

新工具链需要新的最低版本驱动程序的主要原因之一是处理PTX代码的JIT编译和二进制代码的JIT链接。

15.4.1 CUDA小版本中的现有CUDA应用程序(Existing CUDA Applications within Minor Versions of CUDA)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ nvidia-smi

+-----------------------------------------------------------------------------+
| NVIDIA-SMI 450.80.02 Driver Version: 450.80.02 CUDA Version: 11.0 |
|-------------------------------+----------------------+----------------------+
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|===============================+======================+======================|
| 0 Tesla T4 On | 00000000:00:1E.0 Off | 0 |
| N/A 39C P8 9W 70W | 0MiB 15109MiB | 0% Default |
| | | N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=============================================================================|
| No running processes found |
+-----------------------------------------------------------------------------+

当我们在系统上运行CUDA 11.1应用程序(即静态链接了cudart 11.1)时,我们发现即使驱动程序报告了(驱动本身属于)11.0版本(如上面nvidia-smi命令的输出),它也能成功运行,也就是说,不需要在系统上更新驱动程序或其他工具包组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ samples/bin/x86_64/linux/release/deviceQuery
samples/bin/x86_64/linux/release/deviceQuery Starting...

CUDA Device Query (Runtime API) version (CUDART static linking)

Detected 1 CUDA Capable device(s)

Device 0: "Tesla T4"
CUDA Driver Version Runtime Version 11.0 11.1
CUDA Capability Major/Minor version number: 7.5

...<snip>...

deviceQuery, CUDA Driver = CUDART, CUDA Driver Version = 11.0, CUDA Runtime Version = 11.1, NumDevs = 1
Result = PASS

通过使用新的CUDA版本,用户可以从新的CUDA编程模型API、编译器优化和数学库功能中收益。

以下各节讨论一些注意事项。

15.4.1.1 处理新的CUDA功能和驱动API(Handling New CUDA Features and Driver APIs)

CUDA API的一个子集不需要新的驱动程序,它们都可以在没有任何驱动程序依赖的情况下使用。例如,cuMemMap API或CUDA 11.0之前引入的任何API(如cudaDeviceSynchronize)不需要驱动程序升级。如果要使用小版本中引入的其他CUDA API(这些API依赖于新驱动程序),必须回退代码(应该是不再使用新API或者修改代码的意思)。这种情况与现状没有什么不同,开发人员使用宏在编译时把依赖于CUDA版本的特性排除在外。用户应参考CUDA头文件和文档,以了解版本中引入的新CUDA API。

当使用工具包小版本中公开的功能时,如果应用程序运行在较旧的CUDA驱动程序上,则该功能在运行时可能不可用。希望利用此功能的用户应通过动态检查代码查询其可用性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static bool hostRegisterFeatureSupported = false;
static bool hostRegisterIsDeviceAddress = false;

static error_t cuFooFunction(int *ptr)
{
int *dptr = null;
if (hostRegisterFeatureSupported) {
cudaHostRegister(ptr, size, flags);
if (hostRegisterIsDeviceAddress) {
qptr = ptr;
}
else {
cudaHostGetDevicePointer(&qptr, ptr, 0);
}
}
else {
cudaMalloc();
cudaMemcpy();
}
gemm<<<1,1>>>(dptr);
cudaDeviceSynchronize();
}

int main()
{
rest of code here
cudaDeviceGetAttribute(
&hostRegisterFeatureSupported,
cudaDevAttrHostRegisterSupported,
0);
cudaDeviceGetAttribute(
&hostRegisterIsDeviceAddress,
cudaDevAttrCanUseHostPointerForRegisteredMem,
0);
cuFooFunction(/* malloced pointer */);
}

如果没有新的CUDA驱动程序,应用程序的调用的接口可能根本无法工作,最好立即返回错误:

1
2
3
4
5
6
7
8
9
10
#define MIN_VERSION 11010
cudaError_t foo()
{
int version = 0;
cudaGetDriverVersion(&version);
if (version < MIN_VERSION) {
return CUDA_ERROR_INSUFFICIENT_DRIVER;
}
proceed as normal
}

上面这种情况将添加一个新的错误码,以指示正在运行的驱动程序中缺少该功能:cudaErrorCallRequiresNewerDriver

15.4.1.2 使用PTX(Using PTX)

PTX为通用并行线程执行定义了虚拟机和ISA。PTX程序在加载时通过JIT编译器(CUDA驱动程序的一部分)转换为目标硬件指令集。由于PTX由CUDA驱动程序编译,新的工具链将生成与旧的CUDA驱动程序不兼容的PTX。当PTX用于和将来的设备兼容(最常见的情况)时,这不是问题,但在用于(当前的)运行时编译时可能会导致问题。

对于继续使用PTX的代码,为了支持在旧驱动程序上编译,必须首先通过静态ptxjit编译器库或NVRTC将代码转换为设备代码,并通过编译选项指定为某一架构(例如sm_80)而不是虚拟架构(例如compute_80)生成代码。对于这项工作,CUDA工具包附带了一个新的nvptxcompiler_static静态库。

我们可以在以下示例中看到这种用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
char* compilePTXToNVElf()
{
nvPTXCompilerHandle compiler = NULL;
nvPTXCompileResult status;

size_t elfSize, infoSize, errorSize;
char *elf, *infoLog, *errorLog;
int minorVer, majorVer;

const char* compile_options[] = { "--gpu-name=sm_80",
"--device-debug"
};

nvPTXCompilerGetVersion(&majorVer, &minorVer);
nvPTXCompilerCreate(&compiler, (size_t)strlen(ptxCode), ptxCode);
status = nvPTXCompilerCompile(compiler, 2, compile_options);
if (status != NVPTXCOMPILE_SUCCESS) {
nvPTXCompilerGetErrorLogSize(compiler, (void*)&errorSize);

if (errorSize != 0) {
errorLog = (char*)malloc(errorSize+1);
nvPTXCompilerGetErrorLog(compiler, (void*)errorLog);
printf("Error log: %s\n", errorLog);
free(errorLog);
}
exit(1);
}

nvPTXCompilerGetCompiledProgramSize(compiler, &elfSize));
elf = (char*)malloc(elfSize);
nvPTXCompilerGetCompiledProgram(compiler, (void*)elf);
nvPTXCompilerGetInfoLogSize(compiler, (void*)&infoSize);

if (infoSize != 0) {
infoLog = (char*)malloc(infoSize+1);
nvPTXCompilerGetInfoLog(compiler, (void*)infoLog);
printf("Info log: %s\n", infoLog);
free(infoLog);
}

nvPTXCompilerDestroy(&compiler);
return elf;
}

15.4.1.3 生成动态代码(Dynamic Code Generation)

NVRTC是CUDA C++的运行时编译库。它接受字符串形式的CUDA C++源代码,并创建可用于获取PTX的句柄。NVRTC生成的PTX字符串可以由cuModuleLoadData和cuModuleLoadDataEx加载。

目前还不支持处理可重定位的对象,因此CUDA驱动程序中的cuLink API集不具备增强兼容性的能力。这些API当前需要与CUDA运行时组件的版本匹配的(升级后的)驱动程序。

如PTX部分所述,PTX到设备代码的编译与CUDA驱动程序紧密相关,因此生成的PTX可能比部署系统上的驱动程序支持的更新。使用NVRTC时,建议首先通过PTX用户工作流中描述的步骤将生成的PTX代码转换为最终设备代码。这确保了代码的兼容性。或者,NVRTC可以直接从CUDA 11.1开始生成cubins。使用新API的应用程序可以使用驱动程序API cuModuleLoadData和cuModuleLoadDataEx直接加载最终的设备代码。

NVRTC过去通过选项-arch只支持虚拟架构,因为它只生成PTX。它现在也将支持实际的架构并生成SASS。如果指定了实际的架构,则接口需要增加功能以判断和处理PTX或cubin。

下面的示例显示了如何调整现有示例以使用新功能,相关代码由USE_CUBIN宏保护:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <nvrtc.h>
#include <cuda.h>
#include <iostream>

void NVRTC_SAFE_CALL(nvrtcResult result) {
if (result != NVRTC_SUCCESS) {
std::cerr << "\nnvrtc error: " << nvrtcGetErrorString(result) << '\n';
std::exit(1);
}
}

void CUDA_SAFE_CALL(CUresult result) {
if (result != CUDA_SUCCESS) {
const char *msg;
cuGetErrorName(result, &msg);
std::cerr << "\ncuda error: " << msg << '\n';
std::exit(1);
}
}

const char *hello = " \n\
extern \"C\" __global__ void hello() { \n\
printf(\"hello world\\n\"); \n\
} \n";

int main()
{
nvrtcProgram prog;
NVRTC_SAFE_CALL(nvrtcCreateProgram(&prog, hello, "hello.cu", 0, NULL, NULL));
#ifdef USE_CUBIN
const char *opts[] = {"-arch=sm_70"};
#else
const char *opts[] = {"-arch=compute_70"};
#endif
nvrtcResult compileResult = nvrtcCompileProgram(prog, 1, opts);
size_t logSize;
NVRTC_SAFE_CALL(nvrtcGetProgramLogSize(prog, &logSize));
char *log = new char[logSize];
NVRTC_SAFE_CALL(nvrtcGetProgramLog(prog, log));
std::cout << log << '\n';
delete[] log;
if (compileResult != NVRTC_SUCCESS)
exit(1);
size_t codeSize;
#ifdef USE_CUBIN
NVRTC_SAFE_CALL(nvrtcGetCUBINSize(prog, &codeSize));
char *code = new char[codeSize];
NVRTC_SAFE_CALL(nvrtcGetCUBIN(prog, code));
#else
NVRTC_SAFE_CALL(nvrtcGetPTXSize(prog, &codeSize));
char *code = new char[codeSize];
NVRTC_SAFE_CALL(nvrtcGetPTX(prog, code));
#endif
NVRTC_SAFE_CALL(nvrtcDestroyProgram(&prog));
CUdevice cuDevice;
CUcontext context;
CUmodule module;
CUfunction kernel;
CUDA_SAFE_CALL(cuInit(0));
CUDA_SAFE_CALL(cuDeviceGet(&cuDevice, 0));
CUDA_SAFE_CALL(cuCtxCreate(&context, 0, cuDevice));
CUDA_SAFE_CALL(cuModuleLoadDataEx(&module, code, 0, 0, 0));
CUDA_SAFE_CALL(cuModuleGetFunction(&kernel, module, "hello"));
CUDA_SAFE_CALL(cuLaunchKernel(kernel, 1, 1, 1, 1, 1, 1, 0, NULL, NULL, 0));
CUDA_SAFE_CALL(cuCtxSynchronize());
CUDA_SAFE_CALL(cuModuleUnload(module));
CUDA_SAFE_CALL(cuCtxDestroy(context));
delete[] code;
}

15.4.1.4 编译小版本兼容库的建议(Recommendations for building a minor-version compatible library)

我们建议对CUDA运行时组件进行静态链接,以最小化依赖关系。需要验证你的库没有在已建立的ABI契约之外存在依赖项、breakages、命名空间等。

遵循库的soname的语义版本控制。拥有语义版本化的ABI意味着需要维护和版本化接口。当发生影响此ABI契约的更改时,应遵循语义规则并为库增加版本号。缺少依赖项也会中断二进制兼容性,因此你应该为依赖于这些接口的功能提供回退或保证。当存在破坏ABI的更改(如API弃用和修改)时,增加大版本。新的API可以添加到小版本中。

有条件地(即不要随便)使用功能,以保持与旧版驱动程序的兼容性。如果没有使用新功能(或者有条件地使用这些功能并提供回退功能),就能够保持兼容性。

不要向外暴露可能更改的ABI结构。指向某个size的结构的指针是更好的解决方案。

当从工具箱链接此动态库时,该库必须等于或高于应用程序链接中涉及的任何一个组件所需的库。例如,如果您链接CUDA 11.1动态运行时组件,并使用11.1中的功能,并且还链接了一个单独共享库(这个库链接了CUDA 11.2动态运行时组件(需要11.2功能)),则最后的链接步骤必须包括CUDA 11.2或更新的动态运行时组件。

15.4.1.5 在应用程序中利用小版本兼容性的建议(Recommendations for taking advantage of minor version compatibility in your application)

某些功能可能不可用,因此需要在合适的情况下进行查询。这在编译与GPU架构、平台和编译器无关的应用程序时很常见。然而,我们现在还要加入“底层驱动”因素。

与上一节一样,如果使用CUDA运行时组件,我们建议在编译应用程序时静态链接到CUDA运行时组件。当直接使用驱动程序API时,我们建议使用新驱动程序入口点访问API(cuGetProcAddress),可参考CUDA工具包文档。

当使用共享库或静态库时,请按照库的发行说明确定该库是否支持小版本兼容性。

16. 开发准备

17. 工具

17.1. 英伟达SMI

NVIDIA系统管理界面(NVIDIA-smi)是一个命令行实用程序,可帮助NVIDIA GPU设备的管理和监控。此实用程序允许管理员查询GPU设备状态,并使用适当的权限允许管理员修改GPU设备状态。nvidia smi针对特斯拉和某些Quadro GPU,但其他nvidia GPU也提供有限的支持。nvidia smi在Linux上附带nvidia GPU显示驱动程序,并附带64位Windows Server 2008 R2和Windows 7。nvidia-smi可以将查询的信息作为XML或人类可读的纯文本输出到标准输出或文件。有关详细信息,请参阅nvidia-smi文档。请注意,nvidia smi的新版本不能保证与以前的版本向后兼容。

17.1.1.可查询状态

  • ECC错误计数:报告了可纠正的单比特错误和可检测的双比特错误。提供了当前引导周期和GPU寿命的错误计数。
  • GPU利用率:报告GPU和内存接口的计算资源的当前利用率。
  • 主动计算过程:报告GPU上运行的活动进程列表,以及相应的进程名称/ID和分配的GPU内存。
  • 时钟和性能状态:报告了几个重要时钟域的最大和当前时钟速率,以及当前GPU性能状态(pstate)。
  • 温度和风扇转速:报告了当前GPU核心温度,以及主动冷却产品的风扇速度。
  • 电源管理:报告这些测量值的产品报告了当前板功率消耗和功率限制。
  • 识别:报告了各种动态和静态信息,包括板序列号、PCI设备ID、VBIOS/Inforom版本号和产品名称。

17.1.2.可修改状态

  • ECC模式:启用和禁用ECC报告。
  • ECC复位:清除单位和双位ECC错误计数。
  • 计算模式:指示计算进程是否可以在GPU上运行,以及它们是以独占方式运行还是与其他计算进程同时运行。
  • 持久性模式:指示当没有应用程序连接到GPU时,NVIDIA驱动程序是否保持加载状态。在大多数情况下,最好启用此选项。
  • GPU重置:通过辅助总线重置重新初始化GPU硬件和软件状态。

17.2 NVML

NVIDIA管理库(NVML)是一个基于C的界面,可直接访问通过NVIDIA smi公开的查询和命令,作为构建第三方系统管理应用程序的平台。NVML API随CUDA工具包(自8.0版起)一起提供,并且作为GPU部署工具包的一部分,也可以在NVIDIA开发者网站上单独提供,通过单个头文件附带PDF文档、存根库和示例应用程序。

为NVML API提供了一组额外的Perl和Python绑定。这些绑定公开了与基于C的接口相同的特性,并提供了向后兼容性。Perl绑定通过CPAN提供,Python绑定通过PyPI提供。所有这些产品(nvidia-smi、NVML和NVML语言绑定)都随每个新CUDA版本更新,并提供大致相同的功能。

17.3.群集管理工具

管理GPU群集将有助于实现最大的GPU利用率,并帮助您和您的用户获得最佳性能。许多业界最流行的集群管理工具通过NVML支持CUDA GPU。

17.4.编译器JIT缓存管理工具

应用程序在运行时加载的任何PTX设备代码都由设备驱动程序进一步编译为二进制代码。这被称为实时编译(JIT)。实时编译增加了应用程序加载时间,但允许应用程序从最新的编译器改进中受益。这也是应用程序在编译应用程序时不存在的设备上运行的唯一方法。

当使用PTX设备代码的JIT编译时,NVIDIA驱动程序将生成的二进制代码缓存在磁盘上。这种行为的某些方面,例如缓存位置和最大缓存大小,可以通过使用环境变量来控制;请参阅CUDA C++编程指南的实时编译。

17.5.可视设备

在CUDA应用程序启动之前,可以通过CUDA_visible_devices环境变量重新排列CUDA应用软件可见并枚举的已安装CUDA设备集合。应用程序可见的设备应以逗号分隔列表的形式包含在系统范围内的可枚举设备列表中。例如,要仅使用系统范围设备列表中的设备0和2,请在启动应用程序之前将CUDA_VISIBLE_devices设置为0,2。然后,应用程序将分别将这些设备枚举为设备0和设备1。

0%