前言

做嵌入式 Linux 或边缘 AI 项目时,很多性能问题最后都会绕回一个朴素但容易被低估的事实:算力不等于吞吐,CPU、NPU、GPU 跑得再快,只要数据喂不上去,整机性能就会被内存系统卡住。

我第一次真正意识到 DDR 带宽的重要性,是在一块多核 ARM SoC 上做 4 路摄像头视频分析。算法同事看 NPU 利用率只有 40% 左右,以为模型还可以继续加大;系统同事看 CPU 使用率也不高,以为瓶颈不在软件。直到我们把 ISP、RGA、NPU、VPU 同时压起来,再去读 DDR 控制器计数器,才发现内存读写已经接近平台可持续带宽的上限。那一刻,所谓“还有很多算力没用上”,其实只是“大家都在等内存”。

这篇文章想把这个问题讲透一点:DDR 带宽不是一个孤立参数,它贯穿了 CPU Cache、AXI/NoC 互联、DMA burst、内存控制器调度、DRAM Bank 冲突、刷新开销以及 Linux 调度策略。很多项目里大家会直接跑一个 memcpystream,看到数字不错就认为内存没问题;但真实业务往往不是连续大块搬运,而是多个主设备同时访问、读写混合、缓存命中率波动、实时任务和后台任务互相抢总线。

本文会从 SoC 视角出发,拆解一条内存访问路径,并给出一套可以落地的排查和优化方法。示例代码以 Linux 用户态为主,兼顾裸机/RTOS 下的思路。目标不是把每个 DDR 时序参数都背下来,而是建立一个工程上有用的判断框架:什么时候该看 Cache Miss,什么时候该看 AXI outstanding,什么时候该怀疑 DDR controller 的 page policy,什么时候该从数据布局和 DMA burst 入手。

SoC DDR 带宽路径与调优观测点

一、先把“带宽”这件事说清楚

DDR 厂商手册里常见的理论带宽计算很简单:

理论带宽 = 数据总线宽度 / 8 × 数据传输速率

例如 32-bit LPDDR4X,数据速率 4266 MT/s,理论峰值约为:

32 / 8 × 4266 = 17064 MB/s ≈ 17 GB/s

这个数字看起来很漂亮,但工程上最容易踩的坑,就是把理论峰值当作业务可用带宽。实际系统里至少有几类损耗:

  1. 协议和控制开销:DRAM 不是一个无限快的 SRAM,行打开、预充电、刷新、读写切换都会消耗周期。
  2. 访问模式损耗:连续访问和随机访问差别巨大,同一 Row 命中和频繁换 Row 的效率完全不同。
  3. 多主设备竞争:CPU、GPU、NPU、ISP、VPU、显示控制器、PCIe、USB 都可能通过 AXI/NoC 访问 DDR。
  4. Cache 行粒度放大:CPU 读一个 4 字节整数,如果它不在 Cache 里,通常会拉回一整条 Cache Line。
  5. 软件栈额外拷贝:视频帧、网络包、AI tensor 如果在多个模块之间来回复制,带宽会被悄悄吃掉。

所以我更喜欢把带宽分成三个层次:

  • 理论峰值带宽:由 DDR 类型、频率、位宽决定,用于判断上限。
  • 平台可持续带宽:在稳定温度、电压、频率下,通过基准测试长期跑出来的数字。
  • 业务有效带宽:真实业务中,真正转化为有效计算的数据吞吐。

优化时最重要的是第三个。一个平台跑 STREAM 能到 12 GB/s,不代表你的视觉 pipeline 就能用到 12 GB/s。如果算法访问模式很差,或者多媒体 DMA 和 CPU 同时抢总线,业务有效带宽可能只有几 GB/s,甚至更低。

二、一次内存访问到底经过了哪里

从 CPU 角度看,一行 C 代码可能只是:

sum += buffer[i];

但在 SoC 内部,这次读取可能经历下面的路径:

  1. CPU 先查 L1 Data Cache;
  2. L1 miss 后查 L2;
  3. L2 miss 后查 LLC 或系统级缓存;
  4. 仍然 miss,就通过 ACE/CHI/AXI 接口发起读事务;
  5. 请求进入 NoC 或 AXI interconnect,和其他 master 仲裁;
  6. DDR controller 接收请求,决定访问哪个 channel、rank、bank、row;
  7. 如果目标 row 已打开,直接读;否则需要 precharge/activate;
  8. 数据经过 PHY 回来,再沿互联返回 CPU;
  9. Cache line 被填入,CPU 才能继续执行依赖这份数据的指令。

这个路径里任何一段都可能成为瓶颈。CPU 侧看到的是 cache-missesstalled-cycles、IPC 下降;互联侧看到的是 outstanding 堆积、QoS 延迟变大;DDR 控制器侧看到的是读写队列拥塞、page miss 增加、refresh 周期影响;业务侧看到的则是帧率下降、推理延迟抖动、实时线程偶发超时。

如果只盯着一个指标,很容易误判。例如 CPU Cache Miss 高,不一定表示 DDR 频率不够,也可能是数据结构布局导致空间局部性太差;DDR 带宽占用高,也不一定要提高频率,可能是多了一次无意义的内存拷贝。

三、建立第一组基准:连续带宽、随机延迟和业务混压

调优前必须先建立基线。我的习惯是至少跑三类测试:

3.1 连续读写带宽

连续带宽可以用 STREAM,也可以写一个简化版测试。下面这个程序不如专业工具严谨,但适合快速确认不同 buffer 大小、不同线程数下的变化趋势:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <time.h>
#include <string.h>

static double now_sec(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec / 1000000000.0;
}

int main(int argc, char **argv) {
    size_t mb = argc > 1 ? strtoull(argv[1], NULL, 10) : 512;
    size_t bytes = mb * 1024ULL * 1024ULL;
    uint8_t *src, *dst;

    if (posix_memalign((void **)&src, 64, bytes) != 0) return 1;
    if (posix_memalign((void **)&dst, 64, bytes) != 0) return 1;
    memset(src, 0x5a, bytes);
    memset(dst, 0x00, bytes);

    double t0 = now_sec();
    for (int r = 0; r < 20; ++r) {
        memcpy(dst, src, bytes);
    }
    double t1 = now_sec();

    double gb = (double)bytes * 20.0 / 1024 / 1024 / 1024;
    printf("copy bandwidth: %.2f GB/s\n", gb / (t1 - t0));
    free(src);
    free(dst);
    return 0;
}

编译运行:

gcc -O3 -march=native memcopy_bw.c -o memcopy_bw
./memcopy_bw 1024

注意,这个结果主要反映大块连续拷贝能力,还受到 libc memcpy 实现、CPU 预取、Cache 策略影响。它适合做“平台状态是否正常”的健康检查,但不能代表所有业务。

3.2 随机访问延迟

很多控制类、图结构、稀疏张量、数据库索引类负载不是带宽优先,而是延迟优先。连续带宽高的平台,如果随机访问延迟很差,业务一样会慢。可以用 lmbenchlat_mem_rd,也可以自己构造链表追指针测试。核心思路是让下一次访问依赖上一次读取结果,破坏 CPU 预取器的发挥空间。

3.3 混压测试

真实 SoC 里最关键的是混压:CPU 跑内存测试的同时,让摄像头采集、显示刷新、NPU 推理、视频编码一起工作。很多问题只有在混压下出现,因为 DDR controller 和 NoC 仲裁策略这时才真正被打满。

我通常会记录三组数据:

  • 空载下的连续带宽和随机延迟;
  • 业务单独运行时的 DDR 计数器和帧率;
  • 基准测试与业务同时运行时的延迟抖动。

如果单独测试都很好,一混压就抖,优先查 QoS、DMA burst、内存拷贝和任务绑核,而不是急着改 DDR 时序。

(第一部分完,约 2200 字)

四、用 perf 先判断是不是 Cache 问题

在 Linux 上,第一步通常不是直接看 DDR controller,而是先看 CPU 的硬件性能计数器。因为很多所谓“内存带宽不够”,根因其实是 Cache 使用方式太差。

常用命令如下:

perf stat -e cycles,instructions,cache-references,cache-misses,LLC-loads,LLC-load-misses ./your_app

如果平台事件支持更完整,还可以看:

perf stat -e stalled-cycles-frontend,stalled-cycles-backend,branch-misses,dTLB-load-misses ./your_app

几个经验判断:

  • IPC 很低,backend stall 很高:CPU 大概率在等内存或执行单元资源。
  • LLC miss 比例高:数据工作集超出缓存,或者访问局部性差。
  • dTLB miss 高:大数组随机访问、页表压力大,可以考虑 hugepage 或改善布局。
  • cache-misses 高但 DDR 带宽不高:可能是随机小访问导致延迟瓶颈,而不是带宽瓶颈。

一个非常典型的例子是 AoS(Array of Structs)和 SoA(Struct of Arrays)的差异。假设我们只需要处理像素的亮度 y,但数据结构却把多个字段混在一起:

typedef struct {
    uint8_t y;
    uint8_t u;
    uint8_t v;
    uint8_t flag;
    uint32_t timestamp;
} PixelMeta;

uint64_t sum_y_aos(PixelMeta *p, size_t n) {
    uint64_t s = 0;
    for (size_t i = 0; i < n; ++i) {
        s += p[i].y;
    }
    return s;
}

CPU 每次拉回 Cache Line,里面包含很多当前循环不需要的字段。改成 SoA 后,访问会连续得多:

typedef struct {
    uint8_t *y;
    uint8_t *u;
    uint8_t *v;
    uint8_t *flag;
    uint32_t *timestamp;
} PixelPlane;

uint64_t sum_y_soa(PixelPlane *p, size_t n) {
    uint64_t s = 0;
    for (size_t i = 0; i < n; ++i) {
        s += p->y[i];
    }
    return s;
}

这类优化没有改 DDR 频率,也没有碰内核,却能显著减少无效带宽。尤其在图像处理、传感器融合、推理前后处理里,数据布局往往比单纯“加线程”更重要。

五、AXI/NoC 层:别让 master 互相踩脚

SoC 里的 DDR 不是 CPU 独占资源。摄像头 ISP 可能持续写入帧缓冲,显示控制器周期性读取 framebuffer,NPU 读取权重和 feature map,VPU 编码器读写码流和参考帧。它们通常都通过 AXI 或片上 NoC 进入内存系统。

AXI 层常见的几个调优点包括:

5.1 Burst 长度

DDR 喜欢连续访问,AXI burst 太短会带来额外命令开销。对 DMA 来说,尽量让传输地址连续、长度对齐、burst 足够长。比如图像一行 stride 如果没有按 64/128 字节对齐,DMA 可能被迫拆成更多事务。

在驱动里申请 DMA buffer 时,至少要确认:

  • 起始地址是否满足硬件对齐;
  • 每行 stride 是否满足模块要求;
  • buffer 是否跨越硬件不支持的边界;
  • 是否发生了 cache sync 导致额外拷贝或刷写。

5.2 Outstanding 能力

AXI master 可以同时挂起多个未完成事务。Outstanding 太小,延迟无法被隐藏;太大,又可能挤压其他实时 master。NPU/GPU 这类吞吐型设备通常需要较大的 outstanding,显示、音频、某些实时采集链路则更关心延迟上限。

如果芯片手册提供 NoC 或 DDR port 的 outstanding 配置,建议不要盲目拉满,而是按业务分组测试:

  • 单 NPU 推理吞吐;
  • NPU + ISP;
  • NPU + ISP + VPU;
  • 加入 CPU 后处理线程。

看的是整体帧率和 P99 延迟,而不是某一个模块的峰值。

5.3 QoS 优先级

很多 SoC 的 AXI port 有 QoS 字段或内部仲裁权重。显示控制器、音频、摄像头输入这类实时流,一旦饿死就会花屏、爆音或丢帧;AI 推理慢一点通常只是延迟增加。因此,QoS 的目标不是让所有模块“公平”,而是让实时链路有确定性,让吞吐型模块吃剩余带宽。

一个实用策略是:

  1. 先保证显示/采集链路不丢;
  2. 再给编码器、NPU 设置中等优先级;
  3. CPU 后台任务、日志、文件 IO 降低优先级;
  4. 对吞吐型 DMA 使用大 burst,但限制 outstanding,避免长时间占住通道。

六、DDR Controller:Row Hit、读写切换和刷新

到了 DDR controller 层,问题会变得更“硬件”。这里的核心是调度器如何把上层来的请求转换成 DRAM 命令序列。

DRAM 内部按 bank、row、column 组织。访问已经打开的 row,叫 row hit;如果要访问另一个 row,就需要 precharge 当前 row,再 activate 新 row,开销明显更大。因此连续访问、按行访问、减少跨 bank/row 的随机跳转,通常能提升效率。

控制器还要处理读写切换。读和写在总线上方向不同,频繁切换会产生 turnaround penalty。很多 controller 会倾向于攒一批读或一批写再切换,以提高总线效率;但如果攒得太久,实时写入或读取的延迟可能变差。

刷新也是不可忽略的因素。DRAM 需要周期性 refresh,温度越高,刷新压力可能越大。某些项目在高温箱里出现周期性延迟尖峰,最后发现不是 CPU 调度问题,而是内存刷新和业务峰值叠加。

如果平台暴露 DDR controller 计数器,建议关注:

  • read/write command 数量;
  • row hit / row miss;
  • bank conflict;
  • refresh 周期;
  • port busy 或 queue full;
  • 各 master 的带宽占比。

不同厂商接口差异很大,有的在 /sys,有的通过 devfreq,有的要读寄存器。不要只看一个总带宽数字,最好能按 master 或 port 拆开,否则很难知道谁在消耗带宽。

七、Linux 侧常见的隐藏带宽消耗

在应用层,最浪费 DDR 的通常不是计算,而是“搬来搬去”。视频和 AI pipeline 里尤其明显:

Camera -> ISP -> 内存 -> CPU memcpy -> NPU input -> 内存 -> CPU 后处理 -> VPU/Display

如果每个箭头都落 DDR,每个模块之间再做一次格式转换,带宽很快就被吃光。优化方向包括:

  • 使用 DMA-BUF 在模块之间共享 buffer;
  • 尽量让 ISP/RGA/NPU/VPU 直接处理物理连续或 IOMMU 映射后的 buffer;
  • 减少 CPU 参与大块图像拷贝;
  • 统一颜色格式,避免 NV12/RGB/BGR 来回转换;
  • 对只读权重、查找表使用合适的缓存策略;
  • 对一次性 DMA buffer 避免不必要的 cache invalidate/clean。

很多时候,把两次 memcpy 去掉,比把 DDR 频率从 3200 提到 4266 更有效,也更省电。

(第二部分完,约 2400 字)

八、一个可复用的带宽排查脚本

下面这个 Python 脚本用于自动跑不同 buffer 大小下的拷贝测试,并记录 perf stat 的关键指标。实际项目里我会把它放进 bring-up 工具箱,每次改 DDR 频率、内核版本、驱动 DMA 策略后都跑一遍。

#!/usr/bin/env python3
import csv
import re
import subprocess
from pathlib import Path

SIZES_MB = [64, 128, 256, 512, 1024]
EVENTS = "cycles,instructions,cache-references,cache-misses,LLC-loads,LLC-load-misses"
BIN = "./memcopy_bw"

perf_re = re.compile(r"^\s*([0-9,]+)\s+([A-Za-z0-9_-]+)", re.M)
bw_re = re.compile(r"copy bandwidth:\s+([0-9.]+)\s+GB/s")

def run_one(size_mb: int):
    cmd = ["perf", "stat", "-e", EVENTS, BIN, str(size_mb)]
    p = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
    out = p.stdout + "\n" + p.stderr

    row = {"size_mb": size_mb, "bandwidth_gbps": None}
    m = bw_re.search(out)
    if m:
        row["bandwidth_gbps"] = float(m.group(1))

    for value, name in perf_re.findall(out):
        row[name] = int(value.replace(",", ""))
    return row

rows = [run_one(s) for s in SIZES_MB]
keys = sorted({k for r in rows for k in r.keys()})
Path("results").mkdir(exist_ok=True)
with open("results/memory_perf.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=keys)
    writer.writeheader()
    writer.writerows(rows)

for r in rows:
    miss = r.get("cache-misses", 0)
    ref = r.get("cache-references", 1)
    miss_rate = miss / ref * 100 if ref else 0
    print(f"{r['size_mb']:4d} MB  {r.get('bandwidth_gbps', 0):6.2f} GB/s  cache miss {miss_rate:5.2f}%")

这个脚本不能替代专业测试,但有两个好处:第一,它让每次优化都有数据记录;第二,它能快速发现“改动看似无关,内存行为却变了”的情况。例如某次驱动修改把 buffer 从 cacheable 改成 non-cacheable,CPU 后处理性能会立刻掉下来;某次设备树改错 DDR devfreq 档位,连续带宽也会明显变化。

九、实战调优顺序:不要一上来就改 DDR 参数

DDR 参数很诱人,因为它看起来离瓶颈最近。但在量产项目里,随意修改 DDR training、ODT、时序、频率,风险远高于收益。我的建议顺序是:

9.1 先确认频率和工作模式

检查 DDR 是否跑在预期频率,devfreq 是否被省电策略压低,双通道是否都启用,位宽是否符合硬件设计。很多“性能问题”最后只是设备树频点、bootloader 初始化或电源模式配置不对。

cat /sys/class/devfreq/*/cur_freq 2>/dev/null
cat /sys/class/devfreq/*/available_frequencies 2>/dev/null

不同平台节点名称不一样,上面命令只是示意。关键是把空载、业务运行、混压时的频率都记录下来。

9.2 再减少无效流量

优先去掉重复拷贝、格式来回转换、日志大吞吐写盘、调试 overlay、无意义的 buffer 清零。尤其是图像类项目,memsetmemcpy 经常藏在看起来不起眼的封装函数里。

可以临时用 LD_PRELOAD 包装 memcpy 做统计,也可以在代码里对大块拷贝加 trace。不要凭感觉判断,很多团队最后会惊讶地发现,CPU 每帧搬运的数据量比原始图像大几倍。

9.3 然后优化访问局部性

包括数据结构从 AoS 改 SoA、循环顺序调整、tile/block 处理、预取、对齐、减少随机访问。矩阵和图像算法里,分块往往非常有效:让工作集留在 L1/L2 里,而不是每一步都回 DDR。

一个简单的二维数组遍历例子:

// 差:按列访问,跨行 stride 大,Cache 利用率低
for (int x = 0; x < width; ++x) {
    for (int y = 0; y < height; ++y) {
        acc += img[y * stride + x];
    }
}

// 好:按行访问,连续读取
for (int y = 0; y < height; ++y) {
    const uint8_t *row = img + y * stride;
    for (int x = 0; x < width; ++x) {
        acc += row[x];
    }
}

9.4 最后再碰 QoS 和 DDR 控制器

当确认无效流量已经压下去、访问模式也合理,但混压下仍然抖动,再去调 QoS、outstanding、读写队列、水线、DDR devfreq governor。每次只改一个变量,并记录平均值、P95、P99,而不是只看峰值。

十、几个常见故障现象和定位方向

10.1 单测很快,业务很慢

优先怀疑多 master 竞争、额外拷贝和 Cache 同步。连续内存测试无法复现业务的读写混合和实时约束,必须做混压。

10.2 平均帧率够,偶发掉帧

看 P99 延迟、调度抢占、DDR refresh、QoS、水温/温度导致的降频。显示、摄像头、音频类问题尤其要关注最坏情况,而不是平均值。

10.3 CPU 占用不高但程序慢

可能是 backend stall,线程在等内存。用 perf stat 看 IPC、cache miss、dTLB miss,再结合火焰图看热点是不是大数组随机访问。

10.4 NPU 利用率上不去

不一定是模型小,也可能是输入预处理、tensor layout 转换、权重读取、NPU 与 CPU/NPU 共享 DDR 造成等待。检查是否支持零拷贝输入,是否每帧都做了不必要的 NHWC/NCHW 转换。

10.5 高温后性能下降

检查 DDR devfreq、CPU/GPU/NPU 降频、DRAM refresh、PMIC 限流。高温问题不要只盯 CPU 温度,内存和电源策略也会影响吞吐。

十一、量产项目里的建议清单

最后给一份我在项目评审里常用的 checklist:

  • DDR 频率、位宽、通道数是否和硬件设计一致;
  • bootloader 与内核里的 DDR/devfreq 配置是否一致;
  • 是否有 STREAM、随机延迟、业务混压三类基线数据;
  • 是否记录了 CPU PMU、DDR controller、NoC/AXI port 计数器;
  • 视频/AI pipeline 是否做到 DMA-BUF 或等价的零拷贝;
  • 大 buffer 是否对齐,stride 是否满足 DMA burst;
  • CPU 是否存在大块 memcpymemset、格式转换;
  • 热点数据结构是否有良好的空间局部性;
  • 实时 master 的 QoS 是否高于吞吐型后台任务;
  • 是否用 P95/P99 延迟评估,而不是只看平均吞吐;
  • 高温、低电压、省电模式下是否重复验证。

总结

DDR 带宽调优不是单点优化,而是一条链路的系统工程。CPU 看到的是 Cache Miss,DMA 看到的是 burst 和对齐,NoC 看到的是仲裁和 outstanding,DDR controller 看到的是 row hit、读写切换和刷新,业务最终看到的是帧率、延迟和稳定性。

真正有效的调优顺序应该是:先建立基线,再确认频率和硬件配置;先减少无效拷贝,再改善数据局部性;先通过 perf 和业务 trace 定位瓶颈,再去调整 QoS、NoC 和 DDR controller。除非你已经有充分数据证明瓶颈在内存控制器,否则不要一上来就改 DDR 时序。

如果只能记住一句话,那就是:内存系统优化的目标不是跑出最高的 GB/s,而是在真实业务混压下,把有效数据稳定、可预测地送到需要它的计算单元。 这也是芯片架构和嵌入式软件最有意思的交界处——硬件给了你上限,软件决定你能接近多少。

(全文完,约 6800 字)