<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jun-Sik Yoo, PhD</title>
    <description>The latest articles on DEV Community by Jun-Sik Yoo, PhD (@junsik_yoo).</description>
    <link>https://dev.to/junsik_yoo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4128638%2F69fa5749-6b67-46c7-876e-bae0a6970617.jpg</url>
      <title>DEV Community: Jun-Sik Yoo, PhD</title>
      <link>https://dev.to/junsik_yoo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/junsik_yoo"/>
    <language>en</language>
    <item>
      <title>A Pedagogical Introduction to Porting a Conjugate Gradient Solver to CUDA</title>
      <dc:creator>Jun-Sik Yoo, PhD</dc:creator>
      <pubDate>Wed, 16 Sep 2026 20:39:48 +0000</pubDate>
      <link>https://dev.to/junsik_yoo/a-pedagogical-introduction-to-porting-a-conjugate-gradient-solver-to-cuda-aih</link>
      <guid>https://dev.to/junsik_yoo/a-pedagogical-introduction-to-porting-a-conjugate-gradient-solver-to-cuda-aih</guid>
      <description>&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do we actually port an existing CPU application to a GPU?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;profile the original CPU application and identify the dominant kernel,&lt;/li&gt;
&lt;li&gt;move sparse matrix-vector multiplication (SpMV) to the GPU,&lt;/li&gt;
&lt;li&gt;migrate the remaining vector operations and reductions,&lt;/li&gt;
&lt;li&gt;examine why accelerating individual kernels is not enough,&lt;/li&gt;
&lt;li&gt;keep the CG working set resident on the GPU, and&lt;/li&gt;
&lt;li&gt;profile the resulting implementation to see where the bottleneck moved.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The purpose is not to produce the fastest possible Conjugate Gradient implementation. Instead, the goal is to make the &lt;strong&gt;porting and performance-engineering process itself visible&lt;/strong&gt;: profile, form a hypothesis, change one part of the application, measure again, and follow the bottleneck.&lt;/p&gt;

&lt;p&gt;All of the code and intermediate CUDA-porting commits used in this article are available in my &lt;a href="https://github.com/junsik45/HPCCG" rel="noopener noreferrer"&gt;HPCCG GitHub repository&lt;/a&gt;, so the individual stages can be checked out and reproduced while following along.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Starting Point: Vanilla HPCCG
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SpMV&lt;/strong&gt; (&lt;code&gt;HPC_sparsemv&lt;/code&gt;): computes &lt;code&gt;Ap = A * p&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAXPBY&lt;/strong&gt; (&lt;code&gt;waxpby&lt;/code&gt;): computes &lt;code&gt;w = alpha * x + beta * y&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DDOT&lt;/strong&gt; (&lt;code&gt;ddot&lt;/code&gt;): computes the dot product &lt;code&gt;x · y&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One CG iteration strings these together roughly as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_iter&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;normr&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tolerance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;waxpby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nrow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// p = r&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;oldrtrans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rtrans&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;rtrans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ddot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nrow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// global reduction&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rtrans&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;oldrtrans&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;waxpby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nrow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// p = r + beta * p&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;normr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rtrans&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// convergence check&lt;/span&gt;

  &lt;span class="n"&gt;HPC_sparsemv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Ap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                     &lt;span class="c1"&gt;// Ap = A * p&lt;/span&gt;

  &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ddot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nrow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Ap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// global reduction&lt;/span&gt;
  &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rtrans&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;waxpby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nrow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// x = x + alpha * p&lt;/span&gt;
  &lt;span class="n"&gt;waxpby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nrow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Ap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// r = r - alpha * Ap&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep this loop in mind. Every porting decision later in the article is really a decision about &lt;strong&gt;where each line of this loop runs and where its data lives&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Profiling the CPU baseline
&lt;/h3&gt;

&lt;p&gt;Before writing a single line of CUDA, we need to know where the time goes. Conveniently, HPCCG already reports a per-kernel timing breakdown.&lt;/p&gt;

&lt;p&gt;For a &lt;code&gt;100^3&lt;/code&gt; problem, corresponding to one million rows, the original single-process CPU implementation took:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;KernelTime (s)Fraction of total&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SpMV&lt;/td&gt;
&lt;td&gt;2.819&lt;/td&gt;
&lt;td&gt;83.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAXPBY&lt;/td&gt;
&lt;td&gt;0.342&lt;/td&gt;
&lt;td&gt;10.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DDOT&lt;/td&gt;
&lt;td&gt;0.215&lt;/td&gt;
&lt;td&gt;6.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.378&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The solver required 149 iterations and reached a final residual of approximately &lt;code&gt;8.0 × 10⁻²¹&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This gives us our first hypothesis:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If SpMV dominates, moving SpMV to the GPU should be the highest-leverage first step.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Commit &lt;code&gt;51fdbe5&lt;/code&gt; — &lt;em&gt;Starting off CUDA migration -- sparsemv is hotspot&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. Moving SpMV to the GPU
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The data structure problem
&lt;/h3&gt;

&lt;p&gt;The first obstacle is not actually the kernel. It is the matrix representation.&lt;/p&gt;

&lt;p&gt;HPCCG stores the matrix as arrays of per-row pointers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;nrow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;cur_vals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ptr_to_vals_in_row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;cur_inds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ptr_to_inds_in_row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;cur_nnz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;nnz_in_row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cur_nnz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;cur_vals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cur_inds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;

  &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is convenient on the CPU, but copying the top-level structure to device memory would not make its host pointers magically become valid GPU pointers.&lt;/p&gt;

&lt;p&gt;I therefore flattened the matrix into &lt;strong&gt;Compressed Sparse Row (CSR)&lt;/strong&gt; form:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;row_offsets[nrow + 1]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cols[nnz]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vals[nnz]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each array is contiguous and can be allocated and copied to the device independently.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Commit &lt;code&gt;05ace3b&lt;/code&gt; — &lt;em&gt;sparsemv device copy and free routine&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The first CUDA SpMV
&lt;/h3&gt;

&lt;p&gt;The initial CUDA kernel deliberately uses the simplest possible mapping: &lt;strong&gt;one CUDA thread per matrix row&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;__global__&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;spmv_kernel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DeviceCSRMatrix&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blockIdx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;blockDim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;threadIdx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nrow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;row_offsets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
             &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;row_offsets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;

        &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point I intentionally did &lt;strong&gt;not&lt;/strong&gt; optimize the sparse-memory access pattern. The goal was first to establish a correct GPU baseline.&lt;/p&gt;

&lt;p&gt;The rest of CG still ran on the CPU, so each SpMV required the input vector to reach the GPU and the result to return to the CPU.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Commit &lt;code&gt;ef5c262&lt;/code&gt; — &lt;em&gt;Add CUDA CSR SpMV path to HPCCG&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is an important intermediate stage. A GPU kernel can be much faster than its CPU counterpart while the application as a whole remains limited by everything surrounding that kernel.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Migrating WAXPBY and DDOT
&lt;/h2&gt;

&lt;h3&gt;
  
  
  WAXPBY
&lt;/h3&gt;

&lt;p&gt;WAXPBY is the easiest operation to port. Each output element is independent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;__global__&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;waxpby_kernel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blockIdx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;blockDim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;threadIdx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During the migration I first compared the CPU and GPU results before switching the actual solver call sites over.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Commits &lt;code&gt;4b78d30&lt;/code&gt; — &lt;em&gt;Added waxpby CUDA kernel and correctness check&lt;/em&gt;&lt;br&gt;
&lt;code&gt;870b4bf&lt;/code&gt; — &lt;em&gt;Finished migrating WAXPBY&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  DDOT: now we need a reduction
&lt;/h3&gt;

&lt;p&gt;DDOT is different. WAXPBY maps one input element to one output element; DDOT collapses an entire vector into a single scalar.&lt;/p&gt;

&lt;p&gt;I implemented it as a simple two-stage reduction.&lt;/p&gt;

&lt;p&gt;In the first kernel, each thread accumulates a local sum using a grid-stride loop. Threads within each block then reduce those values through shared memory, producing one partial result per block.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;x[i] * y[i]
      ↓
thread-local accumulation
      ↓
shared-memory block reduction
      ↓
one partial / block
      ↓
second reduction
      ↓
device scalar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A second kernel reduces the block-level partial results to the final device scalar.&lt;/p&gt;

&lt;p&gt;I deliberately used a custom reduction rather than immediately calling a library routine such as CUB or cuBLAS. For this exercise, seeing the reduction and its synchronization structure explicitly was part of the point.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Commit &lt;code&gt;1101bf2&lt;/code&gt; — &lt;em&gt;DDOT also is on GPU&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But this creates a new problem.&lt;/p&gt;

&lt;p&gt;CG needs these scalar results to compute &lt;code&gt;alpha&lt;/code&gt;, &lt;code&gt;beta&lt;/code&gt;, and the convergence criterion. If the vector operations live on the GPU but every reduction result immediately returns to the CPU, the algorithm repeatedly forces the two processors to synchronize.&lt;/p&gt;

&lt;p&gt;We have accelerated all three kernels, but we have not yet fixed the application.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Why Accelerating Kernels Is Not Enough
&lt;/h2&gt;

&lt;p&gt;At this point, SpMV, WAXPBY, and DDOT all have CUDA implementations.&lt;/p&gt;

&lt;p&gt;It is tempting to declare the port complete.&lt;/p&gt;

&lt;p&gt;It isn't.&lt;/p&gt;

&lt;p&gt;Nsight Systems showed that the hybrid implementation was repeatedly entering &lt;code&gt;cudaMemcpy&lt;/code&gt;. In one intermediate profile I observed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cudaMemcpy        304 calls
cudaMalloc         10 calls
cudaLaunchKernel 1195 calls
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CUDA API attributed roughly 405 ms to the &lt;code&gt;cudaMemcpy&lt;/code&gt; calls, compared with roughly 29 ms of host API time spent launching kernels.&lt;/p&gt;

&lt;p&gt;There is an important subtlety here: &lt;strong&gt;this does not mean that copying a few bytes over PCIe literally takes milliseconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A blocking &lt;code&gt;cudaMemcpy&lt;/code&gt; is also a synchronization point. Its API duration can include time spent waiting for previously queued GPU work to complete.&lt;/p&gt;

&lt;p&gt;So the important observation was not simply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;PCIe is slow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The application is repeatedly forcing the CPU and GPU to synchronize.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a different level of performance problem.&lt;/p&gt;

&lt;p&gt;We started with a &lt;strong&gt;kernel-level&lt;/strong&gt; bottleneck: SpMV.&lt;/p&gt;

&lt;p&gt;After accelerating the kernels, we exposed an &lt;strong&gt;application-dataflow&lt;/strong&gt; bottleneck.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Keeping the CG Working Set on the GPU
&lt;/h2&gt;

&lt;p&gt;The next optimization therefore does not change the mathematics of SpMV, DDOT, or WAXPBY at all.&lt;/p&gt;

&lt;p&gt;It changes &lt;strong&gt;where the data lives&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The main CG working set consists of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;x
r
p
Ap
A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the GPU-resident version, these vectors and the CSR matrix are allocated on the GPU before entering the iteration loop. The iterative solver then operates directly on device pointers.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Allocate and initialize the GPU working set once.&lt;/span&gt;

&lt;span class="n"&gt;spmv_cuda&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_Ap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;waxpby_cuda&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_Ap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ddot_cuda&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_partial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_rtrans&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_iter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// update p on the GPU&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;

    &lt;span class="n"&gt;spmv_cuda&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_Ap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;ddot_cuda&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_Ap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_partial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_pAp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// compute alpha and update x and r on the GPU&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Copy final solution back.&lt;/span&gt;
&lt;span class="n"&gt;cudaMemcpy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"GPU-resident" here does &lt;strong&gt;not&lt;/strong&gt; mean that the entire CG solver has been turned into one giant CUDA kernel.&lt;/p&gt;

&lt;p&gt;The host still orchestrates a sequence of kernels. What changed is that the large working vectors no longer travel between CPU and GPU after every operation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Commits &lt;code&gt;a4c1be8&lt;/code&gt; — &lt;em&gt;Fully GPU resident CG kernel&lt;/em&gt;&lt;br&gt;
&lt;code&gt;bfedb51&lt;/code&gt; — &lt;em&gt;Add GPU-resident native CUDA HPCCG baseline&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The result depends strongly on problem size
&lt;/h3&gt;

&lt;p&gt;This produced an interesting result.&lt;/p&gt;

&lt;p&gt;For the small &lt;code&gt;20^3&lt;/code&gt; problem:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;20^3&lt;/code&gt;Serial CPUGPU-resident CUDA&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total time&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.9 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;51.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CG iterations&lt;/td&gt;
&lt;td&gt;149&lt;/td&gt;
&lt;td&gt;149&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The GPU implementation is about &lt;strong&gt;3× slower&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But for &lt;code&gt;100^3&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;100^3&lt;/code&gt;Serial CPUGPU-resident CUDA&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total time&lt;/td&gt;
&lt;td&gt;3.378 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.468 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CG iterations&lt;/td&gt;
&lt;td&gt;149&lt;/td&gt;
&lt;td&gt;149&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the GPU implementation is approximately &lt;strong&gt;7.2× faster end-to-end&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is one of the most useful results of the exercise.&lt;/p&gt;

&lt;p&gt;Both problems require the same 149 CG iterations. But &lt;code&gt;20^3&lt;/code&gt; contains only 8,000 rows, whereas &lt;code&gt;100^3&lt;/code&gt; contains one million — &lt;strong&gt;125 times as many rows&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For the small problem, there is simply not enough useful work to amortize repeated kernel launches, reductions, and synchronization.&lt;/p&gt;

&lt;p&gt;For the larger problem, GPU throughput dominates those fixed costs.&lt;/p&gt;

&lt;p&gt;So rather than saying simply that "the GPU is faster," I find it more useful to think in terms of an &lt;strong&gt;application-level crossover&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;small problem
    useful GPU work &amp;lt; GPU overhead
              ↓
          CPU wins

large problem
    useful GPU work &amp;gt;&amp;gt; GPU overhead
              ↓
          GPU wins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A profiling caveat: CPU timers are not GPU timers
&lt;/h3&gt;

&lt;p&gt;There is another subtle lesson hidden in these results.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For example, the GPU-resident &lt;code&gt;100^3&lt;/code&gt; run reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total:     0.468121 s
DDOT:      0.002686 s
WAXPBY:    0.002234 s
SPARSEMV:  0.002139 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those per-operation numbers should &lt;strong&gt;not&lt;/strong&gt; be interpreted as accumulated GPU execution times. They largely reflect asynchronous host-side launch behavior.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Porting an application to an asynchronous execution model can change not only its performance, but also the meaning of its existing profiler.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Following the Bottleneck
&lt;/h2&gt;

&lt;p&gt;Once the large vector transfers were gone, the smaller synchronization points became visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Eliminating an unnecessary scalar copy
&lt;/h3&gt;

&lt;p&gt;One particularly simple example was the residual scalar.&lt;/p&gt;

&lt;p&gt;Initially, before computing the next residual, I preserved the previous value using a device-to-device copy.&lt;/p&gt;

&lt;p&gt;But there was no reason to move the data at all.&lt;/p&gt;

&lt;p&gt;I already had two scalar buffers. They could simply exchange roles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;swap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_rtrans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_oldrtrans&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;ddot_cuda&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_partial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_rtrans&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;compute_division&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;d_rtrans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;d_oldrtrans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;d_beta&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The swap changes two host-side pointer values. No GPU data moves.&lt;/p&gt;

&lt;p&gt;That one change reduced the number of &lt;code&gt;cudaMemcpy&lt;/code&gt; calls in the profile from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;304 → 156
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly &lt;strong&gt;148 copies disappeared&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The interesting part is what happened next.&lt;/p&gt;

&lt;p&gt;The total API time attributed to &lt;code&gt;cudaMemcpy&lt;/code&gt; barely changed, and end-to-end runtime improved only modestly, from roughly 0.55 s to around 0.49 s in those runs.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because the removed copies were cheap device-to-device scalar copies. The expensive calls that remained were primarily host-visible synchronization points.&lt;/p&gt;

&lt;p&gt;This is another useful profiling lesson:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Removing many operations is not necessarily the same thing as removing the expensive operations.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Inside the SpMV kernel
&lt;/h3&gt;

&lt;p&gt;Once application dataflow was no longer dominating the discussion, I returned to the original hotspot: SpMV.&lt;/p&gt;

&lt;p&gt;Nsight Compute showed the following picture for the &lt;code&gt;100^3&lt;/code&gt; one-thread-per-row CSR kernel:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MetricMeasurement&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Achieved occupancy&lt;/td&gt;
&lt;td&gt;90.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Theoretical occupancy&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DRAM throughput&lt;/td&gt;
&lt;td&gt;63.3% of peak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SM compute throughput&lt;/td&gt;
&lt;td&gt;11.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active warps / scheduler&lt;/td&gt;
&lt;td&gt;~11.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eligible warps / scheduler&lt;/td&gt;
&lt;td&gt;~0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L1 hit rate&lt;/td&gt;
&lt;td&gt;54.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L2 hit rate&lt;/td&gt;
&lt;td&gt;63.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At first glance, the occupancy looks excellent.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here, roughly 11 warps were active per scheduler, while only about &lt;strong&gt;0.06 warps were eligible&lt;/strong&gt; on average. The scheduler had no eligible warp during approximately &lt;strong&gt;98% of sampled cycles&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The source counters provided another clue: Nsight Compute reported approximately &lt;strong&gt;43.7 million excessive memory sectors&lt;/strong&gt;, about &lt;strong&gt;72% of the total sectors&lt;/strong&gt;, associated with uncoalesced global accesses.&lt;/p&gt;

&lt;p&gt;So increasing occupancy would be the wrong first optimization target. There are already plenty of resident warps.&lt;/p&gt;

&lt;p&gt;The more interesting problem is &lt;strong&gt;memory access efficiency&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The indirect access&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;vals&lt;/code&gt; and &lt;code&gt;cols&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nsight Compute tells us that the current memory access pattern is inefficient. Determining exactly which loads dominate would be the next controlled experiment.&lt;/p&gt;

&lt;p&gt;And that is where I intentionally stop optimizing this CUDA implementation for now.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if we solve the same porting problem using a performance-portability abstraction instead of writing CUDA directly?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>performance</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
