最新资讯
dev.to 如何在专用 GPU 服务器上用 OpenAI 兼容 API 部署 LLM dev.to 审计 153 份 Claude Code 与 Codex 会话记录:User-Agent 与 URL 中的邮箱泄露 dev.to 你的 Agent Harness 应具备的 17 项能力 dev.to 离线 AI 记忆中 0.8 余弦阈值为何失效 dev.to Kimi K3.1 Pro 开放权重:自托管 2.8T 模型前的开发者清单 dev.to 如何通过工具调用引导模型以提升 Harness 质量 dev.to AI Agent 审计轨迹不等于对话记录 dev.to 精简 MCP server 工具数量:更少工具、更小影响面,双重收益 dev.to 为什么 git checkout 无法撤销 AI 编码 agent 的改动? wechat AI短剧门槛降降降!实测即梦短剧工作室:剧本分镜预演一条龙,伙伴计划长期护航 dev.to 让模型改写文档前,先量化事实保真度 dev.to 回放 153 个 Claude Code 与 Codex 会话,审计 Agent 的生产影响范围
多元动态
登录 注册
行业动态 dev.to · @Jun-Sik Yoo, PhD

将共轭梯度求解器移植到 CUDA 的教学式入门

原文 我的收藏

文章以 Mantevo 项目的共轭梯度迷你应用 HPCCG 为例,演示如何把已有 CPU 科学计算程序增量移植到 GPU:先剖析原 CPU 应用找出主导内核,将稀疏矩阵向量乘 SpMV 迁移到 GPU,再迁移其余向量运算与归约,并分析为何只加速单个内核不够、如何让 CG 工作集常驻 GPU,最后重新剖析以观察瓶颈转移。

AI推荐理由 这是一篇面向教学的 CUDA 移植实践文章,以 Mantevo 项目的共轭梯度迷你应用 HPCCG 为例,给出增量式迁移路径:先剖析 CPU 应用定位主导内核,再把稀疏矩阵向量乘 SpMV 移到 GPU,随后迁移向量运算与归约,并讨论仅加速单个内核为何不够、如何让 CG 工作集常驻 GPU,最后重新剖析以观察瓶颈转移。内容具体、步骤可复现,对做 GPU 加速和科学计算的开发者有实际参考价值;局限是文章为教程性质,未提供性能数据结论,且来源为个人技术博客,权威性有限。

72

正文

GPU programming tutorials often begin with isolated examples: vector addition, reductions, matrix multiplication, and memory coalescing. These are useful for learning individual CUDA concepts, but there is another question that quickly arises when working with real scientific software: How do we actually port an existing CPU application to a GPU? In this article, I will walk through a small but realistic example using HPCCG, a Conjugate Gradient mini-application from the Mantevo project. Rather than rewriting the entire solver for CUDA at once, we will migrate it incrementally: profile the original CPU application and identify the dominant kernel, move sparse matrix-vector multiplication (SpMV) to the GPU, migrate the remaining vector operations and reductions, examine why accelerating individual kernels is not enough, keep the CG working set resident on the GPU, and profile the resulting implementation to see where the bottleneck moved. As a final experiment, we will return to the original CPU implementation and port it independently to Kokkos, then run essentially the same Kokkos source using both CPU and CUDA backends. This gives us a simple way to compare an explicitly written CUDA implementation with a performance-portability approach. The purpose is not to produce the fastest possible Conjugate Gradient implementation. Instead, the goal is to make the porting and performance-engineering process itself visible: profile, form a hypothesis, change one part of the application, measure again, and follow the bottleneck. All of the code and intermediate CUDA-porting commits used in this article are available in my HPCCG GitHub repository, so the individual stages can be checked out and reproduced while following along. 1. The Starting Point: Vanilla HPCCG HPCCG solves a linear system arising from a 27-point stencil on a structured 3D grid, using an unpreconditioned Conjugate Gradient method. The solver is deliberately simple, which makes it a good porting target: almost all of the work is done by three kernels. SpMV (HPC_sparsemv): computes Ap = A * p WAXPBY (waxpby): computes w = alpha * x + beta * y DDOT (ddot): computes the dot product x · y One CG iteration strings these together roughly as follows: for (int k = 1; k tolerance; k++) { if (k == 1) { waxpby(nrow, 1.0, r, 0.0, r, p); // p = r } else { oldrtrans = rtrans; rtrans = ddot(nrow, r, r); // global reduction double beta = rtrans / oldrtrans; waxpby(nrow, 1.0, r, beta, p, p); // p = r + beta * p } normr = sqrt(rtrans); // convergence check HPC_sparsemv(A, p, Ap); // Ap = A * p double alpha = ddot(nrow, p, Ap); // global reduction alpha = rtrans / alpha; waxpby(nrow, 1.0, x, alpha, p, x); // x = x + alpha * p waxpby(nrow, 1.0, r, -alpha, Ap, r); // r = r - alpha * Ap } Enter fullscreen mode Exit fullscreen mode Keep this loop in mind. Every porting decision later in the article is really a decision about where each line of this loop runs and where its data lives. Profiling the CPU baseline Before writing a single line of CUDA, we need to know where the time goes. Conveniently, HPCCG already reports a per-kernel timing breakdown. For a 100^3 problem, corresponding to one million rows, the original single-process CPU implementation took: KernelTime (s)Fraction of total SpMV 2.819 83.5% WAXPBY 0.342 10.1% DDOT 0.215 6.4% Total 3.378 100% The solver required 149 iterations and reached a final residual of approximately 8.0 × 10⁻²¹. SpMV is clearly the dominant operation, accounting for more than 80% of the CPU runtime. This is not particularly surprising. Each matrix row accesses neighboring elements through indexed loads, while performing relatively little arithmetic per byte moved. This gives us our first hypothesis: If SpMV dominates, moving SpMV to the GPU should be the highest-leverage first step. Commit 51fdbe5 — Starting off CUDA migration -- sparsemv is hotspot 2. Moving SpMV to the GPU The data structure problem The first obstacle is not actually the kernel. It is the matrix representation. HPCCG stores the matrix as arrays of per-row pointers: for (int i = 0; i ptr_to_vals_in_row[i]; const int* const cur_inds = A->ptr_to_inds_in_row[i]; const int cur_nnz = A->nnz_in_row[i]; for (int j = 0; j > GPU overhead ↓ GPU wins Enter fullscreen mode Exit fullscreen mode A profiling caveat: CPU timers are not GPU timers There is another subtle lesson hidden in these results. HPCCG's original timing infrastructure was written for synchronous CPU functions. When it surrounds a CUDA kernel launch, however, the host can return before the GPU has actually finished executing the kernel. For example, the GPU-resident 100^3 run reports: Total: 0.468121 s DDOT: 0.002686 s WAXPBY: 0.002234 s SPARSEMV: 0.002139 s Enter fullscreen mode Exit fullscreen mode Those per-operation numbers should not be interpreted as accumulated GPU execution times. They largely reflect asynchronous host-side launch behavior. I therefore use the application's total wall-clock time for the end-to-end comparison, and Nsight Systems or Nsight Compute when reasoning about individual CUDA operations. Porting an application to an asynchronous execution model can change not only its performance, but also the meaning of its existing profiler. 6. Following the Bottleneck Once the large vector transfers were gone, the smaller synchronization points became visible. Eliminating an unnecessary scalar copy One particularly simple example was the residual scalar. Initially, before computing the next residual, I preserved the previous value using a device-to-device copy. But there was no reason to move the data at all. I already had two scalar buffers. They could simply exchange roles: std::swap(d_rtrans, d_oldrtrans); ddot_cuda(n, d_r, d_r, d_partial, d_rtrans); compute_division>>( d_rtrans, d_oldrtrans, d_beta ); Enter fullscreen mode Exit fullscreen mode The swap changes two host-side pointer values. No GPU data moves. That one change reduced the number of cudaMemcpy calls in the profile from: 304 → 156 Enter fullscreen mode Exit fullscreen mode Exactly 148 copies disappeared. The interesting part is what happened next. The total API time attributed to cudaMemcpy barely changed, and end-to-end runtime improved only modestly, from roughly 0.55 s to around 0.49 s in those runs. Why? Because the removed copies were cheap device-to-device scalar copies. The expensive calls that remained were primarily host-visible synchronization points. This is another useful profiling lesson: Removing many operations is not necessarily the same thing as removing the expensive operations. Inside the SpMV kernel Once application dataflow was no longer dominating the discussion, I returned to the original hotspot: SpMV. Nsight Compute showed the following picture for the 100^3 one-thread-per-row CSR kernel: MetricMeasurement Achieved occupancy 90.9% Theoretical occupancy 100% DRAM throughput 63.3% of peak SM compute throughput 11.7% Active warps / scheduler ~11.1 Eligible warps / scheduler ~0.06 L1 hit rate 54.0% L2 hit rate 63.1% At first glance, the occupancy looks excellent. But occupancy only tells us how many warps can reside on the SM. It does not tell us how many of those warps are actually ready to issue an instruction. Here, roughly 11 warps were active per scheduler, while only about 0.06 warps were eligible on average. The scheduler had no eligible warp during approximately 98% of sampled cycles. The source counters provided another clue: Nsight Compute reported approximately 43.7 million excessive memory sectors, about 72% of the total sectors, associated with uncoalesced global accesses. So increasing occupancy would be the wrong first optimization target. There are already plenty of resident warps. The more interesting problem is memory access efficiency. The indirect access x[A.cols[j]] Enter fullscreen mode Exit fullscreen mode is an obvious candidate, but it is not necessarily the only culprit. In a one-thread-per-row CSR mapping, neighboring threads also walk different segments of vals and cols. Nsight Compute tells us that the current memory access pattern is inefficient. Determining exactly which loads dominate would be the next controlled experiment. And that is where I intentionally stop optimizing this CUDA implementation for now. The point of this exercise was not to squeeze every last percent out of CSR SpMV. We now have enough information to ask a different question: What happens if we solve the same porting problem using a performance-portability abstraction instead of writing CUDA directly?

来源报道 · 1 篇 · 以下链接均前往原始媒体

← 返回精选动态 原文

更多内容