這段內容講的是離散顯卡(Discrete GPU)中的內存管理模型,重點是CPU和GPU各自獨立管理自己的物理內存,以及它們如何通過虛擬內存和DMA引擎實現高效通信。以下是詳細的理解和梳理:
1. 基本概念
- CPU 和 GPU 是兩個獨立的處理單元
- 它們各自擁有自己的物理內存區域:
- CPU的物理內存叫 System Memory(系統內存,即RAM)
- GPU的物理內存叫 Video Memory(顯存,即VRAM)
2. 虛擬內存和內存管理單元(MMU)
- CPU通過**內存管理單元(MMU)**訪問系統內存
- MMU 提供虛擬內存支持,使得:
- 物理內存中不連續的內存頁,可以映射為連續的虛擬地址空間
- 允許操作系統將不常用內存頁暫存到磁盤,實現“虛擬內存擴展”
- GPU也有自己的MMU,實現類似的虛擬內存抽象,管理顯存
3. CPU和GPU共享內存的兩種情況
- CPU物理內存(系統內存)映射給GPU訪問
- 系統內存中的某些物理頁,被映射到CPU和GPU的虛擬地址空間中
- CPU和GPU都可以通過自己的虛擬地址訪問這塊共享的物理內存
- 這實現了CPU和GPU間通過內存通信的通道
- GPU物理內存(顯存)映射給CPU訪問
- 顯存中的物理頁也可以映射到CPU和GPU的虛擬地址空間
- 這樣CPU可以直接訪問顯存中的數據
4. DMA引擎(Direct Memory Access)
- DMA引擎是GPU內部的專用硬件,用于高效地在CPU內存和GPU顯存間復制數據
- 重要的是,DMA通常不處理虛擬內存,只能直接對物理內存進行操作
- 因為物理內存頁通常是不連續的,DMA復制時需要逐頁操作
5. 總結和理解
角色 | 作用及特點 |
---|---|
CPU | 有自己的系統內存和MMU,管理虛擬內存 |
GPU | 有自己的顯存和MMU,管理自己的虛擬內存 |
虛擬內存 | 將不連續的物理頁映射為連續的虛擬地址 |
CPU/GPU共享內存 | 系統內存或顯存頁可以映射到雙方的虛擬地址空間,支持通信 |
DMA引擎 | 高效復制CPU <-> GPU物理內存數據,需逐頁復制 |
這套模型幫助理解離散顯卡內存架構的復雜性,尤其是為什么數據復制效率不易達到理論最高,以及現代GPU/CPU設計如何通過虛擬內存和DMA硬件優化性能。 |
集成顯卡(Integrated GPU)中的內存管理模型,和之前的離散顯卡相比,有些不同。下面幫你詳細解讀:
1. 集成顯卡的CPU和GPU關系
- CPU和GPU幾乎是合成一體的單個芯片或單元
- 他們共享同一塊物理內存區域(系統內存),不再是分開的系統內存和顯存
- 但CPU和GPU依然擁有各自獨立的虛擬地址空間
- 這意味著雖然物理內存共享,但兩者的虛擬地址映射可能不同
- 理論上存在共享虛擬地址空間的可能(如OpenCL的共享虛擬內存SVM),但并不通用,不能依賴
2. 共享物理內存,但虛擬地址空間分離
- CPU和GPU訪問的是同一塊物理內存(系統內存),
- 但各自的MMU管理各自的虛擬地址映射
- 這種情況下,CPU和GPU可以直接通過物理內存通信,避免了之前離散顯卡中頻繁的跨內存拷貝
3. 文件系統和頁面調度的限制
- GPU訪問的系統內存不連接文件系統
- 這點和離散GPU類似
- 也就是說,GPU不能像CPU一樣依賴操作系統的頁面交換機制(如頁面置換到硬盤)
- GPU無法利用文件系統進行“自動頁面缺失(page fault)”處理
- 雖然技術上可以實現GPU頁缺失機制,但這不是主流或廣泛支持的用法
4. 總結和理解
方面 | 說明 |
---|---|
CPU & GPU | 物理上融合為一個單元,使用同一塊物理內存 |
虛擬地址空間 | CPU和GPU有各自獨立的虛擬地址空間 |
共享虛擬地址空間 | 存在可能(如OpenCL SVM),但不可假設普遍支持 |
文件系統支持 | GPU訪問的內存不連接文件系統,無法使用文件系統頁面調度 |
性能影響 | 共享物理內存減少數據拷貝成本,但缺頁處理受限 |
你可以想象
- 離散GPU就像是兩臺電腦互相通過網線(PCIe)通信,數據需要復制通過通道
- 集成GPU則像是一個電腦內的兩個核心共享一塊內存,但每個核心訪問內存的“視角”不同(各自虛擬地址空間)
這部分內容講的是 Command Lists(命令列表) 在CPU和GPU協作中的作用,下面幫你詳細拆解和理解:
1. CPU和GPU的執行模型差異
- CPU是**亂序執行(Out-of-Order)**的處理器
- 可以動態調整指令執行順序,以提升效率(比如推測執行)
- GPU是**順序執行(In-Order)**的處理器
- 嚴格按照命令給出的順序執行任務,不會亂序
- 這就要求提交給GPU的工作順序必須合理高效
2. Command Lists是什么?
- 命令列表是CPU準備好的“工作清單”
- CPU負責構建這份命令調度(Schedule),組織好任務順序
- GPU接收到命令列表后,順序執行其中的命令
3. 類比:Gromit和火車
- 火車代表GPU,只能沿著鋪好的軌道前進(嚴格順序執行)
- Gromit代表CPU,負責提前鋪好軌道(生成命令列表)
- 軌道即命令列表,必須鋪設合理,火車才能順暢運行
4. Fences(柵欄)是什么?
- Fence是一個操作系統對象,用來監控GPU的執行進度
- 舉例:初始Fence值是0
- GPU執行命令,執行到第一個Fence位置時,Fence值變成1
- 表示GPU完成了第1段命令
- GPU繼續執行,到達Fence 2時,Fence值變成2,依此類推
- 通過Fence,CPU或系統可以檢測GPU執行到什么程度了,方便同步或資源管理
5. 總結
角色 | 作用 |
---|---|
CPU | 準備命令列表(合理安排任務順序) |
GPU | 按命令列表順序執行任務 |
Command List | CPU提交給GPU的任務清單 |
Fence | 用于追蹤GPU執行進度的同步機制 |
如何利用Fence實現CPU和GPU之間的同步:
場景說明
- Fence初始值是1(這個值一般是任意的起始狀態)
- CPU準備了一串命令列表,并在命令列表的末尾放了一個特殊命令——signal(信號命令)
- 這個signal命令的作用是:當GPU執行到這里時,會把Fence的值從1更新到2
執行流程
- CPU階段
- CPU生成命令列表,命令序列可能是:
cmd1
,cmd2
,cmd3
, …,signal
signal
命令附帶的效果是“將Fence的值設為2”
- CPU生成命令列表,命令序列可能是:
- GPU階段
- GPU接收命令列表,順序執行
- 執行每條命令,最后執行
signal
命令時,GPU會把Fence值從1改成2
- 同步機制
- CPU通過檢查Fence的值是否≥2來判斷GPU是否完成了命令列表中的所有工作
- 如果Fence≥2,CPU就知道GPU已經執行到signal命令之后了,意味著GPU完成了之前的所有命令
- 同理,GPU內部也能用Fence進行同步,比如多個命令隊列之間協調執行順序
形象解釋
- Fence就是一個“進度條”或者“里程碑”,它的值隨著GPU執行進度遞增
- CPU可以通過Fence知道GPU跑到了哪一步,從而決定下一步的操作(比如是否提交新的命令、釋放資源等)
- GPU也能利用Fence同步自身的不同工作流
總結表格
角色 | 行為 |
---|---|
CPU | 生成命令列表,并在結尾加上signal 命令,表示GPU執行到這兒時更新Fence的值 |
GPU | 按順序執行命令,執行到signal 時更新Fence值 |
Fence | 共享變量,CPU和GPU通過檢查它的值來判斷GPU執行進度,實現同步 |
GPU 命令列表中常見的命令類型及其背后的抽象模型,我來幫你系統性地理解這些內容:
大致分類(GPU 命令的種類)
GPU 接收的命令并不是像 CPU 那樣執行通用指令,而是高度結構化的“任務請求”,主要分為以下幾類:
1. DMA 拷貝類(數據傳輸命令)
示例:
Copy{src, dst}
- 用于在 CPU 和 GPU、或 GPU 的不同區域之間復制數據
- 通常映射到底層的 DMA Engine,能實現高性能、異步的數據拷貝
2. 執行 GPU 程序(計算/繪圖調用)
示例:
SetProgram{program}
SetParams{buf, tex, 1337}
Draw{N vertices} // 圖形渲染
Dispatch{N threads} // 通用計算(GPGPU)
- 類似于函數調用的抽象模型:
- 設置程序(類似函數名)
- 設置參數(函數參數)
- 發起執行(函數調用)
GPU編程流程就像是:
set_registers(params);
jump_to_shader_entry();
3. 渲染控制命令(圖形管線配置)
示例:
SetRenderTarget{rt}
SetGfxPipeline{pipeline}
- 設置渲染目標(比如一塊屏幕區域或紋理)
- 設置圖形流水線(配置如光柵化、混合、深度測試等狀態)
4. 內存 & 對象模型(資源管理與狀態同步)
示例:
MemoryBarrier{object}
Transition{object, a, b}
Construct{object}
Destruct{object}
MemoryBarrier
:手動控制數據一致性(GPU 不像 CPU 有自動緩存一致性機制)Transition
:GPU 的某些資源在不同用途間需要顯式“狀態轉換”- 例如:一張紋理從“渲染輸出”變為“采樣輸入”,需要做狀態轉換
Construct
/Destruct
:- 抽象上的資源生命周期控制
- 類似 C++ 的
placement new/delete
- 實際底層不會叫這個名字,而是分成一堆底層資源創建與釋放命令
思維類比:函數調用 vs GPU命令序列
CPU/C++ | GPU Command List |
---|---|
設置函數參數 | SetParams{...} |
調用函數 | Dispatch{...} 或 Draw{...} |
析構對象 | Destruct{object} (抽象) |
局部變量構造 | Construct{object} (抽象) |
小結
- GPU 命令本質上是一個面向任務的指令模型,種類清晰、目標明確
- 命令的構成是高度結構化的,分配了明確的職責,比如數據傳輸、程序調用、渲染狀態設置、資源同步等
- 類似 CPU 的函數調用和內存管理機制,但需要手動控制同步、狀態轉換和資源生命周期
GPU 編程中一個非常關鍵但經常被忽視的細節 —— 命令參數的讀取時機(indirection,間接尋址),我來幫你逐步理解:
核心問題:命令參數何時讀取?
GPU 在什么時候讀取命令參數?是在CPU 錄制命令的時候,還是在GPU 執行命令的時候?
兩種方式的對比
方式 | 含義 | 示例 | 特點 |
---|---|---|---|
Record-Time(錄制時) | 參數在 CPU 構建命令列表時就確定了 | Dispatch{512} | 更快 更容易優化 靈活性差 |
Execute-Time(執行時) | 參數在 GPU 執行時從內存中讀取 | DispatchIndirect{&512} | 更靈活 數據驅動 可能較慢 |
舉個例子說明:
Dispatch{N_threads}
// 直接記錄:512 個線程
command_list.record(Dispatch{512});
- 參數是個立即數(value)
- GPU 可以在命令列表生成階段提前優化好調度
- 不依賴運行時數據
DispatchIndirect{&N_threads}
// 從內存中讀取線程數
uint32_t N = ComputeThreadCountSomehow();
command_list.record(DispatchIndirect{&N});
- 參數是引用 / 指針
- GPU 執行時從內存中讀取這個值
- 可以根據其他計算結果動態決定線程數
- 更靈活,但更難優化
Trade-off 總結
方面 | Record-Time 參數 | Execute-Time 參數(Indirect) |
---|---|---|
性能 | 更高,易優化 | 可能慢,不易推測 |
靈活性 | 低 | 高(數據驅動、邏輯更豐富) |
實現復雜度 | 低 | 高 |
示例 | Draw{100} | DrawIndirect{&count} |
實際應用場景
- Record-time(立即值)適合固定流程:大部分渲染、預定義計算
- Execute-time(間接尋址)適合數據驅動流程:基于之前 GPU 輸出決定下一步行為(如 GPU 級剔除后的渲染)
接下來的話題:Descriptors
你這段最后提到要進入 descriptors —— 它跟資源綁定密切相關,比如紋理、緩沖區綁定到著色器,是現代 GPU API(如 Vulkan、D3D12)中非常核心的機制。
GPU 編程中極為重要的概念 —— Descriptor(描述符),它是現代圖形/計算 API(如 D3D12、Vulkan、Metal)核心機制之一。下面是逐點解析,幫助你真正吃透這個概念:
什么是 Descriptor?
簡單說:Descriptor 就是 GPU 眼中對資源的定義。
- 描述符 = 內存地址 + 元數據
- 描述的是 buffer 或 texture 的布局與位置
類比理解
你可以把 descriptor 想象成一本圖書館里的卡片:
- 地址(Address):告訴你書在哪一排哪一列(物理地址)
- 元數據(Metadata):告訴你這本書有幾頁、是什么語言、是否彩印(數據大小、類型、格式等)
例子:AMD GCN3 Texture Descriptor(128 位)
在 GCN3 架構中,一個紋理描述符的具體布局如下:
位范圍 | 含義 |
---|---|
[39:0] | Address:紋理內存地址 |
[77:64] | Width:寬度(像素) |
[91:78] | Height:高度(像素) |
[127:92] | 其他標志/格式信息 |
這些字段告訴 GPU: |
- 要訪問哪一塊內存?
- 數據的形狀是什么?
- 怎么解釋這塊數據?
用法示例:偽裝配代碼
dst = image_sample(xy, texture_descriptor, sampler_descriptor);
解釋:
texture_descriptor
:告訴 GPU 從哪里讀取紋理、如何解釋紋理sampler_descriptor
:告訴 GPU 如何采樣(過濾、邊緣處理等)xy
:采樣坐標dst
:結果寫入的位置(顏色、深度等)
這個調用最終會變成底層硬件執行的采樣操作,離開了 descriptor,GPU 連紋理在哪都不知道。
為什么需要 Descriptor?
- GPU 是高度并行的,需要能獨立高效地訪問資源
- 每個線程不能靠 CPU 傳參,它需要從描述符表中快速查表
- 提前定義好描述符能讓 GPU 直接執行,不需要解釋
延伸:Descriptor Heap / Table
在 D3D12、Vulkan 中,你會維護一張 “描述符表” 或 “描述符堆”:
- 就像 GPU 的“資源目錄”
- 每個描述符代表一個資源(紋理、緩沖區等)
- 著色器訪問資源時,不是直接用地址,而是通過“索引 + 根參數 + offset”來找到 descriptor
總結記憶
項目 | 內容 |
---|---|
什么是 Descriptor? | GPU 用來訪問資源的硬件對象:地址 + 元數據 |
描述哪些資源? | Texture、Buffer、Sampler 等 |
存儲在哪里? | 通常在描述符表 / 堆中,供 GPU 索引 |
為什么重要? | GPU 無需 CPU 干預即可并行訪問資源 |
示例? | image_sample(xy, texture_desc, sampler_desc); |
這部分講的是 實時渲染器架構(Real-Time Renderer Architecture) 的大局觀設計 —— 即如何把“游戲世界的邏輯”轉換成“屏幕上的圖像”。我們來逐步拆解,幫你建立對整套架構的清晰認知:
實時渲染系統:核心流程圖解
Continuous DiscreteBehaviors Events↓ ↓+------------------------------+| Simulation | ← 游戲邏輯| ("Game Objects") |+------------------------------+↓Update Scene Graph↓+------------------------------+| Scene | ← 高層描述:幾何體、材質、燈光等+------------------------------+↓+------------------------------+| Renderer | ← 將場景轉為GPU指令(圖形資源)+------------------------------+↓Submit Commands↓GPU Execution↓+------------------------------+| Swap Chain | ← 雙緩沖幀管理(顯示幀切換)+------------------------------+↓Compositing / Display
概念拆解
1. Simulation(模擬)
- 負責應用邏輯,如物理、AI、動畫、用戶輸入
- 抽象單位是“Game Object”
- 處理連續狀態變化 + 離散事件(如按鍵、碰撞等)
2. Scene(場景)
- 表示“需要被渲染的世界狀態”
- 高層資源描述,如:
Geometry
:網格、頂點數據Material
:表面著色邏輯、紋理Instances
:幾何體的具體位置與變換Camera
:觀察視角Light
:光照源及其類型/位置/顏色等
Scene ≠ Renderer,它只是個邏輯數據結構,不關心如何畫。
3. Renderer(渲染器)
- 讀取 Scene,生成圖形命令(Command Lists)
- 管理 GPU 資源:
Buffers
:存儲數據(如變換矩陣、頂點、索引)Textures
:紋理圖像Shaders
:程序(Vertex、Fragment、Compute 等)Passes
:渲染流程的階段(如 Shadow Pass, G-Buffer Pass)
4. GPU 提交 + Swap Chain 顯示
- 命令列表被提交給 GPU 執行
- 完成后輸出到 Swap Chain:
- 顯示幀緩存機制(通常雙緩沖/三緩沖)
- 如果不是全屏,幀最終會被桌面 compositor 混合顯示
- 最終呈現在顯示器上
性能目標:10~100 毫秒/幀
應用類型 | 推薦幀時間 | 幀率目標 |
---|---|---|
快節奏游戲 | 10~16ms | 60~90 FPS |
普通游戲 | 16~33ms | 30~60 FPS |
工具類程序(如 3D 建模) | 可接受更高延遲 | 可低至 15 FPS |
總結記憶
模塊 | 職責 |
---|---|
Simulation | 游戲邏輯 + 控制場景內容變化 |
Scene | 高層資源組織結構 |
Renderer | 構建 GPU 指令 + 管理資源 |
GPU + Swap Chain | 執行繪制 + 顯示輸出 |
你可以把整個系統想象成一臺流水線工廠,Simulation 是設計圖紙、Scene 是原材料倉庫、Renderer 是裝配線操作員,GPU 是機器人臂,Swap Chain 是成品傳送帶。 |
這部分介紹了**Ring Buffer(環形緩沖區)**在實時渲染系統中的應用,尤其是用于 CPU → GPU 數據傳輸 的高效機制。下面是結構化總結與理解:
Ring Buffer(環形緩沖區)
基本定義
- 是一種循環使用的連續內存區域,用來進行數據流的生產與消費。
- 適用于CPU 和 GPU 并行工作場景(producer-consumer 模型)。
應用場景:CPU 向 GPU 傳輸數據
- CPU:不斷地寫入新的數據。
- GPU:異步讀取這些數據進行渲染。
- 二者操作同一個環形緩沖區,但操作的“位置”不同,避免沖突。
優勢: - 避免頻繁創建/銷毀資源。
- 實現高效、可重用的上傳路徑。
- 提供流式數據更新方式(如動態相機參數、動畫數據等)。
示例 API
auto [pCPU, pGPU] = pRing->Alloc<Camera>();
*pCPU = camera;
pCmdList->SetDrawParam(CAMERA_PARAM_IDX, pGPU);
解讀:
Alloc<T>()
分配一段用于T
類型(如 Camera)數據的空間。- 返回值是二元組:一個是
pCPU
(CPU 虛擬地址),另一個是pGPU
(GPU 虛擬地址)。 - 使用
pCPU
寫數據,用pGPU
作為繪制參數傳給命令列表。
Descriptor Ring Buffer 也是一種 Ring Buffer!
auto [pCPU, pGPU] = pDescriptorRing->Alloc(1);
WriteDescriptor(pCPU, desc);
pCmdList->SetDrawParam(TEXTURE_PARAM_IDX, pGPU);
- 用法類似,用于分配臨時描述符(綁定紋理、緩沖等資源的元數據結構)。
- 描述符是 GPU 使用資源時的“視圖”,包含地址和訪問方式等信息。
性能提醒:
對于 離散 GPU(如獨顯),數據最終應拷貝到 專用顯存(video memory),否則系統內存讀性能可能拖慢渲染。
總結一句話:
Ring Buffer 是連接 CPU 和 GPU 的高效流式橋梁,支持異步上傳數據或描述符,并最大限度減少內存分配成本。
這一部分深入講解了 Ring Buffer 的兩個棘手問題:內存不足(Out-of-Memory) 和 環繞(Wrap-Around),并提供了實際工程上的處理建議。
1. Ring Buffer: Out-of-Memory(內存不足)
問題場景:
- CPU 想在 ring buffer 中分配一段內存。
- 但這一段內存 仍在被 GPU 讀取中,尚未完成處理。
解決方法:
- CPU 阻塞等待 GPU 完成。
- 使用 GPU-Fence 來同步:
if (AllocWouldOverlapWithGPU()) {WaitForFence(gpuFence); }
本質:
避免 CPU 寫入還未被 GPU 消費的數據區域,確保數據一致性。
2. Ring Buffer: Wrap-Around(緩沖區環繞)
問題:
- Ring Buffer 是循環結構,當分配位置接近尾部時,可能需要“從頭開始”。
- 如果處理不好,可能出現 數據重疊或邏輯混亂。
推薦做法 1:使用 虛擬偏移(virtual offset)
// virtualOffset 持續增長,不受 RING_SIZE 限制
uint64_t virtualOffset = ...;
uint64_t realOffset = virtualOffset & (RING_SIZE - 1);
data = buffer[realOffset];
優勢:
- 避免 wrap-around 檢查邏輯復雜化。
- 配合 Fence 使用更簡單:Fence value 可直接綁定 virtual offset。
- Power-of-two 大小 ring 幫助簡化位運算。
推薦做法 2:禁用 wrap-around,按幀預分配
// 每幀分配固定大小內存
const size_t PER_FRAME_BUDGET = 64KB;
if (allocSize > PER_FRAME_BUDGET) {assert(false && "Need to increase per-frame memory budget");
}
優勢:
- 極簡實現。
- 保證分幀分配邏輯清晰。
- 適合大多數典型幀時間控制的實時應用(如游戲渲染)。
推薦閱讀:
Fabian Giesen 的博客
? 深入講解 ring buffer 的數據結構原理、同步策略,以及設計不變量(invariants)。
總結一句話:
用虛擬偏移簡化 wrap-around,用 Fence 處理同步沖突,用幀預算確保穩定性 —— Ring Buffer 成為 GPU 數據流式傳輸的可靠利器。
這一節講述了如何在 Ring Buffer 中實現無鎖(Lock-Free)內存分配 —— 這對于高性能 GPU 數據流非常關鍵,尤其是在多線程或并發渲染環境下。
核心思想:用原子變量 std::atomic<uint64_t> offset
實現無鎖分配
基本做法(結構化數據):
std::atomic<uint64_t> offset;
uint64_t alloc(uint64_t n) {return offset.fetch_add(n); // 原子地分配 n 個單位
}
fetch_add(n)
會原子地把offset
增加n
,返回舊值。- 所以你拿到的就是你該寫入的位置。
- 多線程下也不會有數據覆蓋或競爭問題。
應對 原始數據(Raw Byte Data)+ GPU 對齊需求
GPU 通常有對齊要求,比如 DirectX 12 下最大可能要求是 512 字節對齊(如上傳貼圖數據時)。
對齊版本的分配函數:
#define WORST_ALIGNMENT 512
uint64_t aligned_alloc(uint64_t sz) {uint64_t padded_sz = (sz + WORST_ALIGNMENT - 1) & ~(WORST_ALIGNMENT - 1);uint64_t alloced = offset.fetch_add(padded_sz);assert(alloced + sz <= RING_SIZE); // 簡單溢出檢查return alloced;
}
技巧說明:
概念 | 解釋 |
---|---|
fetch_add | 原子操作,避免加鎖,天然線程安全。 |
對齊(Alignment) | (sz + A - 1) & ~(A - 1) 是常見的向上對齊寫法。 |
512 字節對齊 | 適配所有 GPU 子系統的最壞情況,雖然浪費點空間,但簡單可靠。 |
無鎖優勢 | 性能極高,線程間不會互相阻塞,適合現代多核系統中的實時任務。 |
總結一句話:
用
atomic::fetch_add
實現無鎖并發分配,用統一對齊消除 GPU 對齊問題 —— 簡單、穩定、快得離譜。
Ring Buffer(環形緩沖區)在實時渲染中的優缺點,特別是在 CPU → GPU 數據流場景中的應用。以下是中文結構化的要點歸納:
Ring Buffer 優點(Pros)
優點 | 說明 |
---|---|
簡化內存管理 | 統一分配策略,避免手動管理多個小 buffer |
API 極其簡單 | 通過一個 Alloc() 函數即可分配空間 |
避免碎片化 | 連續線性分配,不會留下“內存洞” |
多功能構建塊 | 適用于各種用途,如幾何體上傳、紋理流、sprite 批渲染等 |
典型用法示例:
- Procedural Geometry(程序化幾何體):直接上傳 vertex/index 數據。
- Texture Streaming(紋理流式傳輸):分段上傳 mipmap 或貼圖 block。
- Sprite Batch:合并多個小對象的繪制調用,提高效率。
Ring Buffer 缺點(Cons)
缺點 | 說明 |
---|---|
內存大小需要“校準” | 太小:性能差/易崩潰;太大:浪費寶貴顯存/系統內存 |
沒有統一配置方式 | 不同用例對內存類型和對齊策略需求不同 |
緩存策略復雜 | 寫合并(Write-Combined)、寫回(Write-Back)等策略影響訪問性能 |
物理內存選擇困難 | 系統內存 vs. 顯存:取決于設備架構和數據使用模式 |
內存策略的選擇影響極大
比如:
- 寫合并(Write-Combined, WC):
- 對 CPU 寫入性能好,但不能頻繁讀取。
- 不適合需要頻繁讀取或修改的 procedural data。
- 寫回(Write-Back, WB):
- 更通用,適合需要讀取的 CPU 數據結構。
- 內存類型選擇:
- System Memory:適用于集成顯卡或數據上傳階段。
- Video Memory:適合長期 GPU 訪問的資源(如材質、頂點緩沖)。
總結建議:
Ring Buffer 是非常強大的構建塊,但它不是“萬能工具”。你必須根據具體用途(渲染什么、更新頻率、訪問模式)合理設置大小、內存類型和緩存屬性。
并行命令錄制(Parallel Command Recording),這是 Vulkan 和 DirectX 12 等現代圖形 API 中的核心優化特性之一。下面是中文歸納與重點說明:
并行命令錄制:核心思想
CPU 寫命令是很重的任務。
如果你要渲染大量對象(比如成千上萬個),那么即使還沒交給 GPU 執行,僅在 CPU 上構造這些命令本身就會成為性能瓶頸。
解決方案:
將 命令錄制任務分配到多個 CPU 線程上并行執行,每個線程寫自己的命令緩沖區(Command List),最后把它們匯總提交給 GPU。
示例結構圖(可視化):
Thread 0: cmd cmd cmd ↘
Thread 1: cmd cmd cmd cmd → [Submit to GPU]
Thread N: cmd cmd cmd ↗
每個線程獨立錄制命令,最后統一提交。
簡單用例:場景中有大量“規律性”的工作
CmdList lists[NUM_JOBS];
parallel_for (jobID = 0 .. NUM_JOBS) {lists[jobID].SetRenderTarget(rt);foreach (object in job) {lists[jobID].SetGeometry(object.geometry);lists[jobID].SetMaterial(object.material);lists[jobID].Draw(object.num_vertices);}
}
Submit(lists); // 提交所有命令緩沖區
說明:
- 將場景對象分成若干“工作塊”(jobs)。
- 每個線程獨立處理一個 job。
- 每個線程錄制自己的命令列表。
- 所有命令列表錄制完后一次性提交。
實用建議
建議 | 原因 |
---|---|
不要過早并行化 | 對于小批量命令(例如 UI 渲染),并行錄制得不償失 |
每個命令緩沖區應耗費至少 50μs 的 GPU 時間 | 太短會導致提交開銷掩蓋了收益 |
每次 Submit 盡量包含 ≥500μs 的 GPU 工作量 | 提交操作本身有代價,別太頻繁 |
小結
優勢 | 應用場景 |
---|---|
更高 CPU 并發利用率 | 大量對象渲染(例如:開放世界游戲、海量粒子) |
降低單線程瓶頸 | 命令錄制分散到多個核心 |
和現代 GPU API 匹配 | Vulkan / DX12 等天然支持多線程命令構建 |
這部分講的是 “困難情況:不規則的工作量”,也就是當你渲染的對象種類很多,且每個對象準備命令所需的 CPU 工作量差異較大時,命令錄制并行化就沒那么簡單了。
核心點總結:
- 對象異構(Heterogeneous):
場景中有不同類型的 Drawable,比如:Blob
:需要運行marching cubes算法進行多邊形化Subdiv
:需要進行三角形細分Particles
:需要進行粒子分箱和排序
- 工作量不均勻:
不同對象的命令準備時間相差很大,甚至受其它因素影響,比如物體距離攝像機的遠近或遮擋情況。
為什么這很難?
- 很難均勻分配任務給多個線程,因為:
- 有些線程會處理復雜對象,耗時長
- 有些線程處理簡單對象,耗時短,導致 CPU 線程負載不均
- 會導致線程等待,浪費多核資源,降低并行效率。
現實中的挑戰與思考:
- 動態負載均衡:需要設計工作竊取(work stealing)或者更智能的任務分配策略,避免某些線程提前完成而空閑。
- 優先級和剔除:可以根據距離攝像機或遮擋情況提前剔除或降低處理優先級,減少命令準備的總開銷。
- 異步任務處理:復雜計算盡量異步或在后臺線程中進行,渲染線程專注于輕量命令錄制。
這是解決**不規則工作量(Irregular Work)**的經典方法——Fork/Join 并行。
// 遞歸并行繪制函數,參數:
// cmdlist - 當前線程/任務用的命令列表
// drawables - 當前需要繪制的對象集合
void draw_par(cmdlist, drawables) {// 判斷當前任務的“工作量”是否低于閾值// 如果工作量小,直接串行繪制,避免過度拆分導致開銷過大if (drawables.cost() < WORTH_SPLITTING) {draw_seq(cmdlist, drawables); // 串行繪制當前這批對象} else {// 否則將任務拆分為兩個子任務,分別處理左右兩半對象auto [left, right] = drawables.split();// 并行啟動繪制左半部分,繼續使用當前命令列表spawn draw_par(cmdlist, left);// 并行啟動繪制右半部分,給右邊子任務新建獨立命令列表,避免線程沖突spawn draw_par(new CmdList(), right);// 等待左右兩個子任務都完成繪制命令的錄制sync;}// 這行看起來像注釋或示意(非實際代碼)// 可能表示這里有對命令列表3的操作或者繪制// draw_seq// CmdList 3
}
// 從外部調用入口,傳入新的命令列表和所有待繪制對象,開始遞歸并行繪制
draw_par(new CmdList(), all_drawables);
核心思路:
- 遞歸拆分任務
把待繪制對象列表遞歸拆分成更小的子列表,直到任務足夠小(低于某個“值得拆分”的閾值WORTH_SPLITTING
),然后串行執行。 - 多線程并行執行
大任務拆分后,利用任務調度器(task scheduler)異步運行左右兩個子任務(spawn),并在最后用同步(sync)等待子任務完成。 - 獨立命令列表
每個子任務有自己的CmdList
,獨立記錄 GPU 命令,最后匯總提交。
優點:
- 任務細分能適配不同復雜度的對象,實現負載均衡。
- 結合任務調度器(如工作竊取 Work Stealing),能高效利用多核 CPU。
- 代碼結構清晰,易維護。
背景問題
- 在并行繪制(fork/join)不規則工作負載時,每個子任務通常創建獨立的命令列表(CmdList)。
- 如果多個任務實際上在同一個CPU上順序執行,創建多個命令列表就顯得浪費,增加額外開銷。
關鍵思想:Hyperobject優化
- Hyperobject 是一種語言特性(起源于Cilk++),用于在并行任務中管理線程/任務局部狀態。
- 通過“偷用父任務的命令列表”,可以讓運行于同一個CPU的連續任務共用同一個命令列表,避免重復創建。
- 這種方式減少了命令列表的數量,從而降低了管理和合并命令列表的開銷。
重要特性
- 保持繪制順序不變
Hyperobject管理的命令列表不會改變繪制命令的提交順序,這對于避免圖像閃爍和性能波動至關重要。 - 優化性能
避免過度拆分命令列表,提高CPU多線程寫命令效率。
推薦資料
- “Reducers and Other Cilk++ Hyperobjects” 論文
- 這些論文介紹了Cilk調度器中如何整合Hyperobject及其實現細節。
總結
使用Hyperobject優化方案,可以更智能地管理不規則并行工作負載中的命令列表,減少開銷并保持繪制順序的穩定性,是一種非常優雅的設計思路。
GPU調度(Scheduling GPU Work & Memory)核心概念
1. 大框架(Big Picture)
- GPU的工作可以看成是一個幀圖(frame graph),由多個**渲染通道(passes)**組成。
- 這些通道之間會通過共享的資源(如紋理Texture、緩沖Buffer)傳遞數據。
- 調度器的任務就是根據這個依賴關系圖,按照正確且高效的順序提交GPU任務。
2. 調度職責(Duties)
- 任務提交順序有效且高效
需要確保依賴關系被滿足,不會提前使用未生成的數據,同時還要盡量減少GPU空閑,提升性能。 - 資源管理
調度器負責管理內存資源(比如紋理和緩沖區)的分配與釋放,盡可能地在資源的生命周期內使用,減少內存浪費。- 例如:紋理Texture 2在Pass 1中生成,Pass 2中使用,用完就釋放。
- 動態適應
因為實時渲染中場景內容不斷變化,調度器應支持動態重建任務圖(每幀可能不同),不能假設任務圖是靜態的。
3. 實際意義
- 通過這種幀圖調度方式,GPU的工作被組織得更清晰且高效,避免了資源沖突和冗余占用。
- 支持復雜渲染管線,同時保持靈活性和性能。
Scheduling: Classic Multi-Pass Approach(經典多通道渲染調度)
1. 代碼結構:
- 第一個Pass:
- 綁定渲染目標為
shadowMap
(深度緩沖區)。 - 設置陰影繪制管線(shadowPipeline)。
- 遍歷場景中的所有物體,設置幾何體并繪制。
- 該Pass輸出深度信息,寫入
shadowMap
(可讀寫)。
- 綁定渲染目標為
- 第二個Pass:
- 綁定回屏緩沖和深度緩沖為渲染目標。
- 設置場景繪制管線(scenePipeline)。
- 設置陰影貼圖(shadowMap)作為輸入紋理參數(只讀)。
- 遍歷場景中的物體,設置幾何體、材質,繪制。
- 該Pass使用第一個Pass產生的陰影深度貼圖,進行光照計算等。
2. 狀態轉換:
shadowMap
從第一Pass的讀寫深度緩沖變成第二Pass的只讀紋理。- 這種狀態切換需要顯式的資源屏障(barriers),保證GPU正確處理同步和內存一致性。
3. API差異:
- 傳統的OpenGL和Direct3D 11驅動通常會自動幫你處理資源狀態切換和同步。
- 低級別API(如Vulkan和DirectX 12)要求程序員顯式管理這些狀態轉換和資源屏障。
4. 總結:
- 經典多通道渲染是一種典型的圖形編程模式,先寫入一個渲染目標,然后在后續Pass讀取它。
- 在現代低級圖形API中,正確管理資源狀態和同步是顯式且必要的,這增加了代碼的復雜度但提高了性能可控性。
Frame Graph 和作業提交(Work Submission)概述
1. Frame Graph是什么?
- Frame Graph(幀圖)是現代渲染管線中用來組織和調度渲染任務的一種數據結構。
- 它將渲染過程拆分為多個“Pass”(渲染通道),每個Pass讀寫不同的資源(如紋理、緩沖區)。
- 通過分析Pass之間的依賴關系,Frame Graph能幫助優化渲染工作流,比如去除不必要的渲染操作,合理安排Pass執行順序,以及有效管理內存資源。
2. 關鍵優化點:
- 死代碼消除(Dead Code Elimination):
如果某個Pass產生的資源沒有被后續Pass使用(比如某個紋理沒被采樣),整個Pass可以跳過,避免浪費計算資源。 - 數據訪問沖突和屏障插入(Data Hazard & Barriers):
如果Pass1寫入了某個資源,而Pass2需要讀取這個資源,必須在兩者之間插入同步屏障(memory barrier),保證數據正確傳遞,避免訪問沖突。 - Pass間的延遲管理(Latency Management):
有些Pass緊密連在一起執行,緩存利用率高但可能造成CPU或GPU等待;而把它們錯開執行可以減少阻塞但會增加內存開銷。如何平衡這兩者是一個復雜的調度問題。 - 命令列表提交(Command List Recording & Submission):
調度器根據依賴關系和優先級,依次執行各Pass對應的命令錄制和提交操作。
3. 調度算法:List Scheduling
- 給每個任務(Pass)分配一個優先級(優先級是通過啟發式算法決定的)。
- 選擇當前所有滿足資源和依賴要求的最高優先級任務執行。
- 依次重復直到所有任務完成。
- 例如任務A優先級最高,先執行;如果任務B資源不足,先執行C。
4. 內存和資源生命周期管理
- 資源(紋理、緩沖等)只在其被使用的Pass生命周期內存在。
- 用完后可以釋放資源,或者在條件允許時復用內存(比如兩個資源大小和格式相同且生命周期不重疊)。
- 這種復用降低了顯存占用,提高效率。
- 類似于D3D12中的
CreatePlacedResource
,需要顯式管理內存和資源綁定。
總結
- Frame Graph是一種把渲染流程視為任務圖,進行全局優化的技術。
- 通過死代碼剔除、同步屏障插入、合理調度和內存復用,實現高性能且內存友好的渲染流程。
- 新的底層圖形API(Vulkan、D3D12)需要程序員手動管理資源狀態和屏障,復雜但控制更細。
- 需要根據具體應用和硬件做出權衡和優化。