reasoning
Let me analyze the reference model:
1. `linear1`: (16384, 2048) @ (2048, 4096) -> (16384, 4096)
2. `sigmoid`: element-wise on (16384, 4096)
3. `linear2`: (16384, 4096) @ (4096, 1024) -> (16384, 1024)
4. `logsumexp`: over dim=1 on (16384, 1024) -> (16384,)
This is basically two GEMMs with a sigmoid in between, followed by a logsumexp reduction.
Let's compute the FLOPs and memory:
- Gemm1: 16384 * 2048 * 4096 * 2 = ~275 GFLOPs
- Gemm2: 16384 * 4096 * 1024 * 2 = ~137 GFLOPs
- Total compute: ~412 GFLOPs
Memory for inputs/outputs:
- Input: 16384 * 2048 * 4 = 134 MB
- Hidden: 16384 * 4096 * 4 = 268 MB
- Output: 16384 * 1024 * 4 = 67 MB
- Total weights: 2048*4096*4 + 4096*1024*4 = 33.5 + 16.8 = 50 MB
The H100 has 67 TFLOPS FP32 and 3.35 TB/s memory bandwidth. With 412 GFLOPs and reading ~500MB total, this is compute-bound for the GEMMs.
However, the PyTorch reference will:
1. Launch cublas for gemm1
2. Launch sigmoid kernel
3. Launch cublas for gemm2
4. Launch logsumexp kernel
We can potentially fuse all of this into a single kernel or at least fuse some operations to reduce memory bandwidth. But given the sizes, the GEMMs are the bottleneck and cublas is very optimized. Fusing sigmoid and logsumexp might help reduce memory traffic but likely won't beat cuBLAS on the GEMMs themselves.
Actually, let me think about this differently. Could we run faster by fusing everything? The bottleneck is the two large GEMMs. PyTorch already uses cuBLAS for these, which should be near optimal.
Wait - let me reconsider. Maybe we can use CUTLASS or custom kernels to fuse the operations. But writing a custom GEMM kernel that beats cuBLAS is very hard.
Actually, looking more carefully: can we use Tensor Cores? The problem says fp32 precision is required. H100 has 67 TFLOPS FP32 but 989 TFLOPS TF32. If we can use TF32, that would be 15x faster. But the problem requires "numerically equivalent outputs at fp32 precision." Using TF32 might not pass the 1e-4 tolerance check since TF32 has less precision.
Hmm, but maybe we could use a cuBLAS GEMM and fuse the rest? Or maybe there's a trick with the logsumexp.
Let me think about what PyTorch does:
- linear1 + sigmoid: we could fuse these
- linear2 + logsumexp: we could potentially fuse these too!
If we fuse linear2 + logsumexp, we compute logsumexp over columns. For each row i:
output[i] = log(sum_j exp(sum_k hidden[i,k] * W2[k,j]))
But wait, doing the full logsumexp fused with GEMM requires computing the entire row of linear2 output, then doing logsumexp on it. This still requires computing the full GEMM.
Hmm, another approach: use a custom kernel that calls cublas for the GEMMs but fuses sigmoid and does logsumexp more efficiently? But PyTorch's logsumexp should be pretty efficient too.
Let me think if there's a mathematical simplification.
logsumexp(x @ W2 + b2) where x is post-sigmoid.
No obvious algebraic simplification.
Actually, I think the best approach might be to:
1. Use a single cuBLAS GEMM call via cublasGemmEx or similar for the first matmul
2. Fuse the sigmoid into the first GEMM output
3. Use another cuBLAS GEMM
4. Fuse logsumexp
But we can't easily fuse with cuBLAS without writing our own GEMM.
Wait - can we use CUTLASS? PyTorch's load_inline with nvcc can compile CUTLASS. But that's complex.
Actually, let me think about whether we can just write a kernel that calls cublas twice and fuses the rest. The memory overhead of storing the intermediate (16384, 4096) tensor is significant. If we can compute gemm1, apply sigmoid, then gemm2, then logsumexp without writing everything back to global memory between steps... but with separate kernels, each step writes to global memory.
If we write a single kernel that:
1. Loads a tile of input
2. Computes gemm1 tile via shared memory tiling
3. Applies sigmoid
4. Computes gemm2 tile
5. Computes logsumexp
But writing a competitive GEMM kernel from scratch is very hard, especially for matrices this large.
Let me think about another approach. Can we use `torch.matmul` inside our kernel? No, because we'd need to write a custom CUDA kernel.
Actually, maybe the reference implementation in PyTorch is not that optimized because:
1. It does `self.linear1(x)` which calls cuBLAS - fast
2. `torch.sigmoid(x)` - elementwise, fast
3. `self.linear2(x)` - calls cuBLAS - fast
4. `torch.logsumexp(x, dim=1)` - reduction, might use multiple kernels
PyTorch's logsumexp typically does:
- max(x, dim=1)
- x - max
- exp
- sum
- log
That's 5 kernel launches plus some temporaries. A fused kernel could be faster.
Also, PyTorch linear includes bias addition. We need to handle bias.
Let me try a pragmatic approach: write a custom kernel that
1. Calls cublasGemm for gemm1
2. Fuses sigmoid + bias
3. Calls cublasGemm for gemm2
4. Fuses logsumexp
Actually, we can maybe do even better by using a single kernel for the whole thing with proper tiling. Let me try writing a kernel that handles small tiles.
Given the matrix sizes:
- Input: 16384 x 2048
- Hidden: 16384 x 4096
- Output: 16384 x 1024
For the first GEMM: C = A * B + bias
16384x2048 * 2048x4096 = 16384x4096
We can tile this. Each block computes a tile of C. Use shared memory for A tile and B tile.
Let me design a fused kernel. For each row block (or set of rows):
- Load a chunk of rows from A into shared memory
- For each tile of B:
- Load B tile into shared memory
- Compute partial GEMM
- Add bias, apply sigmoid
- For second GEMM:
- The intermediate is the sigmoid output (tile)
- Load W2 tile
- Compute output tile
- For logsumexp:
- We need the max first, then compute exp and sum
Actually, doing logsumexp within the same kernel is tricky because you need a two-pass approach (first find max, then compute logsumexp). We could do this with shared memory reductions or with an intermediate buffer.
Wait, here's another idea: what if we do gemm1 + sigmoid + bias with a custom kernel, then call cuBLAS for gemm2, then do a custom logsumexp kernel? This avoids the hard task of writing a competitive GEMM for gemm2.
But gemm2 is 137 GFLOPs - it's a significant compute that cuBLAS will handle very well. Writing our own might not match.
Let me try the approach:
1. `torch.matmul` for gemm1 (uses cuBLAS)
2. Custom fused kernel for sigmoid + bias + gemm2 + logsumexp
No wait, if we call torch.matmul, we're going through PyTorch again and not using a custom kernel for the main path.
The requirement is: "Replace the forward computation of the PyTorch model below with a custom CUDA kernel."
So we need to write custom CUDA. Let me think about what's actually fastest.
Actually, let me try a middle ground: use custom kernels for the elementwise parts and let torch handle the GEMMs, but wrap it in a way that avoids extra memory copies. But that's not really a custom CUDA kernel replacement.
Hmm, let me look at it from a practical angle. The evaluation is timing `ModelNew.forward` vs `Model.forward`. PyTorch's version uses cuBLAS for GEMMs which is extremely fast. But there are 4 separate kernel launches with memory round trips.
If I write a single fused kernel:
- Each block handles some rows
- First GEMM: input (row_tile, 2048) @ W1 (2048, 4096) -> (row_tile, 4096)
- We can accumulate this in registers
- Sigmoid + bias
- Second GEMM: (row_tile, 4096) @ W2 (4096, 1024) -> (row_tile, 1024)
- Logsumexp on (row_tile, 1024)
- Output: (row_tile,)
For the first GEMM with output size 4096, if each thread computes 8 elements, and we have 1024 threads per block, that's 8192 elements = 2 rows of 4096. For 16384 rows, we need 8192 blocks.
Actually, let me think about register pressure. Each thread doing 4096 dot products of length 2048 is way too much. We need to tile properly.
Better approach: tile the computation at the block level.
- Each block computes a tile of row_indices x col_indices
- For first GEMM: output tile is ROWS x COLS1 where COLS1 is some tile of the 4096 outputs
- After sigmoid, we need to compute second GEMM. But the second GEMM needs the full hidden vector (all 4096 dims) to produce any output column.
This coupling makes it tricky. To compute even a single output of the second GEMM, we need all 4096 hidden values. So we can't just tile the second GEMM independently without having the full hidden row.
So for each row (or group of rows), we need to:
1. Compute all 4096 hidden values for that row
2. Apply sigmoid
3. Compute all 1024 output values
4. Compute logsumexp
If a block handles multiple rows, say 8 rows:
- Compute 8 x 4096 hidden values (first GEMM)
- Apply sigmoid
- Compute 8 x 1024 output values (second GEMM)
- Compute 8 logsumexp values
For step 1: 8 x 4096 values, each is a dot product of length 2048.
Total work per block: 8 * 4096 * 2048 = 67M multiply-adds.
If we have 128 threads in the block, each thread does about 524K ops. That's a lot but manageable with tiling.
Actually, let me try a different approach. Let's use `cublas` from within our CUDA extension for the GEMMs, and write custom kernels for the rest. We can call cublasGemm twice and fuse the elementwise operations.
But wait, to fuse we still need to write intermediate results to memory. Unless we use a single custom kernel that orchestrates everything...
Actually, a very practical approach: use `cublasGemm` for both matmuls, but allocate workspace and use custom kernels for:
1. `sigmoid_with_bias_add`
2. `fused_logsumexp_reduction`
This avoids the complexity of writing a GEMM but still gets some fusion. But is this faster than PyTorch?
PyTorch already does this efficiently. The overhead of 4 kernel launches is tiny on H100. The main thing is whether we can reduce memory bandwidth.
Hmm, what if we use TF32 for the GEMMs? cuBLAS may already use TF32 by default on H100. Actually, let me check: on H100, cuBLAS typically uses TF32 for FP32 GEMMs when the tensor dimensions are multiples of 8 (which they are). So PyTorch's matmul is already using TF32 tensor cores.
Wait, if cuBLAS uses TF32, then the output won't be exactly the same as a pure FP32 compute, but it should match PyTorch since PyTorch also uses cuBLAS. So if I also use cuBLAS, I'll get the same result.
What about writing a single kernel that does the entire computation using CUTLASS-like approach but with custom tiling to fuse operations?
Let me try a different angle. Given that:
- bs=16384, input=2048, hidden=4096, output=1024
What if we process multiple rows simultaneously where each thread handles one output element, and we accumulate directly without storing intermediate hidden activations in global memory?
For each output row:
1. Compute 4096 hidden values via matvec with W1
2. sigmoid each
3. Compute 1024 output values via matvec with W2
4. logsumexp
To avoid storing all 4096 hidden values, we could interleave step 3 with step 1. But since each output in step 3 depends on ALL 4096 hidden values, we must compute all of them first.
So we need to store 4096 FP32 values per row in shared memory or registers.
If a block handles one row and has 1024 threads:
- We need 4096 FP32 = 16KB for the hidden activations. This fits easily in shared memory.
- Each thread computes 4 hidden values (4096/1024).
- For each hidden value: dot product of length 2048.
To compute a dot product of length 2048 efficiently:
- We can load the input vector (2048 floats = 8KB) into shared memory once per block.
- Each thread then computes its assigned hidden value by iterating over the input and reading from shared memory.
Wait, but W1 is 2048 x 4096 = 32M floats = 128MB. That's too big for shared memory. We need to load W1 tiles.
Actually, if we have one block per row:
- Load input row (2048 floats) into shared memory once.
- For each chunk of W1 columns:
- Each thread in the block loads a portion of W1
- Compute partial dot products in registers
- Synchronize, repeat
But W1 is column-major or row-major in memory? PyTorch stores weights as (out_features, in_features) for nn.Linear, so W1 is (4096, 2048).
If we have 1024 threads per block handling 1 row:
- We want each thread to compute some output features.
- Thread i computes output features i, i+1024, i+2048, i+3072.
- For each output feature j, we need dot product of input[0:2048] with W1[j, 0:2048].
Since input is the same for all threads, we can load input into shared memory (8KB). Then each thread just reads W1[j, k] sequentially and multiplies with input[k] from shared memory.
The memory access pattern to W1: each thread reads 4 columns (4096/1024=4), each column has 2048 floats. But W1 is stored as (4096, 2048), so reading W1[j, :] for a fixed j means strided access with stride 2048.
Actually no - in row-major storage, W1[j, k] is contiguous in k for fixed j. So thread computing output j reads W1[j, 0], W1[j, 1], ..., W1[j, 2047]. This is perfectly contiguous if j's are assigned smartly.
Wait, but different threads will read different j's. If thread 0 reads j=0, thread 1 reads j=1, etc., then thread 0 reads W1[0, 0:2048] and thread 1 reads W1[1, 0:2048]. These are 2048 floats apart in memory. This is NOT coalesced.
For coalesced access, adjacent threads should read adjacent memory. Here, W1[j, k] for adjacent j are strided by 2048.
To get coalescing, we should have threads in a warp read with adjacent j and same k. But then each thread computes a different j, which is what we want.
Actually, let's think about memory access pattern more carefully. W1 is (4096, 2048) in row-major. W1[j, k] is at offset j*2048 + k.
If warp reads W1[j, k] where j varies across the warp and k is the same, then thread i reads W1[j_i, k]. The addresses are j_i*2048 + k. With j_i = lane_id, the stride is 2048 floats = 8192 bytes. On H100, a cache line is 128 bytes. So each thread in a warp would hit a different cache line. That's 32 cache lines for 32 threads. Very bad.
But if we transpose the weights, we can get coalescing! If W1^T is (2048, 4096), then W1^T[k, j] = W1[j, k]. If we store weights as (in_features, out_features), then for fixed k, varying j gives contiguous memory.
So we should transpose the weights when loading from PyTorch. In the constructor, we can store `W1_t = W1.t().contiguous()` and `W2_t = W2.t().contiguous()`.
Wait, but we're implementing ModelNew which is loaded with the same arguments. The weights are created randomly. We can transpose them in __init__.
OK so with transposed weights W1_t of shape (2048, 4096):
- Thread i (in a warp) computing output j_i reads W1_t[k, j_i] for same k. This is contiguous in j_i. Coalesced!
Let me design the kernel with this approach:
**Kernel design:**
- Grid: (num_rows / rows_per_block) blocks
- Block: 256 or 512 or 1024 threads
- Each block handles `rows_per_block` rows
For each row in the block:
1. **First GEMM**: Compute hidden activations (4096) using input row and W1_t
- Load input row into shared memory (2048 floats)
- Each thread computes a subset of hidden outputs (e.g., 4096/256 = 16 per thread)
- Iterate k from 0 to 2048:
- Load W1_t[k, thread_j]
- Multiply with input[k], accumulate
- (But this doesn't coalesce well if threads read different k)
Let me rethink coalescing.
Actually, let's use a more traditional GEMM tiling approach but adapted for this specific small-batch, large-feature case.
For 1 row at a time, or a few rows:
- BlockDim = (THREADS)
- Each thread handles a contiguous chunk of output features.
For W1_t (2048, 4096), to compute output j:
- output[j] = sum_k input[k] * W1_t[k, j]
If thread i handles j in [i*CHUNK, (i+1)*CHUNK), then for each k:
- All threads in a warp read input[k] (broadcast from shared mem or cache)
- Thread i reads W1_t[k, j_chunk_start : j_chunk_end] which is contiguous
For this to be coalesced within a warp:
- Thread 0 reads W1_t[k, 0:CHUNK]
- Thread 1 reads W1_t[k, CHUNK:2*CHUNK]
- These are contiguous. Yes, coalesced!
So if CHUNK = 4 and we have 1024 threads:
- Each thread handles 4 outputs, total 4096 outputs
- For each k, thread i reads W1_t[k, 4*i : 4*(i+1)]
- Threads in a warp (32 threads) read 128 contiguous floats = 512 bytes. Perfect coalescing!
This is a great access pattern.
For 1 row with 1024 threads and CHUNK=4:
- Register usage: 4 floats for accumulators + maybe some temporaries. Very low register pressure.
- We iterate k from 0 to 2048:
- Load input[k] into shared memory or just rely on L1 cache (broadcast)
- Each thread loads 4 floats from W1_t[k, :] and accumulates
Total memory reads per row:
- input: 2048 floats
- W1_t: 2048 * 4096 floats
- Total: 8.4M floats = 33.6MB for W1_t per row, but W1_t is read across all rows.
Wait, W1_t is 2048 * 4096 * 4 = 32MB. For 16384 rows, total memory bandwidth for W1_t alone is 32MB * 16384 = 512GB. That's not right - W1_t is reused across rows!
Actually yes, W1_t is read from memory once and cached. With L2 cache (50MB on H100), W1_t at 32MB mostly fits. So W1_t cache hit rate should be very high.
Similarly W2_t is 4096 * 1024 * 4 = 16MB. Also fits in L2.
Input is 16384 * 2048 * 4 = 128MB.
Output is 16384 * 4 bytes = 64KB.
So memory bandwidth is not the bottleneck; compute is. Each dot product is 2048 FMAs, and we have 16384 * 4096 = 67M outputs for first GEMM. That's 137 GFLOPs. Second GEMM is 16384 * 1024 * 4096 = 68B ops = 137 GFLOPs. Total 274 GFLOPs of FMA.
At 67 TFLOPS FP32, this takes ~4ms. At 989 TFLOPS TF32, it takes ~0.28ms. So TF32 is crucial.
Can we use TF32 tensor cores in our custom kernel? That requires using WMMA or WGMMA instructions, which is very complex.
Alternatively, maybe cuBLAS is so good that the best approach is:
1. Call cuBLAS for gemm1 and gemm2
2. Fuse sigmoid+bias and logsumexp in custom kernels
But PyTorch already does this with almost the same efficiency. The main win might be avoiding the memory round-trip for intermediate activations... but PyTorch's operations are also fused where possible (e.g., addmm fuses bias+GEMM).
Actually, PyTorch's `nn.Linear` calls `addmm` which fuses bias into the GEMM using cublasGemmEx with C = alpha*A*B + beta*C. So bias is already fused in cuBLAS.
And `torch.sigmoid` is a separate elementwise kernel that reads hidden, writes sigmoid(hidden). That's one memory pass.
Then `linear2` is another fused GEMM+bias.
Then `logsumexp` requires: max reduction, subtract, exp, sum, log. That's several passes.
So total memory passes:
1. Read input, read W1 -> write hidden (GEMM1, compute-bound)
2. Read hidden, write sigmoid_hidden (memory-bound, 2 * 268MB = 536MB)
3. Read sigmoid_hidden, read W2 -> write output (GEMM2, compute-bound)
4. Read output for logsumexp (max, subtract, exp, sum) -> write final (memory-bound, ~134MB)
The memory-bound passes are:
- Pass 2: ~536MB
- Pass 4: ~134MB
Total: ~670MB
At 3.35 TB/s, that's ~0.2ms. The compute-bound GEMMs at TF32 would be ~0.3ms each. So total ~0.8ms.
If we can eliminate the memory-bound passes by fusing everything into a single kernel, we save ~0.2ms. But writing a GEMM kernel that's competitive with cuBLAS on H100 is really hard.
Wait... what if we can write a kernel that:
1. Calls cuBLAS for gemm1 (producing hidden in a workspace)
2. Calls a custom fused kernel that reads hidden, applies sigmoid, calls cublasGemm on the fly... no, we can't call cuBLAS from a CUDA kernel.
What about using CUTLASS? We could include CUTLASS headers and write fused GEMM kernels. But CUTLASS is complex and may not compile easily with load_inline.
Another option: use Triton? No, load_inline is for CUDA C++.
Let me think about whether there's a simpler mathematical reformulation.
For the first GEMM + sigmoid:
h = sigmoid(x @ W1 + b1)
For the second GEMM + logsumexp:
output = logsumexp(h @ W2 + b2, dim=1)
Wait! logsumexp(a + b) over dim=1, where a is a matrix and b is a row vector. That's equivalent to:
= log(sum_j exp(h @ W2[j] + b2[j]))
= log(sum_j exp(h @ W2[j]) * exp(b2[j]))
= log(sum_j exp(h @ W2[j]) * c_j) where c_j = exp(b2[j])
And log(sum_j exp(h @ W2[j]) * c_j) = logsumexp(h @ W2[j] + log(c_j)) but that's circular.
Actually, we can absorb the bias into logsumexp:
output[i] = log(sum_j exp((h @ W2)[i,j] + b2[j]))
= log(sum_j exp((h @ W2)[i,j]) * exp(b2[j]))
= log(sum_j exp((h @ W2)[i,j] + b2[j]))
There's no simplification. But we can precompute exp(b2[j]) if useful.
Actually, could we compute the second GEMM and logsumexp in a single pass per row using online softmax/logsumexp algorithm?
For each row i, we want:
output[i] = logsumexp_j (sum_k h[i,k] * W2[k,j] + b2[j])
Yes! For a fixed row i, we can compute the j outputs one by one (or in tiles), track the running max and running sum for logsumexp, without storing all j outputs. This is the key insight!
Online logsumexp algorithm:
- max_val = -inf
- sum_val = 0
- For each j:
- val_j = dot(h, W2[:,j]) + b2[j]
- if val_j > max_val:
- sum_val = sum_val * exp(max_val - val_j) + 1
- max_val = val_j
- else:
- sum_val = sum_val + exp(val_j - max_val)
- output = max_val + log(sum_val)
This lets us compute the logsumexp in a single pass, without storing all 1024 output values!
Now the question is: can we fuse this with the first GEMM + sigmoid?
If each block handles one row:
1. Compute all 4096 hidden values h[i, :] = sigmoid(x[i,:] @ W1 + b1)
2. Store h[i,:] in shared memory
3. For j = 0 to 1023:
- Compute val_j = dot(h[i,:], W2[:,j]) + b2[j]
- Update online logsumexp state
4. Write output[i] = final logsumexp
Steps 1 and 2 require computing 4096 hidden values. With 1024 threads, each thread can compute 4 hidden values via dot products of length 2048.
Wait, computing a dot product of length 2048 per hidden value: if each thread computes 4, that's 4*2048 = 8192 FMAs per thread. In a kernel, each thread can loop over k=0 to 2048 and accumulate.
But W1 is shape (4096, 2048). To get good coalescing, we need to access W1 properly.
Actually, let's use W1_t of shape (2048, 4096). Then h[j] = sum_k x[k] * W1_t[k,j].
With 1024 threads and each handling 4 outputs (CHUNK=4):
For k in 0..2047:
x_k = x[k] (broadcast or loaded to shared mem)
load W1_t[k, thread_idx*4 : thread_idx*4+4]
accumulate
Then apply sigmoid+bias.
For step 3: compute dot(h, W2[:,j]). W2 is shape (4096, 1024), W2_t is (1024, 4096).
val_j = sum_k h[k] * W2_t[j, k]
Now j goes from 0 to 1023. We have h in shared memory (4096 floats = 16KB). We can compute val_j using shared memory for h.
But wait, for online logsumexp, we need to process val_j sequentially per row. If we compute val_j in parallel across j, we can't do the online update easily because it requires sequential processing.
Actually, we can compute all val_j first (parallel across j), store them in shared memory (1024 floats = 4KB), then do a parallel reduction for max, then compute logsumexp.
That requires storing 1024 floats per row in shared memory, which is fine. But it requires two passes over the computed values.
Wait, for 1024 threads and only 1024 outputs, we might just have each thread compute one val_j:
- Thread j computes val_j = dot(h, W2_t[j, :])
h is in shared memory. W2_t[j, :] has shape (4096,). Thread j reads W2_t[j, k] for k=0..4095.
With W2_t of shape (1024, 4096), thread j reads row j. This is strided by 4096 floats = 16KB between adjacent threads. Not coalesced!
For coalescing, we want adjacent threads to read adjacent memory. So W2 should be transposed differently: W2_t2 of shape (4096, 1024). Then W2_t2[k, j] = W2[k, j].
For val_j = sum_k h[k] * W2_t2[k, j]
Thread j reads W2_t2[k, j] for varying k, fixed j.
For a fixed k, threads j read W2_t2[k, 0], W2_t2[k, 1], ..., W2_t2[k, 31]. These are contiguous! Coalesced!
So we need W2 stored as (4096, 1024) i.e., the original PyTorch layout. But then for val_j computation:
For k from 0 to 4095:
h_k = h[k] (from shared mem)
load W2[k, j] for all j (coalesced in j)
val_j += h_k * W2[k, j]
Wait, no. To compute val_j = sum_k h[k] * W2[k, j], if we fix k and vary j, we get coalesced access. But we need all k.
So: for k from 0 to 4095:
h_k = h[k] (shared mem)
For each j assigned to a thread:
val_j += h_k * W2[k, j]
But if thread j computes just one val_j, then for each k, thread j reads W2[k, j]. Adjacent threads read W2[k, j] and W2[k, j+1], which are contiguous. Coalesced!
This works! With 1024 threads, each thread computes one val_j (total 1024 values, one per j). But we only have 1024 outputs and 1024 threads. Each thread computes a dot product of length 4096.
For k=0 to 4095:
h_k = h[k] (load once, broadcast to all threads or cache-friendly)
val += h_k * W2[k, j] (thread j loads W2[k, j])
Wait, W2[k, j] with j = threadIdx.x, and k varying. For fixed k, W2[k, :] is contiguous. Thread j reads W2[k, j]. This is coalesced!
For next k (k+1), thread j reads W2[k+1, j]. The stride from W2[k, j] to W2[k+1, j] is 1024 floats = 4KB. If we're iterating sequentially, this may not be great for cache. But with 1024 threads each reading 4 bytes, that's 4KB per k step. Actually, 1024 threads * 4 bytes = 4096 bytes = 4KB. A cache line is 128 bytes, so 32 cache lines per k step. The next k step needs another 32 cache lines that are 4KB apart.
On H100, each SM has L1 cache. The access pattern to W2 is effectively reading a column-major matrix by rows? No, W2 is (4096, 1024). W2[k, j] for all j at fixed k. Next iteration: W2[k+1, j] for all j. In memory, W2[k, 0:1023] is one row, W2[k+1, 0:1023] is the next row. We're reading row by row. This is perfectly sequential and cache-friendly at the block level!
But wait - if multiple blocks read W2 simultaneously, they compete for L2 cache. With 16384 rows and maybe 32 rows per block, we'd have 512 blocks. Each block reads all of W2 (16MB). Total W2 reads: 512 * 16MB = 8GB. But W2 should stay in L2 cache (50MB), so cache hit rate will be high.
Now for W1: shape (4096, 2048). We need W1_t = W1.T = (2048, 4096) for the first GEMM.
For h[j] = sum_k x[k] * W1_t[k, j].
With 1024 threads, each could compute 4 outputs (CHUNK=4).
For k=0 to 2047:
x_k = x[k]
thread i loads W1_t[k, 4*i:4*(i+1)]
accumulate
This is also reading W1_t row by row. W1_t is 32MB. Total reads: 512 blocks * 32MB = 16GB. W1_t at 32MB fits in L2 cache. Good.
So here's the full kernel design:
**Grid**: (num_blocks,) where each block handles `ROWS_PER_BLOCK` rows, e.g., 4 or 8 rows.
**Block**: 1024 threads (maximum on many GPUs, but check)
Actually with 1024 threads, we can only have limited blocks per SM due to occupancy. Let me check: H100 has 64K registers per SM * 255 max per thread means up to 256 threads with 255 registers, or with fewer registers, more threads.
For our kernel, each thread uses very few registers. If a block has 1024 threads and each uses ~32 registers, that's 32K registers, fitting well in 64K. We can have 2 blocks per SM.
With 16384 rows / 4 rows_per_block = 4096 blocks. On H100 with ~132 SMs (actually H100 80GB has 132 SMs? Let me check... Actually H100 SXM5 has 132 SMs). So 4096 blocks / 132 = ~31 blocks per SM. With 2 blocks per SM concurrently, we'd need ~15 waves. That's fine.
But wait, let me reconsider using 1024 threads. Perhaps fewer threads with more rows per block is better for latency hiding.
Actually, for each row we need:
1. Load input row (2048 floats) into shared memory
2. Compute 4096 hidden values
3. Apply sigmoid + bias
4. Compute 1024 output values via second GEMM
5. Compute logsumexp on 1024 values
6. Output 1 scalar
Steps 2 and 4 are compute-heavy. Step 4 requires all hidden values computed, so we need a block-level sync between steps 3 and 4.
With 1024 threads computing 4 hidden values each (step 2), and each hidden value is a dot product of 2048, we do 4096*2048 = 8.4M FMAs. At 2 TFLOPS per SM (estimated), that's 4ms per row... wait, that seems high. Let me recalculate.
Actually, each FMA is 2 FLOPs. 4096*2048 FMAs = 8.4M FMAs = 16.8 MFLOPs per row. For the block doing 4 rows: 67.1 MFLOPs. Plus second GEMM: 4*1024*4096 = 16.8M FMAs = 33.5 MFLOPs. Total per block: ~101 MFLOPs.
At 67 TFLOPS across 132 SMs, that's ~500 GFLOPS per SM. If we have 2 blocks per SM running concurrently, each gets ~250 GFLOPS. 101 MFLOPs / 250 GFLOPS = 0.4 microseconds. But memory will be the bottleneck.
Hmm, that seems too optimistic. Let me think about memory bandwidth more carefully.
For step 2 (first GEMM):
- Read input: 4 rows * 2048 floats = 8KB
- Read W1_t: 32MB per block, but cached across blocks. Effective bandwidth depends on cache.
- Compute: 4 * 4096 * 2048 FMAs
But since W1_t is read from memory but cached in L2, the first block that loads it pays the memory cost, subsequent blocks hit L2. With enough SMs, multiple blocks can be loading different parts simultaneously.
Actually, since W1_t is 32MB and L2 is 50MB, it won't fully fit along with other data. But with good cache replacement, it should still have decent hit rate.
For the second GEMM (step 4):
- Read W2: 16MB
- Compute: 4 * 1024 * 4096 FMAs
Total compute per block: 4 * (4096*2048 + 1024*4096) = 4 * (8.4M + 4.2M) = 50.3M FMAs = 100.6 MFLOPs.
At 67 TFLOPS FP32, if we could fully utilize the GPU, handling 16384 rows would take:
16384 rows * (4096*2048 + 1024*4096) FMAs = 16384 * 12.58M = 206B FMAs = 412 GFLOPs.
At 67 TFLOPS = 67,000 GFLOPS: 412 / 67000 = 6.1ms.
Wait, I get different numbers. Let me recalculate.
First GEMM: 16384 * 2048 * 4096 = 137,438,953,472 multiply-adds = 137.4B FMAs
Second GEMM: 16384 * 4096 * 1024 = 68,719,476,736 FMAs = 68.7B FMAs
Total: 206.1B FMAs = 206 GFMAs
At 67 TFLOPS (67,000 GFLOPS): 206,000 / 67,000 = 3.07ms (since each FMA is 1 operation for throughput counting, or 2 FLOPs)
Actually, GPUs count FMA as 1 instruction but 2 FLOPs. 67 TFLOPS means 33.5 TFMA/s.
206 GFMAs / 33.5 TFMA/s = 6.1ms.
But with TF32 tensor cores, 989 TFLOPS = 494.5 TFMA/s.
206 / 494.5 = 0.42ms.
So if we can use TF32 tensor cores, we get 0.42ms. But writing tensor core code is hard.
If we use FP32 on tensor cores (50 TFLOPS? no, FP32 on H100 is 67 TFLOPS without tensor cores for non-tc ops, or is it?).
Actually, H100 FP32 is 67 TFLOPS. TF32 tensor core is 494 TFLOPS (without sparsity). The 989 number includes sparsity.
Using cuBLAS with TF32 would get us ~494 TFLOPS for the GEMM parts. For our custom kernel using scalar FP32, we get 67 TFLOPS. That's 7.4x slower on compute!
So our custom scalar kernel would take ~6ms for compute, while cuBLAS TF32 takes ~0.8ms for both GEMMs plus some overhead for elementwise. The custom kernel would be much slower.
This means we MUST use cuBLAS for the GEMMs if we want to be competitive. Writing a custom GEMM that uses tensor cores is possible but extremely hard.
So the best strategy is:
1. Store weights in a format where we can call cuBLAS efficiently
2. For the rest, write fused kernels to minimize memory bandwidth
But wait - even if we use cuBLAS, can we fuse the sigmoid and logsumexp with the GEMMs? We can't fuse with cuBLAS directly, but we can reduce memory bandwidth by doing elementwise ops in-place or with minimal temporary memory.
Actually, what if we:
1. Call cublas for gemm1, output to workspace1
2. Launch custom kernel: read workspace1, apply sigmoid, output to workspace2
3. Call cublas for gemm2, output to workspace3
4. Launch custom kernel: logsumexp on workspace3 -> output
Can we avoid the workspace copies? What if workspace1 == workspace2 (in-place sigmoid)? Signmoid is elementwise and can be done in-place. Then we save one memory allocation and a write.
Similarly, can we do logsumexp directly without allocating extra memory? Yes, we can write a custom kernel that reads the gemm2 output and computes logsumexp.
So our fused version:
1. cublas gemm1 (x, W1) -> h
2. custom fused kernel: h = sigmoid(h + b1) in-place
3. cublas gemm2 (h, W2) -> out
4. custom kernel: logsumexp(out + b2) -> final
Wait, cuBLAS addmm already fuses bias into gemm1 and gemm2. So step 1 produces h + b1, step 3 produces out + b2. Then step 2 applies sigmoid in-place. Step 4 does logsumexp.
Is this faster than PyTorch? PyTorch does the same thing essentially, but:
- PyTorch may allocate new tensors for each intermediate
- PyTorch's logsumexp may use a different algorithm or temporary allocations
Actually, PyTorch does:
1. linear1: addmm, fused
2. sigmoid: elementwise kernel, reads and writes to new tensor
3. linear2: addmm, fused
4. logsumexp: typically calls aten::logsumexp which does max, subtract, exp, sum, log with temporaries
Our custom version could:
1. cublas gemm1, writes to workspace (same as PyTorch)
2. Custom kernel that reads workspace, applies sigmoid, writes back in-place (saves allocating a new tensor, but memory bandwidth is the same)
3. cublas gemm2 (same as PyTorch)
4. Custom logsumexp kernel that reads output and writes final result directly
The main win is in step 4 (fused logsumexp without temporaries) and step 2 (in-place sigmoid without allocation overhead). Memory bandwidth savings are modest.
Hmm, but there might be another trick. Let's check if PyTorch is using TF32 by default. On Ampere and newer, `torch.backends.cuda.matmul.allow_tf32` is True by default. So PyTorch's matmul uses TF32.
If our custom cublas calls also use TF32, we match. But we need to make sure.
For the custom logsumexp kernel, we can do a two-pass approach:
1. First pass: compute max per row
2. Second pass: compute log(sum(exp(x - max)))
Or a single-pass online algorithm. For 1024 columns, a two-pass with shared memory reduction should be fine.
But actually, with 16384 rows and 1024 columns, a reduction per row is straightforward. Each block handles some rows.
Let me think about the kernel more. Actually, we could try to fuse steps 2 and 3: after cublas gemm1 produces (with bias), we have h. We could apply sigmoid to h in-place, then launch cublas gemm2. Could we avoid the sync? We need a device sync between the custom kernel and cublas call.
Hmm, but CUDA kernel launches are async and coordinated via CUDA streams. The overhead of launching kernels is tiny (<10 microseconds).
OK, let me step back. Maybe the real opportunity is to use CUTLASS or a custom kernel that leverages the fact that we only need the final logsumexp, not all 1024 outputs.
Can we call cuBLAS for gemm1, then write a custom kernel that:
1. Reads gemm1 output tile
2. Applies sigmoid
3. For that tile, computes partial contributions to logsumexp without storing all 1024 outputs
To do this, we need to compute dot products with W2 columns and accumulate online logsumexp. This is a mat-vec + logsumexp fusion.
For a block handling a set of rows:
For each row:
1. Load sigmoid(hidden) into shared memory
2. For j = 0 to 1023 (columns of W2):
a. Compute dot(hidden, W2[:,j])
b. Update online logsumexp state
But computing dot(hidden, W2[:,j]) for each j individually is slow - it's 1024 dot products of length 4096, one at a time. That's 1024*4096 = 4.2M multiplies per row. But we can't parallelize across j because online logsumexp needs sequential processing.
Wait, we CAN parallelize across j for the dot products if we do a two-pass logsumexp:
- First, compute ALL 1024 dot products in parallel and store in shared memory
- Then do a reduction to find max
- Then sum exp(x - max)
This requires 1024 floats of shared memory per row. For a block handling 4 rows, that's 4096 floats = 16KB. Very manageable!
And the dot products can be computed efficiently: for k = 0 to 4095, load h[k] (shared mem), load W2[k, j_block] (coalesced), and accumulate. This is essentially a matmul with one row!
With 1024 threads and 1024 outputs, each thread computes 1 output. For 4 rows, we need 4 iterations. Total FMAs: 4 * 1024 * 4096 = 16.7M. The exact same as cublas gemm2 for 4 rows!
But can this match cublas performance? cublas uses TF32 tensor cores (494 TFLOPS). Our custom kernel uses scalar FP32 (67 TFLOPS). So our custom kernel for the second GEMM would be ~7x slower.
Unless... we can somehow call cublas from the host wrapper, get the full output, and then do our custom logsumexp. But then we pay memory bandwidth for writing and reading the 1024 outputs per row.
The memory bandwidth for 16384 * 1024 floats = 67MB. At 3.35 TB/s, that's 0.02ms. Negligible compared to compute time.
So the overhead of writing the intermediate and reading it for logsumexp is tiny.
Thus, the most practical approach is:
1. Call cublas for gemm1 and gemm2 (as fast as possible)
2. Write custom kernels for sigmoid and logsumexp to avoid PyTorch overhead and temporary allocations
But will this actually be faster than PyTorch? PyTorch's implementation is already pretty well optimized. The main potential wins:
- Avoiding Python overhead? No, both are called from Python.
- Better fusion? We can fuse bias+sigmoid if cuBLAS didn't already fuse bias.
- Better logsumexp implementation?
Actually, wait. For `nn.Linear`, PyTorch calls `torch.nn.functional.linear` which calls `torch.addmm` or `torch.matmul + bias`. On CUDA, `addmm` calls cuBLAS with C = beta*C + alpha*A*B. This DOES fuse the bias addition into the GEMM.
For sigmoid, PyTorch launches an elementwise kernel.
For logsumexp, PyTorch's implementation might not be optimal in terms of fusing operations or register usage.
So our best bet is to:
1. Use `cublasSgemm` or `cublasGemmEx` for both GEMMs
2. Write a custom in-place fused sigmoid+bias kernel if needed, or just sigmoid since cuBLAS handles bias
3. Write an efficient logsumexp reduction kernel
But using `cublasGemmEx` with TF32 explicitly would match PyTorch.
Wait, I just realized - we don't need to call cuBLAS manually if we can just use `torch.matmul`! But the problem says to write a custom CUDA kernel. However, the host wrapper can call torch operations... No, typically we want the heavy lifting in CUDA.
Actually, let me re-read the problem: "Replace the forward computation of the PyTorch model below with a custom CUDA kernel." This suggests we should write a CUDA kernel that does the computation. But we CAN call cuBLAS from C++ via the cublas library.
Let me write a kernel file that:
1. Uses `cublasSgemm` or `cublasGemmEx` for the GEMMs
2. Has custom CUDA kernels for sigmoid and logsumexp
Actually, since we can call `cublasGemmEx` with `CUDA_R_32F` and let it use TF32 automatically, that should match PyTorch.
For the logsumexp kernel, I want it to be very efficient. Here's a design:
- Block handles `ROWS_PER_BLOCK` rows (e.g., 8 rows)
- Within a block, use shared memory to find max, then compute logsumexp
- Each thread handles some columns
- Two-pass approach
For logsumexp on an array of length 1024:
Pass 1: Find max over 1024 elements
- We can do a tree reduction in shared memory
- 1024 threads, each loads one element, tree reduction to find max
Pass 2: Compute sum of exp(x - max)
- Each thread computes exp(x - max) for its elements and stores in shared memory
- Tree reduction to sum
- Final: log(sum) + max
This can be done with 1024 threads per block, handling 1 row per block. Or fewer threads handling multiple elements.
With 1024 threads and 1024 columns:
- Each thread loads 1 element
- Reduction to find max using __shfl_down sync (warp-level) then shared memory
- Each thread computes exp(x - max), reduction to sum
- Compute final result
For multiple rows per block (say 4 rows), we can do this sequentially 4 times in the same block, or use a 2D grid.
Actually, with 1024 threads, 1 row per block means 16384 blocks. That's fine.
For the sigmoid kernel, it's straightforward: each thread handles some elements.
Now, the key question: can we get speedup over PyTorch by doing this? The main issue is that PyTorch is already doing all of this. Where can we win?
1. **Kernel launch overhead**: PyTorch launches multiple kernels for logsumexp (max, sub, exp, sum, log). Our custom kernel does it in one launch. But kernel launch overhead is ~5 microseconds per launch. PyTorch's logsumexp is maybe 5 kernels = 25 us. Outside of logsumexp, PyTorch launches: addmm (1), sigmoid (1), addmm (1), logsumexp (multi-kernel). Total ~8 kernel launches = ~40 us. Our version: addmm (1), sigmoid (1), addmm (1), logsumexp (1) = ~4 launches = ~20 us. Difference: ~20 us. Negligible.
2. **Memory bandwidth**: PyTorch's logsumexp might allocate temporaries, causing additional memory traffic. Our fused kernel avoids this. But memory bandwidth is 3.35 TB/s. Logsumexp on 67MB with 2-3 passes is ~134-200MB of traffic = 0.04-0.06ms. Also small.
3. **cuBLAS performance**: PyTorch uses `addmm` which calls `cublasLtMatmul` or `cublasGemmEx`. Our manual cublas calls should match.
4. **Tensor layout**: PyTorch stores linear weights as (out_features, in_features). cuBLAS handles this. We can keep the same layout.
5. **Special tricks**: Can we fuse sigmoid with the second GEMM? No, because we need sigmoid output as a dense matrix for the GEMM.
Hmm, let me think about whether there's any issue with PyTorch's logsumexp. Looking at PyTorch source, `logsumexp` typically calls `_logsumexp` which computes max, then subtracts, then exp, then sum, then log. It may create temporary tensors for max and other intermediates. Our custom kernel can do it all in registers/shared memory.
For 16384 rows * 1024 cols = 16.7M elements:
- PyTorch logsumexp: reads 16.7M for max, writes 16.3K for max result, reads 16.7M + 16.3K broadcast for subtract, writes 16.7M, reads 16.7M for exp, writes 16.7M, reads 16.7M for sum, writes 16.3K, reads 16.3K for log, writes 16.3K.
Total memory: read ~67M elements, write ~50M elements. At 4 bytes each: ~470MB read, ~200MB write.
At 3.35 TB/s: ~0.14ms for reads + ~0.06ms for writes = ~0.2ms.
Our fused kernel: reads 67MB once, writes 64KB once. ~0.02ms.
But wait, this assumes the reduction is infinitely fast. Reduction on 1024 elements with 1024 threads has very little compute.
So we save ~0.18ms with a custom logsumexp. Is that significant? Maybe, if the total time is 1-2ms.
Actually, let me look at this differently. PyTorch's logsumexp is likely implemented as a single `logsumexp` kernel or a small sequence that doesn't necessarily write all intermediates to global memory. Let me check what PyTorch actually does.
PyTorch's `logsumexp` typically calls into ATen, which might use TensorIterator for reductions. For `dim=1` reduction, it might launch a `max` reduction kernel and then a `sumexp` kernel. The max result is stored and broadcasted. This does involve some global memory traffic.
But modern PyTorch also has a fused `logsumexp` implementation for CUDA in some cases. I'm not entirely sure.
OK, let me also consider another approach: using CUTLASS with custom fusion. PyTorch's `load_inline` can include CUTLASS headers from the CUDA toolkit path. On H100, CUTLASS should be available.
With CUTLASS, we could write a fused GEMM + epilogue kernel. For example:
- First GEMM: x @ W1 + bias1 -> sigmoid -> output
- Second GEMM: output @ W2 + bias2 -> output
But then we still need logsumexp. We could potentially write a custom epilogue for the second GEMM that does online logsumexp!
CUTLASS allows custom epilogues. We could write an epilogue that:
1. Computes each output element
2. Updates online logsumexp state in shared memory
3. After all outputs for a row are computed, writes the final logsumexp
This is very powerful but complex. Let me think if it's feasible with load_inline.
Actually, I think for a first attempt, let me try the simpler approach:
1. Manual cublas calls
2. Custom fused kernels
And if that doesn't give enough speedup, I'll try more advanced approaches.
Wait, I should also consider whether using `torch.compile` or other techniques would help, but the problem specifically asks for a custom CUDA kernel via `load_inline`.
Let me draft the kernel:
```python
import torch
from torch.utils.cpp_extension import load_inline
cuda_source = """
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <cublas_v2.h>
#include <cmath>
#define BLOCK_SIZE 1024
__global__ void sigmoid_kernel(float* data, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
float x = data[idx];
data[idx] = 1.0f / (1.0f + expf(-x));
}
}
__global__ void logsumexp_kernel(const float* input, float* output, int rows, int cols) {
int row = blockIdx.x;
if (row >= rows) return;
const float* row_ptr = input + row * cols;
// Find max
__shared__ float sdata[BLOCK_SIZE];
float local_max = -INFINITY;
for (int i = threadIdx.x; i < cols; i += blockDim.x) {
local_max = fmaxf(local_max, row_ptr[i]);
}
sdata[threadIdx.x] = local_max;
__syncthreads();
// Tree reduction for max
for (int s = blockDim.x / 2; s > 0; s >>= 1) {
if (threadIdx.x < s) {
sdata[threadIdx.x] = fmaxf(sdata[threadIdx.x], sdata[threadIdx.x + s]);
}
__syncthreads();
}
float max_val = sdata[0];
__syncthreads();
// Compute sum of exp(x - max)
float local_sum = 0.0f;
for (int i = threadIdx.x; i < cols; i += blockDim.x) {
local_sum += expf(row_ptr[i] - max_val);
}
sdata[threadIdx.x] = local_sum;
__syncthreads();
// Tree reduction for sum
for (int s = blockDim.x / 2; s > 0; s >>= 1) {
if (threadIdx.x < s) {
sdata[threadIdx.x] += sdata[threadIdx.x + s];
}
__syncthreads();
}
if (threadIdx.x == 0) {
output[row] = logf(sdata[0]) + max_val;
}
}
"""
```
Hmm, but BLOCK_SIZE = 1024 uses 1024 floats = 4KB of shared memory. That's fine.
For the cublas calls, I need to be careful about initialization. We can create a cublas handle per stream or a global handle.
Actually, using cublas from a C++ extension requires linking against cublas. `load_inline` should pick this up automatically if we include the right headers and use the functions.
But I realize there's a potential issue: if I use cublas from within a `torch::Tensor` operation, I need to make sure I'm using the same CUDA stream as PyTorch.
Let me write the host wrapper:
```cpp
torch::Tensor forward_cuda(torch::Tensor x, torch::Tensor w1, torch::Tensor b1, torch::Tensor w2, torch::Tensor b2) {
int batch_size = x.size(0);
int input_size = x.size(1);
int hidden_size = w1.size(0);
int output_size = w2.size(0);
auto h = torch::empty({batch_size, hidden_size}, x.options());
auto out = torch::empty({batch_size, output_size}, x.options());
auto result = torch::empty({batch_size}, x.options());
// cublas handle
cublasHandle_t handle;
cublasCreate(&handle);
float alpha = 1.0f, beta = 0.0f;
// h = x @ w1^T + b1
// x: (batch, input), w1: (hidden, input)
// cublasSgemm: C = alpha * A * B + beta * C
// We want h = x * w1^T, so A = x (batch, input), B = w1 (hidden, input)^T = w1^T (input, hidden)
// C = h (batch, hidden)
// A is (m, k), B is (k, n), C is (m, n)
// m = batch, k = input, n = hidden
// A = x, B = w1^T, but w1 is (hidden, input) so we need transposes
cublasSgemm(handle, CUBLAS_OP_T, CUBLAS_OP_N,
hidden_size, batch_size, input_size,
&alpha,
w1.data_ptr<float>(), input_size,
x.data_ptr<float>(), input_size,
&beta,
h.data_ptr<float>(), hidden_size);
// Add bias and sigmoid
// Actually, we can use cublas for bias too by providing beta=1 with bias broadcast... no, cuBLAS doesn't broadcast in the same way.
// Actually, cublasSgemm can do C = alpha*A*B + beta*C, so if we first fill h with b1, then call with beta=1.
// sigmoid kernel
int n = batch_size * hidden_size;
sigmoid_kernel<<<ceil((float)n / 256), 256>>>(h.data_ptr<float>(), n);
// out = h @ w2^T + b2
cublasSgemm(handle, CUBLAS_OP_T, CUBLAS_OP_N,
output_size, batch_size, hidden_size,
&alpha,
w2.data_ptr<float>(), hidden_size,
h.data_ptr<float>(), hidden_size,
&beta,
out.data_ptr<float>(), output_size);
// logsumexp
logsumexp_kernel<<<batch_size, BLOCK_SIZE>>>(out.data_ptr<float>(), result.data_ptr<float>(), batch_size, output_size);
cublasDestroy(handle);
return result;
}
```
Wait, there are several issues:
1. cublasSgemm expects column-major matrices by default. The layout matters.
2. We need the correct leading dimensions.
3. Creating/destroying a cublas handle inside the forward pass is very slow! We should create it once.
For the cublas handle, we can use a static variable:
```cpp
cublasHandle_t get_cublas_handle() {
static cublasHandle_t handle = nullptr;
if (!handle) {
cublasCreate(&handle);
}
return handle;
}
```
But we need to make sure the handle is destroyed at program exit. Alternatively, we can create it in a class or just leak it (fine for a benchmark).
For the cublas layout: PyTorch tensors are row-major. cuBLAS is column-major by default. The transposition flags help.
If we want C (row-major) = A (row-major) * B (row-major)^T, then:
- In column-major terms: C^T = B * A^T
- Or we can think of it as: with CUBLAS_OP_N for both A and B, we get standard gemm on column-major.
- For row-major A (m,k) and row-major B (n,k), we want C = A * B^T.
- This is equivalent to: C^T = B * A^T in column-major.
cublasSgemm parameters:
- transa, transb: operations on A and B
- m, n, k: dimensions
- A is ldA x (transa == N ? k : m) in memory...
Actually, the standard formula is:
C = op(A) * op(B)
where op(A) has dimensions m x k, op(B) has dimensions k x n, C has dimensions m x n.
For our case:
- A = x (batch_size, input_size) = row-major, so shape (m, k) with m=batch, k=input
- B = w1 (hidden_size, input_size) = row-major. We need B^T which is (input, hidden) = (k, n) with n=hidden.
- C = h (batch, hidden) = (m, n)
If we set:
- transa = N, so op(A) = A, shape (m, k). In column-major, A has shape (k, m). So ldA = k.
- transb = T, so op(B) = B^T, shape (k, n). B is (hidden, input) in row-major = (n, k). B^T in column-major is (k, n). So ldB = n (since B has n rows in column-major terms... wait).
This gets confusing. Let me use a different approach.
PyTorch's `addmm` calls cuBLAS under the hood. We can look up how PyTorch calls it, but simpler: we can just transpose the weight matrices and use non-transposed gemm.
If we store W1 as (input_size, hidden_size) transposed from PyTorch's (hidden_size, input_size), then:
- A = x (batch, input) = row-major (m, k)
- B = W1_t (input, hidden) = row-major (k, n)
- C = x @ W1_t = (m, n) in row-major
In cuBLAS column-major terms:
- C^T = B^T * A^T (all column-major)
- C^T is (n, m)
- B^T is (n, k) in col-major, which is the transpose of row-major B, so it's just W1_t stored col-major... no.
Let me just test with PyTorch's behavior. In PyTorch, `x @ w1.t()` where x is (m,k) and w1 is (n,k):
- y = torch.matmul(x, w1.t()) gives (m, n)
- In cublas, this is called with m=batch, n=hidden, k=input, and appropriate transpose flags.
Actually, PyTorch's linear layer computes `x @ w^T + b` where w is (out, in). This is equivalent to `addmm(b, x, w.t())`. PyTorch calls cuBLAS with `m=output, n=batch, k=input` for `op(B) * op(A)` or something similar.
You know what, instead of trying to figure out the exact cublas parameters, I could just use `at::cuda::blas::gemm` from libtorch, which is what PyTorch uses internally and handles the row-major translation:
```cpp
at::cuda::blas::gemm<float>(
't', 'n',
hidden_size, batch_size, input_size,
alpha,
w1.data_ptr<float>(), input_size,
x.data_ptr<float>(), input_size,
beta,
h.data_ptr<float>(), hidden_size
);
```
Wait, `at::cuda::blas` might not be a public API. Let me use the cublas API directly.
The standard mapping for row-major C = A * B^T:
```cpp
cublasSgemm(handle,
CUBLAS_OP_T, CUBLAS_OP_N, // transA, transB
n, m, k, // n=hidden, m=batch, k=input
&alpha,
w1.data_ptr<float>(), k, // A = w1, ldA = k (input_size)
x.data_ptr<float>(), k, // B = x, ldB = k (input_size)
&beta,
h.data_ptr<float>(), n // C = h, ldC = n (hidden_size)
);
```
Wait, let me verify this. If A is w1 (hidden, input) and we transpose it, we get (input, hidden). B is x (batch, input), and we don't transpose, so it's (batch, input) in column-major which is actually (input, batch). Then op(B) * op(A) = (input, batch)^T * (input, hidden)^T? No, that's not right.
cublasSgemm computes: C = alpha * op(A) * op(B) + beta * C
Where op(A) is m x k, op(B) is k x n, C is m x n.
If transA = T, then op(A) = A^T. If A is (hidden, input) in memory, A^T has shape (input, hidden) = (k, n).
If transB = N, then op(B) = B. If B is (batch, input) in memory, in column-major it's actually stored as (input, batch). But cuBLAS doesn't know about row-major, it just sees a matrix with ldA rows and k cols...
Actually, cuBLAS works with column-major storage. A matrix with dimensions (lda, k) in column-major has lda rows and k columns. Element (i, j) is at i + j * lda.
For a row-major matrix of shape (batch, input) = (m, k):
- Element (row, col) in row-major is at row * k + col.
- In column-major with ldA = k, element (i, j) is at i + j * k.
- So row i, col j in row-major maps to i + j * k, which means in column-major it's stored as a matrix with ldA = k, and dimensions (k, m).
So a row-major (m, k) matrix is exactly a column-major (k, m) matrix with leading dimension k!
Similarly, w1 is row-major (hidden, input) = (n, k). In column-major, this is (k, n) with ldA = k.
Now, if we want C (row-major (m, n)) = A (row-major (m, k)) * B (row-major (n, k))^T:
In column-major:
- A_rowmajor(m, k) = A_colmajor(k, m) with ldA = k
- B_rowmajor(n, k) = B_colmajor(k, n) with ldB = k
- B^T in row-major is (k, n) = B_colmajor(n, k) with ldB = n... wait.
For matmul C = A * B^T:
- A is (m, k)
- B^T is (k, n)
- C is (m, n)
In column-major terms:
- A_colmajor is (k, m) with ldA = k
- We want op(B) to be (k, n). B in memory is (k, n) in column-major with ldB = k. This is B_colmajor = (k, n) with ldB = k, which corresponds to B_rowmajor = (n, k).
- So op(A) = A_colmajor = (k, m), op(B) = B_colmajor = (k, n)
- C_colmajor = op(A)^T * op(B) = (m, k) * (k, n) = (m, n)... no, cublas computes op(A) * op(B).
Wait, I think I have it backwards.
cuBLAS Sgemm: C = op(A) * op(B)
- op(A) is m x k
- op(B) is k x n
- C is m x n
For row-major A (m, k), in column-major it's (k, m) with ldA = k. If we set transA = T, then op(A) = A^T = (m, k). Perfect!
For row-major B (n, k), in column-major it's (k, n) with ldB = k. If we set transB = N, then op(B) = B = (k, n).
Wait, but B is (n, k) in row-major. In column-major representation with ldB = k, what are its dimensions? A column-major matrix with ldB rows and k columns... Element (i, j) is at i + j * ldB. For row-major B[n][k], element (row, col) is at row * k + col. This equals i + j * k with i = row, j = col. So it's a column-major matrix with ldB = k, and the dimensions are (k rows, n columns). Wait, no: j goes from 0 to n-1 (since there are n rows in row-major), so it's k rows and n columns in column-major. So B_colmajor is (k, n).
If transB = N, op(B) = B_colmajor = (k, n).
Then op(A) with transA = T is (m, k) (since A_colmajor is (k, m) and its transpose is (m, k)).
And op(B) with transB = N is (k, n).
So C = op(A) * op(B) = (m, k) * (k, n) = (m, n).
Yes! This is the correct mapping:
```cpp
cublasSgemm(handle,
CUBLAS_OP_T, CUBLAS_OP_N,
m, n, k,
&alpha,
A_ptr, k, // A is row-major (m, k), in col-major it's (k, m) with ld=k; transposed gives (m, k)
B_ptr, k, // B is row-major (n, k), in col-major it's (k, n) with ld=k; no transpose gives (k, n)
&beta,
C_ptr, n // C is row-major (m, n)
);
```
Wait, I need to double-check the parameters. cublasSgemm takes:
- transa, transb
- m, n, k
- *alpha
- *A, lda
- *B, ldb
- *beta
- *C, ldc
Where op(A) is m x k, op(B) is k x n, C is m x n.
lda is the leading dimension of A (before transpose, I believe).
For our case:
- We want C (batch, hidden) = x (batch, input) * w1^T (input, hidden)
- m = batch_size, n = hidden_size, k = input_size
- A = x, transA = T, lda = input_size
- B = w1, transB = N, ldb = input_size
- C = h, ldc = hidden_size
Actually wait, B should be w1, which is (hidden, input) in row-major. But PyTorch stores nn.Linear weights as (out_features, in_features). So w1 is (hidden, input).
In row-major, w1 is (hidden, input) = (n, k). In column-major it's (k, n) with ld = k. With transB = N, op(B) = (k, n). Correct!
So:
```cpp
cublasSgemm(handle,
CUBLAS_OP_T, CUBLAS_OP_N,
batch_size, hidden_size, input_size,
&alpha,
x.data_ptr<float>(), input_size,
w1.data_ptr<float>(), input_size,
&beta,
h.data_ptr<float>(), hidden_size
);
```
Wait, no. Looking at cublas docs:
cublasSgemm(handle, transa, transb, m, n, k, alpha, A, lda, B, ldb, beta, C, ldc)
The standard is that op(A) has dimensions m × k, so A itself (before op) is:
- If transa = N: A has dimensions m × k, lda >= m
- If transa = T: A has dimensions k × m, lda >= k
Oh no, I think I've been confusing leading dimensions.
In standard BLAS/CUBLAS, for a matrix with dimensions (rows, cols):
- In column-major, lda is the distance between columns, which equals the number of rows.
- If A is m × k in col-major, lda = m.
- If A^T is used, then op(A) is k × m, and A in memory is k × m in col-major, so lda = k.
But we're using row-major tensors from PyTorch. A PyTorch tensor of shape (m, k) in row-major has:
- Stride in the last dimension = 1
- Stride in the first dimension = k
In column-major, this same memory layout corresponds to a matrix of shape (k, m) with lda = k.
For x of shape (batch, input) = (m, k):
- In standard col-major terms, it's (k, m) with lda = k.
- For transA = T: op(A) = A^T = (m, k), and the underlying A in col-major is (k, m), so lda = k.
For w1 of shape (hidden, input) = (n, k):
- In col-major terms, it's (k, n) with lda = k.
- For transB = N: op(B) = B = (k, n), and the underlying B in col-major is (k, n), so ldb = k.
Then C = op(A) * op(B) = (m, k) * (k, n) = (m, n).
C in col-major is (n, m), ldc = n.
In PyTorch row-major, C of shape (m, n) has memory layout where element (i, j) is at i*n + j. In col-major with ldc = n, element (i, j) is at i + j*n... wait, that's not right either.
Let me just be very concrete. Memory layout of PyTorch tensor T[s0][s1] in row-major:
offset = i0 * s1 + i1
For x[batch][input]: offset = i * input + j
For w1[hidden][input]: offset = i * input + j
For h[batch][hidden]: offset = i * hidden + j
cublasSgemm computes in column-major. If we treat the same memory as column-major, a matrix of dimensions (lda, k) in col-major has:
offset = i + j * lda
So x with offset = i * input + j corresponds to col-major with lda = input, and dimensions (input, batch).
For C = A^T * B where:
A is col-major with dimensions (input, batch), so A^T is (batch, input)
B is col-major with dimensions (input, hidden), so B is (input, hidden)
C = A^T * B = (batch, input) * (input, hidden) = (batch, hidden)
C in col-major has dimensions (hidden, batch) with ldc = hidden.
And in memory, C[i][j] in row-major (with i in [0, batch), j in [0, hidden)) is at i*hidden + j.
In col-major with dimensions (hidden, batch), element (j, i) is at j + i*hidden. So element (i, j) in row-major corresponds to element (j, i) in col-major. This matches because row-major (m, n) = col-major (n, m).
So for our case:
cublasSgemm(handle, CUBLAS_OP_T, CUBLAS_OP_N, batch, hidden, input, alpha, x, input, w1, input, beta, h, hidden);
Where:
- A = x, transA = T: A is col-major (input, batch), A^T is (batch, input)
- B = w1, transB = N: B is col-major (input, hidden), B is (input, hidden)
- C is col-major (hidden, batch), which corresponds to row-major (batch, hidden)
Let me verify with m, n, k:
- op(A) is m x k = batch x input
- op(B) is k x n = input x hidden
- C is m x n = batch x hidden
So m = batch, n = hidden, k = input.
lda: leading dimension of A. A in col-major is (input, batch), so lda = input.
ldb: leading dimension of B. B in col-major is (input, hidden), so ldb = input.
ldc: leading dimension of C. C in col-major is (hidden, batch), so ldc = hidden.
Yes! This is correct:
```cpp
cublasSgemm(handle, CUBLAS_OP_T, CUBLAS_OP_N,
batch_size, hidden_size, input_size,
&alpha,
x.data_ptr<float>(), input_size,
w1.data_ptr<float>(), input_size,
&beta,
h.data_ptr<float>(), hidden_size);
```
Wait, I need to be more careful. cublasSgemm expects A, B, C as column-major matrices. For PyTorch tensors in row-major:
- x (batch, input): in col-major it's (input, batch) with leading dim = input. But the actual memory has batch rows and input columns in row-major. When viewed as col-major, it's input rows and batch columns.
- So A in col-major terms is indeed (input, batch).
But the cublas docs say the matrix dimensions should match what op expects. For transA = T, A has dimensions k × m, so lda = k. For our A (x viewed as col-major), dimensions are input × batch, and k = input, m = batch. So lda = k = input. Correct!
Similarly for B with transB = N, B has dimensions k × n, ldb = k. B (w1 viewed as col-major) is input × hidden = k × n. So ldb = k = input. Correct!
For C, dimensions are m × n, ldc = m? Wait, for col-major C with dimensions m × n, ldc = m. But C in col-major is hidden × batch = n × m...
I think I'm getting confused with m and n.
Let me use cublas terms directly:
cublasSgemm(handle, transa, transb, m, n, k, alpha, A, lda, B, ldb, beta, C, ldc)
Where the result matrix C has dimensions m × n, and op(A) is m × k, op(B) is k × n.
For our matmul: we want output to be (batch, hidden).
So m = batch_size, n = hidden_size.
If transa = T, then A in memory is k × m. For x viewed as col-major, it's input × batch, which is k × m. So lda = k = input_size. Good.
If transb = N, then B in memory is k × n. For w1 viewed as col-major, it's input × hidden, which is k × n. So ldb = k = input_size. Good.
C in memory is m × n in col-major, which means ldc = m = batch_size ???
Wait, if C is m × n in col-major, then ldc = m. But C in col-major with ldc = m has memory layout offset = i + j * m.
For our h tensor of shape (batch, hidden) in row-major: offset = i * hidden + j.
These don't match unless m = hidden.
So if we say C is m × n = batch × hidden in col-major, then ldc = batch. But h in row-major is equivalent to col-major with ldc = hidden.
This means we can't directly use h as C without transposing the result!
Ohhhhh I see now. The issue is:
Row-major (m, n) = Col-major (n, m) with ldc = n.
But cublas says C is col-major (m, n) with ldc = m.
So if we want to write to a row-major (batch, hidden) tensor, cublas will write it as if it's col-major (batch, hidden), which has the same memory layout as row-major (hidden, batch).
So to get row-major (batch, hidden), we need to compute C = A^T * B in col-major (batch, hidden) which has the memory pattern of row-major (hidden, batch). That's not what we want!
To get row-major (batch, hidden) output, we need col-major (hidden, batch) output, which means we need the cublas result to be (hidden, batch) in col-major, so m = hidden, n = batch.
Alternatively, we can treat the operation as:
C^T = B^T * A in col-major.
Or, we can swap the operands. There's a standard trick for row-major:
C (row-major m,n) = A (row-major m,k) * B (row-major n,k)^T
is equivalent to:
C^T (col-major n,m) = B (col-major k,n) * A^T (col-major k,m)
= cublas('N', 'T', n, m, k, B, k, A, k, C, n)
So:
cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_T,
hidden_size, batch_size, input_size,
alpha,
w1.data_ptr<float>(), input_size,
x.data_ptr<float>(), input_size,
beta,
h.data_ptr<float>(), hidden_size);
Let me verify this:
- transa = N: op(A) = A = w1. w1 in col-major is (input, hidden) = (k, n). So op(A) is (k, n)... but we need op(A) to be m × k with m=hidden. Hmm, that doesn't work either.
Actually, the standard result for row-major C = A * B^T using cublas is:
cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_T, n, m, k, alpha, B, k, A, k, beta, C, n);
Wait, this treats B as the first operand and A as the second operand:
- op(B) with trans = N: B is (k, n) in col-major = (n, k) in row-major. op(B) = (k, n)... no, in cublas op(B) is k × n? Let me check.
For cublasSgemm('N', 'T', ...):
- op(A) = A, dimensions m × k
- op(B) = B^T, dimensions k × n
- C = op(A) * op(B) = (m × k) * (k × n) = m × n
Wait, if transb = T, then B in memory is n × k, so op(B) = B^T = k × n. But B in col-major with dimensions n × k means it's row-major (k, n).
For our w1 which is row-major (hidden, input) = (n, k), in col-major it's (k, n). So if we pass w1 as B with transb = N, op(B) = B = (k, n) in col-major. But then op(B) dimensions are k × n, and op(A) would need to be m × k.
If we pass x as A with transa = T: x is row-major (m, k), in col-major it's (k, m). A with transa = T means op(A) = A^T = (m, k).
Then C = op(A) * op(B) = (m, k) * (k, n) = (m, n) in col-major, which is row-major (n, m). Not what we want.
If we pass w1 as A with transa = N: w1 in col-major is (k, n). op(A) = A = (k, n). But op(A) should be m × k, and here m=hidden, so op(A) = hidden × k. But w1 in col-major is input × hidden. Doesn't match.
I'm going in circles. Let me just use the fact that for row-major matrices:
cublasSgemm(CUBLAS_OP_T, CUBLAS_OP_N, m, n, k, A, k, B, k, C, n)
gives C = A^T * B in col-major (m, n), which equals row-major (n, m). So the result is transposed.
Alternatively:
cublasSgemm(CUBLAS_OP_N, CUBLAS_OP_T, n, m, k, B, k, A, k, C, n)
gives C = B * A^T in col-major (n, m), which equals row-major (m, n). This is what we want!
Wait, let's check:
- B is w1 (hidden, input) = (n, k) in row-major. In col-major it's (k, n) with ld = k.
- transa = N: op(B) = B = (k, n). But cublas syntax is op(A) * op(B), and we set A = B, B = A.
- m = n = hidden, n = m = batch, k = input.
- op(A) = op(B_matrix) = (k, n) = (input, hidden). But we said m = hidden, so op(A) should be m × k = hidden × input. But op(A) is input × hidden. That doesn't match!
OK let me just write small test code mentally.
Given row-major x = [[1, 2], [3, 4]] (2, 2) and row-major w1 = [[5, 6], [7, 8]] (2, 2).
h = x * w1^T = [[1*5+2*6, 1*7+2*8], [3*5+4*6, 3*7+4*8]] = [[17, 23], [39, 53]]
Memory of x: [1, 2, 3, 4]
Memory of w1: [5, 6, 7, 8]
Memory of h should be: [17, 23, 39, 53]
cublasSgemm with various settings...
If we use CUBLAS_OP_T, CUBLAS_OP_N, m=2, n=2, k=2:
- op(A) is A^T. If A = x, A in col-major is [[1,3],[2,4]] (2x2). A^T = [[1,2],[3,4]].
- op(B) is B. B = w1, B in col-major is [[5,7],[6,8]] (2x2).
- C = A^T * B = [[1,2],[3,4]] * [[5,7],[6,8]] = [[17, 23], [39, 53]] in col-major.
- C in col-major [[17,23],[39,53]] corresponds to row-major [[17,39],[23,53]].
That's NOT what we want! The result is transposed.
If we use CUBLAS_OP_N, CUBLAS_OP_T, m=2, n=2, k=2:
- A = x, op(A) = A = [[1,3],[2,4]] in col-major = (2x2)
- B = w1, op(B) = B^T = [[5,7],[6,8]]^T = [[5,6],[7,8]]
- C = A * B^T = [[1,3],[2,4]] * [[5,6],[7,8]] = [[26, 30], [38, 44]] in col-major
- Row-major: [[26, 38], [30, 44]]. Still not right.
If we use CUBLAS_OP_N, CUBLAS_OP_N:
- A = x = [[1,3],[2,4]]
- B = w1 = [[5,7],[6,8]]
- C = A * B = [[1,3],[2,4]] * [[5,7],[6,8]] = [[23, 31], [34, 46]] in col-major
- Not what we want.
If we use CUBLAS_OP_T, CUBLAS_OP_T:
- A = x, A^T = [[1,2],[3,4]]
- B = w1, B^T = [[5,6],[7,8]]
- C = [[1,2],[3,4]] * [[5,6],[7,8]] = [[19, 22], [43, 50]] in col-major
- Row-major: [[19, 43], [22, 50]]. Not right.
Now try with swapped A and B, CUBLAS_OP_N, CUBLAS_OP_T:
- A = w1 = [[5,7],[6,8]] in col-major
- B = x = [[1,3],[2,4]] in col-major
- m=2, n=2, k=2
- op(A) = A = [[5,7],[6,8]] (2x2)
- op(B) = B^T = [[1,2],[3,4]] (2x2)
- C = A * B^T = [[5,7],[6,8]] * [[1,2],[3,4]] = [[26, 38], [30, 44]] in col-major
- Row-major: [[26, 30], [38, 44]].
Try CUBLAS_OP_T, CUBLAS_OP_N with A = w1, B = x:
- A = w1, A^T = [[5,6],[7,8]]
- B = x, B = [[1,3],[2,4]]
- C = [[5,6],[7,8]] * [[1,3],[2,4]] = [[17, 39], [23, 53]] in col-major
- Row-major: [[17, 23], [39, 53]]. This is exactly what we want!
So: cublasSgemm(handle, CUBLAS_OP_T, CUBLAS_OP_N, m, n, k, A=w1, lda=k, B=x, ldb=k, beta, C=h, ldc=n)
where m=hidden, n=batch, k=input.
Wait, C in col-major [[17, 39], [23, 53]] has dimensions 2x2 (m=hidden=2, n=batch=2).
But we want C to be row-major (batch, hidden) = (2, 2).
The memory is [17, 39, 23, 53]. In row-major (2, 2), this is [[17, 39], [23, 53]].
But we expected [[17, 23], [39, 53]].
Oh, I see. The cublas result col-major [[17, 39], [23, 53]] with m=2, n=2. Reading as row-major (2,2): element (0,0)=17, (0,1)=39, (1,0)=23, (1,1)=53.
But the correct h is [[17, 23], [39, 53]], which in memory is [17, 23, 39, 53].
So cublas gives [17, 39, 23, 53], we want [17, 23, 39, 53]. These are different!
My earlier calculation was wrong. Let me recalculate h = x * w1^T:
x = [[1, 2], [3, 4]]
w1 = [[5, 6], [7, 8]]
w1^T = [[5, 7], [6, 8]]
Row 0 of x: [1, 2]
Dot with col 0 of w1^T (which is row 0 of w1): [5, 6] -> 1*5 + 2*6 = 17
Dot with col 1 of w1^T (which is row 1 of w1): [7, 8] -> 1*7 + 2*8 = 23
Row 1 of x: [3, 4]
Dot with col 0: [5, 6] -> 3*5 + 4*6 = 15 + 24 = 39
Dot with col 1: [7, 8] -> 3*7 + 4*8 = 21 + 32 = 53
So h = [[17, 23], [39, 53]], memory [17, 23, 39, 53].
But when I computed with cublas CUBLAS_OP_T, CUBLAS_OP_N with A=w1, B=x:
A = w1 in col-major: [[5, 7], [6, 8]] (the columns of w1 in row-major are the rows in col-major? Let's clarify.)
PyTorch tensor w1[[5,6],[7,8]] in memory is [5, 6, 7, 8].
In column-major with 2 rows: column 0 is [5, 7], column 1 is [6, 8]. So col-major matrix is [[5, 6], [7, 8]].
A^T = [[5, 7], [6, 8]].
B = x in col-major: x = [1, 2, 3, 4]. Col-major: [[1, 2], [3, 4]].
A^T * B = [[5, 7], [6, 8]] * [[1, 2], [3, 4]]
= [[5*1+7*3, 5*2+7*4], [6*1+8*3, 6*2+8*4]]
= [[26, 38], [30, 44]]
C in col-major: [[26, 30], [38, 44]].
As row-major (2,2): [[26, 38], [30, 44]].
Still not matching.
Let me try A=x, B=w1 with CUBLAS_OP_T, CUBLAS_OP_T:
A = x in col-major: [[1, 2], [3, 4]], A^T = [[1, 3], [2, 4]]
B = w1 in col-major: [[5, 6], [7, 8]], B^T = [[5, 7], [6, 8]]
C = A^T * B^T = [[1, 3], [2, 4]] * [[5, 7], [6, 8]]
= [[1*5+3*6, 1*7+3*8], [2*5+4*6, 2*7+4*8]]
= [[23, 31], [34, 52]]
As row-major: [[23, 34], [31, 52]]. No.
Try A=w1, B=x with CUBLAS_OP_T, CUBLAS_OP_T:
A^T = [[5, 7], [6, 8]]
B^T = [[1, 3], [2, 4]]
C = A^T * B^T = [[5*1+7*2, 5*3+7*4], [6*1+8*2, 6*3+8*4]]
= [[19, 43], [22, 50]]
As row-major: [[19, 22], [43, 50]]. No.
Try A=x, B=w1 with CUBLAS_OP_N, CUBLAS_OP_T:
A = [[1, 2], [3, 4]]
B^T = [[5, 7], [6, 8]]
C = [[1*5+2*6, 1*7+2*8], [3*5+4*6, 3*7+4*8]] = [[17, 23], [39, 53]]
Wait, A in col-memory is [[1, 3], [2, 4]] and B^T should be... B = w1 = [5, 6, 7, 8], col-memory is [[5, 7], [6, 8]], B^T = [[5, 6], [7, 8]].
C = A * B^T = [[1, 3], [2, 4]] * [[5, 6], [7, 8]]
= [[1*5+3*7, 1*6+3*8], [2*5+4*7, 2*6+4*8]]
= [[26, 30], [38, 44]]
As row-major: [[26, 38], [30, 44]]. No.
Hmm, I keep getting wrong results because I'm confusing what op(B) means when B is in row-major memory.
Let me think differently. In cublas, all matrices are assumed column-major. A PyTorch row-major tensor of shape (a, b) occupies the same memory as a column-major tensor of shape (b, a).
So:
- x (batch=2, input=2) in row-major = (2, 2) col-major
- w1 (hidden=2, input=2) in row-major = (2, 2) col-major
For h = x * w1^T where x and w1 are row-major:
In col-major:
- x_col = (2, 2) col-major
- w1_col = (2, 2) col-major
- w1_row is the transpose of w1_col, so w1_row = (2, 2) col-major... wait.
Actually, w1^T in row-major corresponds to (w1_col)^T in column-major.
w1 is row-major (hidden, input). w1^T is row-major (input, hidden).
w1_col is col-major (input, hidden).
(w1_col)^T is col-major (hidden, input).
For x * w1^T in row-major:
= x_row-major (m, k) * w1^T_row-major (k, n)
= in col-major: x_col (k, m) * (w1^T)_col (n, k)
And (w1^T)_col with shape (n, k) in col-major is the same memory as w1_row (n, k) in row-major... which is the same as w1_col (k, n) in col-major transposed.
So (w1^T)_col = w1_col^T.
We want: C_col = x_col * (w1^T)_col = x_col * w1_col^T
Where x_col is (k, m) = (input, batch)
w1_col is (k, n) = (input, hidden)
w1_col^T is (n, k) = (hidden, input)
C_col = x_col (input, batch) * w1_col^T (hidden, input)
Wait, matrix multiply requires (a,b) * (b,c). Here x_col is (input, batch) and w1_col^T is (hidden, input). The inner dimensions don't match!
Actually, I had it wrong. We need:
C = A * B where A is m×k and B is k×n.
For x (2,2) row-major times w1^T (2,2) row-major:
A = x = (2,2), B = w1^T = (2,2), C = (2,2).
In col-major representation:
x_col with shape (2,2) [since row-major (2,2) = col-major (2,2)]
w1_col with shape (2,2)
w1^T in row-major (2,2) corresponds to what in col-major?
If w1_row is [[5,6],[7,8]], then w1_row^T is [[5,7],[6,8]].
In col-major, [[5,7],[6,8]] has columns [5,7] and [6,8], so memory is [5,7,6,8].
Original w1 has memory [5,6,7,8].
So w1^T in row-major does NOT equal w1 in col-major.
In fact, w1^T in row-major = w1_col^T in col-major... no.
w1_row-major (a, b) = w1_col-major (b, a).
If w1_row is (hidden, input), w1_col is (input, hidden).
(w1_row)^T is (input, hidden).
(w1_col)^T is (hidden, input).
(w1_row)^T in col-major would have shape (hidden, input).
But w1_col has shape (input, hidden).
So (w1_row)^T in col-major = (w1_col).
Let me verify: w1_row = [[5, 6], [7, 8]], memory [5, 6, 7, 8].
w1_row^T = [[5, 7], [6, 8]], memory [5, 7, 6, 8].
w1_col with memory [5, 6, 7, 8] is columns [5, 7] and [6, 8], so matrix [[5, 6], [7, 8]] in col-major = [[5, 7], [6, 8]] in row-major = w1_row^T.
Yes! w1_col = w1_row^T in row-major representation.
So we want C_row = x_row * w1_row^T.
In col-major: C_col = x_col * w1_col.
Because x_col is the col-major representation of x_row, and w1_col is the col-major representation of w1_row^T (which is what we need as the second operand).
Wait: C_row = x_row * w1_row^T.
In col-major, C_col = C_row^T = (x_row * w1_row^T)^T = w1_row * x_row^T.
And w1_row in col-major is w1_col^T.
This is getting too messy. Let me look for the standard formula.
Standard: For row-major C = A * B^T:
Call cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_T, n, m, k, alpha, B, k, A, k, beta, C, n).
Wait, this is a known formula. Let me derive it carefully.
C = A * B^T
A is m×k row-major, B is n×k row-major, C is m×n row-major.
In col-major:
A_row (m,k) = A_col (k,m)
B_row (n,k) = B_col (k,n)
C_row (m,n) = C_col (n,m)
We want C_row = A_row * B_row^T.
Taking transpose of both sides:
C_row^T = (A_row * B_row^T)^T = B_row * A_row^T
In col-major: C_col = B_row * A_row^T.
B_row in col-major is B_col (k,n)... wait, B_row (n,k) = B_col (k,n).
A_row^T in col-major: A_row (m,k) = A_col (k,m). A_row^T is (k,m). In col-major, this is (m,k).
Actually, matrix multiplication is easier to think about in terms of the dimensions:
C[mxn] = A[mxk] * B^T[kxn] (all row-major)
In col-major:
C^T[nxm] = B[nxk] * A^T[kxm]
= B_col(k,n) * A_col(k,m)^T
= B_col(k,n) * ???
Let's use the property: if M is row-major, then M^T is also the col-major representation of M.
So A_row = A_col(k,m) and A_row^T = A_col(m,k)... no.
OK, I think the cleanest way is to accept that I can use any transpose combination and just verify with a simple 2x2 test in the actual code. Let me just use the standard invocation.
Actually, I recall that for row-major C = A * B where A is m×k and B is k×n (standard matmul), you use:
cublasSgemm(handle, CUBLAS_OP_T, CUBLAS_OP_T, m, n, k, alpha, A, k, B, n, beta, C, m)
For C = A * B^T where B is n×k:
This is equivalent to C = A * B^T with B^T being k×n. So it's standard matmul with second operand having shape k×n.
In row-major, if B is n×k, then B^T is k×n in row-major, which is n×k in col-major.
So B_col = B^T_row.
We want C = A_row * B^T_row.
In col-major: C_col = A_col * B_col ?
I think I found it. For row-major C = A * B^T where A is (m,k) and B is (n,k):
cublasSgemm(handle, CUBLAS_OP_T, CUBLAS_OP_N, m, n, k, alpha, A, k, B, k, beta, C, m)
Let me verify with my 2x2 example:
m=2, n=2, k=2
A=x=[1,2,3,4]
B=w1=[5,6,7,8]
C should be [17,23,39,53]
cublasSgemm('T', 'N', 2, 2, 2, 1.0, A, 2, B, 2, 0.0, C, 2)
A with trans T: 2x2
B with trans N: 2x2
Col-major A = [[1,3],[2,4]] (2x2)
A^T = [[1,2],[3,4]] (2x2)
Col-major B = [[5,7],[6,8]] (2x2)
B = [[5,7],[6,8]] (2x2)
C = A^T * B = [[1,2],[3,4]] * [[5,7],[6,8]] = [[17,23],[39,53]]
C in col-major = [[17,23],[39,53]], which as row-major is [[17,39],[23,53]].
Still transposed!
Hmm, I get it now. CUBLAS_OP_T, CUBLAS_OP_N gives us the right numbers but in transposed order in memory. The result in memory is [17, 23, 39, 53] in col-major, which means as row-major it's [[17, 39], [23, 53]].
But the correct result as row-major should be [17, 23, 39, 53] = [[17, 23], [39, 53]].
So the cublas result IS transposed compared to what we want. To fix this, we can either:
1. Call with swapped m and n, and swapped A and B.
2. Transpose the output after.
Let me try: cublasSgemm('N', 'T', n, m, k, alpha, B, k, A, k, beta, C, n)
m_result = n = 2, n_result = m = 2
A_op = B with N: B_col = [[5,7],[6,8]] (2x2)
B_op = A with T: A_col = [[1,3],[2,4]], A_col^T = [[1,2],[3,4]] (2x2)
C = A_op * B_op = [[5,7],[6,8]] * [[1,2],[3,4]] = [[26,38],[30,44]]
Col-major [[26,30],[38,44]] = row-major [[26,38],[30,44]]. Still not right.
Try: cublasSgemm('T', 'T', n, m, k, alpha, B, k, A, k, beta, C, n)
A_op = B^T = [[5,6],[7,8]]
B_op = A^T = [[1,2],[3,4]]
C = [[5,6],[7,8]] * [[1,2],[3,4]] = [[23,34],[31,52]]
Col-major [[23,31],[34,52]] = row-major [[23,34],[31,52]]. No.
Try: cublasSgemm('N', 'N', n, m, k, alpha, B, k, A, k, beta, C, n)
A_op = B = [[5,7],[6,8]]
B_op = A = [[1,3],[2,4]]
C = [[5,7],[6,8]] * [[1,3],[2,4]] = [[19,43],[22,50]]
Col-major [[19,22],[43,50]] = row-major [[19,43],[22,50]]. No.
So none of the combinations with swapped A/B and various transpositions gives the right result. The fundamental issue is that cublas computes in column-major, and we want row-major output.
For C = A * B^T where A and B are row-major:
Result in memory should be [17, 23, 39, 53].
We found that cublas('T', 'N', m, n, k, A, k, B, k, C, m) gives col-major C = [[17,23],[39,53]], which in memory is [17, 39, 23, 53]. This is transposed!
So if we want the right memory layout, we need C to be col-major [[17,23],[39,53]] which corresponds to m=2 rows and n=2 columns in col-major, but element (0,0)=17, (1,0)=39, (0,1)=23, (1,1)=53. This is the same as row-major [[17,23],[39,53]]!
Wait, I think I see the issue. In col-major, element (i, j) is at memory[i + j*ld]. For a 2x2 matrix:
- (0,0) = 0, (1,0) = 1, (0,1) = 2, (1,1) = 3
So memory[0] = C[0][0], memory[1] = C[1][0], memory[2] = C[0][1], memory[3] = C[1][1].
For [[17,23],[39,53]]: C[0][0]=17, C[1][0]=39, C[0][1]=23, C[1][1]=53.
Memory: [17, 39, 23, 53].
But our cublas result for ('T', 'N', m=2, n=2) was C = A^T * B = [[17,23],[39,53]] in col-major. In col-major, this means:
C[0][0] = 17, C[1][0] = 23, C[0][1] = 39, C[1][1] = 53.
Memory: [17, 23, 39, 53].
Oh! I had it backwards earlier. In col-major, the first dimension is the row index, and elements are stored column by column.
For cublas result C = [[17,23],[39,53]] in the mathematical sense (matrix multiplication result):
In col-major storage: column 0 is [17, 39]^T, column 1 is [23, 53]^T.
Memory: [17, 39, 23, 53].
But in row-major storage, row 0 is [17, 23], row 1 is [39, 53].
Memory: [17, 23, 39, 53].
So indeed, cublas gives us col-major [17, 39, 23, 53], but we want row-major [17, 23, 39, 53]. To get row-major [17, 23, 39, 53] from cublas, we need cublas to output a col-major matrix whose col-major memory is [17, 23, 39, 53].
For col-major memory [17, 23, 39, 53]:
Column 0: [17, 23]^T, Column 1: [39, 53]^T.
Col-major matrix: [[17, 39], [23, 53]].
So we need cublas to output the matrix [[17, 39], [23, 53]] in col-major.
And we found that cublas('T', 'N', m=2, n=2, k=2, A=x, B=w1) outputs col-major [[17,23],[39,53]], which has memory [17,39,23,53].
The transpose of [[17,23],[39,53]] is [[17,39],[23,53]]. Which is what we need!
So we need cublas to compute the transpose of what it currently does. This means we should swap m and n:
If we call cublas('T', 'N', n=2, m=2, k=2, A=x, B=w1):
- op(A) = A^T, dimensions n x k = 2 x 2
- op(B) = B, dimensions k x m = 2 x 2
- C = op(A) * op(B) = (2x2) * (2x2) = 2x2, with C having dimensions n x m = 2 x 2.
In col-major, C = A^T * B where A_col is (k,m) = (2,2), B_col is (k,n) = (2,2).
Wait, with m=2 (the C rows?) and n=2 (the C cols?), but we swapped.
Actually, standard cublas: C has dimensions m x n (in the cublas parameter sense). If we set the first dim parameter to n=hidden and the second to m=batch:
- C has dimensions hidden x batch in cublas terms.
- op(A) is hidden x k, op(B) is k x batch.
- A^T is hidden x k means A in memory is k x hidden = (input, hidden) = w1 in col-major. So A = w1.
- B is k x batch means B in memory is k x batch = (input, batch) = x in col-major. So B = x.
So: cublasSgemm(handle, CUBLAS_OP_T, CUBLAS_OP_N, hidden, batch, input, alpha, w1, input, x, input, beta, h, hidden)
Let me verify: m=hidden=2, n=batch=2, k=input=2.
A = w1 = [5,6,7,8], A_col = [[5,7],[6,8]], A^T = [[5,6],[7,8]]
B = x = [1,2,3,4], B_col = [[1,3],[2,4]], B = [[1,3],[2,4]]
C = A^T * B = [[5,6],[7,8]] * [[1,3],[2,4]] = [[17, 39], [23, 53]]
C has dimensions m x n = 2 x 2 in col-major.
Memory: [17, 23, 39, 53].
As row-major (n, m) = (2, 2): [[17, 39], [23, 53]].
Still not right. We want row-major (batch, hidden) = (2, 2) with memory [17, 23, 39, 53]. And that's exactly what we got! C in col-memory [17, 23, 39, 53] with dimensions 2x2 means element (0,0)=17, (1,0)=23, (0,1)=39, (1,1)=53.
In row-major interpretation of the same memory: element (0,0)=17, (0,1)=23, (1,0)=39, (1,1)=53. This is [[17, 23], [39, 53]]!
Perfect! So the correct call is:
```cpp
cublasSgemm(handle,
CUBLAS_OP_T, CUBLAS_OP_N,
hidden_size, batch_size, input_size,
&alpha,
w1.data_ptr<float>(), input_size,
x.data_ptr<float>(), input_size,
&beta,
h.data_ptr<float>(), hidden_size);
```
Let me verify the dimensions:
- m = hidden_size (first dimension after transpose)
- n = batch_size
- k = input_size
- A = w1, trans = T: A in memory is k x m = input x hidden. w1 is row-major (hidden, input) = col-major (input, hidden). So w1 in col-major is (input, hidden) = (k, m). With trans=T, op(A) = (m, k) = (hidden, input). Good.
- B = x, trans = N: B in memory is k x n = input x batch. x is row-major (batch, input) = col-major (input, batch) = (k, n). With trans=N, op(B) = (k, n) = (input, batch). Good.
- C = h, dimensions m x n = hidden x batch in col-major, ldc = m = hidden.
- But h is row-major (batch, hidden). In col-major, (hidden, batch) with ldc=hidden corresponds to row-major (batch, hidden). Check!
Wait, for C in col-major with dimensions hidden x batch and ldc = hidden:
Memory offset = i + j * hidden.
In row-major (batch, hidden), memory offset = j * hidden + i.
These match! Yes!
So the first cublas call is correct. Now for the second GEMM:
out = h * w2^T where h is (batch, hidden), w2 is (output, hidden).
Using the same logic:
cublasSgemm(handle, CUBLAS_OP_T, CUBLAS_OP_N, output_size, batch_size, hidden_size, alpha, w2, hidden_size, h, hidden_size, beta, out, output_size);
Great! Now I can write the code. But I also need to handle the bias. PyTorch's linear includes bias. cuBLAS gemm doesn't include bias automatically unless we use beta=1 with a pre-initialized C.
For the first GEMM: h = x @ w1^T + b1
We can either:
1. Fill h with b1 first, then call cublas with beta=1
2. Call cublas with beta=0, then add bias in a separate kernel or fused with sigmoid.
Option 2 is better because we can fuse bias + sigmoid in one kernel.
Actually, for better performance, we can just initialize h to 0, call cublas with beta=0, and then in our custom sigmoid kernel we add bias and apply sigmoid.
Wait, but the sigmoid kernel currently just does sigmoid. We can modify it to do `sigmoid(x + bias)`.
For the second GEMM: out = h @ w2^T + b2
We also need to add b2 before logsumexp. We can do this in the logsumexp kernel.
Actually, for logsumexp, adding a constant doesn't change the result: logsumexp(x + c) = logsumexp(x) + c if c is a scalar. But b2 is a vector, different for each column.
logsumexp_j(x_j + b_j) ≠ logsumexp_j(x_j) + constant.
So we must add b2 before logsumexp.
We can modify the logsumexp kernel to add bias as it reads elements.
OK here's the plan:
1. cublas gemm1: h = x @ w1^T (no bias)
2. Custom kernel: h = sigmoid(h + b1) in-place
3. cublas gemm2: out = h @ w2^T (no bias)
4. Custom kernel: result = logsumexp(out + b2)
Wait, can we fuse step 2 and 3? No, because we need all of h to compute the second GEMM.
Actually, wait. Could we do:
1. cublas gemm1: h = x @ w1^T, write to h
2. Custom kernel: h = sigmoid(h + b1)
3. cublas gemm2: out = h @ w2^T + b2, where we pre-fill out with b2 and use beta=1.
For step 3, if we pre-fill out with b2 (using broadcasting), then cublas gemm with beta=1 will compute out = 1 * (h @ w2^T) + 1 * b2 = h @ w2^T + b2. This saves us a kernel launch!
But filling out with b2 requires a kernel launch or `torch` operation. Since we're in C++, we can launch a simple kernel or use `at::` operations. But we're trying to be fast, so maybe just add b2 in the logsumexp kernel.
Actually, for logsumexp, we need to read each element. Adding b2[j] to each element as we read it is essentially free. So let's just add b2 in the logsumexp kernel.
For step 2, adding b1 in the sigmoid kernel is also free.
So:
1. cublas gemm1: h = x @ w1^T
2. sigmoid_bias kernel: h[i,j] = sigmoid(h[i,j] + b1[j])
3. cublas gemm2: out = h @ w2^T
4. logsumexp_bias kernel: result[i] = logsumexp(out[i,j] + b2[j])
This minimizes kernel launches and memory bandwidth.
Now, one important detail: cublas handle creation. Creating a handle on every forward pass is expensive. We should create it once. Here's how:
```cpp
cublasHandle_t get_cublas_handle() {
static cublasHandle_t handle = nullptr;
if (!handle) {
cublasCreate(&handle);
}
// Ensure we're using the current stream
cublasSetStream(handle, at::cuda::getCurrentCUDAStream());
return handle;
}
```
But `at::cuda::getCurrentCUDAStream()` might require `#include <ATen/cuda/CUDAContext.h>`. In PyTorch C++ extensions, this should be available.
Let me draft the complete kernel code.
One more optimization for the sigmoid kernel: using grid-stride loops for good occupancy.
For the logsumexp kernel, I'll use shared memory reduction with 1024 threads. Since output_size = 1024, each block handles one row and 1024 threads handle one element each. This is perfect.
Actually, with block_size = 1024, we need shared memory for reduction. I'll use float sdata[1024] in shared memory.
For the logsumexp with bias, the kernel reads `input[row * cols + i] + bias[i]`.
Let me write the code: