简介:这份PDF资料面向底层软件工程师、安全工程师及对Arm架构感兴趣的开发者,系统梳理Armv8/Armv9缓存机制。内容从缓存基本概念与使用场景切入,逐步深入到多级缓存组织、查询原理、多核多cluster多系统间缓存一致性维护,并详解MESI协议、CCI/CMN总线、big.LITTLE与DynamIQ架构差异,以及CLIDR_EL1、CTR_EL0、CCSIDR_EL1等系统寄存器的查询与控制方法。资源包为1个PDF文件,大小约5.71MB,结构完整、章节清晰,便于按主题检索学习。目前已有351人学习。对于深度学习等计算密集型应用,理解并优化缓存性能直接影响模型训练效率,读者可借此建立从硬件架构到软件维护的完整知识链路,掌握flush/invalidate指令、页表缓存属性配置及非cacheable内存处理等实用技能,提升系统级性能调优能力。
1. 深度学习 cache 系列:从显存到磁盘,一次把缓存层级讲透
跑过深度学习训练的人都遇到过这种场景:模型结构没改,超参没动,第二个 epoch 却比第一个快了一倍;或者反过来,明明显存够大,batch size 一调大就 OOM。这些现象背后几乎都指向同一个东西——cache。深度学习里的 cache 不是单一概念,它至少横跨四个层级:GPU 显存里的 KV cache、CPU 内存里的 page cache、框架层的算子缓存(cuDNN autotune)、以及磁盘上的数据集缓存。很多人调参调半天,其实瓶颈在数据加载的 page cache 命中率上,跟模型本身没关系。
这个系列要解决的问题很具体:让你搞清楚每一层 cache 分别在哪里、怎么查看、怎么调、什么时候该清、什么时候该留。适合两类人——一类是刚配好深度学习环境、发现 GPU 利用率忽高忽低的;另一类是模型能跑但吞吐上不去、想从系统层面找瓶颈的。不涉及具体算法推导,只讲工程落地。
2. KV cache 与显存:推理加速的核心机制和显存账本
2.1 自回归推理为什么离不开 KV cache
Transformer 做自回归生成时,每生成一个新 token,都需要拿当前 token 的 query 去和前面所有 token 的 key、value 做注意力计算。如果没有缓存,每步都要把整个序列重新算一遍 K 和 V,计算量随序列长度平方增长。KV cache 的思路很直接:把每一步算出来的 K、V 存下来,下一步直接复用,只算新 token 的 K、V。这样每步的计算量从 O(n²) 降到 O(n)。
代价是显存。KV cache 的大小可以用一个公式估算:
KV cache 显存 ≈ 2 × batch_size × seq_len × num_layers × num_heads × head_dim × dtype_bytes以 LLaMA-7B 为例,32 层、32 头、head_dim=128、fp16(2 字节),单条 2048 序列的 KV cache 大约是 2×1×2048×32×32×128×2 ≈ 1.07 GB。batch size 开到 16,光 KV cache 就吃掉 17 GB,这还没算模型权重和激活值。所以推理服务里 batch size 上不去,很多时候不是权重放不下,是 KV cache 放不下。
2.2 用代码实测 KV cache 的显存占用
下面这段代码在 PyTorch 里手动模拟 KV cache 的分配,帮你建立直觉:
import torch def kv_cache_size(batch_size, seq_len, num_layers, num_heads, head_dim, dtype=torch.float16): """计算 KV cache 显存占用(字节)""" bytes_per_element = torch.tensor([], dtype=dtype).element_size() # 2 表示 K 和 V 各一份 total_elements = 2 * batch_size * seq_len * num_layers * num_heads * head_dim total_bytes = total_elements * bytes_per_element return total_bytes # LLaMA-7B 配置 size = kv_cache_size( batch_size=16, seq_len=2048, num_layers=32, num_heads=32, head_dim=128, dtype=torch.float16 ) print(f"KV cache 占用: {size / 1024**3:.2f} GB") # 输出: KV cache 占用: 16.00 GB这段代码的逻辑很直白:把 K 和 V 两份缓存的元素总数算出来,乘以 dtype 的字节数。参数里num_layers和num_heads直接决定缓存规模,seq_len是线性因子,batch_size也是线性因子。实际部署时,如果你用的是 HuggingFace transformers,可以通过model.config拿到这些值。注意head_dim不一定等于hidden_size / num_heads,有些模型(比如 LLaMA)的 head_dim 是独立配置的,别算错。
2.3 PagedAttention 和 KV cache 的显存碎片问题
朴素 KV cache 实现有个坑:它要求为每条序列预分配一段连续显存,长度按最大可能序列来。但实际生成时序列长度是动态的,预分配多了浪费,少了又不够。更麻烦的是显存碎片——不同序列长短不一,释放后留下的空洞没法复用。
vLLM 提出的 PagedAttention 借鉴了操作系统虚拟内存分页的思路,把 KV cache 切成固定大小的 block(通常 16 个 token 一个 block),用 block table 做逻辑到物理的映射。这样显存利用率能从朴素实现的 20%~40% 提到 90% 以上。如果你在用 vLLM 部署推理服务,gpu_memory_utilization这个参数控制的就是留给 KV cache 的显存比例,默认 0.9。调低它会减少并发吞吐,调高可能 OOM,一般建议从 0.85 开始试。
提示:用
nvidia-smi看到的显存占用包含 CUDA context、模型权重、激活值和 KV cache,不要把所有占用都算到 KV cache 头上。想看纯 KV cache 占用,用 vLLM 的 metrics 接口或者 PyTorch 的torch.cuda.memory_allocated()做差值。
3. Linux page cache 与数据加载:被忽视的训练吞吐瓶颈
3.1 page cache 怎么影响 DataLoader 的吞吐
深度学习训练里,数据加载经常是瓶颈。你 GPU 利用率上不去,nvidia-smi显示 GPU 利用率在 30%~60% 之间跳,大概率是 DataLoader 喂不饱。而 DataLoader 的吞吐又和 Linux page cache 直接相关。
page cache 是内核用空闲内存缓存磁盘文件内容的机制。第一次读一个数据集文件,数据从磁盘进 page cache 再进用户态;第二次读同一个文件,如果 page cache 还在,就直接从内存拿,不走磁盘。对于图像数据集这种大量小文件随机读的场景,page cache 命中率决定了你的数据加载速度能差一个数量级。
查看当前 page cache 状态:
# 查看内存和 cache 使用情况 free -h # 查看 page cache 详细统计 cat /proc/meminfo | grep -E "Cached|Buffers|Dirty" # 查看某个进程的 page cache 命中情况 sudo perf stat -e cache-references,cache-misses -p <pid>free -h里的buff/cache列就是 page cache 加 buffer 的总量。如果这个值很大而available还够,说明内核在积极缓存文件,这是好事。但如果你的数据集比内存大得多,page cache 频繁换入换出,反而会拖慢加载。
3.2 用 vmtouch 和 fadvise 控制数据集缓存策略
vmtouch是个轻量工具,能查看和锁定文件在 page cache 里的状态:
# 安装 sudo apt install vmtouch # 查看数据集目录有多少在 page cache 里 vmtouch /data/imagenet/train/ # 把整个数据集锁进内存(适合内存足够大的场景) vmtouch -t /data/imagenet/train/ # 把数据集从 page cache 里踢出去(适合内存紧张时释放) vmtouch -e /data/imagenet/train/vmtouch -t会遍历目录下所有文件并 touch 一遍,把它们加载进 page cache。如果你的训练集是 50 GB 而机器有 128 GB 内存,训练前跑一次vmtouch -t,第一个 epoch 就能跑出后续 epoch 的速度。反过来,如果内存不够,用vmtouch -e主动释放,避免 page cache 挤占训练进程的匿名内存导致 OOM。
另一个更细粒度的控制是posix_fadvise,可以在代码里告诉内核「这个文件我接下来要顺序读,你预读多一点」或者「这个文件我读一次就不用了,别缓存」:
import os import mmap def advise_sequential(filepath): """告诉内核这个文件会被顺序读取,加大预读""" fd = os.open(filepath, os.O_RDONLY) # POSIX_FADV_SEQUENTIAL: 顺序访问,内核会加大预读窗口 os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_SEQUENTIAL) # POSIX_FADV_WILLNEED: 提前把数据读进 page cache os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_WILLNEED) return fd fd = advise_sequential("/data/imagenet/train/n01440764_10026.JPEG") # 后续用 os.read 或 mmap 读取 os.close(fd)POSIX_FADV_SEQUENTIAL会让内核把预读窗口翻倍,适合大文件顺序读。POSIX_FADV_WILLNEED是异步预读,不阻塞当前调用。POSIX_FADV_DONTNEED则相反,告诉内核读完就释放,适合一次性遍历的大文件。这几个 flag 在 DataLoader 的__getitem__里按需调用,能明显改善小文件随机读的性能。
3.3 DataLoader 的 num_workers 和 page cache 的配合
num_workers设多少合适,和 page cache 命中率强相关。如果数据集完全在 page cache 里,worker 进程读数据几乎不阻塞,num_workers设成 CPU 核数的 1~2 倍就够。如果数据集在磁盘上且 page cache 命中率低,加 worker 只是让更多进程一起等 IO,反而增加上下文切换开销。
一个实用的判断方法:训练时开iostat -x 1,看%util和await。如果%util接近 100% 且await很高,说明磁盘是瓶颈,加 worker 没用,得换 NVMe 或者加大内存让 page cache 装下数据集。如果%util很低但 GPU 利用率也低,说明瓶颈在 CPU 预处理(解码、增广),这时候加 worker 才有效。
注意:
num_workers > 0时,每个 worker 是独立进程,page cache 是内核级的、所有进程共享,所以 worker 之间不会重复缓存同一份数据。但 worker 进程自己的 Python 对象(比如解码后的 tensor)是各自独立的,这部分内存会随 worker 数线性增长。
4. 框架层 cache:cuDNN autotune、编译缓存与算子缓存
4.1 cuDNN benchmark 的 autotune cache
PyTorch 里有个经典设置:torch.backends.cudnn.benchmark = True。打开后,cuDNN 会在第一次遇到某个卷积配置时,跑多种算法(FFT、Winograd、implicit GEMM 等),选最快的那个,把结果缓存起来。后续遇到相同配置直接查缓存。
这个 cache 的 key 是卷积的输入尺寸、kernel 大小、stride、padding、dtype 等参数的组合。如果你的输入尺寸固定(比如分类任务固定 224×224),打开 benchmark 能提速 10%~30%。但如果输入尺寸变化频繁(比如检测任务多尺度训练),每次尺寸变化都触发一轮 autotune,反而拖慢训练。
import torch # 输入尺寸固定的场景,打开 benchmark torch.backends.cudnn.benchmark = True # 输入尺寸变化频繁的场景,关掉 benchmark,用默认算法 # torch.backends.cudnn.benchmark = False # 查看当前是否开启 print(torch.backends.cudnn.benchmark)autotune 的结果存在内存里,进程退出就没了。如果你每次启动训练都要花几分钟做 autotune,可以考虑用torch.backends.cudnn.benchmark_limit限制尝试的算法数量,或者把输入尺寸固定下来。
4.2 TorchInductor 的编译缓存
PyTorch 2.x 的torch.compile会在第一次运行时把模型图编译成 Triton kernel,编译结果缓存在磁盘上。默认路径是/tmp/torchinductor_<username>/。第二次运行相同模型时,如果缓存命中,启动时间能从几十秒降到几秒。
# 查看编译缓存目录 ls -lh /tmp/torchinductor_$(whoami)/ # 设置缓存目录到更大的磁盘 export TORCHINDUCTOR_CACHE_DIR=/data/torch_cache # 清空编译缓存(模型结构改了之后建议清一次) rm -rf /tmp/torchinductor_$(whoami)/编译缓存的 key 包含模型图结构、输入 shape、dtype、以及 PyTorch 版本。如果你升级了 PyTorch 或者改了模型结构,旧缓存不会命中,但也不会自动删除,会一直占磁盘。定期清理是个好习惯。另外,TORCHINDUCTOR_CACHE_DIR指向的磁盘如果 IO 慢,编译缓存的读写反而会成为启动瓶颈,建议放在 NVMe 上。
4.3 算子缓存和 CUDA graph 的配合
CUDA graph 是另一种 cache 思路:把一串 kernel launch 录制成一个图,后续用一次 launch 提交整个图,省掉每个 kernel 的 launch 开销。对于小 kernel 多的模型(比如 Transformer 的 attention 里一堆小算子),CUDA graph 能明显降低 CPU 侧开销。
但 CUDA graph 要求输入输出的内存地址固定,所以通常配合静态 buffer 使用。PyTorch 里可以用torch.cuda.graphs.CUDAGraph手动录制,也可以用torch.compile(mode="reduce-overhead")让编译器自动处理。
import torch # 手动录制 CUDA graph 的简化示例 static_input = torch.randn(16, 3, 224, 224, device='cuda') static_output = torch.empty(16, 1000, device='cuda') # 预热(必须在录制前跑几次,让 cuDNN autotune 完成) for _ in range(3): static_output.copy_(model(static_input)) graph = torch.cuda.CUDAGraph() with torch.cuda.graph(graph): static_output.copy_(model(static_input)) # 后续推理直接 replay graph.replay()这段代码的关键点是预热。CUDA graph 录制时不能有动态内存分配,所以 cuDNN autotune 必须在录制前完成。预热 3 次是常见做法,确保所有算子都选好了算法、分配好了 workspace。录制后的graph.replay()开销极低,适合推理服务里固定 shape 的场景。
提示:CUDA graph 和 KV cache 配合时要小心。KV cache 的长度是动态增长的,如果每步都重新录制 graph,开销比省下来的还大。常见做法是把 KV cache 预分配到最大长度,用 attention mask 控制实际使用的部分,这样 shape 固定,graph 可以复用。
5. 避坑与排查:cache 相关的 5 个血泪教训
5.1 现象:第二个 epoch 突然变快,以为模型有问题
原因:第一个 epoch 在从磁盘读数据,page cache 逐渐填充;第二个 epoch 数据已经在内存里,加载速度大幅提升。这是正常现象,不是模型或代码的问题。
解决:训练前用vmtouch -t把数据集预热进 page cache,让第一个 epoch 就跑出正常速度。如果内存装不下整个数据集,至少把验证集和常用训练子集预热。
5.2 现象:打开 cudnn.benchmark 后训练反而变慢
原因:输入尺寸不固定,每次 shape 变化都触发一轮 autotune,autotune 本身的开销超过了它带来的加速。
解决:确认输入尺寸是否固定。如果做多尺度训练,关掉 benchmark,或者把尺度限制在几个固定值上,让 autotune 缓存能命中。也可以用torch.backends.cudnn.deterministic = True配合固定算法,牺牲一点速度换稳定性。
5.3 现象:vLLM 推理服务吞吐上不去,GPU 利用率低
原因:gpu_memory_utilization设得太保守,留给 KV cache 的显存不够,并发请求被排队。或者max_num_seqs设得太小,限制了同时处理的序列数。
解决:逐步调高gpu_memory_utilization(从 0.85 到 0.95),观察是否 OOM。同时检查max_num_seqs和max_model_len,确保 KV cache 能容纳预期的并发量。用 vLLM 的/metrics接口看gpu_cache_usage_perc,如果接近 1.0 说明 KV cache 是瓶颈。
5.4 现象:torch.compile 后第一次运行极慢,后续正常
原因:第一次运行在做图编译和 Triton kernel 生成,编译结果写入磁盘缓存。后续运行命中缓存,所以快。
解决:这是预期行为。如果启动时间敏感,可以在服务启动时跑一次 warmup,把编译缓存建好。注意TORCHINDUCTOR_CACHE_DIR要指向持久化磁盘,别放在/tmp里被清理掉。升级 PyTorch 版本后缓存会失效,需要重新 warmup。
5.5 现象:训练到一半 OOM,但显存监控显示还有余量
原因:page cache 占用了大量内存,当训练进程需要更多匿名内存时,内核回收 page cache 不及时,触发 OOM killer。或者 CUDA 的显存碎片导致虽然总空闲显存够,但没有连续的大块可用。
解决:用vmtouch -e释放不必要的 page cache,或者用echo 3 > /proc/sys/vm/drop_caches清空(需要 root)。显存碎片问题可以用PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True让 PyTorch 用可扩展段分配器,减少碎片。
6. 进阶技巧:用 cache 命中率做训练性能回归监控
前面讲的都是单点优化,这一章讲怎么把 cache 指标变成持续监控的一部分。我自己的习惯是在训练脚本里加一段轻量的性能埋点,每个 epoch 记录几个关键指标:GPU 利用率、page cache 命中率、DataLoader 等待时间、KV cache 占用(推理场景)。这些指标不需要很精确,但能帮你在性能退化时快速定位是哪一层出了问题。
具体做法是用pynvml读 GPU 利用率,用/proc/meminfo算 page cache 变化量,用time.perf_counter()包住 DataLoader 的迭代。下面是一个最小实现:
import time import pynvml from pathlib import Path pynvml.nvmlInit() handle = pynvml.nvmlDeviceGetHandleByIndex(0) def read_page_cache_gb(): """从 /proc/meminfo 读 page cache 大小(GB)""" with open("/proc/meminfo") as f: for line in f: if line.startswith("Cached:"): kb = int(line.split()[1]) return kb / 1024 / 1024 return 0.0 def log_epoch_metrics(epoch, dataloader, model): gpu_util = pynvml.nvmlDeviceGetUtilizationRates(handle).gpu cache_before = read_page_cache_gb() t0 = time.perf_counter() for batch in dataloader: pass # 实际训练逻辑 load_time = time.perf_counter() - t0 cache_after = read_page_cache_gb() print(f"epoch={epoch} gpu_util={gpu_util}% " f"load_time={load_time:.2f}s " f"page_cache_delta={cache_after - cache_before:.2f}GB")这段代码的逻辑是:每个 epoch 开始时读一次 page cache 大小,遍历完 DataLoader 后再读一次,差值反映这个 epoch 从磁盘加载了多少新数据。如果某个 epoch 的page_cache_delta接近 0 但load_time仍然很高,说明瓶颈不在磁盘 IO,而在 CPU 预处理。如果gpu_util长期低于 70% 且load_time占比高,就该考虑加 page cache 或者换更快的存储。
参数上,pynvml的nvmlDeviceGetUtilizationRates返回的是瞬时值,采样频率取决于你调用它的频率。我一般每个 epoch 采一次,够用。如果要更细的粒度,可以起一个后台线程每秒采一次,取平均。/proc/meminfo的Cached字段包含 page cache 和 tmpfs,如果你有 tmpfs 挂载,需要减去对应部分,不过训练场景里一般可以忽略。
这套监控跑一段时间后,你会对「正常」的 cache 行为有一个基线认知。比如我的一个图像分类任务,基线是gpu_util92%~95%,page_cache_delta第一个 epoch 约等于数据集大小,后续 epoch 接近 0。某次换了数据增强库之后,gpu_util掉到 78%,page_cache_delta没变,load_time涨了 40%,定位到是新库的 CPU 解码变慢了。如果没有这套埋点,可能就要花半天去猜是模型问题还是数据问题。
最后说一个我踩过的坑:别在训练进程里频繁调用drop_caches。有次我为了「保持内存干净」,在每个 epoch 结束后清一次 page cache,结果第二个 epoch 开始每个 epoch 都从磁盘重新读数据,训练时间直接翻倍。page cache 是朋友不是敌人,除非内存真的不够,否则不要主动清。希望帮到你。
本文还有配套的精品资源,点击获取