• 正文
  • 相关推荐
申请入驻 产业图谱

嵌入式C代码Cache优化|i.MX6ULL Cortex-A7 读取延迟实测

32分钟前
26
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

让 AI 写优化代码时,重点不是多说几次请优化,而是需要把平台、热路径、数据规模、正确性要求和测速方式一起交给它。

下面我们一起来通过一个 AI 代码优化的实测例子:基于 i.MX6ULL 的CPU 读取效率实测数据。

1. CPU读取怎样更快?

CPU 读一个变量时,不会每次都直接去 DDR。

它先查 L1 D-Cache,没找到再查 L2 Cache,还是没有,才继续访问后面的主存。

工作集变大,更容易越过近端 Cache,单次访问就更可能变慢。

CPU Cache 不是按单个字节 / 单个变量读写,而是以 Cache Line 为最小传输单元,通常 64 字节(x86、ARM 主流)。

所以,提高读取效率的第一件事,是让后面马上会用到的数据尽量待在同一条 Cache Line 或相邻 Cache Line 里。

1.1 数据放得对,CPU 少搬运

如果几个字段总是一起使用,把它们放进同一个结构体,读取时更容易共用 Cache Line。

// x、y 经常一起处理:放在同一个结构体里
typedef struct {
    int x;
    int y;
} Point;

// 批量只处理 x:把 x 单独连续存放,少搬运 y
typedef struct {
    int *x;
    int *y;
} PointBatch;

数组、缓冲区和结构体尽量连续存放。

高频路径里如果不断申请零散的小块内存,地址会分散,Cache Line 利用率也会下降。固定缓冲区、内存池或一次性分配的大块数组,通常更容易分析和优化。

这不是要求所有数据都改成全局数组。

数据结构仍然要服从业务语义,只在真正的热路径上检查布局。冷路径偶尔多一次访问,通常不值得为它打乱整个模块。

1.2 热循环怎么写

高频循环优先使用顺序访问。

// 推荐:地址连续,循环容易展开,也方便编译器优化
int sum_array(const int *a, size_t n)
{
    int sum = 0;

    for (size_t i = 0; i < n; ++i) {
        sum += a[i];
    }
    return sum;
}

如果热数据超过 L1,再超过 L2,单次扫描不可能永远留在 Cache 里。

这时可以按数据块处理,让每个小块在被替换前尽量用完。

例如矩阵、图像和大数组做多轮计算时,先处理一小块连续数据,再进入下一块,通常比每个元素都跨很远访问更容易控制工作集。

1.3 让 AI 优化读取效率的提示词

让 AI 写优化代码时,重点不是多说几次请优化,而是把平台、热路径、数据规模、正确性要求和测速方式一起交给它。

可以参考下面这版提示词:

你是嵌入式 C 性能工程师,目标平台是 i.MX6ULL,
单核 Cortex-A7,Linux 4.9.88,可用内存约 486 MiB。

请优化下面这段热路径代码。输入数据规模为【填写范围】,
热路径大致占 CPU 时间的【填写比例】。

先不要直接给“优化后代码”,按顺序完成:

1. 找出热循环和有效工作集;
2. 判断当前数据布局、访问顺序和依赖关系;
3. 给出 1~2 个改动方案,并说明预期收益和代价;
4. 生成保持相同接口、相同结果的 C11 实现;
5. 给出正确性测试和相同输入的基准测试;
6. 分别测试原版本和优化版,至少重复两轮;
7. 列出没有证据支持的假设。

约束:
- 不在热路径新增频繁 malloc/free;
- 不为了“看起来高级”强行使用指针跳跃;
- 不默认认为预取、SIMD 或循环展开一定更快;
- 没有实测前,不把推测写成结论;
- 如果收益依赖于特定数据分布,明确写出适用条件。

提示词最后一段很重要。

AI 可以很快给出更连续的数组布局、更少的依赖跳转和更明确的循环边界,但它看不到板上的 Cache 计数器,也无法替你判断真实业务的数据分布。

最终方案还是要经过编译、上板和两轮基准测试。

1.4 实测例子说明

下面的测试只保留一种最主要的变化:访问方式。

同一份数据,连续读和跳着读,走到的层级与等待时间并不一样:

顺序模式每次只向前走 4 字节,同一条 Cache Line 会被后面的访问反复使用。

指针追踪模式则是先读一个值,再拿这个值决定下一次读哪里。下一次地址依赖上一次加载结果,CPU 很难提前做后面的访问。

代码里的两种访问方式如:

顺序模式沿着数组连续读取,每次前进 4 字节:

// 顺序读取:下一次地址天然可预测
sum += words[index];
if (++index == word_count) {
    index = 0;
}

随机追踪模式按 64 字节的 Cache Line 跳转,下一次读哪里由当前读到的值决定:

// 指针追踪:下一次访问依赖上一次加载结果
index = *(volatile uint32_t *)(
    area + (size_t)index * CACHE_LINE_BYTES);
sum += index;

工作集越大,数据越可能落到更远的 Cache 或 DDR;访问又存在依赖时,等待就更容易暴露到总耗时里。

2. 两种访问方式耗时测试

测试板是单核 Cortex-A7,运行 Buildroot,内核版本 4.9.88,内存约 498720 KiB。

测试期间 CPU 保持在 528 MHz,日志里的初始温度约 59.1℃,结束约 61.0℃。

每个数据尺寸先跑一遍预热,再采 5 次并取中位数;同样的粗扫和细扫各跑了两轮。

测试流程与三组扫描

3. 测试结果总览

两组测试都从 4 KiB 扫到 256 KiB,每次固定执行 400 万次访问。

变量只有有效工作集和访问方式,比较时以同一模式内的趋势为准。

观察项 实测结果
顺序读取 7.95 到 9.27 ns/次,曲线接近平线
随机追踪 11.58 到 177.50 ns/次,工作集越大,代价越明显
32 KiB 附近 曲线开始变化,但没有固定倍数的断崖
128 KiB 以后 延迟继续上升,没有稳定平台

4. 32 KiB 附近:有变化,但不是固定断崖

32 KiB 附近的细扫结果

随机追踪的粗扫里,24 KiB 是 11.45 ns/次,28 KiB 升到 16.95 ns/次。

32 KiB 是 18.34 ns/次,36 KiB 到 21.32 ns/次。

变化确实出现在 L1 D-Cache 容量附近,但起点并不整齐落在 32 KiB 这一条线上。

28 到 36 KiB 的第一轮从 17.48 升到 22.71 ns/次,第二轮却从 11.53 升到 15.76 ns/次。

这组数据能说明的是:工作集接近 L1 容量后,随机访问成本开始上升。

5. 顺序访问与随机追踪:差距主要来自访问路径

顺序访问和随机追踪的曲线完全不同

顺序访问从 4 KiB 到 256 KiB 几乎是一条平线。

4 KiB 是 7.95 ns/次,32 KiB 和 128 KiB 都是 8.30 ns/次,256 KiB 也只有 9.27 ns/次,前后约 1.17 倍。

随机追踪则从 11.58 ns/次升到 177.50 ns/次,约 15.3 倍。

连续读取能反复利用同一个 Cache Line,地址也容易提前判断。

指针追踪每一步都要等上一次加载完成,下一次地址才知道在哪里,延迟更容易暴露到总耗时里。

这里能确认的是访问连续性影响很大。但顺序访问快不能只归因于硬件预取。

6. 超过 128 KiB 后,延迟还在继续上升

128 KiB 附近仍在持续上升

只看容量参数,很容易把 128 KiB 想成一个明显台阶。

随机追踪的粗扫中位数却从 128 KiB 的 69.12 ns/次,继续升到 160 KiB 的 92.02 ns/次。

到 192 KiB 是 132.79 ns/次,256 KiB 则是 177.50 ns/次。

曲线没有在 128 KiB 后稳定下来。

细扫也呈现同样的趋势。

124 到 132 KiB 的第一轮从 35.11 升到 52.72 ns/次,第二轮从 52.26 升到 67.38 ns/次。

轮次之间有高度差,但方向一致:工作集继续变大,延迟还在上升。

7. 总结

这组实测表明,i.MX6ULL 的读取效率不只取决于工作集大小,也更取决于访问方式。

顺序访问基本稳定,随机指针追踪明显变慢。因此在程序设计中,应优先保证数据连续、减少地址依赖。

相关推荐

本公众号专注于嵌入式技术,包括但不限于C/C++、嵌入式、物联网、Linux等编程学习笔记,同时,公众号内包含大量的学习资源。欢迎关注,一同交流学习,共同进步!