Introduction
Welcome to the next stage of our journey in optimizing matrix multiplication. In our last lesson, we focused on the final step of the data path: moving data from shared memory into registers. By implementing register-level tiling, we drastically increased the arithmetic intensity of our kernel, ensuring that each element loaded into the fastest memory tier was used many times. This gave us a substantial performance boost by keeping the GPU's compute units fed with data already at their fingertips.
However, a bottleneck remains. While the computation within our tiles is now highly efficient, the process of loading those tiles from global memory into shared memory is still suboptimal. Our current implementation issues one memory instruction for every single float. Given that modern GPUs have very wide memory buses, this is like using a single bucket to empty a swimming pool when you have a fire hose available.
This lesson will teach you how to use that fire hose. We will focus on vectorized memory loads, a technique that allows a single instruction to fetch multiple data elements at once. By using CUDA's built-in vector types, like float4, we can instruct the hardware to perform wide 128-bit memory transactions, effectively quadrupling our load bandwidth from global memory in a single stroke. This is a fundamental optimization for any memory-bound operation and is critical for pushing performance closer to the theoretical peak.
The Principle of Vectorized Memory Access
The core idea behind vectorization is to match the granularity of our memory requests to the capability of the hardware. A GPU's memory controller is designed to fetch data in large, contiguous chunks called cache lines (typically 32, 64, or 128 bytes). When threads in a warp access consecutive memory addresses (coalesced access), the hardware can satisfy all 32 requests with a minimal number of these wide transactions.
Vectorized loads take this a step further. Instead of the hardware implicitly combining scalar float requests, we explicitly tell the compiler to issue a single, wide instruction to load a multi-element data type. For example, a float4 is a 16-byte structure. A single instruction to load a float4 will translate into a 128-bit memory operation.
The performance benefit is direct and substantial, as it allows us to saturate the available memory bandwidth more effectively.

To understand the concept and its hardware underpinnings, the following article provides an excellent overview.
Learn CUTLASS the hard way! - Kapil Sharma
This section from a blog post by Kapil Sharma introduces the concept of vectorized memory operations in CUDA.
Please read the introductory section on vectorized memory operations. Pay close attention to the table that compares the instructions generated for float, float2, and float4 loads. Notice that loading four floats can be done with a single LDG.E.128 instruction, which is the key to this optimization.
Implementing Vectorized Loads in CUDA
To implement vectorized loads, we use CUDA's built-in vector types (e.g., float4) and a C++ feature, reinterpret_cast. This cast is a low-level tool that tells the compiler to treat a pointer of one type as a pointer of another, without changing the underlying bits. In our case, we will cast our float* pointer (pointing to the start of a matrix) to a float4*.
Why is this necessary? The article "How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance" provides a key insight. The compiler cannot know for sure if the generic float* pointer you pass into a kernel is aligned to a 16-byte boundary, which is a requirement for a 128-bit load instruction. By using reinterpret_cast<float4*>, you are making a promise to the compiler that the pointer is, in fact, correctly aligned. This allows it to safely generate the more efficient, wide LDG.E.128 instruction.
// Assume 'gmem_ptr' is a float pointer to global memory,
// aligned to a 16-byte boundary.
// Cast the float pointer to a float4 pointer and load 4 floats at once.
float4 data = reinterpret_cast<const float4*>(gmem_ptr)[0];
// The four float values can now be accessed via data.x, data.y, data.z, data.w
This explicit instruction is precisely the kind of low-level control that allows you to extract maximum performance from the hardware. The following video provides a simple, visual demonstration of loading multiple elements at once.
Mini Project: How to program a GPU? | CUDA C/C++
This segment from Tushar Gautam's "Mini Project: How to program a GPU?" visually demonstrates the concept of vectorization.
Watch the section on vectorization. The video simplifies the concept by showing 2-element vectorization, but the principle is identical for the float4 type we will be using. Notice how a single thread can now load multiple consecutive elements in one go.
Updating the GEMM Kernel with Vectorized Loads
Now, let's apply this to our matrix multiplication kernel. The most logical place to use vectorized loads is where we have the highest volume of memory traffic from a contiguous source: the copy from global memory to shared memory.
Our strategy will be:
- Define the loops that load the
AandBtiles from global memory. - Within these loops, use
reinterpret_castto treat the source global memory pointers asfloat4*. - Each thread will now be responsible for loading one
float4(4 elements) instead of a singlefloat. This means we'll need fewer threads, or fewer iterations, to load an entire tile. - The loaded
float4variable is then broken down into itsx, y, z, wcomponents, which are stored in the correct locations in the shared memory tile.
The following resource provides a complete kernel that implements this.
Learn CUTLASS the hard way! - Kapil Sharma
This section presents the full CUDA kernel implementing vectorized loads. While the code is complex, the core change is confined to the global-to-shared memory copy.
Study the code provided in the Kernel section. Focus on these parts: Loading tile_a: Observe the use of reinterpret_cast<const float4*> to load from matrix_a. The code also performs a transpose on-the-fly (storing tmp_a.x, tmp_a.y, etc., into a column-major layout in shared memory). This is a common secondary optimization to prepare the data for efficient access during the compute phase. Loading tile_b: A similar reinterpret_cast is used to load tmp_b and store its components into the shared memory tile_b. Compare this loading logic to our previous kernel. You'll see that the number of memory instructions issued to global memory has been quartered.
Performance Analysis
As expected, reducing the number of memory transactions and better utilizing the memory bus width leads to a significant performance improvement. The kernel can now fetch the data it needs from global memory much faster, reducing the time spent waiting on memory and allowing the compute units to start their work sooner.

The following reading provides a table that quantifies this improvement.
Learn CUTLASS the hard way! - Kapil Sharma
This final section provides benchmark results comparing all the optimization stages we've covered.
Read the performance summary and examine the final comparison table. For a 4096x4096 matrix, vectorization provides a 1.27x TFLOPS improvement over the 2D block tiling kernel from our last lesson. This is a substantial gain achieved simply by changing how we load data.
This step closes a major performance gap. We have now optimized the data path from global memory to shared memory (vectorization) and from shared memory to registers (register tiling).
Conclusion
In this lesson, you've learned to wield one of the most direct and effective tools for memory optimization in CUDA. By aligning your code with the capabilities of the underlying hardware, you've significantly boosted the effective memory bandwidth of our GEMM kernel.
Key Takeaways:
- Vectorized Loads: Using CUDA's vector types (like
float4) allows you to issue a single wide memory instruction (e.g., 128-bit) to load multiple elements at once. - Bandwidth Utilization: This technique better utilizes the GPU's wide memory bus, leading to a significant increase in effective memory bandwidth and a reduction in memory-bound bottlenecks.
- Implementation: The key is to use
reinterpret_castto promise the compiler that your source data is aligned, enabling it to generate efficient vectorized load instructions likeLDG.E.128. - Application in GEMM: The primary target for this optimization in our GEMM kernel is the initial data copy from global memory to shared memory tiles.
We are getting remarkably close to a professionally optimized kernel. However, our implementations so far have relied on a convenient assumption: that the matrix dimensions are perfectly divisible by our tile sizes. In the real world, this is rarely the case.
Our next lesson will address this final piece of the puzzle. We will learn how to handle boundary conditions by adding logic to our kernels that safely processes matrices of any size, preventing out-of-bounds memory accesses without sacrificing performance in the common case.