Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions tests/python/test_linear_dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Vulkan Linear dispatch correctness for small batches.

The Vulkan matmul kernel tiles 4 rows and returns incorrect results for M < 4
(the M=1 decode step), which produced garbage tokens during generation.
``_wrap_linear`` pads small batches up to 4 rows and slices back; these tests
verify the wrapped Linear matches a plain ``nn.Linear`` across M=1..8.
"""

import pytest
import torch
from torch import nn

_rs = pytest.importorskip("vllm_vulkan._rs", exc_type=ImportError)


def _require_device():
if not _rs.is_available():
pytest.skip("no Vulkan device available")


def _ready_ops():
from vllm_vulkan import vulkan_ops
from vllm_vulkan._rs import VulkanContext

if not vulkan_ops.is_ready():
vulkan_ops.set_context(VulkanContext(0))
return vulkan_ops


@pytest.mark.parametrize("m", [1, 2, 3, 4, 8, 17])
@pytest.mark.parametrize("bias", [False, True])
def test_wrapped_linear_matches_torch_for_small_batch(m, bias):
_require_device()
_ready_ops()
from vllm_vulkan.model_runner import _wrap_linear

torch.manual_seed(0)
k, n = 896, 896
lin = nn.Linear(k, n, bias=bias)
ref = nn.Linear(k, n, bias=bias)
ref.load_state_dict(lin.state_dict())

_wrap_linear(lin) # dispatch lin.forward to Vulkan

x = torch.randn(m, k)
got = lin(x)
exp = ref(x)
assert torch.allclose(got, exp, atol=1e-2, rtol=1e-2), f"M={m} bias={bias} mismatch"


def test_raw_kernel_exact_for_m_at_least_4():
"""Guard the working path: the matmul kernel is exact for M>=4. (The M<4
path is wrong, which is what _wrap_linear pads around; a future shader fix
should make this hold for all M and let the workaround be removed.)
"""
_require_device()
ops = _ready_ops()
torch.manual_seed(0)
k, n = 896, 896
w = torch.randn(n, k)
for m in (4, 8, 32):
x = torch.randn(m, k)
assert torch.allclose(
ops.linear(x, w, None), torch.nn.functional.linear(x, w), atol=1e-2, rtol=1e-2
), f"M={m}"
13 changes: 11 additions & 2 deletions vllm_vulkan/model_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,17 @@ def vk_forward(x: torch.Tensor, *args, **kwargs):

bias = getattr(module, "bias", None)
try:
result = vulkan_ops.linear(x.float(), weight.float(), bias)
result = result.to(x.dtype)
# The Vulkan matmul kernel tiles 4 rows and returns incorrect
# results for M < 4 (e.g. the M=1 decode step), producing garbage
# tokens during generation. Pad the row dim up to 4, then slice back.
xf = x.float().reshape(-1, x.shape[-1])
m = xf.shape[0]
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Critical Caching and Empty Batch Issues

  1. Weight Caching Bypass (Performance Disaster): Passing weight.float() to vulkan_ops.linear creates a temporary float32 tensor with a new untyped storage on every single forward pass. Because _get_or_upload_weight uses id(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 original weight directly avoids this, as vulkan_ops.linear and _vulkan_matvec are already designed to handle the original weight and cache it correctly.

  2. Empty Batch Crash: If m == 0 (which can happen in vLLM during empty iterations or warmup phases), xf[-1:] will raise an IndexError: index -1 is out of bounds. Handling m == 0 explicitly by returning an empty tensor avoids this crash and skips unnecessary Vulkan kernel launches.

Suggested change
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
  1. Focus on correctness, safety, and maintainability — in that order. Ensure correctness and prevent crashes (safety) under empty inputs. (link)

result = result.reshape(*x.shape[:-1], result.shape[-1]).to(x.dtype)
except Exception as exc:
logger.debug("Vulkan linear failed (%s)", exc)
return orig(x, *args, **kwargs)
Expand Down