326 lines
11 KiB
Python
326 lines
11 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
import random
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm import _custom_ops as ops
|
|
from vllm.platforms import current_platform
|
|
|
|
DEVICE_TYPE = current_platform.device_type
|
|
|
|
|
|
def round_up(x, base):
|
|
return ((x + base - 1) // base) * base
|
|
|
|
|
|
def CEILDIV(x, y):
|
|
return (x + y - 1) // y
|
|
|
|
|
|
def sample_data(num_experts, max_loras, num_tokens, topk_num):
|
|
topk_ids = torch.zeros((num_tokens, topk_num), dtype=torch.int32)
|
|
token_lora_mapping = torch.zeros((num_tokens,), dtype=torch.int32)
|
|
|
|
for i in range(num_tokens):
|
|
pool = list(range(num_experts))
|
|
random.shuffle(pool)
|
|
for j in range(topk_num):
|
|
topk_ids[i, j] = pool[j]
|
|
token_lora_mapping[i] = random.randint(0, max_loras - 1)
|
|
|
|
return topk_ids.to(DEVICE_TYPE), token_lora_mapping.to(DEVICE_TYPE)
|
|
|
|
|
|
@pytest.mark.parametrize("num_tokens", [100, 200, 1024, 4096]) # 81920
|
|
@pytest.mark.parametrize("topk_num", [6])
|
|
@pytest.mark.parametrize("num_experts", [64, 128, 256, 512])
|
|
@pytest.mark.parametrize("max_loras", [2, 32])
|
|
@pytest.mark.parametrize("block_size", [16])
|
|
def test_moe_lora_align_block_size(
|
|
num_tokens, topk_num, num_experts, max_loras, block_size
|
|
):
|
|
# sample data
|
|
random.seed(1)
|
|
topk_ids, token_lora_mapping = sample_data(
|
|
num_experts, max_loras, num_tokens, topk_num
|
|
)
|
|
|
|
# compute paddings
|
|
max_num_tokens_padded = topk_ids.numel() + num_experts * (block_size - 1)
|
|
max_num_tokens_padded = round_up(max_num_tokens_padded, block_size)
|
|
if topk_ids.numel() < num_experts:
|
|
max_num_tokens_padded = topk_ids.numel() * block_size
|
|
max_num_m_blocks = CEILDIV(max_num_tokens_padded, block_size)
|
|
|
|
# init output tensors
|
|
sorted_token_ids = torch.full(
|
|
(max_loras * max_num_tokens_padded,),
|
|
topk_ids.numel(),
|
|
dtype=torch.int32,
|
|
device=DEVICE_TYPE,
|
|
)
|
|
expert_ids = torch.full(
|
|
(max_loras * max_num_m_blocks,),
|
|
num_experts,
|
|
dtype=torch.int32,
|
|
device=DEVICE_TYPE,
|
|
)
|
|
num_tokens_post_pad = torch.zeros(
|
|
(max_loras,), dtype=torch.int32, device=DEVICE_TYPE
|
|
)
|
|
adapter_enabled = torch.ones(
|
|
(max_loras + 1,), dtype=torch.int32, device=DEVICE_TYPE
|
|
)
|
|
lora_ids = torch.arange(max_loras + 2, dtype=torch.int32, device=DEVICE_TYPE)
|
|
|
|
# call kernel
|
|
ops.moe_lora_align_block_size(
|
|
topk_ids,
|
|
token_lora_mapping,
|
|
num_experts,
|
|
block_size,
|
|
max_loras,
|
|
max_num_tokens_padded,
|
|
max_num_m_blocks,
|
|
sorted_token_ids,
|
|
expert_ids,
|
|
num_tokens_post_pad,
|
|
adapter_enabled,
|
|
lora_ids,
|
|
)
|
|
|
|
# verify values
|
|
expert_ids = expert_ids.view(max_loras, -1)
|
|
sorted_token_ids = sorted_token_ids.view(max_loras, -1, block_size)
|
|
|
|
for lora_idx in range(max_loras):
|
|
for token_idx in range(sorted_token_ids.size(1)):
|
|
block = sorted_token_ids[lora_idx][token_idx]
|
|
indices = block[block != topk_ids.numel()]
|
|
if indices.numel() > 0:
|
|
expert_id = expert_ids[lora_idx][token_idx]
|
|
assert torch.all(topk_ids.view(-1)[indices] == expert_id)
|
|
|
|
|
|
# Sentinel values for the regression tests below. Distinctive out-of-domain
|
|
# ints so that "kernel never wrote this slot" is directly observable: the
|
|
# kernel only ever writes a real expert id in [0, num_experts) or -1
|
|
# (expert_ids), a token index or the `numel` padding value (sorted_token_ids),
|
|
# and a block-aligned cumsum count (num_tokens_post_pad).
|
|
SENTINEL_EXPERT = -2
|
|
SENTINEL_TOKEN = -7
|
|
SENTINEL_NPAD = -13
|
|
|
|
|
|
def _build_and_run_align(
|
|
*,
|
|
num_lora_tokens,
|
|
num_base_tokens,
|
|
max_loras,
|
|
num_experts=64,
|
|
topk_num=6,
|
|
block_size=16,
|
|
lora_ids_override=None,
|
|
disabled_slots=(),
|
|
seed=1,
|
|
):
|
|
"""Build inputs the way ``LoRAKernelMeta.prepare_tensors`` does, run
|
|
``moe_lora_align_block_size``, and return a dict of result tensors plus
|
|
derived sizes. Output buffers are pre-filled with ``SENTINEL_*`` so
|
|
callers can assert which slots the kernel did / did not touch.
|
|
|
|
Tokens are assigned to LoRA slot 0 (first ``num_lora_tokens``) then -1
|
|
(remaining ``num_base_tokens``), matching the "mixed base + 1 LoRA"
|
|
shape used to repro vllm-project/vllm#32235.
|
|
|
|
``lora_ids_override``: optional 1-D int tensor of length ``max_loras+1``
|
|
used verbatim. Default mirrors ``prepare_tensors`` (sorted-unique into
|
|
the head, -1 tail).
|
|
``disabled_slots``: iterable of slot indices to clear in ``adapter_enabled``.
|
|
"""
|
|
random.seed(seed)
|
|
num_tokens = num_lora_tokens + num_base_tokens
|
|
assert num_tokens > 0, "test requires at least one token"
|
|
|
|
topk_ids = torch.zeros((num_tokens, topk_num), dtype=torch.int32)
|
|
token_lora_mapping = torch.empty((num_tokens,), dtype=torch.int32)
|
|
for i in range(num_tokens):
|
|
pool = list(range(num_experts))
|
|
random.shuffle(pool)
|
|
for j in range(topk_num):
|
|
topk_ids[i, j] = pool[j]
|
|
token_lora_mapping[i] = 0 if i < num_lora_tokens else -1
|
|
topk_ids = topk_ids.to(DEVICE_TYPE)
|
|
token_lora_mapping = token_lora_mapping.to(DEVICE_TYPE)
|
|
|
|
max_num_tokens_padded = topk_ids.numel() + num_experts * (block_size - 1)
|
|
max_num_tokens_padded = round_up(max_num_tokens_padded, block_size)
|
|
if topk_ids.numel() < num_experts:
|
|
max_num_tokens_padded = topk_ids.numel() * block_size
|
|
max_num_m_blocks = CEILDIV(max_num_tokens_padded, block_size)
|
|
|
|
if lora_ids_override is None:
|
|
lora_ids = torch.full(
|
|
(max_loras + 1,), -1, dtype=torch.int32, device=DEVICE_TYPE
|
|
)
|
|
unique_ids = torch.unique(token_lora_mapping, sorted=True)
|
|
lora_ids[: unique_ids.numel()] = unique_ids.to(torch.int32)
|
|
else:
|
|
assert lora_ids_override.numel() == max_loras + 1
|
|
lora_ids = lora_ids_override.to(dtype=torch.int32, device=DEVICE_TYPE)
|
|
|
|
adapter_enabled = torch.ones(
|
|
(max_loras + 1,), dtype=torch.int32, device=DEVICE_TYPE
|
|
)
|
|
for slot in disabled_slots:
|
|
adapter_enabled[slot] = 0
|
|
|
|
sorted_token_ids = torch.full(
|
|
(max_loras * max_num_tokens_padded,),
|
|
SENTINEL_TOKEN,
|
|
dtype=torch.int32,
|
|
device=DEVICE_TYPE,
|
|
)
|
|
expert_ids = torch.full(
|
|
(max_loras * max_num_m_blocks,),
|
|
SENTINEL_EXPERT,
|
|
dtype=torch.int32,
|
|
device=DEVICE_TYPE,
|
|
)
|
|
num_tokens_post_pad = torch.full(
|
|
(max_loras,), SENTINEL_NPAD, dtype=torch.int32, device=DEVICE_TYPE
|
|
)
|
|
|
|
ops.moe_lora_align_block_size(
|
|
topk_ids,
|
|
token_lora_mapping,
|
|
num_experts,
|
|
block_size,
|
|
max_loras,
|
|
max_num_tokens_padded,
|
|
max_num_m_blocks,
|
|
sorted_token_ids,
|
|
expert_ids,
|
|
num_tokens_post_pad,
|
|
adapter_enabled,
|
|
lora_ids,
|
|
)
|
|
|
|
return {
|
|
"lora_ids": lora_ids,
|
|
"sorted_token_ids": sorted_token_ids,
|
|
"expert_ids": expert_ids,
|
|
"num_tokens_post_pad": num_tokens_post_pad,
|
|
"max_num_tokens_padded": max_num_tokens_padded,
|
|
"block_size": block_size,
|
|
"max_loras": max_loras,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"max_loras",
|
|
[
|
|
1,
|
|
2,
|
|
],
|
|
)
|
|
def test_moe_lora_align_block_size_mixed_base_and_lora(max_loras):
|
|
"""Regression test for issue #32235: real LoRA slot must not be skipped
|
|
when ``active_lora_ids`` has -1 at position 0."""
|
|
out = _build_and_run_align(
|
|
num_lora_tokens=8, num_base_tokens=8, max_loras=max_loras
|
|
)
|
|
|
|
# Sanity check on the layout being tested.
|
|
assert out["lora_ids"][0].item() == -1, (
|
|
"prepare_tensors layout mismatch: -1 expected at position 0 for mixed batch"
|
|
)
|
|
|
|
real_slot = 0
|
|
post_pad = out["num_tokens_post_pad"][real_slot].item()
|
|
assert post_pad != SENTINEL_NPAD, (
|
|
f"num_tokens_post_pad[{real_slot}] was never written by the kernel; "
|
|
"the align kernel skipped the real LoRA slot."
|
|
)
|
|
assert (
|
|
0 < post_pad <= out["max_num_tokens_padded"]
|
|
and post_pad % out["block_size"] == 0
|
|
), f"num_tokens_post_pad[{real_slot}]={post_pad} is not a valid block-aligned count"
|
|
|
|
expert_row = out["expert_ids"].view(max_loras, -1)[real_slot]
|
|
assert (expert_row != SENTINEL_EXPERT).all(), (
|
|
f"expert_ids row for slot {real_slot} has unwritten sentinel entries; "
|
|
"the align kernel skipped the real LoRA slot."
|
|
)
|
|
|
|
sorted_row = out["sorted_token_ids"].view(max_loras, -1)[real_slot]
|
|
assert (sorted_row != SENTINEL_TOKEN).all(), (
|
|
f"sorted_token_ids row for slot {real_slot} has unwritten sentinel "
|
|
"entries; the align kernel skipped the real LoRA slot."
|
|
)
|
|
|
|
|
|
def test_moe_lora_align_block_size_disabled_adapter_untouched():
|
|
"""Disabled-adapter slot rows must remain untouched by all three align
|
|
kernels. Pins the invariant protected by the ``adapter_enabled`` guard
|
|
in ``lora_count_and_sort_expert_tokens_kernel``: without it the sort
|
|
kernel reads uninitialized ``token_mask`` values for disabled slots and
|
|
pollutes ``sorted_token_ids`` / ``cumsum_buffer``."""
|
|
max_loras = 1
|
|
out = _build_and_run_align(
|
|
num_lora_tokens=16,
|
|
num_base_tokens=0,
|
|
max_loras=max_loras,
|
|
disabled_slots=(0,),
|
|
)
|
|
# Sanity: slot 0 IS present in active_lora_ids (otherwise we would only
|
|
# exercise the lora_id == -1 / >= max_loras guards).
|
|
assert (out["lora_ids"] == 0).any().item()
|
|
|
|
assert out["num_tokens_post_pad"][0].item() == SENTINEL_NPAD, (
|
|
"num_tokens_post_pad[0] was modified for a disabled adapter slot."
|
|
)
|
|
expert_row = out["expert_ids"].view(max_loras, -1)[0]
|
|
assert (expert_row == SENTINEL_EXPERT).all(), (
|
|
"expert_ids row for disabled slot 0 was partially written."
|
|
)
|
|
# Row specifically protected by the sort-kernel adapter_enabled guard.
|
|
sorted_row = out["sorted_token_ids"].view(max_loras, -1)[0]
|
|
assert (sorted_row == SENTINEL_TOKEN).all(), (
|
|
"sorted_token_ids row for disabled slot 0 was polluted by the sort "
|
|
"kernel; lora_count_and_sort_expert_tokens_kernel must skip "
|
|
"adapter_enabled == 0 slots."
|
|
)
|
|
|
|
|
|
def test_moe_lora_align_block_size_lora_id_oob_guard():
|
|
"""Regression test for the ``lora_id >= max_loras`` guard.
|
|
|
|
Production ``LoRAKernelMeta.prepare_tensors`` pre-fills the tail of
|
|
``active_lora_ids`` with -1, so the existing ``lora_id == -1`` check
|
|
covers the extra slot. This test bypasses that invariant and injects
|
|
an out-of-range value (5 with max_loras=1) at the tail to verify the
|
|
explicit guard prevents OOB reads against ``adapter_enabled`` and
|
|
OOB writes against the max_loras-sized output buffers. Without the
|
|
guard, an illegal-memory-access would surface on the next CUDA sync.
|
|
"""
|
|
max_loras = 1
|
|
lora_ids_override = torch.tensor([0, 5], dtype=torch.int32)
|
|
out = _build_and_run_align(
|
|
num_lora_tokens=16,
|
|
num_base_tokens=0,
|
|
max_loras=max_loras,
|
|
lora_ids_override=lora_ids_override,
|
|
)
|
|
# The .item() call below syncs and would surface any async
|
|
# illegal-memory-access from the OOB iteration.
|
|
assert out["num_tokens_post_pad"][0].item() != SENTINEL_NPAD, (
|
|
"real LoRA slot 0 was skipped by the align kernel"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__])
|