一、生活里的例子:图书馆的密集书库
一座大型图书馆,书不放在阅览室,而是放在后面的密集书库:
- 你要一本书,管理员先走到对应的书库房间
- 把一整排书架推出来(密集书架平时是合拢的)
- 从这一排里取出你要的那本
- 如果下一本书在同一排,直接再拿就行,很快
- 如果在另一排,要先把这排推回去,再推出新的一排,慢很多
- 另外,书会慢慢受潮,管理员要定期翻晒
| 图书馆 | DRAM |
|---|---|
| 一间书库 | Bank |
| 推出一整排书架 | 激活(ACT):把一整行读到行缓冲区 |
| 推出来的那一排 | 行缓冲区(Row Buffer) |
| 从这排里拿某本书 | **读(READ)**某一列 |
| 推回去 | 预充电(PRE):关闭这一行 |
| 定期翻晒 | 刷新(REF) |
| 好几间书库可以同时工作 | 多个 Bank 并行 |
记住两点就理解了 DRAM 的大部分性能特征:同一行里连续取很快,换行很慢。
二、层级结构:从内存条到电容
CPU 的内存控制器 └─ 通道(Channel) 双通道 = 两条独立的数据通路 └─ 内存条(DIMM) └─ Rank 一组芯片共同组成 64 位宽的数据 └─ 芯片(Chip) 内存条上那一颗颗黑色方块 └─ Bank Group → Bank └─ 存储阵列:行(Row)× 列(Column) └─ 单元:1 个晶体管 + 1 个电容| 层级 | 典型规模(DDR4 为例) | 作用 |
|---|---|---|
| 通道 | 台式机常见 2 个 | 带宽叠加 |
| Rank | 每条 1~2 个 | 8 颗 ×8 芯片拼成 64 位 |
| Bank | 每颗芯片 16 个(4 个 Bank Group) | 可以并行工作 |
| 行 | 每个 Bank 几万行 | 激活的基本单位 |
| 行大小 | 每颗芯片约 1KB,整个 Rank 约 8KB | 一次激活打开这么多数据 |
存储阵列长什么样
列 0 列 1 列 2 ... 列 N 行 0 ───── ● ● ● ● ← 字线(Word Line) 行 1 ───── ● ● ● ● 行 2 ───── ● ● ● ● ... │ │ │ │ 位线 位线 位线 位线(Bit Line) │ │ │ │ ┌─────────────────────────┐ │ 灵敏放大器 = 行缓冲区 │ ← 一次装下整整一行 └─────────────────────────┘ │ 列选择 ▼ 数据 I/O- 字线横着走:选中某一行,这一行所有晶体管一起打开
- 位线竖着走:把每个电容的电荷引到底部的灵敏放大器
- 灵敏放大器就是行缓冲区:放大信号,同时暂存这一整行
三、一次读取的完整过程
三个核心命令
| 命令 | 做什么 |
|---|---|
| ACT(激活) | 选中一行 → 电荷流到位线 → 灵敏放大器放大并锁存整行 |
| READ / WRITE | 从行缓冲区里选出某几列,传出或写入 |
| PRE(预充电) | 把行数据写回电容,关闭这一行,位线恢复到中间电压,准备下一次激活 |
上一篇讲过"读取是破坏性的":激活时电容里的电荷已经被分走,正是灵敏放大器把放大后的结果写回单元,数据才得以保留。
三种情况,三种速度
| 情况 | 需要的步骤 | 速度 |
|---|---|---|
| 行命中:要的行已经在行缓冲区里 | READ | 最快 |
| 行空闲:Bank 里没有打开的行 | ACT → READ | 中等 |
| 行冲突:Bank 里打开的是别的行 | PRE → ACT → READ | 最慢 |
行命中: [READ]────→ 数据 行空闲: [ACT]──────[READ]────→ 数据 行冲突: [PRE]──────[ACT]──────[READ]────→ 数据四、时序参数:内存条上那串数字
内存条标签上常见DDR4-3200 CL16-18-18-38这样的字样:
| 参数 | 全称 | 含义 |
|---|---|---|
| CL | CAS Latency | 发出 READ 后,等多久数据出来 |
| tRCD | RAS to CAS Delay | 发出 ACT 后,等多久才能 READ |
| tRP | Row Precharge | 发出 PRE 后,等多久才能 ACT 新的一行 |
| tRAS | Row Active Time | 一行至少要打开多久才能关 |
这些数字的单位是时钟周期,要换算成纳秒才有意义:
DDR4-3200:数据速率 3200 MT/s,时钟 1600 MHz 一个周期 = 1 / 1600MHz = 0.625 ns CL16 → 16 × 0.625 = 10 ns tRCD18 → 11.25 ns tRP18 → 11.25 ns| 情况 | 估算(只算 DRAM 本身) |
|---|---|
| 行命中 | CL ≈ 10 ns |
| 行空闲 | tRCD + CL ≈ 21 ns |
| 行冲突 | tRP + tRCD + CL ≈ 32 ns |
再加上 CPU 查完各级缓存、经过内存控制器排队的时间,一次真正的内存访问通常在 60~100 ns。
一个常被误解的点
| 内存 | CL | 换算成纳秒 |
|---|---|---|
| DDR4-2400 CL15 | 15 | 12.5 ns |
| DDR4-3200 CL16 | 16 | 10 ns |
| DDR5-6000 CL30 | 30 | 10 ns |
DDR5 的 CL 数字看起来是 DDR4 的两倍,但因为时钟更快,实际延迟差不多。几十年来,内存的带宽提升了几十倍,延迟却几乎一直停在十几纳秒的量级 —— 电容充放电、长导线传信号,这些物理过程很难再快多少。
五、带宽:DDR、预取和突发
DDR 是什么意思
Double Data Rate(双倍数据速率):时钟的上升沿和下降沿各传一次数据。所以 DDR4-3200 的时钟是 1600MHz,每秒传输 3200M 次。
带宽计算
DDR4-3200,单通道 64 位: 3200M 次/秒 × 8 字节 = 25.6 GB/s 双通道:51.2 GB/s存储阵列那么慢,怎么喂饱这么快的接口?
靠预取(Prefetch):存储阵列内部一次并行读出很多位,再由接口快速地一个接一个送出去。
| 代际 | 预取 | 一次突发(Burst) |
|---|---|---|
| DDR4 | 8n | BL8:连续 8 次传输 × 64 位 =64 字节 |
| DDR5 | 16n | 每条分成两个 32 位子通道,BL16 × 32 位 =64 字节 |
64 字节正好是 CPU 缓存行的大小。这不是巧合:CPU 每次缺失缓存,就向内存要一整行 64 字节,内存一次突发刚好送完。所以哪怕你只读一个
int,实际搬运的也是 64 字节。
六、多 Bank 并行:把慢操作藏起来
一个 Bank 在做 PRE 和 ACT 时,其他 Bank 可以同时工作。内存控制器会交错发命令:
时间 → Bank 0: [ACT]────[READ]────[PRE] Bank 1: [ACT]────[READ]────[PRE] Bank 2: [ACT]────[READ]────[PRE] 数据总线: ███ ███ ███ ← 几乎一直在传数据单个 Bank 很慢,但多个 Bank 轮流上,数据总线就能保持忙碌。这和网络里"一条连接跑不满带宽,就开多条并发"是同一个思路。
地址映射:物理地址怎么拆
内存控制器把物理地址的各个位拆给不同层级(具体映射因平台而异,下面只是示意):
物理地址:│ 行 │ Rank │ Bank │ 通道 │ 列 │ 缓存行内偏移 │ 高位 低位设计思路是:连续的地址先在同一行内的列上推进,再分散到不同的通道和 Bank。于是顺序访问一大块内存时,既能享受行命中,又能利用多通道、多 Bank 并行。
七、刷新:仓库的日常维护
上一篇讲过电容会漏电。具体怎么刷新:
| 参数 | DDR4 典型值 | 含义 |
|---|---|---|
| 刷新周期 | 64 ms(高温时 32 ms) | 所有行都要在这段时间内刷新一遍 |
| REF 命令数 | 8192 次 / 64ms | 把工作分成 8192 小份 |
| tREFI | 约 7.8 μs | 平均每隔这么久发一次 REF |
| tRFC | 几百纳秒(容量越大越长) | 每次刷新期间,该 Rank 不能被访问 |
时间 → ──正常访问──[REF]──正常访问──[REF]──正常访问── ↑ 几百 ns 不可用 ↑ 每 7.8μs 来一次估算一下代价:几百纳秒 ÷ 7.8 微秒 ≈百分之几的时间花在刷新上。芯片容量越大,要刷新的行越多,这个比例越高,所以新标准一直在改进刷新方式(例如 DDR5 支持按 Bank 刷新,刷新一个 Bank 时其他 Bank 照常工作)。
温度越高漏电越快,所以超过 85°C 时刷新频率要加倍 —— 这也是"内存过热会变慢甚至出错"的原因之一。
八、内存控制器:仓库的调度员
现代 CPU 内部集成了内存控制器,它负责:
| 工作 | 说明 |
|---|---|
| 翻译地址 | 物理地址 → 通道 / Rank / Bank / 行 / 列 |
| 发命令 | ACT、READ、WRITE、PRE、REF,并严格遵守所有时序参数 |
| 请求重排 | 优先处理行命中的请求(常见策略 FR-FCFS:先服务行命中,再按到达顺序) |
| 行策略 | 开页:读完先不关,赌下一次还是这行;闭页:读完立刻关,减少冲突代价 |
| 安排刷新 | 在合适的时机插入 REF |
| 纠错 | 支持 ECC 时负责检查和纠正错误 |
请求重排的思路,和前面网络负载篇的"带宽预算 + 优先级"很像:资源有限,先做代价最小、收益最大的事,同时防止某些请求被饿死。
九、各种 DRAM 家族
原理都是"1 个晶体管 + 1 个电容",区别在于接口和封装的取舍:
| 类型 | 用在哪 | 特点 |
|---|---|---|
| DDR4 / DDR5 | 台式机、服务器内存条 | 容量大、通用;DDR5 把稳压芯片(PMIC)放到内存条上,芯片内部带 On-die ECC |
| LPDDR4X / LPDDR5 | 手机、轻薄本 | 低电压、低功耗,通常焊在主板上或与芯片封装在一起 |
| GDDR6 / GDDR6X | 显卡显存 | 位宽大、频率高,追求带宽,功耗和成本较高 |
| HBM | 高端 GPU、AI 加速卡 | 多层芯片垂直堆叠,接口位宽上千位,带宽极高 |
注意 DDR5 的 On-die ECC 只在芯片内部纠错,用于弥补工艺缩小带来的可靠性下降,不等于服务器上那种完整的 ECC 内存。
十、和游戏性能的关系:为什么"内存访问模式"这么重要
一组数量级
| 访问位置 | 大约耗时 | 换算成 4GHz CPU 周期 |
|---|---|---|
| 寄存器 | < 1 ns | 1 |
| L1 缓存 | ~1 ns | ~4 |
| L2 缓存 | ~3~5 ns | ~15 |
| L3 缓存 | ~10~20 ns | ~50 |
| DRAM | ~60~100 ns | ~300 |
一次缓存缺失去访问 DRAM,CPU 可能要空等几百个周期。这几百个周期里本来能做上千次加法。
好的访问模式 vs 坏的访问模式
| 模式 | 效果 |
|---|---|
| 顺序遍历数组 | 每 64 字节缺失一次,后面全命中;CPU 硬件预取器还能提前拉数据;DRAM 端多是行命中 |
| 随机跳着访问 | 几乎每次都缺失缓存,DRAM 端频繁行冲突 |
| 链表、指针满天飞的对象图 | 下一个地址要等上一个读完才知道,无法预取,延迟一次次串行叠加 |
动手验证:顺序 vs 随机
usingSystem;usingSystem.Diagnostics;classMemoryAccessTest{staticvoidMain(){constintN=1<<25;// 3200 多万个 int,约 128MB,远大于 L3vardata=newint[N];varseq=newint[N];varrnd=newint[N];for(inti=0;i<N;i++){data[i]=i;seq[i]=i;rnd[i]=i;}// 打乱索引(Fisher-Yates)varr=newRandom(42);for(inti=N-1;i>0;i--){intj=r.Next(i+1);(rnd[i],rnd[j])=(rnd[j],rnd[i]);}Console.WriteLine($"顺序访问:{Measure(data,seq)}ms");Console.WriteLine($"随机访问:{Measure(data,rnd)}ms");}staticlongMeasure(int[]data,int[]index){varsw=Stopwatch.StartNew();longsum=0;for(inti=0;i<index.Length;i++)sum+=data[index[i]];// 访问的元素数量完全相同sw.Stop();if(sum==42)Console.WriteLine();// 防止被编译器优化掉returnsw.ElapsedMilliseconds;}}两次循环做的加法一样多,读的元素也一样多,但随机访问通常会慢数倍甚至十几倍(具体倍数取决于 CPU、内存和缓存大小)。差距几乎全部来自缓存缺失和 DRAM 的行冲突。
在 Unity 里的体现
| 做法 | 为什么快 / 慢 |
|---|---|
✅数组 /NativeArray连续存放数据 | 顺序遍历,缓存和 DRAM 都友好 |
| ✅SoA(结构体数组拆成多个数组) | 只更新位置时,只读位置数组,不把无关字段搬进缓存 |
| ✅ECS / DOTS | 同类组件连续排列,本质就是为内存访问模式设计的架构 |
| ✅ 对象池复用连续内存 | 减少碎片和 GC(前面对象池那一篇) |
❌ 每帧遍历大量GameObject再GetComponent | 对象分散在堆里,到处跳着读 |
❌ 用LinkedList、大量class小对象 | 指针追逐,无法预取 |
AoS(常见写法): [位置|血量|名字|AI状态][位置|血量|名字|AI状态]... 只想更新位置,却把整个对象都搬进缓存 SoA: 位置: [位置][位置][位置][位置]... ← 每 64 字节都是有用数据 血量: [血量][血量][血量][血量]...前面讲网络时说"往返次数比数据量更关键";内存也是一样:访问 DRAM 的次数和方式,往往比计算量更决定性能。
十一、常见的误解
| 误解 | 实际情况 |
|---|---|
| “频率越高,延迟越低” | 频率主要提升带宽;以纳秒计的延迟变化不大,要同时看 CL |
| “读一个字节就只传一个字节” | 最少传一整个缓存行(64 字节) |
| “内存访问时间是固定的” | 行命中、行空闲、行冲突差好几倍,还受刷新和排队影响 |
| “内存是随机访问的,所以顺序无所谓” | "随机访问"指能访问任意地址,不代表任意顺序都一样快 |
| “插满内存条一定更快” | 必须按主板说明组成双通道才有带宽加成;Rank 多了有时还得降频 |
| “DDR5 自带 ECC,等于服务器 ECC 内存” | On-die ECC 只保护芯片内部,传输路径上的错误它管不到 |
| “内存不够就加,性能问题就解决了” | 容量解决的是"装不下";访问模式不好,再大的内存也一样慢 |
一句话总结
DRAM = 海量电容按"通道 → Rank → Bank → 行 → 列"组织起来的仓库。读取要先激活一整行到行缓冲区,再读出需要的列,换行前还要预充电关闭旧行;电容会漏电,所以每 64ms 要把所有行刷新一遍。
它的性能特征可以概括为三句话:带宽很大,延迟很高,同一行里连续访问最快。CPU 用缓存、预取和多 Bank 并行来掩盖延迟,而程序员能做的,就是让数据连续存放、顺序访问。