CPU 那些事儿 - 前端(下)

728 阅读14分钟

背景

在上一篇里我们从硬件的角度认识了CPU前端的几个重要部件和他们的实现原理,本篇我们将站在软件的角度上考虑,看下如何通过软件构造去探查硬件的实现细节;

取指带宽

上一篇里我们提到了影响CPU前端性能的重点之一就是取指带宽(即CPU前端每周期能从指令缓存L1 I-cache/uOp cache 中提取多少条指令/字节数)。即流水线中的下图部分:

x5-pipe.png

这里我们需要首先知道探测CPU微架构的一些方法,即通过精心构造的指令序列,观察CPU在时序、执行顺序、资源冲突、缓存行为等方面的响应,从而逆向推导出其微架构实现细节;因此我们需要先确定采用的指令及指令流,这里我们采用NOP指令,因为我们探测的是前端资源,所选取的指令最好和CPU后端硬件资源不发生关系,而NOP指令由于不进行任何实际操作,很多现代CPU都对其进行了前端消除的优化,即在经过前端取指译码后就可以标记完成执行,从而不占用CPU后端的硬件资源。

既然如此,那我们是不是可以直接构造大量连续的NOP指令,然后通过测量其执行的延迟Latency来获得取指带宽呢?答案是否定的,由于前端的取指宽度有可能大于后级的Decoder或者Renaming,因此这种方式测量的瓶颈并不是取指的宽度,而是Decode Width或者Rename Width,这也给我们引出一个在探测CPU微架构时的核心策略:即排除流水线其他模块的影响,尽可能的构造探测对象结构或功能的瓶颈

既然如此,那么前端取指的瓶颈是什么呢?根据Cache的相关知识我们知道在采用 VIPT(Virtually Indexed Physically Tagged)或 PIPT(Physically Indexed Physically Tagged)I-Cache 的微架构中,CPU 取指操作涉及到以下两个关键步骤:

  • 访问 I-TLB:将虚拟地址转换为物理地址;
  • 访问 I-Cache:根据物理地址读取指令内容。

由于大多数ARM架构处理器每次取指周期中,I-TLB 通常只能执行一次页表转换,当指令跨越页边界(page boundary)时,则需要两个周期完成取指过程——即第一个周期只能从当前页末取回少量转换完成的指令,第二个周期才从下一页继续获取剩余指令。根据这个特点结合上面介绍的NOP指令特性,我们可以通过以下方式,构造CPU 每轮取指都经历跨页,从而迫使其进入“带宽受限”的状态:

  • 将循环起始地址放在页的末尾,只有一条 nop;
  • 紧接着在下一页中布置大量连续的 nop;
  • 最后以一条 jmp 返回页尾的 nop 开头,构成闭环。
Page N:   1:
Page N:   nop
Page N+1: nop
Page N+1: nop
Page N+1: nop
Page N+1: ...
Page N+1: jmp 1b

假设我们探测的目标处理器特性如下:

  • 每周期最多取指宽度为 W(即W条指令);
  • 后端能够执行最多 E 条 nop 指令/周期。

通过我们前面的分析和推导,可以得出如下预设:

  • 第一周期只能 fetch 到页末的 1 条 nop;
  • 第二周期开始,如果 fetch width 不足以一次性从页 1 中取到 E 条 nop,则后端无法保持 E 的执行吞吐;
  • 只有当 W ≥ E + 1 时,才有可能维持后端的满速运行。

因此,通过调整 nop 的数量,测量指令block的执行时长,从而就可以得到CPU前端的取指带宽信息。为了让执行代码尽可能减少受编译器的影响,我们采用汇编实现核心指令流,下面是根据我们根据前面的理论推导实现的指令block:

// ifetch_blocks.S (ARM64)

// 这里可以根据实际进行调整,基本上当前已知最先进的CPU前端取指宽度16,两个周期最大32条指令取指宽度,因此这里设置循环的最大NOP数量40
#ifndef NOP_PER_BLOCK
#define NOP_PER_BLOCK 40
#endif

        .text
        .align  2

// ---- 一个 block(Page0 & Page1) ----
// 每个 block 的 page0 只在页尾放 1 条 nop
// page1 紧随其后,放 NOP_PER_BLOCK 条 nop + tail。

        // 第一个 block:跳到它开始
        .macro FIRST_BLOCK name
        .balign 4096
        .space  4096 - 4
\name\()_FIRST:
        nop                        // page0 尾部,仅 1 条 nop

        // page1: K 条 nop
        .rept   NOP_PER_BLOCK
        nop
        .endr

        // tail: 用于控制循环跳转
        subs    x0, x0, #1
        b.ne    0f
        ret                        // x0 == 0 时返回到 C
0:      b       1f                 // 跳到“下一 block”的 page0尾
        .endm

        // 中间 block:为了覆盖不同codesize
        .macro MIDDLE_BLOCK name
        .balign 4096
        .space  4096 - 4
1:      nop

        .rept   NOP_PER_BLOCK
        nop
        .endr

        subs    x0, x0, #1
        b.ne    0f
        ret
0:      b       1f
        .endm

        // 最后一个 block:继续时回到第一个 block
        .macro LAST_BLOCK_TO_FIRST name
        .balign 4096
        .space  4096 - 4
1:      nop

        .rept   NOP_PER_BLOCK
        nop
        .endr

        subs    x0, x0, #1
        b.ne    0f
        ret
0:      b       \name\()_FIRST     // 回到第一个 block 的 page0尾
        .endm

// ---- blocks == 1 的特例:单 block 自环 ----
        .macro GEN_RING1 name
        .global \name
\name:
        b       \name\()_FIRST

        .balign 4096
        .space  4096 - 4
\name\()_FIRST:
        nop

        .rept   NOP_PER_BLOCK
        nop
        .endr

        subs    x0, x0, #1
        b.ne    0f
        ret
0:      b       \name\()_FIRST
        .endm

// ---- 生成一个含 N 个 blocks 的环 ----
        .macro GEN_RING name, blocks
        .global \name
\name:
        b       \name\()_FIRST

        FIRST_BLOCK \name

        .if (\blocks-2) > 0
        .rept   (\blocks-2)
        MIDDLE_BLOCK \name
        .endr
        .endif

        .if (\blocks) >= 2
        LAST_BLOCK_TO_FIRST \name
        .endif
        .endm

// ---- 实例化 9 个RING Block:遍历1..256 blocks ----
        GEN_RING1       itlb_loop_b1
        GEN_RING        itlb_loop_b2,    2
        GEN_RING        itlb_loop_b4,    4
        GEN_RING        itlb_loop_b8,    8
        GEN_RING        itlb_loop_b16,   16
        GEN_RING        itlb_loop_b32,   32
        GEN_RING        itlb_loop_b64,   64
        GEN_RING        itlb_loop_b128,  128
        GEN_RING        itlb_loop_b256,  256

剩下的我们通过C来构造测量执行时长的部分:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <time.h>
 
#ifndef NOP_PER_BLOCK
#define NOP_PER_BLOCK 40   // 与汇编文件中保持一致
#endif

#define PAGE_SIZE 4096
// 汇编提供的 9 个ring block,读者可以自行增加获得更细腻的结果
void itlb_loop_b1   (uint64_t iters);
void itlb_loop_b2   (uint64_t iters);
void itlb_loop_b4   (uint64_t iters);
void itlb_loop_b8   (uint64_t iters);
void itlb_loop_b16  (uint64_t iters);
void itlb_loop_b32  (uint64_t iters);
void itlb_loop_b64  (uint64_t iters);
void itlb_loop_b128 (uint64_t iters);
void itlb_loop_b256 (uint64_t iters);
 
static inline uint64_t rd_cntvct(void){ uint64_t v; __asm__ volatile("mrs %0, S3_3_C14_C0_2":"=r"(v)); return v; }
static inline uint64_t rd_cntfrq(void){ uint64_t v; __asm__ volatile("mrs %0, S3_3_C14_C0_0":"=r"(v)); return v; }
static inline uint64_t ns_now_raw(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}

typedef void (*loop_fn_t)(uint64_t);
typedef struct { int blocks; loop_fn_t fn; } entry_t;
 
int main(void){
    const size_t page_size   = PAGE_SIZE;
    const size_t block_bytes = page_size * 2;  // 一个 block = 8KB
    const double insts_iter  = (double)NOP_PER_BLOCK + 4.0;
 
    entry_t table[] = {
        { 1,   itlb_loop_b1   },
        { 2,   itlb_loop_b2   },
        { 4,   itlb_loop_b4   },
        { 8,   itlb_loop_b8   },
        { 16,  itlb_loop_b16  },
        { 32,  itlb_loop_b32  },
        { 64,  itlb_loop_b64  },
        { 128, itlb_loop_b128 },
        { 256, itlb_loop_b256 },
    };
 
    printf("code_size_bytes,cycles_per_iter,inst_per_cycle\n");
    for (size_t i=0; i<sizeof(table)/sizeof(table[0]); ++i){
        int blocks = table[i].blocks;
        loop_fn_t fn = table[i].fn;
 
        // warmup
        fn(30000);
 
        // 迭代次数(小 code size 多迭代)
        uint64_t N;
        size_t code_bytes = (size_t)blocks * block_bytes;
        if (blocks <= 16)       N = 50000000ull;
        else if (blocks <= 64)  N = 10000000ull;
        else if (blocks <= 128) N = 3000000ull;
        else                    N = 2000000ull;
 
        uint64_t t0 = ns_now_raw();
        fn(N);
        uint64_t t1 = ns_now_raw();
 
        double ns_per_iter  = (double)(t1 - t0) / (double)N;
        double ipc             = (ns_per_iter > 0.0) ? (insts_iter / ns_per_iter) : 0.0;
        printf("%zu,%.6f,%.6f\n", code_bytes, ns_per_iter, ipc);
        fflush(stdout);
    }
    return 0;
}

我们再用如下的python将我们的微架构探测代码在高通8650绑定在Cortex-X4核上测试,结果进行直观画图展示:

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("ifbw_result.csv")

plt.figure(figsize=(8,5))
plt.plot(df["code_size_bytes"], df["inst_per_cycle"], marker="o", linewidth=1)

plt.xscale("log", base=2)

xticks = [2**i for i in range(13, 22)]
plt.xticks(xticks, [str(v) for v in xticks], rotation=45)

plt.xlabel("Code size (bytes)")
plt.ylabel("Instructions per cycle (IPC)")
plt.title("ARM64 Frontend Fetch Bandwidth vs Code Size (stride-sampled)")
plt.grid(True, which="both", ls="--", alpha=0.6)
plt.tight_layout()
plt.show()

ifu_bw.png 可以看到,在64KB范围内,取指宽度能够很好的维持在9.5左右,这也和官方发布的L1I Cache大小64KB,10 inst./cycle的数据相符合。这里我们只固定了几个采样测试点,读者可以按照参考上面的代码设置更细的步长stride,从而获得更加顺滑的曲线结果。

分支预测

除了取指带宽,前端另外一个重要部件就是分支预测器,上一篇我们讲过分支预测器主要包括逻辑单元和缓存单元:

  • 逻辑单元:用于推测分支是否发生,强调预测准确率,这部分的设计芯片厂商各有千秋,并且也在不断迭代优化,因此我们这里不做过多介绍;
  • 缓存单元:如BTB用于存储历史分支目标地址,以支持快速跳转取指,强调覆盖范围; 缓存越大就能保存越多的分支记录信息,简单理解就是越大越好,但芯片设计又是一个极度讲究PPA(Power,Performance,Area)平衡的工程,到底多大合适,就是一个比较讲究的事,这里我们以BTB(用来缓存分支跳转的目标地址)容量探测为例,看下如何探查它的详细设计。

根据之前的知识,我们知道BTB 本质上是一块针对跳转目标地址的 Cache,其大小决定了处理器能同时追踪多少个独立的分支目标。超出容量的分支目标将被逐出,从而造成性能下降。考虑到ARM体系结构中的指令定长为4 Byte,因此我们借鉴之前的思路通过构造高密度的分支指令流,逐步填满 BTB,进而观察性能指标(如 IPC、执行延迟)是否发生突变,从而推测出 BTB 的容量边界:

// 1 branch per 4 byte
1: b 1f
1: b 1f
1: b 1f
1: b 1f
...

同时,由于Cache通常采用多路组相连结构,所以分支跳转指令的密度会影响指令块在 BTB Cache 中的命中率,因此为了便于观察硬件在不同分支指令密度下的组相连命中特性,我们可以在分支指令之间插入不同数量的 NOP 指令,以人为调节分支指令的密度:

// 1 branch per 8 byte
1: b 1f
   nop
1: b 1f
   nop
...

通过前面的分析我们可以预设:

  • 当分支数量较少时,所有跳转目标可保留在 BTB 中,预测命中率高,性能稳定;
  • 当分支数量增加到某个阈值时,BTB 被污染,部分分支预测失效,发生 mispredict,性能开始劣化;
  • 通过识别该“性能突变点”对应的分支数量,可反推 BTB 的容量上限。 代码如下:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <sys/mman.h>
#include <unistd.h>
#include <time.h>
#include <string.h>
#include <errno.h>

#define AARCH64_INS_NOP  0xD503201Fu
#define AARCH64_INS_RET  0xD65F03C0u

static inline uint32_t encode_b(int32_t imm_bytes) {
    int64_t imm = imm_bytes >> 2;
    uint32_t imm26 = (uint32_t)(imm & 0x03FFFFFF);
    return 0x14000000u | imm26;
}

static inline uint64_t read_cntvct(void) {
    uint64_t v;
    asm volatile ("mrs %0, cntvct_el0" : "=r"(v));
    return v;
}

static double diff_nsec(struct timespec a, struct timespec b){
    return (a.tv_sec - b.tv_sec)*1e9 + (a.tv_nsec - b.tv_nsec);
}

typedef void (*codefn_t)(void);

void *gen_chain(int num_blocks, int nops_per_block, size_t *out_size) {
    int instrs_per_block = nops_per_block + 1;
    size_t block_bytes = (size_t)instrs_per_block * 4;
    size_t total = block_bytes * (size_t)num_blocks + 4096;

    void *buf = mmap(NULL, total, PROT_READ | PROT_WRITE,
                     MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
    if (buf == MAP_FAILED) { perror("mmap"); return NULL; }

    uint8_t *p = (uint8_t*)buf;
    uintptr_t base = (uintptr_t)p;

    for (int i = 0; i < num_blocks; ++i) {
        uint8_t *block = p + (size_t)i * block_bytes;
        for (int j = 0; j < nops_per_block; ++j) {
            uint32_t ins = AARCH64_INS_NOP;
            memcpy(block + (size_t)j*4, &ins, 4);
        }
        uintptr_t branch_addr = (uintptr_t)block + (size_t)nops_per_block * 4;
        uintptr_t dst = (i+1 < num_blocks)
                        ? base + (size_t)(i+1) * block_bytes
                        : base + (size_t)num_blocks * block_bytes;
        int64_t imm_bytes = (int64_t)dst - (int64_t)branch_addr;
        if ((imm_bytes & 3) != 0) { fprintf(stderr, "unaligned branch\n"); munmap(buf,total); return NULL; }
        int64_t max_offset = ((1LL<<25)-1)*4, min_offset = -((1LL<<25)*4);
        if (imm_bytes<min_offset || imm_bytes>max_offset) { fprintf(stderr,"branch too far\n"); munmap(buf,total); return NULL; }
        uint32_t b_ins = encode_b((int32_t)imm_bytes);
        memcpy(block + (size_t)nops_per_block*4, &b_ins, 4);
    }
    uint8_t *ep = p + (size_t)num_blocks * block_bytes;
    uint32_t ret_ins = AARCH64_INS_RET;
    memcpy(ep, &ret_ins, 4);

    __builtin___clear_cache((char*)buf, (char*)buf + total);
    if (mprotect(buf, total, PROT_READ|PROT_EXEC) != 0) { perror("mprotect"); munmap(buf,total); return NULL; }
    if (out_size) *out_size = total;
    return buf;
}

int main(void) {
    const int runs = 10000;
    const int nops_values[] = {0,1,2,4,8,16};
    const int n_nops = sizeof(nops_values)/sizeof(nops_values[0]);

    printf("nops_per_block,branch_number,avg_cycles_per_run,avg_ns_per_run,total_cycles,total_ns\n");

    for (int ni=0; ni<n_nops; ++ni) {
        int nops = nops_values[ni];
        for (int nb=1; nb<=16384; nb*=2) {
            size_t code_size=0;
            void *fn = gen_chain(nb, nops, &code_size);
            if (!fn) break;
            codefn_t f = (codefn_t)fn;
            for (int w=0; w<10; ++w) f();

            uint64_t t0 = read_cntvct();
            struct timespec s0,s1;
            clock_gettime(CLOCK_MONOTONIC_RAW,&s0);
            for (int r=0;r<runs;++r) f();
            uint64_t t1 = read_cntvct();
            clock_gettime(CLOCK_MONOTONIC_RAW,&s1);

            uint64_t cycles = t1 - t0;
            double ns = diff_nsec(s1,s0);
            double avg_cycles = (double)cycles / runs;
            double avg_ns = ns / runs;

            printf("%d,%d,%.6f,%.6f,%llu,%.0f\n",
                   nops, nb, avg_cycles, avg_ns,
                   (unsigned long long)cycles, ns);

            munmap(fn, code_size);
        }
    }
    return 0;
}

同样我们通过python将输出结果进行图形化:

# -*- coding: utf-8 -*-
import sys
import pandas as pd
import matplotlib.pyplot as plt

def main():
    if len(sys.argv)<2:
        print("Usage: python plot_btb.py result.csv")
        sys.exit(1)
    df = pd.read_csv(sys.argv[1])

    xticks = [1]
    v = 1
    while v < 16384:
        v *= 2
        xticks.append(v)

    plt.figure(figsize=(8,5))
    for nops, group in df.groupby("nops_per_block"):
        g = group.sort_values("branch_number")
        plt.plot(g["branch_number"], g["avg_cycles_per_run"],
                 marker="o", linewidth=1, label=f"nops={nops}")
    plt.xscale("log", base=2)
    plt.xticks(xticks, [str(x) for x in xticks])
    plt.xlim(1,16384)
    plt.xlabel("Branch number (blocks)")
    plt.ylabel("Avg cycles per run")
    plt.title("BTB probe (cycles/run)")
    plt.grid(True, which="both", ls="--", alpha=0.6)
    plt.legend()
    plt.tight_layout()
    plt.savefig("btb_cycles_vs_branch.png", dpi=160)

    plt.figure(figsize=(8,5))
    for nops, group in df.groupby("nops_per_block"):
        g = group.sort_values("branch_number")
        plt.plot(g["branch_number"], g["avg_ns_per_run"],
                 marker="o", linewidth=1, label=f"nops={nops}")
    plt.xscale("log", base=2)
    plt.xticks(xticks, [str(x) for x in xticks])
    plt.xlim(1,16384)
    plt.xlabel("Branch number (blocks)")
    plt.ylabel("Avg ns per run")
    plt.title("BTB probe (ns/run)")
    plt.grid(True, which="both", ls="--", alpha=0.6)
    plt.legend()
    plt.tight_layout()
    plt.savefig("btb_ns_vs_branch.png", dpi=160)

if __name__=="__main__":
    main()

我们在高通SM8750超大核上测试结果如下:

btb_size.png

可以看到在满足理论延迟条件下,BTB容量在2048个entry位置有比较明显的突变,这也是高通官方宣布的L0 BTB的容量大小,并且不同密度的分支指令在容量突变点上也有变化,这就是因为受到Cache硬件本身组相连结构的影响,具体相关知识我们在下一篇Cache文章内容中详细讲解,这里暂时不再继续展开。

小结

最后要重点说明一下,上面探查CPU微架构硬件的测试方法和代码都是基于上一篇中讲到的硬件原理,我们讲解的也是最普适基础的一种实现方式,CPU微架构发展日新月异,很多IC厂商和硬件架构师都在不断优化这些设计,因此如果你发现这些构造的探查方式结果不符合预期的话,不应简单视为方法失效,更可能反映出特定实现的设计取舍或创新。此时可以针对性调整探查条件并做对照实验,复现实验现象并据此推断出可能的设计细节。

本文同步在微信公众号和知乎发布:

wechat-scancode.jpg

引用