使用标记 API(Using Marker APIs)

在某些情况下,我们可能对分析特定代码区域的性能感兴趣,而不是整个应用程序。这可能是你正在开发一段新代码并希望专注于该代码的情况。自然地,你希望跟踪优化进度并捕获额外的性能数据来帮助你。大多数性能分析工具提供了特定的标记 API(marker APIs),让你可以做到这一点。以下是两个示例:

  • Intel VTune 有 __itt_task_begin / __itt_task_end 函数。
  • AMD uProf 有 amdProfileResume / amdProfilePause 函数。

这种混合方法结合了插桩和性能事件计数的优点。标记 API 不是测量整个程序,而是允许我们将性能统计数据归属到代码区域(循环、函数)或功能片段(远程过程调用(RPCs)、输入事件等)。你获取的数据质量可以轻松证明所付出的努力是值得的。例如,在调查仅在特定类型 RPC 下发生的性能错误时,你可以仅为该类型的 RPC 启用监控。

下面我们提供一个使用 libpfm4,1 的非常基本的示例,libpfm4 是用于收集性能监控事件的流行 Linux 库之一。它构建在 Linux perf_events 子系统之上,允许你直接访问性能事件计数器。perf_events 子系统相当底层,因此 libpfm4 包在这里很有用,因为它为识别 CPU 上可用事件添加了发现工具,并为原始 perf_event_open 系统调用提供了包装库。以下代码清单展示了如何使用 libpfm4 来对 C-Ray2 基准测试的 render 函数进行插桩。

+#include <perfmon/pfmlib.h>
+#include <perfmon/pfmlib_perf_event.h>
...
/* render a frame of xsz/ysz dimensions into the provided framebuffer */
void render(int xsz, int ysz, uint32_t *fb, int samples) {
   ...
+  pfm_initialize();
+  struct perf_event_attr perf_attr;
+  memset(&perf_attr, 0, sizeof(perf_attr));
+  perf_attr.size = sizeof(struct perf_event_attr);
+  perf_attr.read_format = PERF_FORMAT_TOTAL_TIME_ENABLED | 
+                          PERF_FORMAT_TOTAL_TIME_RUNNING | PERF_FORMAT_GROUP;
+   
+  pfm_perf_encode_arg_t arg;
+  memset(&arg, 0, sizeof(pfm_perf_encode_arg_t));
+  arg.size = sizeof(pfm_perf_encode_arg_t);
+  arg.attr = &perf_attr;
+   
+  pfm_get_os_event_encoding("instructions", PFM_PLM3, PFM_OS_PERF_EVENT_EXT, &arg);
+  int leader_fd = perf_event_open(&perf_attr, 0, -1, -1, 0);
+  pfm_get_os_event_encoding("cycles", PFM_PLM3, PFM_OS_PERF_EVENT_EXT, &arg);
+  int event_fd = perf_event_open(&perf_attr, 0, -1, leader_fd, 0);
+  pfm_get_os_event_encoding("branches", PFM_PLM3, PFM_OS_PERF_EVENT_EXT, &arg);
+  event_fd = perf_event_open(&perf_attr, 0, -1, leader_fd, 0);
+  pfm_get_os_event_encoding("branch-misses", PFM_PLM3, PFM_OS_PERF_EVENT_EXT, &arg);
+  event_fd = perf_event_open(&perf_attr, 0, -1, leader_fd, 0);
+
+  struct read_format { uint64_t nr, time_enabled, time_running, values[4]; };
+  struct read_format before, after;

  for(j=0; j<ysz; j++) {
    for(i=0; i<xsz; i++) {
      double r = 0.0, g = 0.0, b = 0.0;
+     // capture counters before ray tracing
+     read(event_fd, &before, sizeof(struct read_format));

      for(s=0; s<samples; s++) {
        struct vec3 col = trace(get_primary_ray(i, j, s), 0);
        r += col.x;
        g += col.y;
        b += col.z;
      }
+     // capture counters after ray tracing
+     read(event_fd, &after, sizeof(struct read_format));

+     // save deltas in separate arrays
+     nanosecs[j * xsz + i] = after.time_running - before.time_running;
+     instrs  [j * xsz + i] = after.values[0] - before.values[0];
+     cycles  [j * xsz + i] = after.values[1] - before.values[1];
+     branches[j * xsz + i] = after.values[2] - before.values[2];
+     br_misps[j * xsz + i] = after.values[3] - before.values[3];

      *fb++ = ((uint32_t)(MIN(r * rcp_samples, 1.0) * 255.0) & 0xff) << RSHIFT |
              ((uint32_t)(MIN(g * rcp_samples, 1.0) * 255.0) & 0xff) << GSHIFT |
              ((uint32_t)(MIN(b * rcp_samples, 1.0) * 255.0) & 0xff) << BSHIFT;
  } }
+ // aggregate statistics and print it
  ...
}

在这个代码示例中,我们首先初始化 libpfm 库,然后配置性能事件以及我们将用来读取它们的格式。在 C-Ray 基准测试中,render 函数只被调用一次。在你自己的代码中,要注意不要多次进行 libpfm 初始化。

然后,我们选择要分析的代码区域。在我们的情况下,它是一个内部有 trace 函数调用的循环。我们用两个 read 系统调用包围这个代码区域,它们将在循环前后捕获性能计数器的值。接下来,我们保存差值以供后续处理。在本例中,我们通过计算平均值(average)、第 90 百分位数(90th percentile)和最大值(maximum)进行了聚合(代码未显示)。在 Intel Alder Lake 机器上运行,我们得到如下输出。不需要 root 权限,但 /proc/sys/kernel/perf_event_paranoid 应设置为小于 1。当读取某个线程的计数器时,值仅针对该线程。它可以选择性地包括归属于该线程的内核代码。

$ ./c-ray-f -s 1024x768 -r 2 -i sphfract -o output.ppm
Per-pixel ray tracing stats:
                      avg         p90         max
-------------------------------------------------
nanoseconds   |      4571 |      6139 |     25567
instructions  |     71927 |     96172 |    165608
cycles        |     20474 |     27837 |    118921
branches      |      5283 |      7061 |     12149
branch-misses |        18 |        35 |       146

请记住,我们的插桩测量的是每像素光线追踪(ray tracing)统计数据。将平均数乘以像素数(1024x768)应该大致得到程序的总体统计数据。在这种情况下,一个好的健全性检查是运行 perf stat 并比较我们收集的性能事件的整体 C-Ray 统计数据。

C-ray 基准测试主要强调 CPU 核心的浮点运算性能,通常不应导致测量结果的高方差,换句话说,我们期望所有测量值都非常接近。然而,我们看到情况并非如此,p90 值是平均值的 1.33 倍,max 比平均情况慢 5 倍。最可能的解释是,对于某些像素,算法碰到了边界情况,执行了更多指令,因此运行时间更长。但通过研究源代码或扩展插桩以捕获"慢"像素的更多数据来确认假设总是好的。

我们示例中的额外插桩代码导致了 17% 的开销,这对于本地实验来说是可以接受的,但对于在生产环境中运行来说相当高。大多数大型分布式系统的目标是低于 1% 的开销,某些情况下最多 5% 是可以接受的,但用户不太可能会对 17% 的降速感到满意。管理插桩的开销至关重要,尤其是当你选择在生产环境中启用它时。

开销的计算通常以单位时间或工作量(RPC、数据库查询、循环迭代等)的发生率来表示。如果我们系统上的 read 系统调用大约需要 1.6 微秒的 CPU 时间,并且我们对每个像素(外层循环的一次迭代)调用它两次,则每像素的开销为 3.2 微秒的 CPU 时间。

有许多策略可以降低开销。作为一般规则,你的插桩应该始终有固定成本,例如确定性的系统调用(syscall),而不是列表遍历或动态内存分配。否则它会干扰测量结果。插桩代码有三个逻辑部分:收集信息、存储信息和报告信息。为了降低第一部分(收集)的开销,我们可以降低采样率,例如每 10 个 RPC 采样一次,跳过其余的。对于长时间运行的应用程序,性能可以通过相对廉价的随机采样来监控,即随机选择每个样本中要监控的 RPC。这些方法牺牲了收集精度,但仍然提供了对整体性能特征的良好估计,同时产生非常低的开销。

对于第二和第三部分(存储和聚合),建议仅收集、处理和保留你了解系统性能所需的数据。你可以通过使用计算均值(mean)、方差(variance)、最小值、最大值和其他指标的"在线"算法(online algorithms)来避免将每个样本存储在内存中。这将大大减少插桩的内存占用。例如,方差和标准差可以使用 Knuth 的在线方差算法计算。一个好的实现3使用不到 50 字节的内存。

对于长例程,你可以在开始和结束时以及中间的某些部分收集计数器。在连续运行中,你可以通过二分搜索(binary search)找到例程中性能最差的部分并进行优化。重复此过程,直到消除所有性能较差的地方。如果尾延迟(tail latency)是主要关注点,那么在特别慢的运行中发出日志消息可以提供有用的洞察。

在我们的示例中,我们同时收集了 4 个事件,尽管 CPU 有 6 个可编程计数器。你可以打开更多组,启用不同的事件集。内核将选择不同的组依次运行。time_enabledtime_running 字段表示多路复用情况。它们都以纳秒为单位表示持续时间。time_enabled 字段表示事件组被启用了多少纳秒。time_running 字段表示在该启用时间内实际收集事件的时间。如果你同时启用了两个无法在硬件计数器上同时放置的事件组,你可能会看到两个组的运行时间都收敛到 time_running = 0.5 * time_enabled

同时捕获多个事件使得计算我们在第 4 章中讨论的各种指标成为可能。例如,捕获 INSTRUCTIONS_RETIREDUNHALTED_CLOCK_CYCLES 使我们能够测量 IPC。我们可以通过比较 CPU 周期(UNHALTED_CORE_CYCLES)与固定频率参考时钟(UNHALTED_REFERENCE_CYCLES)来观察频率缩放(frequency scaling)的影响。通过请求消耗的 CPU 周期(UNHALTED_CORE_CYCLES,仅在线程运行时计数)并将其与挂钟时间(wall-clock time)进行比较,可以检测线程何时未运行。此外,我们可以将数字归一化以获得每秒/每时钟/每指令的事件率。例如,通过测量 MEM_LOAD_RETIRED.L3_MISSINSTRUCTIONS_RETIRED,我们可以获得 L3MPKI 指标。如你所见,这给了很大的灵活性。

事件分组的重要属性是计数器在同一个 read 系统调用下以原子方式可用。这些原子束(atomic bundles)非常有用。首先,它允许我们关联每个组内的事件。例如,假设我们测量某段代码的 IPC,发现它非常低。在这种情况下,我们可以将两个事件(指令和周期)与第三个事件(比如 L3 缓存缺失)配对,以检查该事件是否导致了我们正在处理的低 IPC。如果没有,我们可以使用其他事件继续因素分析(factor analysis)。其次,事件分组有助于在工作负载具有不同阶段时减少偏差。由于组内的所有事件都在同一时间测量,它们总是捕获相同的阶段。

在某些情况下,插桩可能成为功能或特性的一部分。例如,开发者可以实现一个插桩逻辑,用于检测 IPC 下降(例如,当有繁忙的兄弟硬件线程运行时)或 CPU 频率下降(例如,由于重负载导致系统节流)。当此类事件发生时,应用程序自动推迟低优先级工作以补偿临时增加的负载。

1. libpfm4 - https://sourceforge.net/p/perfmon2/libpfm4/ci/master/tree/
2. C-Ray benchmark - https://openbenchmarking.org/test/pts/c-ray
3. 精确计算运行方差 - https://www.johndcook.com/blog/standard_deviation/

results matching ""

    No results matching ""