Windows / Linux GPU 虚拟内存布局差异与 CUDA 越界访问行为
1. 问题背景
一个 CUDA 程序中, 某个 buffer 被分配了 1 MB (262,144 floats), 但 kernel 写入了 256 MB (67,108,864 floats), 产生 255 MB 的越界写入。
同一个 buffer overflow, 两种完全不同的运行表现:
| 平台 | GPU 驱动模式 | 越界写入行为 | 运行结果 |
|---|---|---|---|
| Windows | WDDM | 写入相邻有效分配, 无 fault | 静默数据损坏, 程序继续 |
| Linux | TCC / NVIDIA Driver | 写入命中 guard page, GPU MMU fault | cudaErrorIllegalAddress, 程序崩溃 |
本文档详细解释两种平台的 GPU 虚拟内存管理机制差异, 以及为什么同一个 bug 在 Windows 上"不发生"而在 Linux 上"发生"。
2. GPU 虚拟内存基础
2.1 GPU MMU (Memory Management Unit)
现代 NVIDIA GPU 拥有独立的 MMU, 实现 GPU 虚拟地址空间 (GPU Virtual Address Space, VA space) 到 物理显存 (VRAM) 的映射:
GPU 虚拟地址空间 (VA) GPU 物理显存 (VRAM)
┌──────────────────┐ ┌──────────────────┐
│ VA: 0x1000 │─── 页表 ───→ │ PA: 0x0000 │
│ [有效映射] │ │ [物理页 A] │
├──────────────────┤ ├──────────────────┤
│ VA: 0x2000 │ │ PA: 0x1000 │
│ [未映射] │ ─── ✗ ─── │ │
├──────────────────┤ │ │
│ VA: 0x3000 │─── 页表 ───→ │ PA: 0x2000 │
│ [有效映射] │ │ [物理页 B] │
└──────────────────┘ └──────────────────┘
- 有效映射: 页表中存在该 VA → PA 的映射, GPU 访问正常
- 未映射 : 页表中不存在该 VA 的映射, GPU 访问触发 MMU fault
- MMU fault 会被 GPU 报告为
cudaErrorIllegalAddress("an illegal memory access was encountered")
2.2 cudaMalloc 的底层行为
cudaMalloc 在两个平台上都返回一个 GPU 虚拟地址, 但底层的内存分配机制完全不同:
| Windows (WDDM) | Linux (NVIDIA Driver) | |
|---|---|---|
| 底层分配器 | Windows GPU memory allocator (section objects) | NVIDIA driver + mmap |
| 页表管理 | Windows kernel MMU + GPU MMU | Linux kernel MMU + GPU MMU |
| 分配间隔 | 无 guard page | 有 guard page |
| 虚拟地址连续性 | 相邻分配紧密排列 | 相邻分配间有未映射间隔 |
3. Windows: WDDM 模式
3.1 WDDM (Windows Display Driver Model)
Windows 上的 CUDA 使用 WDDM 作为 GPU 驱动模型。WDDM 是 Windows 的标准图形驱动模型, GPU 内存由 Windows 内核的内存管理器统一管理。
3.2 分配机制
cudaMalloc 在 WDDM 模式下的调用链:
cudaMalloc(N)
→ CUDA runtime (cudart)
→ NVIDIA WDDM driver
→ Windows GPU memory allocator
→ DxgkCbAllocateMemory / section objects
→ 在 GPU VA space 中分配一段连续地址
→ 建立 VA → VRAM 的页表映射
→ 返回 GPU VA pointer
WDDM 的关键特性:
-
Section Object 模型 : 每个
cudaMalloc分配对应一个 Windows section object, GPU VA 从一个共享地址空间中分配 -
无 guard page : Windows GPU memory allocator 不在相邻分配间插入未映射的保护页 。两个连续
cudaMalloc返回的虚拟地址是紧密相邻的:cudaMalloc(256 MB) → VA = 0x10000000 (buffer_1)
cudaMalloc(256 MB) → VA = 0x20000000 (buffer_2) ← 紧邻 buffer_1
cudaMalloc( 1 MB) → VA = 0x30000000 (buffer_3) ← 紧邻 buffer_2
cudaMalloc(256 MB) → VA = 0x30100000 (buffer_4) ← 紧邻 buffer_3 -
延迟映射 (lazy mapping) : WDDM 可能不会立即建立 VA → VRAM 的页表映射, 而是在首次访问时触发缺页中断。但一旦建立, 这些映射就是有效的 --- 即使越界访问, 只要 VA 有映射, GPU MMU 就不会 fault。
3.3 越界访问行为
假设程序分配了 4 个 buffer, 其中 buffer_3 (1 MB) 远小于实际需要的 256 MB:
GPU 虚拟地址空间 (Windows WDDM):
┌────────────────────────────────────────────────┐
│ buffer_1 256 MB [有效映射] │ VA: 0x10000000
│ │
├────────────────────────────────────────────────┤
│ buffer_2 256 MB [有效映射] │ VA: 0x20000000
│ │
├────────────────────────────────────────────────┤
│ buffer_3 1 MB [有效映射] │ VA: 0x30000000
│ │ ← kernel 写 256 MB
│ │ ← 越界 255 MB
├────────────────────────────────────────────────┤ ← 无 guard page!
│ buffer_4 256 MB [有效映射] │ VA: 0x30100000
│ │ ← 越界的 255 MB 写入这里
├────────────────────────────────────────────────┤
│ ... │
└────────────────────────────────────────────────┘
当 kernel 向 buffer_3 (1 MB) 写入 256 MB 数据时:
- 前 1 MB : 写入
buffer_3的有效映射区域 → 正常 - 后 255 MB : 写入
buffer_4的有效映射区域 → GPU MMU 看到有效 VA 映射, 不触发 fault
GPU MMU 只检查 "这个虚拟地址是否有页表映射", 不检查 "这个虚拟地址是否属于当前分配的 buffer"。因此, 越界写入静默地覆盖了 buffer_4 的数据, 但程序不会崩溃。
3.4 为什么 Windows 这样设计
WDDM 的无 guard page 设计是 Windows GPU 内存管理器的实现选择, 可能原因包括:
- 性能: guard page 需要额外的页表空间和 MMU 查找开销, Windows 选择了更紧凑的布局
- 共享地址空间: WDDM 的 GPU VA 空间是多个进程共享的, Windows 使用 section object 模型, 分配按需从地址空间中划分, 不额外插入间隔
- 历史原因: WDDM 最初为图形渲染设计, 渲染管线通常不会越界访问, 所以不需要 guard page 保护
4. Linux: NVIDIA Driver + mmap 模式
4.1 NVIDIA 专有驱动
Linux 上的 CUDA 使用 NVIDIA 专有驱动 (nvidia.ko), 不使用 Linux 内核的 KMS/GPU 框架。NVIDIA 驱动内部实现了独立的 GPU 内存管理。
4.2 分配机制
cudaMalloc 在 Linux 模式下的调用链:
cudaMalloc(N)
→ CUDA runtime (cudart)
→ NVIDIA driver (nvidia.ko)
→ rm_alloc_memory (RM API)
→ 在 GPU VA space 中分配地址区间
→ 调用 Linux kernel mmap 建立用户空间映射
→ 在 GPU MMU 页表中建立 VA → VRAM 映射
→ 在分配区间末尾插入 unmapped guard page
→ 返回 GPU VA pointer
NVIDIA Linux 驱动的关键特性:
-
mmap集成 : NVIDIA 驱动通过 Linux 内核的mmap机制管理 GPU 内存映射。每个cudaMalloc对应一次mmap调用, 在进程地址空间中建立映射 -
Guard page (保护页) : NVIDIA 驱动在每次分配的末尾 插入未映射的虚拟地址区间 (guard page)。相邻分配之间有一段没有页表映射的地址空间:
cudaMalloc(256 MB) → VA = 0x7f0000000000 (buffer_1)
VA = 0x7f0010000000 ← guard page (未映射)
cudaMalloc(256 MB) → VA = 0x7f0010100000 (buffer_2)
VA = 0x7f0020100000 ← guard page (未映射)
cudaMalloc( 1 MB) → VA = 0x7f0020110000 (buffer_3)
VA = 0x7f0020120000 ← guard page (未映射) ★
cudaMalloc(256 MB) → VA = 0x7f0020130000 (buffer_4) -
独立页表 : NVIDIA 驱动维护自己的 GPU MMU 页表, 每个
cudaMalloc对应页表中的一个有效区间, 区间之外是未映射的
4.3 越界访问行为
GPU 虚拟地址空间 (Linux NVIDIA driver):
┌────────────────────────────────────────────────┐
│ buffer_1 256 MB [有效映射] │
│ │
├────────────────────────────────────────────────┤
│ ████████ guard page ████████ [未映射] ████████ │ ← 保护间隔
├────────────────────────────────────────────────┤
│ buffer_2 256 MB [有效映射] │
│ │
├────────────────────────────────────────────────┤
│ ████████ guard page ████████ [未映射] ████████ │ ← 保护间隔
├────────────────────────────────────────────────┤
│ buffer_3 1 MB [有效映射] │
│ │ ← kernel 写 256 MB
├────────────────────────────────────────────────┤ ← 命中 guard page!
│ ████████ guard page ████████ [未映射] ████████ │ ← GPU MMU fault!
│ → cudaErrorIllegalAddress │
├────────────────────────────────────────────────┤
│ buffer_4 256 MB [有效映射] │ ← 永远不会到达
│ │
├────────────────────────────────────────────────┤
│ ... │
└────────────────────────────────────────────────┘
当 kernel 向 buffer_3 (1 MB) 写入 256 MB 数据时:
- 前 1 MB : 写入
buffer_3的有效映射区域 → 正常 - 第 1 MB + 1 byte : 写入 guard page 的未映射区域 → GPU MMU 检测到未映射 VA, 触发 fault
- GPU 进入 error state , 报告
cudaErrorIllegalAddress buffer_4及后续所有数据不会被覆盖 (fault 阻止了写入)
4.4 为什么 Linux 有 guard page
NVIDIA Linux 驱动插入 guard page 的设计可能原因:
- 安全性: guard page 提供了硬件级别的 buffer overflow 检测, 帮助开发者尽早发现越界访问
mmap语义 : Linuxmmap天然支持非连续映射, 不同mmap区间之间是未映射的地址空间。NVIDIA 驱动利用了这一特性- 调试便利 : NVIDIA 在 Linux 上提供了更严格的内存保护, 配合
compute-sanitizer等工具可以精确定位越界 - UMA / NUMA 兼容: Linux 的内存管理需要处理更复杂的 NUMA 场景, guard page 是分配器粒度对齐的副产物
4.5 NVIDIA 驱动分配粒度
NVIDIA Linux 驱动的 cudaMalloc 有一个分配粒度 (allocation granularity) 概念:
-
GPU MMU 使用 4 KB 或更大的页大小
-
每次
cudaMalloc内部会向上对齐到驱动粒度 (通常是 2 MB 或更大, 取决于 GPU 架构) -
对齐后剩余的空间自然成为 guard page
cudaMalloc(1 MB):
请求大小: 1 MB
实际分配: 2 MB (向上对齐到粒度)
有效映射: 1 MB
剩余空间: 1 MB (未映射, 作为 guard page)
这意味着即使没有显式的 guard page, 分配对齐也会在相邻分配间产生未映射的间隔。
5. 对比: 同一次越界写入的两种命运
5.1 完整时间线
以 buffer_3 (1 MB) 溢出为例, 以下是同一 kernel 在两种平台上的执行过程:
Windows (WDDM) Linux (NVIDIA driver)
────────────── ─────────────────────
GPU VA 布局:
buffer_3 1MB [有效] buffer_3 1MB [有效]
buffer_4 256MB [有效] ███ guard page ███ [未映射]
buffer_4 256MB [有效]
Kernel 写入:
写 0..1MB (buffer_3) ✓ 正常 ✓ 正常
写 1MB..256MB (越界) ✓ 写入 buffer_4 ✗ 命中 guard page
(有效 VA, MMU 不 fault) (无效 VA, MMU fault!)
GPU 状态:
正常, 继续执行 error state
buffer_4 被覆盖 buffer_4 未被覆盖
后续 kernel:
正常执行 (但数据已损坏) 全部返回 cudaErrorIllegalAddress
(GPU 进入 error state 后所有
操作都返回该错误)
同步点 (cudaMemcpy):
正常完成 返回 cudaErrorIllegalAddress
(感知不到错误) (感知到延迟的错误)
5.2 数据损坏范围对比
Windows Linux
──────── ─────
buffer_3 (1 MB) ✓ 被正确写入 ✓ 被正确写入
─── 越界写入 255 MB ── ─── guard page 阻止越界 ──
buffer_4 ✗ 被覆写 255 MB ✓ 未被覆写
(被 buffer_3 的越界数据覆盖)
后果:
后续计算使用被污染的数据 程序崩溃, 无输出
整个 pipeline 输出错误
(但程序不报错!)
6. CUDA 异步执行与错误延迟
6.1 异步 Kernel Launch
CUDA kernel launch 是异步 的 --- host 端调用 <<<grid, block>>>() 后立即返回, GPU 在后台执行:
Host 线程:
──→ kernel<<<>>>() ──→ 立即返回 (不等 GPU 完成)
│ │
│ ↓ (GPU 在后台排队执行)
│ │
├─→ kernel<<<>>>() ──→ 立即返回
│ │
├─→ cudaMemcpy() ──→ **同步点**: 等待 GPU 完成所有排队操作
│ │
│ ↓
│ 此时才能感知 GPU 上的错误
6.2 错误延迟机制
GPU 上的 fault 不会立即通知 host。错误被记录在 GPU 的 error state 中, host 端在以下同步点才能感知:
| 同步点 | 说明 |
|---|---|
cudaMemcpy / cudaMemcpyAsync + cudaStreamSynchronize |
拷贝数据时等待 GPU 完成 |
cudaDeviceSynchronize() |
等待所有 GPU 操作完成 |
cudaStreamSynchronize() |
等待指定 stream 完成 |
cudaEventSynchronize() |
等待指定 event 完成 |
cudaGetLastError() |
返回最近一次 CUDA 调用的错误 (可能包含延迟的 kernel error) |
6.3 为什么错误出现在下一个 Stage 而不是出错的 Stage
假设 pipeline 包含两个处理阶段, Stage A 中某 kernel 写入 buffer_3 (1 MB) 但实际写入了 256 MB, 产生越界:
Pipeline:
... → Stage A → Stage B → ...
Stage A::Execute():
├─ kernel<<<>>>() → 异步 launch, host 立即返回
│ (GPU: buffer_3 overflow → MMU fault → GPU error state)
│
├─ kernel<<<>>>() → 异步 launch
│ (GPU: 已在 error state, kernel 不执行)
│
│ ... 后续所有 kernel launch 都是异步的 ...
│
└─ return (无 cudaDeviceSynchronize)
(GPU error state 持续, 但 host 不知道)
Stage B::Execute():
├─ Save / checkpoint
│ └─ func_copy_to_host()
│ └─ cudaMemcpy(D→H) → **同步点**
│ (host 等待 GPU 完成所有排队操作)
│ (GPU 返回 error state 中的错误)
│ (cudaMemcpy 返回 cudaErrorIllegalAddress)
│ (host 端终于感知到错误!)
│ (但此时已经远离真正的出错点)
cudaMemcpy 是同步操作 , 它会等待 GPU 完成所有排队的操作。此时 GPU 已经处于 error state (因为 Stage A 的 kernel fault), cudaMemcpy 返回 cudaErrorIllegalAddress。
但日志显示错误来自 Stage B 的 copy-to-host 函数, 而不是 Stage A 的越界 kernel:
[debug] [Stage B Execute] started
[error] [cudaMemcpy] failed, size: 268435456
→ "an illegal memory access was encountered"
6.4 错误检查宏为什么不捕获错误
Stage A 的 kernel launch 后通常有错误检查:
cpp
kernel<<<grid, block>>>(..., buffer_3, ...);
CHECK_ERROR(cudaGetLastError(), "kernel description");
典型错误检查宏的展开:
cpp
#define CHECK_ERROR(call, msg) do {
cudaError_t err = call; // = cudaGetLastError()
if (err != cudaSuccess) {
LOG_ERROR("[CUDA ERROR]: %s", msg);
// ← 只记录日志, 不 return, 不 throw, 不 break
}
} while(0)
错误检查宏只记录日志, 不终止执行 。即使 cudaGetLastError() 返回了错误, 程序仍然继续执行后续的 kernel launch。
在异步模式 下, cudaGetLastError() 在 kernel launch 后立即调用时, 通常只返回 launch 本身的错误 (如 invalid configuration), 不包含 kernel 执行中的错误 (因为 kernel 还没执行完)。kernel 执行中的 fault (如越界访问) 要到同步点才能被 host 感知。
7. CUDA_LAUNCH_BLOCKING=1 的作用
7.1 强制同步执行
设置环境变量 CUDA_LAUNCH_BLOCKING=1 后, CUDA runtime 将每个 kernel launch 变成同步的:
CUDA_LAUNCH_BLOCKING=0 (默认, 异步):
kernel<<<>>>() ──→ host 立即返回
GPU 在后台排队执行
错误延迟到下一个同步点
CUDA_LAUNCH_BLOCKING=1 (同步):
kernel<<<>>>() ──→ host 等待 GPU 执行完成
GPU 执行完毕后返回
如果 kernel 内部 fault, 立即在此处返回错误
7.2 同步模式下的错误定位
Pipeline (CUDA_LAUNCH_BLOCKING=1):
Stage A::Execute():
├─ kernel_1<<<>>>()
│ → host 等待 GPU 完成
│ → GPU 完成, 无 error
│ → ✓
│
├─ kernel_2<<<>>>()
│ → host 等待 GPU 完成
│ → ✓
│
├─ kernel_3<<<>>>() (write → buffer_3) ← 出错点!
│ → host 等待 GPU 完成
│ → GPU 执行: 写入 buffer_3 (1 MB) → 正常
│ → GPU 执行: 写入 buffer_3+1MB → 命中 guard page → MMU fault!
│ → GPU 进入 error state, kernel 中止
│ → host 端 cudaGetLastError() 返回 cudaErrorIllegalAddress
│ → ✓ 精确定位到 kernel_3 的调用位置
│
├─ 后续所有 kernel<<<>>>()
│ → GPU 已在 error state, kernel 不执行
│ → cudaGetLastError() 立即返回 cudaErrorIllegalAddress
│ → 日志中全部报告 "illegal memory access"
7.3 实际日志示例
CUDA_LAUNCH_BLOCKING=1 模式下的日志 (精确报告出错位置):
[error] [CUDA ERROR]: LINE: 471
| INFO: an illegal memory access was encountered
| MSG: kernel_3 ← 精确出错点!
[error] [CUDA ERROR]: LINE: 498
| MSG: kernel_4 ← 级联失败
[error] [CUDA ERROR]: LINE: 504
| MSG: kernel_5
... (后续全部报同样的错误)
8. Windows TCC 模式
8.1 TCC (Tesla Compute Cluster) 模式
除了 WDDM, Windows 上还有 TCC 模式 (Tesla Compute Cluster mode)。TCC 是 NVIDIA 专为计算 GPU (Tesla/Quadro/RTX A 系列) 设计的驱动模式, 绕过 Windows 图形栈:
| WDDM | TCC | |
|---|---|---|
| 适用 GPU | GeForce / 消费级 | Tesla / Quadro / RTX A 系列 |
| Windows 桌面渲染 | 是 | 否 |
| 与 Linux 行为 | 不同 | 接近 |
| Guard page | 无 | 有 (类似 Linux) |
cudaMalloc 行为 |
Windows GPU allocator | NVIDIA driver (类似 Linux) |
重要: 如果 Windows GPU 运行在 TCC 模式, 其行为可能接近 Linux, guard page 可能存在, 越界访问可能触发 fault。
但如果开发环境使用的是 GeForce 或 WDDM 模式的 GPU, 则表现是 WDDM 的"无 guard page"行为。
8.2 如何检查 TCC 模式
bash
nvidia-smi -q | grep "TCC Mode"
如果显示 "TCC Mode: Enabled", 则 GPU 在 TCC 模式下运行; 如果显示 "WDDM" 或 "TCC Mode: Disabled", 则在 WDDM 模式下运行。
9. 总结
9.1 核心差异
Windows (WDDM) Linux (NVIDIA driver)
────────────── ─────────────────────
分配器 Windows GPU allocator NVIDIA driver + mmap
guard page 无 有
分配间隔 紧密排列 有未映射间隔
越界写入 → 写入相邻有效分配 命中 guard page
GPU MMU fault 不触发 (VA 有效) 触发 (VA 未映射)
结果 静默数据损坏 cudaErrorIllegalAddress
9.2 为什么 Windows "不 crash"
不是 Windows 更"健壮", 而是 Windows 的 GPU MMU 检测不到越界:
- GPU MMU 只检查虚拟地址是否有页表映射
- WDDM 的相邻分配紧密排列, 越界写入的 VA 落在相邻分配的有效映射中
- MMU 看到 "有效 VA" → 不 fault → 越界写入静默完成
9.3 为什么 Linux "crash"
不是 Linux 更"脆弱", 而是 Linux 的 GPU MMU 能检测到越界:
- NVIDIA 驱动在相邻分配间插入 guard page (未映射的 VA)
- 越界写入的 VA 落在 guard page 中, 没有页表映射
- MMU 看到 "无效 VA" → fault →
cudaErrorIllegalAddress
9.4 教训
-
Windows 上能跑 ≠ 代码正确 --- WDDM 模式下越界访问不报错, 但数据已被损坏。Windows 的"正常运行"可能掩盖了严重的内存 bug。
-
必须在 Linux 上做 CUDA 越界检测 --- Linux 的 guard page 机制是天然的 buffer overflow 检测器。如果代码在 Linux 上能跑, 说明至少没有越界访问。
-
CUDA_LAUNCH_BLOCKING=1是定位异步错误的第一工具 --- 默认模式下错误延迟到下一个同步点, 报错位置远离出错位置。同步模式下每个 kernel 等待完成, 错误在出错 kernel 本身报告。 -
compute-sanitizer是终极检测工具 ---compute-sanitizer --tool memcheck可以在指令级别检测越界访问, 即使在 Windows 上也能发现 buffer overflow。它是唯一能在 WDDM 模式下检测越界访问的工具。 -
在分配时进行 assert 验证 --- 在内存分配函数中添加
assert(kernel_write_size <= buffer_size)可以在开发阶段尽早发现缓冲区大小错误, 不依赖运行时 fault。