迭代式 MRI 重建 練習題 (Practice - MRI Background and the Iterative Reconstruction Problem)
Related Concepts
- 17-Iterative-MRI-Reconstruction/01-MRI-Background-and-Iterative-Reconstruction — MRI 背景與 Iterative Reconstruction 問題建構
- 17-Iterative-MRI-Reconstruction/02-FHD-Kernel-Parallelism-Structure — FHD Kernel 的平行化結構:Scatter vs Gather 與迴圈轉換
- 17-Iterative-MRI-Reconstruction/03-FHD-Memory-Bandwidth-Optimization — FHD Kernel 的記憶體頻寬優化:Registers 與 Constant Memory
- 17-Iterative-MRI-Reconstruction/04-Hardware-Trig-Accuracy-and-Performance-Tuning — Hardware 三角函數、精度驗證與效能調校
| 關鍵字 / 觸發點 | 答案要點 |
|---|---|
| MRI 兩階段 | acquisition (scan) 在 k-space 取樣 → reconstruction 變影像 m(r) |
| k-space 是什麼 | 空間頻率域 / Fourier transform 域;掃描器沿 trajectory 取樣 |
| Cartesian 為何能用 inverse FFT | 均勻網格 → W(k) 為常數可提出、指數項均勻間隔 → 加總即 inverse FFT |
| non-Cartesian 為何不能直接 FFT | 指數項不再均勻間隔 → 加總非 FFT 形式 → 需 gridding 或 iterative solver |
| 為何不直接矩陣求逆 | F 太大(128³≈2M 欄)→ Gaussian elimination intractable → 用 CG 迭代 |
| 為何專注 FHD 而非 Q / CG | FHD 與 Q 核心結構相同;FHD 每次掃描都要跑(~3h);CG 占 CPU <1%(Q 已預算) |
| FHD 兩瓶頸 | OP/B 僅 0.25~0.75;FP:trig ≈ 13:2(sin/cos 長延遲低吞吐) |
| 第一版 kernel 為何要 atomicAdd | scatter:每 thread(一樣本 m)寫進所有 voxel → 衝突 → 必須 atomic |
| scatter 為何不能 privatization | 輸出 rFhD/iFhD = voxel 總數(百萬級)→ 塞不進 shared memory |
| 消除 atomics 的方法 | 改 gather:每 thread 擁有唯一輸出 voxel n,從所有樣本收集 |
| 為何要 loop fission | interchange 需完美巢狀;rMu/iMu 卡在內迴圈前 → 先 fission 拆出 cmpMu |
| loop interchange 何時合法 | 兩層迴圈所有 iteration 互相獨立、可任意順序執行時 |
| 三選項 thread 數 | ①N×M(太多) ②M(要 atomics) ③N(gather,最佳) |
| 哪些陣列可進 register | index=n(thread 固定、整圈不變/累加):x[n] y[n] z[n] rFhD[n] iFhD[n] |
kx[m] 為何不能進 register |
index=m 每圈變,無法整圈重用 → 改用 constant memory |
| k-space 為何適合 constant memory | 唯讀 + index=m 與 threadIdx 無關 → warp 內 32 thread 同元素 → broadcast,消除 96%+ |
| constant memory 容量限制 | 64KB → chunking,host 端迴圈多次 cudaMemcpyToSymbol + 多次 kernel 啟動 |
| constant cache 為何反而 thrash | cache line 載多字、三分離陣列每 warp 需 3 line → working set > cache line 數(~32) |
| AoS vs SoA(本章) | warp 全 thread 讀同一元素 → AoS(x/y/z 同一 line);相鄰 thread 讀連續 → SoA(coalesce) |
| HW trig | sin/cos → __sin/__cos(SFU intrinsic),throughput 高但精度略低 |
| 精度驗證 | phantom (I0) 反向合成 k-space → 重建 I → 算 PSNR;HW trig 27.6→27.5 dB(可接受) |
| 全章加速 / Amdahl | 基礎→最佳約 10×;FHD 由 ~100% 降到 ~50%,CG solver 成新瓶頸 |
Question 1 - MRI 兩階段與 k-space [recall]
MRI 流程分哪兩個階段?「k-space」是什麼域,掃描器在其中如何取樣?
兩階段:acquisition (scan) 與 reconstruction。acquisition 階段掃描器沿一條預定的 trajectory 在 k-space(即空間頻率域 / Fourier transform 域)取樣資料 s(k);reconstruction 階段把這些樣本轉換成目標影像 m(r),即估計組織的形狀與紋理。trajectory 的選擇同時影響重建影像品質、演算法時間複雜度與掃描時間。
Question 2 - Cartesian 為何能用 inverse FFT [recall]
為什麼 Cartesian 掃描軌跡的資料可以直接用 inverse FFT 重建?non-Cartesian 軌跡(如 spiral)為何不行,改採什麼策略?
Cartesian 是均勻間隔的網格取樣:此時 weighting function W(k) 為常數可提出加總,且 Eq.(17.1) 的指數項在 k-space 均勻間隔 → 整個加總恰好是 inverse FFT(極有效率)。non-Cartesian 的指數項不再均勻間隔,加總不具 FFT 形式,不能直接套 inverse FFT;需先用 gridding(內插到均勻網格後 FFT)或改用 iterative linear solver 重建。
Question 3 - 為何用 CG 而非直接矩陣求逆 [recall]
逆問題
ρ = (FᴴF + λWᴴW)⁻¹ FᴴD表面只是矩陣相乘與求逆,為何不能用 Gaussian elimination 直接解?改用什麼方法?
因為矩陣尺寸過大:即使中等解析度 128³ voxel,F 就有 128³ = 2,097,152 欄,每欄有 N 個元素(N = k-space 樣本數)。如此巨大的矩陣使 Gaussian elimination 等直接求逆法實務上不可行 (intractable)。改用 Conjugate Gradient (CG) 迭代法逼近,不顯式求逆;每次迭代的主成本是 matrix-vector 乘 (FᴴF+λWᴴW)·ρ。
Question 4 - FHD vs Q,為何只平行化 FHD [recall]
FHD 與 Q 有何關係?為何本章選擇平行化 FHD,而把 Q 與 CG solver 排除在外?
FHD(= FᴴD,matrix-vector 乘)與 Q(加速 FᴴF 的 matrix-matrix 乘)核心計算結構完全相同,Q 只是計算量大得多,故討論其一即可。選 FHD 因為它 每次影像擷取都要跑(D 每次不同,~3h CPU);而 Q 每個 scanner+trajectory 只算一次可重用,CG solver(find ρ)因 Q 已預算只占 CPU 時間 <1%,故暫不平行化。
Question 5 - Sequential FHD 的迴圈結構與依賴 [recall]
Fig. 17.4 的循序 FHD 為雙重迴圈(外
m、內n)。哪些 iteration 可平行?存在什麼依賴?它有哪兩個效能瓶頸?
每個 FHD[n] 累加所有 k-space 樣本 m 的貢獻,但任一 voxel 不依賴其他 voxel → 外、內迴圈所有 iteration 皆可平行。唯一依賴:同一外迴圈 iteration 內,內迴圈用到該迭代先算好的 rMu[m]/iMu[m]。兩瓶頸:(1) 記憶體頻寬,compute-to-memory 僅 0.25~0.75 OP/B;(2) 三角函數,FP 算術:trig ≈ 13:2,sin/cos 長延遲低吞吐。
Question 6 - 第一版 scatter kernel 為何需要 atomicAdd [recall]
Fig. 17.5 第一版 FHD kernel 把外迴圈(m)映射到 thread。為何內迴圈必須用 global-memory
atomicAdd?為何不能像 Ch.9 直方圖那樣用 shared-memory privatization 解決?
這是 scatter 結構:每個 thread(一個樣本 m)會寫進所有 rFhD/iFhD voxel,多個 thread 撞同一輸出,必須用 atomicAdd 防止互相覆蓋。不能 privatization 是因為 rFhD/iFhD 大小 = 影像 voxel 總數(百萬級),塞不進 shared memory。出路是改成 gather。
Question 7 - Loop Fission 與 Loop Interchange [recall]
要把 FHD 轉成 gather,必須做 loop interchange,但為何得先做 loop fission?fission 改變了兩部分的執行順序,為何結果仍正確?
loop interchange 要求完美巢狀 (perfectly nested) 迴圈(外、內 for 之間無其他 statement),但 Fig. 17.4 外迴圈在內迴圈前有 rMu/iMu 計算 → 非完美巢狀。Loop fission 把外迴圈 body 拆成「只算 rMu/iMu 的 cmpMu 迴圈」與「完美巢狀的 FHD 迴圈」。fission 後變成「先做完全部 pre,再做全部 inner」;因為 pre 部分(rMu/iMu)不依賴任何前次迭代內迴圈的結果,故改變相對順序不影響結果。
Question 8 - Register Promotion 與 kx[m] 的差異 [recall]
在 gather kernel(Fig. 17.10)中,哪些陣列元素可提升到 register?為何
x[n]可以但kx[m]不行?
看 index 與 threadIdx 的關係。x[n] y[n] z[n] 與 rFhD[n] iFhD[n] 的 index 是 n(thread 固定),整個 m-loop 不變(座標)或僅累加(輸出)→ 可在迴圈前載入 register、圈後寫回一次。kx[m] ky[m] kz[m] 的 index 是 m,每次迭代都變,無法整圈重用 register → register 救不了,改放 constant memory。效果:每圈 global accesses 14→7,OP/B 0.23→0.46。
Question 9 - Constant Memory:Broadcast 與 Chunking [recall]
為何 k-space 陣列
kx/ky/kz特別適合 constant memory?constant memory 有何容量限制,如何在 host 端解決?
適合的原因:(1) k-space 唯讀;(2) index = m 與 threadIdx 無關 → warp 內 32 個 thread 在同一迭代存取同一元素 → constant cache 可broadcast 給全 warp,每 32 次存取 ≥31 次由 cache 服務,消除 96%+ 的 global accesses。限制:constant memory 僅 64KB,但樣本可達數百萬 → 必須 chunking:host 端用迴圈把資料切成 ≤64KB 的塊,每次 cudaMemcpyToSymbol 搬一塊再啟動一次 kernel。加上 constant memory 後僅剩 rMu/iMu 兩次 global access,OP/B 升至 1.63。
Question 10 - Hardware 三角函數與精度驗證 [recall]
__sin()/__cos()與sin()/cos()有何差異?換用硬體版後如何驗證精度沒有壞掉?
__sin/__cos(前綴兩底線)是 compiler 認得的 intrinsic,翻成 SFU (Special Function Units) 硬體指令,throughput 遠高於軟體版(軟體版用 ~13/12 個 FP op 的 5 項 Taylor 級數),但精度略低。因 MRI 是醫學影像,須驗證:用 phantom (I0) 反向合成 k-space → 重建出 I → 代入 PSNR 公式 PSNR = 20·log10(max(I0)/√MSE) 比較;HW trig 使 PSNR 由 27.6 僅降到 27.5 dB(可接受),必要時再目視檢查 artifacts。
Question 11 - 計算各階段 OP/B [application]
gather kernel 內層迴圈每次迭代有 13 個浮點運算。(a) 全走 global memory(14 accesses)的 OP/B?(b) 把 5 個元素放 register(7 accesses)後?(c) 再把 k-space 放 constant memory(剩 2 個 global access)後?
OP/B = 浮點運算數 ÷ (global accesses × 4 bytes/float)。
(a) 13/(14×4) = 0.23 OP/B(bandwidth bound)。
(b) 13/(7×4) = 0.46 OP/B(register promotion,代價每 thread +5 register)。
(c) 13/(2×4) = 1.63 OP/B(constant memory + 編譯器把 rMu/iMu 的 4 次存取合併成 2 次 load)。三步把 0.23 → 1.63,使 bandwidth 不再是唯一瓶頸。
Question 12 - Grid/Block 配置與 chunking memcpy 大小 [application]
(a) 128³ 影像的 gather kernel,block = 1024 threads,grid 幾個 block?(b) M = 1,000,000 樣本的
cmpMu用同樣 block size,grid 幾個?(c) 改用 array-of-structures 後,單次cudaMemcpyToSymbol搬移的 byte 數由4·CHUNK_SIZE變成多少?為什麼?
(a) N = 128³ = 2,097,152 voxel → grid = 2,097,152/1024 = 2048 blocks(每 thread 算一個 voxel n)。
(b) grid = 1,000,000/1024 = 977 blocks(若非整除,需再補一輪)。
(c) 變成 12·CHUNK_SIZE:AoS 把 x/y/z 三個 single-precision(4 byte)分量併進一個 struct 元素,一次 memcpy 同時搬三分量 → 3×4 = 12 bytes/元素,且只需一個陣列、一次 memcpy。
Question 13 - Working Set 與 Constant Cache Thrashing 計算 [application]
某裝置每 block 1024 threads、每 SM 常駐 2 個 block,k-space 存於三個分離陣列(kx/ky/kz)。(a) 一個 SM 同時有幾個 warp?(b) 每 warp 每迭代需 3 條 cache line,最壞情況 working set 幾條?(c) 若該裝置 constant cache 只有 32 條 line,會發生什麼?如何解決?
(a) warps/SM = (1024/32) × 2 = 64 warps。
(b) worst-case working set = 64 × 3 = 192 cache lines(即使假設 3 個 warp 共享同迭代,仍約 64 條)。
(c) 32 條 < working set → 不同 warp 互相搶 line,下一圈要用的元素早被擠出 → cache thrashing,無法消除 global accesses。解法:把 kx/ky/kz 改成 array-of-structures,使一迭代的 x/y/z 三分量落在同一條 cache line,每 warp 只需 1 條 → working set ÷3。
Question 14 - 三種 Kernel 選項:為何 Gather 最佳 [analysis]
Fig. 17.6 的第二個迴圈有三種 kernel 設計:①thread=內迴圈 iteration、②thread=外迴圈(m)、③thread=輸出 voxel(n)。從 thread 數量、是否需 atomics、平行度三方面比較,並說明為何選 ③。
① thread = 內迴圈 → N×M(百萬 × 十萬)個 thread,遠超裝置所需 → 不可行。② thread = 外迴圈(m)→ M 個 thread,平行度尚可,但與 Fig. 17.5 相同是 scatter:每 thread 寫進所有 voxel → 大量衝突 → 需 atomicAdd → 慢。③ thread = 輸出 voxel(n)→ 需先 loop interchange,每 thread 有唯一的 n,只累加自己的 rFhD[n]/iFhD[n] → 無衝突、無 atomics,且 N(=128³≈2.1M)平行度充足。故 ③ 是 gather,為三者最佳。核心心法:讓 thread 擁有唯一輸出,就能消滅 atomics。
Question 15 - AoS vs SoA:與 Coalescing 規則為何相反 [analysis]
在 global memory 中,把 struct 的欄位拆成多個陣列(SoA)才能 coalesce;但本章卻把 k-space 從三個陣列(SoA)改成 array-of-structures(AoS)。為何兩處結論相反?
關鍵在 warp 內 thread 的存取型態。global memory coalescing 的情境是「相鄰 thread 存取連續元素」(如 data[threadIdx.x])→ 此時 SoA 讓同一欄位連續排列,使一次 warp 存取落在連續位址 → coalesced。本章 k-space 的情境是「warp 內所有 thread 存取同一個元素 k[m]」(index m 與 threadIdx 無關)→ 走的是 constant cache 廣播,目標是讓一迭代用到的 x/y/z 三分量擠進同一條 cache line,故用 AoS。存取型態不同(連續 vs 同一元素),最佳佈局就相反。
Question 16 - 10× 加速後的 Amdahl 反噬與 HW Trig 的 OP/B 弔詭 [analysis]
(a) 從基礎版到最佳化版約 10× 加速後,為何 FHD 在整體重建中的占比反而由 ~100% 降到 ~50%?這說明什麼定律?(b) 換用 HW trig 反而把 OP/B 推向 worst-case 0.25,為何整體仍更快?
(a) FHD 被大幅加速後,原本占 CPU <1% 的 CG solver(find ρ) 反而變成約 50% 的執行時間,FHD 只剩 ~50%。這是 Amdahl's Law 的常見現象:某階段被成功加速後,執行時間轉由原本不顯著的其他階段(此處 CG solver)主宰;要再快就得去加速 CG。
(b) OP/B = FP-op ÷ (memory bytes)。把 trig 從「~13 個 Taylor FP op」變成「單一 SFU 指令」會讓分子的 FP-op 計數下降 → OP/B 趨近 worst-case 0.25。但實際更快,因為 SFU 是獨立硬體單元,把 trig 從 FP pipeline 卸載且本身高吞吐、可與 FP 運算重疊。OP/B 數字不能脫離「是哪個單元在算」單獨解讀。
| 優化版本 (Fig) | 關鍵技巧 | 每迭代 global access | OP/B | 解除的限制 |
|---|---|---|---|---|
| Scatter (17.5/17.8) | thread = 樣本 m,寫所有 voxel | — | — | (反例:需 atomicAdd) |
| Gather 基礎 (17.10) | loop fission + interchange,避開 atomics | 14 | 0.23 | 消除 atomics |
| + Registers (17.11) | x/y/z[n]、rFhD/iFhD[n] 入 register |
7 | 0.46 | 部分頻寬 |
| + Constant mem (17.13/16) | kx/ky/kz 入 constant cache + AoS 佈局 |
2 | 1.63 | 頻寬 + cache thrash |
| + HW trig (17.17) | __sin/__cos(SFU intrinsic) |
2 | — | trig 吞吐瓶頸 |
核心觀念鏈:non-Cartesian 軌跡 → 不能 inverse FFT → iterative CG → FHD 是瓶頸(~3h、每次都要)→ scatter→gather(loop fission + interchange)避開 atomics → register promotion → constant memory(broadcast + 64KB chunking)→ AoS 解 cache thrashing → HW trig 解 trig 吞吐 → 全章 ~10×,但 Amdahl 反噬使 CG solver 成新瓶頸。
關鍵公式:ρ=(FᴴF+λWᴴW)⁻¹FᴴD、OP/B = FP_ops/(accesses×4)、gridDim = N/THREADS_PER_BLOCK、working_set = (threads/32)×blocks/SM × cache_lines_per_warp、PSNR = 20·log10(max(I0)/√MSE)。
數字記憶:OP/B 0.23→0.46→1.63;FP:trig=13:2;constant memory 64KB;working set 64×3=192 vs ~32 lines;PSNR double=single=27.6、HW trig=27.5、gridding/iFFT=16.8 dB;全章 ~10×;FHD 占比 100%→50%。
一句話總結:迭代式 MRI 重建是「output-centric gather」案例——先用迴圈轉換把 scatter 變 gather 消滅 atomics,再層層抬高 arithmetic intensity 與 trig 吞吐,最後被 Amdahl 定律提醒新瓶頸何在。