
聊到英伟达MMU估计不少人第一反应是显卡而已搞什么内存管理单元说实话我最初接触GPU虚拟内存也是这个态度直到某次在CUDA项目里拿一张16GB显存的卡跑需要32GB数据集的算法整天在cudaMemcpy里打转改到怀疑人生才意识到显存寻址这件事远比想象中复杂。英伟达MMU从无到有再进化到Grace-Hopper上CPU与GPU的硬件级统一内存几乎就是一部GPU从图形协处理器走向通用计算平台的演进史。这篇文章从一个经常跟显存打交道的开发者视角聊聊MMU的设计变迁、页表和TLB这些底层机制以及驱动里那些围绕虚拟地址的日常。1. 从无虚拟内存时代说起早期显卡是怎么寻址的1.1 一片显存一片天直接拿物理地址操作在Fermi架构之前英伟达GPU根本没有完整意义上的虚拟内存系统。显卡上那几百MB到几GB显存对驱动来说就是一块线性连续的资源区。驱动要做的事情很简单把物理显存切成若干块按规定好的地址范围分配给不同任务。显卡里的图形引擎、顶点引擎、纹理引擎各自访问固定偏移的显存区根本没有“地址翻译”这回事。这种方式在固定功能管线的时代勉强能用但代价很大。显存分配必须是连续的导致大量碎片化多个进程同时使用GPU时驱动必须在切换进程时保存和恢复一大堆寄存器状态非常粗暴更麻烦的是显卡要访问系统内存做DMA传输基本依赖AGP和PCI的地址窗口没有统一抽象。我早期调过G80时代的MCP内存控制问题印象最深的是只要一个context把显存里的数据布局弄乱轻则花屏重则整机挂掉。那时候的程序员想保护自己的内存块完全靠“约定”大家都别碰对方预定的地址段。这在只有一两块显卡的桌面机上能容忍放到服务器和虚拟化场景里就是灾难。1.2 Fermi开始动真格给GPU加一张“地图”2010年英伟达发布Fermi架构时一个容易被忽略但极其重要的变化是GPU第一次拥有了完整的虚拟内存系统。GF100的MMU支持为每个进程维护独立的页表虚拟地址可以映射到设备显存、系统内存甚至其他PCIe设备的内存。比如你申请一块GPU显存物理上可能被拆成好几个不连续片段但驱动通过页表把它们拟合成一段连续的虚拟地址空间GPU内部访问时自动完成翻译。这对驱动开发是革命性的。以前申请显存是“找一块连续空地”现在变成了“分配若干物理页在页表里把虚拟地址连起来”不用再管物理连续性。同时进程切换开销大幅下降因为只需要切换页表基址指针和刷新相关缓存而不是保存恢复一大堆上下文状态。我看过Fermi的白皮书里面专门花了不少篇幅讲“Memory Management”和“Address Translation”。它指出每个上下文都有自己的页表而且支持4KB和2MB两种页面粒度。这个设计思路后来一直延续下来哪怕是今天Blackwell架构里的核心机制仍然能追溯到Fermi打下的这个地基。1.3 把地址统一起来CUDA的UVA硬件MMU就位后软件层面紧跟一步推出了CUDA 3.0时代的“统一虚拟地址”Unified Virtual Addressing, UVA。在UVA之前用CUDA编程得区分设备内存指针和主机内存指针两个地址空间互不相通通信全靠显式cudaMemcpy。UVA之后主机内存和设备内存共用一套64位虚拟地址体系。你拿到一个指针不好直接判断它到底在主机侧还是设备侧但可以通过驱动查询。UVA最直观的价值是简化了API比如cudaMemcpy可以自动判断copy方向。但从MMU角度看它更大的意义是验证了一件事GPU的页表可以同时映射多个物理来源。后来的Unified Memory统一内存能在Pascal上落地正是基于这套从Fermi延续下来的地址翻译框架。2. 页表、TLB与大页GPU寻址的地基2.1 多级页表的设计思路现代英伟达GPU的MMU采用多级页表结构类似CPU的x86-64地址翻译但实现细节上有很大差别。页表本质上就是一个字典树用虚拟地址的高位查第一级目录再逐级往下最终找到物理页框号。GPU内部做一次地址翻译要经过多级查表查表过程中访问的页表项本身可能也要经历缓存和内存读取。页粒度方面我实际开发中经常接触到的组合是4KB小页和2MB大页后续架构还加入了64KB甚至更大的页面。驱动在分配显存时会尽量把连续物理内存合并成大页映射好处是减少页表条目数量、缩小页表占用的显存、减少TLB缺失。但大页不是免费午餐分配和迁移的粒度粗如果程序实际只用了其中一小部分浪费就明显了。我自己做推理引擎显存池时踩过这个坑一开始全部按2MB大页分配结果小batch场景下内存碎片率反而上升因为每次必须占用完整的2MB。后来改成混合策略——大块常驻权重用大页动态张量用小页一下子就顺了。可见页粒度选择不是“越大越好”而是看访问模式。2.2 两级TLB每个SM都有一份小抄地址翻译如果每次都走页表性能会很难看所以GPU芯片里设计了TLBTranslation Lookaside Buffer地址转换后备缓冲器。英伟达GPU的TLB分两级第一级是每个SM私有的小TLB负责该SM内部线程块最近的地址翻译第二级是更大、跨SM共享的L2 TLB。当某个线程访问的地址在L1 TLB未命中时请求转到L2 TLB如果L2也没有才会真正去做页表walk。TLB命中和未命的差距非常大。CPU的TLB miss大约是几百个周期GPU因为地址翻译要走多级页表miss的成本更贵。这也是为什么GPU对“稀疏随机访问”特别敏感——如果一个kernel的访存模式把大量线程的地址撒得很开TLB就会频繁失效性能可能掉一个数量级。很多人写CUDA时只关注Global Memory的合并访问其实TLB locality同样值得留意。我调试过的一个案例两个kernel做同样的矩阵计算第二个只是把循环索引顺序换了一下结果就慢了30%。用profiler一查Global Load的TLB命中率从接近95%掉到60%原因就是地址访问从线形变成了按列跳着走L2 TLB频繁置换。改成按块分组后再测性能恢复。这个故事说明GPU里的虚拟内存不只是驱动操心的事写kernel的人也得心中有数。2.3 GPU的MMU和CPU的MMU有什么不一样如果把GPU的MMU和CPU的MMU对比你会发现几个关键差异。CPU通常只有几核到几十核TLB设计偏向低延迟一旦miss就快速中断、硬件walk甚至借助多级页表缓存GPU则可能同时有数千个线程在跑访存流是高度并发的所以MMU必须能支持大量并行地址翻译同时减少同一页面上的重复工作。另一个差异是CPU的MMU处理的是“一个进程一个地址空间”而GPU在同一个芯片上可能要同时为多个context、多个地址空间提供服务页表切换必须非常快。还有一个容易忽略的点CPU MMU主要面向普通内存而GPU MMU需要处理的物理来源更复杂包括显存、系统内存、PCIe peer内存还要处理peer-to-peer传输。这就导致驱动层不得不做更多迂回设计比如维护多个内存域的映射关系、在迁移时同步两套页表。所有这些复杂性到了Pascal架构开始引入硬件页错误处理之后才真正浮上水面。3. Pascal的分水岭GPU终于敢处理页错误3.1 为什么页错误对GPU是件大事在Pascal之前GPU访问一个未映射的虚拟地址通常意味着kernel直接报错或者整个context被毁掉。没有硬件页错误处理GPU就无法做到“按需加载”内存。比如你分配了一块很大的managed内存但一次只访问一小部分传统方案就得全量分配要么就必须靠驱动提前猜。PascalP100引入的关键能力是GPU可以产生页错误并暂停、恢复访问流。当某个SM访问的地址在页表中不存在时MMU会记录出错的地址、触发异常通知到主机驱动驱动再决定怎么处理可能是迁移页面也可能是分配新页然后让GPU重新执行访存。这听起来简单却是从“GPU只能访问已存在内存”到“GPU可按需触发内存内容移动”的质变。3.2 Unified Memory的硬件实现Unified Memory统一内存在CUDA里表现为cudaMallocManaged分配的内存CPU和GPU都可以用指针直接访问。早期实现更多靠驱动在不同阶段做整段迁移效率不高Pascal之后UVM具备了硬件加速的按需页迁移能力。具体流程大概是GPU访问某个虚拟页发现不在本地显存就触发页错误驱动按页迁移数据更新GPU页表并让访问继续。其中页迁移的本质是数据的copy。NVIDIA的GPU里专门有copy engine做这件事迁移一个页的成本取决于页面大小和互联带宽。开发者的编程体验因此得到巨大改善可以把几十GB的数据塞进“虚拟统一空间”让数据在CPU和GPU之间按需流动不再需要手写一大套cudaMemcpy逻辑。我自己在Pascal时代第一次跑cudaMallocManaged时挺震撼一个循环里交替让CPU和GPU写同一个数组代码毫无同步但结果是对的。背后的页错误和迁移全由驱动兜底。当然震撼之后很快就被性能教育了——毫无访问规划的按需迁移会把程序拖成在拷贝数据中度日。3.3 用好统一内存的实践心得如果你真的想在项目里依赖UVM几点经验值得记牢。第一cudaMemAdvise要善用它能告诉驱动哪些内存页是read-mostly、应该优先放在哪一侧第二cudaMemPrefetch可以做显式预热把数据提前迁到目标设备避免运行时页错误的高昂开销第三对性能敏感的热循环尽量保证GPU侧的驻留页面足够多而不是频繁触发host侧页错误。我试过一个接近真实场景的测试数据集30GBGPU显存24GB。直接cudaMallocManaged然后无脑访问性能比显式cudaMemcpy低了一个量级但加上cudaMemAdvise把只读权重标记为prefer device、把热数据cudaMemPrefetch到GPU耗时立刻下降到接近手动管理内存的90%左右。结论很明确硬件帮你兜底不代表你可以放弃给向导。4. 虚拟化时代的地址转换MMU怎么撑起vGPU和MIG4.1 直通与IOMMUGPU被整个塞进虚拟机服务器场景里最常见的需求是把一张GPU直接给某个虚拟机用这就是PCIe直通pass-through。做法很直接用VFIO把设备绑定给虚拟机然后把PCIe BAR和中断重映射进受控环境。但这里有个问题GPU驱动在虚拟机里做DMA时发出的设备物理地址是虚拟机视角的地址不是宿主机真正的物理地址这个翻译必须由IOMMUIntel VT-d或AMD IOMMU完成。于是出现了两层地址翻译GPU自身的MMU先完成设备虚拟地址到设备物理地址的翻译然后IOMMU再把设备物理地址翻译成宿主机真实物理地址。两层翻译意味着更多的TLB压力和更频繁的地址缓存失效。所以很多虚拟化环境里GPU性能会比物理机有明显损耗IOMMU的TLB miss是重要原因之一。4.2 MIG把MMU的隔离做进硬件传统vGPU靠驱动和服务端进程模拟隔离性依赖软件多租户场景里只要一个guest崩溃其他受影响的风险并不小。A100带来的MIG多实例GPU改变了这个局面把GPU的SM、L2缓存、内存控制器、显存切片都物理切分到多个实例每个实例都有独立的MMU上下文和地址空间。虚拟地址隔离不再靠驱动纯软件约定而是硬件层面的强制隔离。这对数据中心的价值是实打实的。比如一张A100切成3个实例给三个团队分别使用每个实例看到的“显存”和“SM数量”都是独立配额一个实例的非法访存不会污染另一个实例的页表。我自己参与过MIG测试混合跑训练和推理任务互不干扰效果比软件vGPU稳定太多。4.3 PCIe ATS让GPU和IOMMU协同工作为了缓解两层翻译的TLB压力PCIe规范里定义了ATSAddress Translation Services。简单说ATS允许像GPU这样的设备主动向IOMMU发起地址转换请求并把转换结果缓存在自己内部。这样GPU自己的TLB就能缓存“设备物理地址到宿主机物理地址”的映射后续DMA访问就不用每次都找IOMMU。但ATS的启用依赖宿主机BIOS、CPU和GPU三方的支持任何一环不给力路径就退化成每次都过IOMMU。我遇到过一台服务器开了虚拟化后GPU吞吐下降接近20%查了一圈最后发现是BIOS里PCIe ATS选项被默认关闭。打开之后性能恢复。所以如果你做虚拟化或者直通性能调优建议不要忽略这个看起来不起眼的开关。5. Grace-Hopper时代MMU走向“更大的一致域”5.1 CPU和GPU真正住在同一个地址世界里到了Grace-HopperGH200英伟达把Arm架构的Grace CPU和Hopper GPU用NVLink-C2C连在一起而且是内存一致性的连接。这意味着CPU和GPU共享同一物理地址空间CPU可以直接load/store访问GPU的HBMGPU也可以访问CPU侧的LPDDR5X无需再通过页错误和显式拷贝来“搬运”。这个设计让MMU的角色发生了微妙变化。GPU的MMU不再只是“把设备虚拟地址翻译到显存”而要把地址翻译的目标扩展到CPU内存域反过来CPU的MMU也要能访问GPU内存。两边硬件的页表缓存必须协同维持跨芯片一致性。这已经不仅仅是显卡的MMU而是一个异构计算节点的统一MMU系统。5.2 一致性不等于免性能思考GH200被很多做超大模型训练的人追捧因为它省去了大量数据搬移的工程复杂度。但“一致性访问”不等于“任意访问都很快”。GPU侧HBM带宽远高于CPU侧内存CPU访问HBM走NVLink-C2C延迟仍然比访问本机LPDDR5X高不少。如果不关心数据放置一股脑把数据分配在GPU侧又让CPU频繁去访问性能照样拉跨。我在GH200开发机上的实践是把权重和梯度放GPU侧把控制流数据、日志、低频的预处理结果放CPU侧让两个处理器尽量访问本地内存跨域访问只发生在必要的数据交换边界。虽然硬件已经允许偷懒但从性能出发还是要沿用老一套“数据和计算靠近”的原则。5.3 数据中心里的进一步延伸从Blackwell开始GPU的MMU已经不只在GPU内部工作还要面对更复杂的数据中心拓扑多GPU之间通过NVLink交换数据GPU和远端存储之间又可能走NVMe over Fabrics或RDMA。GPU要访问的物理域越来越多MMU需要维护的映射表、地址权限和信息隔离要求也越来越复杂。在前沿平台比如英伟达路线图上的Rubin上我预期会看到更精细的页粒度和更完善的跨设备地址管理机制。MMU在芯片内部的存在感会越来越强但程序员能接触到的抽象接口会更简单。底层硬件负责把复杂的寻址和一致性搞定用户只需要把注意力放在算法和性能布局上。这和GPU从一个图形芯片走向AI计算核心的路线是一致的。6. 驱动与排障视角MMU问题离你其实不远6.1 Linux驱动源码里MMU的代码在哪里不少人在Linux下用过NVIDIA驱动但从没想过它的源码里藏着MMU的大量实现。以传统runfile安装的驱动为例解包或编译目录下通常有nvidia/nv-mem.c、nvidia/nv-memdbg.c、nvidia/nv-vm.c它们负责内存分配、地址映射和虚拟内存管理还有一个独立的uvm子目录专门实现Unified Virtual Memory——也就是UVM层负责页错误处理、页迁移、managed memory等核心逻辑。如果你要排查和虚拟内存相关的驱动问题我建议直接登录后有NVIDIA驱动源码包的机器上搜索关键字比如grep -i mmu nvidia/或者看uvm/uvm.c里的fault处理路径。对大多数普通开发者来说不需要完整读懂这些代码但至少要知道驱动初始化时分配了页表、设置了地址转换规则、注册了fault callback这些动作一旦失败就会表现为设备初始化失败或kernel module加载异常。6.2 内核态UVM的页错误流程UVM的全套逻辑挺庞大但核心流程值得了解。GPU发起访存后如果发生mmu faultMMU会把fault地址记下来并通过中断通知驱动驱动内核态UVM模块收到通知先定位出错的内存对象再决定是分配新页、迁移数据页还是返回错误。如果是CPU侧发起访问但目标页面目前在GPU显存CPU的页错误同样会进入UVM处理流程反向迁移页面。我在自己写的UVM测试demo里故意制造过几百次连续页错误然后看驱动日志能清晰看到每一步fault页面地址、迁移大小、目标内存类型、甚至是copy engine的进度。这种可视化的反馈对理解统一内存的代价特别直观。建议对底层感兴趣的朋友都试一次。6.3 常见驱动异常里的MMU影子很多热词其实都能在MMU层面找到影子。比如WSL2里的英伟达驱动它走的是宿主Windows的虚拟GPU路径Linux侧看到的不是真实PCIe设备而是通过/dev/dxg抽象过来的虚拟设备地址映射基本由宿主机Hypervisor控制。所以WSL2里“驱动有没有生效”和传统Linux安装完全是两码事经常出现Windows驱动正常、WSL里看不到GPU或者相关功能失效的情况。再比如Windows设备管理器里常见的错误代码43本质是设备启动失败。英伟达驱动初始化时如果BAR映射失败、MMIO访问异常、MMU相关资源冲突就可能报43。排查时除了常规重装驱动要检查系统是否开了Hyper-V或内核隔离Device Guard这些功能会改变设备内存映射方式有玩家在BIOS里关掉Re-Size BAR Support后反倒修好了43也是因为地址窗口大小和重映射策略改变影响了驱动初始化。还有装了驱动却找不到NVIDIA控制面板的问题多见于Linux下没装nvidia-settings或runfile装驱动时用了--no-opengl-files之类参数导致面板依赖组件缺失。这个问题倒不涉及MMU但说明驱动组件链的完整性确实会影响你能看到和调用的功能面。6.4 观察GPU内存状态的一些土办法排查MMU相关问题不一定非得写CUDA代码。nvidia-smi -q能看到显存总量、使用率、BAR1 Memory Usage等信息BAR1是显卡暴露给CPU的地址窗口它的大小和映射情况直接关系到DMA和地址转换。如果BAR1用量异常高往往说明驱动或应用在频繁走映射传输而不是走更高效的peer映射。更细一层如果你用CUDA的driver API可以通过cuMemGetAllocationPropertiesFromHandle查询某个分配对象的属性在CUDA 11.2之后还支持更精细的分配池cuMemPool和映射属性。这些接口能让你确认自己拿到的分配是不是按大页做的。想测TLB影响的话可以用Nsight Compute这类profiler跑一遍内存密集kernel观察访存模式和缓存相关指标虽然不一定直接显示TLB但结合代码和地址分布的判断基本能定位问题。结尾最后说点个人体会。从Fermi打下虚拟内存的地基到Pascal引入页错误和按需迁移再到Grace-Hopper把CPU和GPU放进同一个物理地址空间英伟达MMU的核心目标一直没变让程序员的寻址体验更简单同时让硬件能处理更复杂的来源和更细粒度的大并发访问。这不是一个孤立的硬件模块演进而是和CUDA编程模型、驱动架构、虚拟化技术一起螺旋上升的。对于普通开发者和运维人员哪怕你从不写驱动也应该理解页表、大页和TLB这些概念因为GPU虚拟内存相关的问题最终几乎都会以性能下降或设备报错的形式反馈到这里。下次再遇到莫名奇怪的显存表现先别急着怀疑卡坏了查查页粒度和映射路径可能有奇效。