
简介这份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 cacheTransformer 做自回归生成时每生成一个新 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_dim128、fp162 字节单条 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, dtypetorch.float16): 计算 KV cache 显存占用字节 bytes_per_element torch.tensor([], dtypedtype).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_size16, seq_len2048, num_layers32, num_heads32, head_dim128, dtypetorch.float16 ) print(fKV 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 pidfree -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 核数的 12 倍就够。如果数据集在磁盘上且 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. 框架层 cachecuDNN autotune、编译缓存与算子缓存4.1 cuDNN benchmark 的 autotune cachePyTorch 里有个经典设置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(modereduce-overhead)让编译器自动处理。import torch # 手动录制 CUDA graph 的简化示例 static_input torch.randn(16, 3, 224, 224, devicecuda) static_output torch.empty(16, 1000, devicecuda) # 预热必须在录制前跑几次让 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 变化都触发一轮 autotuneautotune 本身的开销超过了它带来的加速。解决确认输入尺寸是否固定。如果做多尺度训练关掉 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_CONFexpandable_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(fepoch{epoch} gpu_util{gpu_util}% fload_time{load_time:.2f}s fpage_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 是朋友不是敌人除非内存真的不够否则不要主动清。希望帮到你。本文还有配套的精品资源点击获取