CPU 应该 搞 0 级 Cache , 而不是 大寄存器
CPU 应该 搞 0 级 Cache , 而不是 大寄存器 。
具体的说, 是 CPU 应该 搞 精简指令集 RISC 和 0 级 Cache , 而 不是 大寄存器 。
0 级 Cache 也可以称为 L0 Cache 。
0 级 Cache 是 离 CPU 最近 的 Cache, 访问 只需要 1 个 时钟周期, 和 寄存器 一样 。
那 0 级 Cache 和 寄存器 有 什么 区别 呢 ?
0 级 Cache 在 内存 地址编制 内, 和 一级 Cache 、二级 Cache 、三级 Cache 、内存 在 一个 统一 的 地址空间 里, 按 统一 的 地址管理 。
而 寄存器 是 不在 内存 地址编制 里 的 。
0 级 Cache 从 下级存储 (一级 Cache 、二级 Cache 、三级 Cache 、内存) 载入载出 哪些 数据 是 完全 由 程序员 控制 的, 具体的, 是 完全 由 程序员 用 汇编指令控制的 。
这是 和 一级 Cache 、二级 Cache 、三级 Cache 的 不同 。
一级 Cache 、二级 Cache 、三级 Cache 载入载出 哪些 数据 是由 CPU 自己决定的, 比如 根据 命中算法, 程序员 无权干涉 。
程序员 用 指令 map_in 将 内存(一级 Cache 、二级 Cache 、三级 Cache) 地址 和 数据 映射 进 0 级 Cache , 如果 0 级 Cache 里 的 存储单元 原来已经 映射 了 地址 和 数据, 此时 将 新的 地址 映射 到 这个 存储单元, 则 旧 的 数据 将 替换 为 新 的 数据, 旧 的 映射地址 将 映射 成 新 的 地址, 如果 旧 的 数据 被 修改过, 则 要 先 写回 对应 的 内存(一级 Cache 、二级 Cache 、三级 Cache) 地址 , 这 称为 map_out , 也可以称为 载出 。
map_in 也可以 称为 载入 。
0 级 Cache 的 好处 是 :
1 指针取值( * 指针) 和 指针字段( 指针 -> 字段 ) 可以 享有 和 局部变量 一样 的 寄存器优化 的 待遇
寄存器优化 就是 把 常用 的 数据 存在 寄存器 里 反复使用 。
在 寄存器 架构下, 指针取值( * 指针) 和 指针字段( 指针 -> 字段 ) 不容易 做 寄存器优化, 因为 指针 会 改变, * 指针 和 指针 -> 字段 会 随 指针 的 改变 而 改变,
同时, * 指针 和 指针 -> 字段 可能 被 其它 同样指向这个 地址 的 指针 修改, 比如 指针2 和 指针 相等, * 指针2 和 指针2 -> 字段 修改 的 数据 就是 * 指针 和 指针 -> 字段 的 数据, 但是 * 指针 和 指针 -> 字段 并不知道 数据 被 修改 。
这还只是 单线程 的 情况 。
多线程 也会 造成 类似 的 数据 不一致 的 情况 。
但 使用 0 级 Cache 的 话, 0 级 Cache 是 按 地址 访问 的, 和 一级 Cache 、二级 Cache 、三级 Cache 、内存 同在一个 地址编制 , 对于 指针取值( * 指针) 和 指针字段( 指针 -> 字段 ), 都是 按 地址访问, 不用 担心 数据不一致 的 问题 。 而 访问 0 级 Cache 的 时间 是 1 个 时钟周期, 和 寄存器 一样快 。
2 多核 数据同步 和 单核多线程 并发 数据一致
这 其实 是 第 1 点 里 说 的 多线程 的 情况, 对于 多核 的 共享数据, 修改 时 要 mutex 并 同步到 各 核 的 Cache, 在 寄存器 架构下, 对于 需要 实时同步 的 多核数据, 是 不能 做 寄存器优化 的, 也就是 要 禁用 寄存器优化, 比如 C++ 里 的 atomic<T> 原子类型 是 禁用 寄存器 优化 的 。
而 现在 用 0 级 Cache, 就不存在这个问题, 0 级 Cache 和 现在 的 1 级 Cache 一样, 修改 原子数据 时 直接 mutex 和 通知 其它 核 同步,
这样 会不会 影响性能 ?
不会 。 读取 时 仍然 是 1 个 时钟周期 , 修改 时 会 发起 mutex , mutex 要 通知 到 其它 核 , 当然 需要 一些 的 时钟周期, 另外, 若 收到 其它 核 已 改写数据 的 通知, 要从 其它 核 的 Cache 里 把 数据 同步过来, 这 也要 一些 时钟周期 。
当 收到 其它 核 发起 mutex 的 通知 时, 会 等待 其它 核 的 mutex 结束, 这 需要 等待一些 时钟周期 。
除此以外, 读取 时 是 1 个 时钟周期 。 也就是说, 如果 自己 不改写, 也 没有 收到 其它 核 mutex 和 改写 的 通知, 读取 0 级 Cache 里 的 原子变量 是 1 个 时钟周期, 和 普通变量 一样 。
对于 单核多线程 并发 共享数据, 要 保证 数据 在 并发中一致, 也要 禁用 寄存器优化, 同理, 用 0 级 Cache, 就不存在这个问题 。
3 编译器 / 程序员 不用 考虑 把 寄存器 里 的 数据 写回 Cache / 内存
在 寄存器 架构下, 常用 的 数据 存在 寄存器 里 反复使用 , 用完后(比如 函数 结束时), 如果 数据 被 修改 过, 要 写回 Cache / 内存 ,
用 0 级 Cache 就 不用 编译器 / 程序员 考虑 这件事 了 。
0 级 Cache 会 记录 哪些 数据 被 修改过, 被 修改 的 才 写回 映射 的 内存地址(当然, 实际上 可能 是 写 Cache , 也可能写 内存),
这 需要 0 级 Cache 的 硬件电路 将 被 修改过的 存储单元 标记 为 “被修改” ,
对于 这一点, 硬件电路 很容易 做到 。
事实上, 在 0 级 Cache 里, 程序员 也不用 考虑 在 什么 “时机” 把 数据 写回 一级 Cache ( 二级 Cache 、三级 Cache 、内存 ),
因为 0 级 Cache 也是 Cache, 和 一级 Cache 、二级 Cache 、三级 Cache 、内存 本身 就是 一个 体系 ,
就好像 程序员 不用 考虑 一级 Cache 的 数据 “写回” 二级 Cache 、三级 Cache 、内存 。
“时机” 比如 上面说的 “用完后(比如 函数 结束时)” , 在 0 级 Cache 里, 程序员 也不用 考虑 这些 。
程序员 只要 考虑 把 哪个 (需要的) 地址 映射 到 0 级 Cache 的 哪个 存储单元, 这个 存储单元 原来 的 数据 如果 修改过的话, 会 自动写回 映射 的 地址 ( 一级 Cache 、二级 Cache 、三级 Cache 、内存 ) 。
4 访问 0 级 Cache 只要 一个 时钟周期, 和 寄存器 一样 。 如果 指针 和 * 指针 都 存 在 0 级 Cache 里, 则 * 指针 一个 时钟周期 就可以 完成, 也就是说 读写 * 指针 一个 时钟周期 就可以 完成 。 读写 指针 -> 字段 也可以 一个 时钟周期 完成 。
实际上, 只要 电路 的 精度 可以, * * 指针 , 指针 -> 字段 -> 字段 也可以 一个 时钟周期 完成 , 甚至, * * * 指针 , 指针 -> 字段 -> 字段 -> 字段 也可以 一个 时钟周期 完成 。
* * * 指针 , 指针 -> 字段 -> 字段 -> 字段 , 要把 指针 的 多次连续访问 放到 一个 时钟周期(指令) 里, 则 指针 的 连续访问次数 越多, 指令 的 电路元件 越多, 元件 数量 随 访问次数 正比增加 。 而 电路精度 越高, 比如 5nm、7nm, 在 同样 的 芯片面积 上, 可以 设计制造 更多的 元件 。
5 在 寄存器 架构 下, 可以 将 对象 的 一些 字段(比如 数组 首地址 、Length) 复制 一个 副本 到 局部变量 里 (栈 里) , 然后 再对 副本 对应 的 局部变量 进行 寄存器优化, 也就是 把 副本 对应的 局部变量 放到 寄存器 里 反复使用 , 说白了, 就是 把 副本 放到 寄存器 里 反复使用 , 这是一种 寄存器优化 , 这种 优化 方式 叫做 “Local Agent” , 副本 就是 Local Agent 。
Local Agent 的 方式 需要 注意一个 问题, 如果 副本 对应 的 对象字段 发生了 改变, 则 要 考虑 把 这个 改变 同步 到 副本, 或者 即使不同步, 仍然 接着 使用 副本,也不会 产生 程序逻辑问题 。
在 0 级 Cache 架构 里 , 如 第 4 点 所说, * 指针 和 指针 -> 字段 都可以在 一个 时钟周期 完成, 也就 不需要 Local Agent 优化 了, 也就不存在 Local Agent (副本) 和 对象字段 的 同步 。
上面说了 大半天, 由 程序员 用 汇编指令 map_in 和 现在 的 用 汇编指令 把 数据 读入寄存器 是 类似的, 还是 一套做法 。 我后来想了一下, 这 完全 没必要 。 可以 由 CPU 直接将 指令 (比如 加法指令) 中 用到 的 数据 从 内存(一级 Cache) 读入(map_in) 到 0 级 Cache 就行 。 这个 做法 就 彻底 了, 相比 现有的 寄存器 架构, 这个 做法 改革 的 就 彻底 了 。 CPU 可以用 流水架构 提前 将 接下来 的 指令 里 要用到 的 多个 数据 (并行的) map_in 到 0 级 Cache , 这样 就 OK 了, 很 简单, 很 清楚 。
这个 设计 极大 的 简化了 编译器 的 工作 , 极大 的 简化了 编译器 的 设计 和 编写 , 因为 编译 的 一个 主要工作 寄存器布局 和 内存屏障 都 不需要 考虑 了, 这 非常爽, 轻松到 要 飞起来 。 真的 不得了 。
在 QQ 群 里 讨论 本文 时, 我说 “静态约束搞死人, 动态判断一句话 。”
静态约束 是指 编译时 通过 语法 约束 让 程序 满足 某些要求 ; 动态判断 是指 在 程序 运行时 判断 程序 是否 满足 某些要求, 若 不满足 则 抛出异常 。
动态判断 类比 CPU 用一些 统计算法 决定 哪些 数据块(Cache Line) 从 一级 Cache 载出到内存(三级 Cache), 哪些 数据块(Cache Line) 在 一级 Cache 多呆一些时间, 可能 比较少 用到 的 数据 优先 载出, 可能 比较多 用到 的 数据 在 一级 Cache 多呆一些时间 ; 从 0 级 Cache 载出 数据 到 一级 Cache 也是一样 。
静态约束 类比 编译器 构思 寄存器布局 和 内存屏障, 也就是 编译器 要 具体 的 给出 每一个 数据 的 读入 寄存器 和 从 寄存器 写回 一级 Cache 的 方案 。
显然, 编译器 构思 寄存器布局 和 内存屏障 的 做法 精准 到 每一个 数据 的 读入 写出, 但是 构思 这些 很费脑筋, 当然 你会说 这是 编译器 构思, 不用人去构思, 但 人 要 构思 能做 这个 构思 的 算法, 也就是 人 要 教会 让 编译器 如何 做 这个 构思, 这 也是 很费脑筋, 颇为艰深 的 。
而 CPU 动态 的 统计判断 数据 的 使用率 来 决定 载出 哪些 数据(块), 这 有 概率统计 的 成分, 但是 简单易行, 人 不需要 考虑 太多东西 。
我提倡 用 模块线路图 来 设计 硬件电路, 硬件电路 本来 就是 模块化 的 , 用 模块线路图 设计 很适合 。 模块 的 规格, 包括 接口 和 电路参数 作为 模块 的 说明书 单独说明 就好 。
其实 设计 CPU 很简单, 主要 是 制造工艺 和 电路计算 比较难 。
精简指令集 RISC 和 0 级 Cache 的 架构 称为 RISC-L0-Cache 架构 , 也称为 L0 Cache 架构 , 简称 L0 架构 。
为什么 会 写 这篇 文章 ? 写 这篇 文章 的 原因 是 最近(2021-03-26) 一直 在 搞 K-GC / D++ , K-GC / D++ 是 ILBC 的 一个 子项目 , 里面 涉及 到 很多 多核数据同步 和 寄存器优化 的 问题 和 设计, 然后 前几天 又看到 民科吧 的 一个 帖 《民科们速速进来学习》 https://tieba.baidu.com/p/7273181457 , 大致内容如下, 就想到了 0 级 Cache 的 想法 。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!