<?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: Musharaf Khan Pathan</title>
    <description>The latest articles on DEV Community by Musharaf Khan Pathan (@musharaf_khanpathan_90de).</description>
    <link>https://dev.to/musharaf_khanpathan_90de</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%2F4174646%2Fbc4d7d95-e0ed-4b0d-aee7-458761d99918.png</url>
      <title>DEV Community: Musharaf Khan Pathan</title>
      <link>https://dev.to/musharaf_khanpathan_90de</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/musharaf_khanpathan_90de"/>
    <language>en</language>
    <item>
      <title>I wrote 21 CUDA kernels on a free GPU. Here's what the profiler taught me.</title>
      <dc:creator>Musharaf Khan Pathan</dc:creator>
      <pubDate>Sat, 10 Oct 2026 05:47:31 +0000</pubDate>
      <link>https://dev.to/musharaf_khanpathan_90de/i-wrote-21-cuda-kernels-on-a-free-gpu-heres-what-the-profiler-taught-me-4ed1</link>
      <guid>https://dev.to/musharaf_khanpathan_90de/i-wrote-21-cuda-kernels-on-a-free-gpu-heres-what-the-profiler-taught-me-4ed1</guid>
      <description>&lt;p&gt;&lt;em&gt;Every number in this post was measured on a free Google Colab Tesla T4. Raw data and profiler reports: &lt;a href="https://github.com/Musharaf-khan/cuda-kernel-lab/blob/main/benchmarks/results.md" rel="noopener noreferrer"&gt;benchmarks/results.md&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (October 2026):&lt;/strong&gt; I kept going after this post. A Nsight counter showed 268 million shared-memory bank conflicts in my "fastest" SGEMM. Warp tiling removed them, and with double buffering and an autotuner, SGEMM now matches cuBLAS on the T4 (101% sustained, still ~10% behind in a single unthrottled launch). I also added a Tensor Core GEMM with WMMA (24.8 TFLOP/s) and a second attention kernel at ~95% of PyTorch's SDPA. Full log, including what didn't work: &lt;a href="https://github.com/Musharaf-khan/cuda-kernel-lab/blob/main/docs/learnings.md" rel="noopener noreferrer"&gt;learnings.md&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Every GPU software posting I read asked about coalescing, occupancy and shared memory. I could recite the definitions but had never seen any of it happen. So I set a rule: write each kernel naively first, measure it, then change one thing at a time and prove with Nsight Compute why it got faster. All of it ran on a free Colab T4 from a MacBook that has no NVIDIA GPU at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea that unlocked everything: the memory hierarchy
&lt;/h2&gt;

&lt;p&gt;A GPU is mostly a machine for moving data. My first kernel, vector add, hit 263.8 GB/s, 82% of the T4's 320 GB/s spec, and could not go faster no matter what, because it does one addition per 12 bytes. Almost every speedup after that was the same move: get the data one level closer to the arithmetic, from DRAM to shared memory to registers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A sum over 16 million floats went from 1.9 GB/s to 247.8 GB/s (&lt;strong&gt;133x&lt;/strong&gt;) by adding up in registers and combining with warp shuffles instead of 16.7 million atomics on one address.&lt;/li&gt;
&lt;li&gt;In matrix multiply, swapping two index lines so a warp reads consecutive addresses made it &lt;strong&gt;8.2x&lt;/strong&gt; faster. Same math, same work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The counter that wasn't the whole story: 16,252,928 bank conflicts
&lt;/h2&gt;

&lt;p&gt;My shared-memory transpose was barely faster than the naive one. Nsight Compute said why: 16,252,928 shared-memory bank conflicts. Reading a column of a &lt;code&gt;float[32][32]&lt;/code&gt; tile puts all 32 threads of a warp in the same memory bank, and the count is exact: 4096² / 32 = 524,288 warp loads, each replayed 31 extra times. Padding the tile to &lt;code&gt;[32][33]&lt;/code&gt; dropped the counter to &lt;strong&gt;0&lt;/strong&gt; and the kernel got 1.8x faster.&lt;/p&gt;

&lt;p&gt;Then I re-ran the full suite the next day on a newer CUDA toolchain. Same code, same 16,252,928 conflicts, but now padding was only worth 6%. The conflicts were still there; most likely the newer compiler schedules the code so their latency hides behind other work (I haven't confirmed that in the machine code yet). That changed how I read profilers: a counter tells you a problem exists, only a stopwatch tells you what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GEMM ladder
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;GFLOPS (4096³)&lt;/th&gt;
&lt;th&gt;% of cuBLAS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;naive&lt;/td&gt;
&lt;td&gt;61.7&lt;/td&gt;
&lt;td&gt;1.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coalesced&lt;/td&gt;
&lt;td&gt;505.8&lt;/td&gt;
&lt;td&gt;15.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shared-memory tiles&lt;/td&gt;
&lt;td&gt;776.0&lt;/td&gt;
&lt;td&gt;23.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8×8 register tiles&lt;/td&gt;
&lt;td&gt;2,841.4&lt;/td&gt;
&lt;td&gt;85.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ &lt;code&gt;float4&lt;/code&gt; global loads/stores&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,101.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;91.6%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The surprise was the third row. Tiling is supposed to cut global memory traffic 32x, and the kernel only got 1.5x faster. The DRAM counters explained it: reads barely moved (1,654 → 1,584 MB), because the L2 cache had already been catching the reuse. The real bottleneck had moved to shared memory, where the inner loop did one multiply-add per two loads. Register tiling fixed that by making each loaded value feed 8 multiply-adds.&lt;/p&gt;

&lt;p&gt;Also humbling: cuBLAS measured anywhere from 3,165 to 3,575 GFLOPS across three runs. Same code, a hotter GPU hitting its 70 W cap. My kernel's ratio to cuBLAS stayed between 90% and 93%, which is why I only report same-run ratios and medians.&lt;/p&gt;

&lt;h2&gt;
  
  
  Softmax and LayerNorm: when the textbook optimization doesn't help
&lt;/h2&gt;

&lt;p&gt;Online softmax is the famous trick: compute the max and the sum in one pass instead of two. I implemented it, and it gave &lt;strong&gt;zero&lt;/strong&gt; speedup. The counters showed why: my "3-pass" version only read 1.4x the minimum bytes, because each 16 KB row was still in cache when the next pass came back for it. What actually won was keeping the whole row in registers so it's read from DRAM exactly once: &lt;strong&gt;1.25x faster than &lt;code&gt;torch.softmax&lt;/code&gt;&lt;/strong&gt;. Same story for LayerNorm (&lt;strong&gt;1.42x faster than &lt;code&gt;F.layer_norm&lt;/code&gt;&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;Fusion did exactly what theory said. A residual add followed by LayerNorm costs 5 passes over memory as two kernels and 4 as one. The counters went from 375 MB to 304 MB, and the time from 1.370 to 1.127 ms: 1.22x, almost precisely 5 : 4. Against PyTorch eager, which launches them separately, the fused kernel is &lt;strong&gt;1.44x&lt;/strong&gt; faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attention without the N×N matrix
&lt;/h2&gt;

&lt;p&gt;The textbook attention writes every score to memory. At 8K tokens that's 4.3 GB; at 16K tokens it needs 17.2 GB and doesn't fit on the GPU at all. My fused kernel streams K and V through shared memory and folds each tile into the output with online softmax, so the score matrix never exists. This is where online softmax earns its reputation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tokens&lt;/th&gt;
&lt;th&gt;Textbook&lt;/th&gt;
&lt;th&gt;Fused (mine)&lt;/th&gt;
&lt;th&gt;PyTorch SDPA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4,096&lt;/td&gt;
&lt;td&gt;43.0 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28.8 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;25.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,192&lt;/td&gt;
&lt;td&gt;196.6 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;112.2 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16,384&lt;/td&gt;
&lt;td&gt;out of memory&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;453.6 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;411.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 2K tokens the fused kernel moved 26x less data through DRAM (59 MB vs 1.5 GB). It is not faster everywhere: below about 2K tokens cuBLAS's matrix multiplies beat my one-thread-per-row loop, and PyTorch's production kernel is still about 10% ahead. That gap has a known cause (my design uses 254 registers per thread and leaves the GPU underoccupied) and a known fix (split rows across warps, use Tensor Cores), which is exactly what FlashAttention-2 does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roofline
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjyu5i8l1azekect2n154.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjyu5i8l1azekect2n154.png" alt="roofline" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every point is measured with hardware counters. The GEMM versions march up and to the right from the memory roof toward the compute roof; softmax and LayerNorm sit on the memory roof, which is exactly where a memory-bound kernel should end up. Once you can draw this chart you stop guessing which optimization to try next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Tensor Cores (WMMA), double buffering, and plugging these kernels into a small LLM inference server to measure their end-to-end effect.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Code, raw numbers and profiler reports: &lt;a href="https://github.com/Musharaf-khan/cuda-kernel-lab" rel="noopener noreferrer"&gt;github.com/Musharaf-khan/cuda-kernel-lab&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cuda</category>
      <category>gpu</category>
      <category>performance</category>
      <category>cpp</category>
    </item>
  </channel>
</rss>
