fix: correct Vulkan Linear output for batches smaller than 4 (garbage decode) - #17
fix: correct Vulkan Linear output for batches smaller than 4 (garbage decode)#17maci0 wants to merge 2 commits into
Conversation
The Vulkan matmul kernel tiles 4 rows and returns numerically incorrect results for M < 4. This includes the M=1 decode step, so token generation produced garbage after the prompt. Pad the row dimension up to 4 in _wrap_linear and slice the result back. Adds tests covering M=1..8 (wrapped Linear vs nn.Linear) and a guard that the raw kernel stays exact for M>=4.
There was a problem hiding this comment.
Code Review
This pull request introduces a workaround for a Vulkan matmul kernel bug that produces incorrect results for small batches (M < 4) by padding the input to 4 rows and slicing the output. It also adds unit tests to verify correctness. However, the review identified a critical performance issue where casting the weight to float on every forward pass bypasses the weight caching mechanism, causing massive overhead by re-uploading weights to the GPU. Additionally, an empty batch (M = 0) will cause a crash. A code suggestion is provided to resolve both issues. Confidence Score: 2/5
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if m < 4: | ||
| xf = torch.cat([xf, xf[-1:].expand(4 - m, -1)], dim=0) | ||
| result = vulkan_ops.linear(xf, weight.float(), bias)[:m] | ||
| else: | ||
| result = vulkan_ops.linear(xf, weight.float(), bias) |
There was a problem hiding this comment.
Critical Caching and Empty Batch Issues
-
Weight Caching Bypass (Performance Disaster): Passing
weight.float()tovulkan_ops.linearcreates a temporary float32 tensor with a new untyped storage on every single forward pass. Because_get_or_upload_weightusesid(weight.untyped_storage())as the cache key, this temporary tensor results in a cache miss every single step. Consequently, the entire weight matrix is re-uploaded to the GPU on every single token generation step, completely defeating the weight caching mechanism and causing massive overhead. Passing the originalweightdirectly avoids this, asvulkan_ops.linearand_vulkan_matvecare already designed to handle the original weight and cache it correctly. -
Empty Batch Crash: If
m == 0(which can happen in vLLM during empty iterations or warmup phases),xf[-1:]will raise anIndexError: index -1 is out of bounds. Handlingm == 0explicitly by returning an empty tensor avoids this crash and skips unnecessary Vulkan kernel launches.
| if m < 4: | |
| xf = torch.cat([xf, xf[-1:].expand(4 - m, -1)], dim=0) | |
| result = vulkan_ops.linear(xf, weight.float(), bias)[:m] | |
| else: | |
| result = vulkan_ops.linear(xf, weight.float(), bias) | |
| if m == 0: | |
| result = xf.new_empty(0, weight.shape[0]) | |
| elif m < 4: | |
| xf = torch.cat([xf, xf[-1:].expand(4 - m, -1)], dim=0) | |
| result = vulkan_ops.linear(xf, weight, bias)[:m] | |
| else: | |
| result = vulkan_ops.linear(xf, weight, bias) |
References
- Focus on correctness, safety, and maintainability — in that order. Ensure correctness and prevent crashes (safety) under empty inputs. (link)
- Guard M==0 (empty iterations/warmup): return an empty result instead of indexing xf[-1:], which raised IndexError. - Pass the original weight to vulkan_ops.linear instead of weight.float(): the kernel handles bf16/fp16/fp32 weights and caches by storage id, so the float cast was forcing a cache miss and re-uploading the weight every step. Verified the original-dtype path matches torch for M=1..8 across dtypes. - Test M=0 too.
|
Good catches, both fixed in the latest push:
Verified on a 7900 XTX (RADV): the original-dtype path matches |
|
Thanks for contributing @maci0 just poking you so you know this one needs work, didn't pass the build |
ReviewRoot cause and fix look correct:
BlockingCI is red: Needs coordination with #19This PR and #19 both rewrite the same lines of Neither PR is wrong on its own, but whichever merges second will need a manual rebase that keeps both the small- Mergeability: 8/10 — correct fix, tests included; blocked only by a one-line lint fix and coordinating the rebase with #19. |
Review (re-check against current main)Conflicts
The fix is now obsoleteMain's
So the small- The Also
SuggestionRebase and reduce this to just |
Problem
Token generation produces garbage after the prompt. The model loads, the prompt
is processed, but every generated token is wrong (e.g. "the the the ...").
Root cause: the Vulkan matmul kernel used by
_wrap_lineartiles 4 rows at atime and returns numerically incorrect results when the row dimension
M < 4.Decode runs one token at a time (
M = 1), so every decode step computes wronglogits while prefill (
M = prompt_len >= 4) is fine — hence correct-lookingprefill but garbage generation.
Verified directly against
torch.nn.functional.linearon a discrete AMD GPU(RADV, gfx1100):
Fix
Pad the row dimension up to 4 in
_wrap_linear, run the kernel, then slice theresult back. Single extra (duplicated) row for
M < 4; no change forM >= 4.After this, generation is coherent ("The capital of France is ... Paris ...").
This is a workaround at the dispatch layer; the underlying matmul shader's
small-
Mtiling could be fixed to drop it later (the added kernel test guardsthe
M >= 4path and documents the expectation).Tests
tests/python/test_linear_dispatch.py(skipped without a Vulkan device):Linearmatches plainnn.LinearforMin1,2,3,4,8,17, with andwithout bias
M >= 4