Skip to content

Commit 84f84de

Browse files
committed
Add tests
1 parent 232c2bb commit 84f84de

1 file changed

Lines changed: 151 additions & 65 deletions

File tree

tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py

Lines changed: 151 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def setup_model(
6464
sequence_parallel: bool = False,
6565
expert_model_parallel_size: int = 1,
6666
num_moe_experts: int = None,
67+
sampling_backend: str = 'torch',
6768
cuda_graph_impl: str = 'none',
6869
):
6970
Utils.initialize_model_parallel(
@@ -144,6 +145,7 @@ def setup_model(
144145
block_size_tokens=block_size_tokens,
145146
enable_prefix_caching=enable_prefix_caching,
146147
max_requests=max_requests,
148+
sampling_backend=sampling_backend,
147149
),
148150
)
149151

@@ -251,47 +253,79 @@ def detokenize(self, inp, skip_special_tokens=False):
251253
sampled_logits >= expected_min_value
252254
), f"The sampled logits should all be greater than {expected_min_value} but its {sampled_logits}"
253255

254-
@pytest.mark.parametrize("backend", ["torch"])
256+
@pytest.mark.parametrize("padding_extra", [0, 4])
257+
@pytest.mark.parametrize("use_cuda_graph", [False, True])
258+
@pytest.mark.parametrize("backend", ["torch", "flashinfer"])
255259
@pytest.mark.parametrize("materialize_only_last_token_logits", [True, False])
260+
# "mixed": per-request mix of filters; exercises the filtered FI kernel.
261+
# "all_default": every request uses default SamplingParams; exercises the
262+
# plain FI kernel (which the mixed profile never hits, since it always has
263+
# at least one actively-filtering request).
264+
@pytest.mark.parametrize("sampling_profile", ["mixed", "all_default"])
256265
def test_sample_from_dynamic_logits(
257-
self, backend: str, materialize_only_last_token_logits: bool
266+
self,
267+
sampling_profile: str,
268+
backend: str,
269+
materialize_only_last_token_logits: bool,
270+
use_cuda_graph: bool,
271+
padding_extra: int,
258272
):
259-
batch_size = 12
273+
if backend == "flashinfer":
274+
pytest.importorskip("flashinfer")
275+
if use_cuda_graph and backend != "flashinfer":
276+
pytest.skip("CUDA graph sampling only applies to the flashinfer backend")
277+
if padding_extra > 0 and not use_cuda_graph:
278+
pytest.skip("Padded request slots only matter for the graph path")
279+
batch_size = 15
280+
padded_n = batch_size + padding_extra
260281
self.setup_model(
261282
torch.float32,
262283
batch_size=batch_size,
263284
static=False,
264285
materialize_only_last_token_logits=materialize_only_last_token_logits,
286+
sampling_backend=backend,
287+
cuda_graph_impl='local' if use_cuda_graph else 'none',
265288
)
266289
self.mock_tokenizer.eod = self.vocab_size
290+
device = torch.cuda.current_device()
267291

268292
context = self.text_generation_controller.inference_wrapped_model.inference_context
269-
270-
# Prepare sampling params in human-readable format, to aid with test maintenance.
271-
sampling_test_cases: List[Tuple[SamplingParams, List[int]]] = [
272-
(SamplingParams(temperature=0.1, top_p=0.01), [9, 6, 10]),
273-
(SamplingParams(temperature=5.0, top_k=15), [0, 3, 2]),
274-
(SamplingParams(top_p=0.8), [4, 1, 7]),
275-
(SamplingParams(temperature=10.0, top_k=5), [11, 5, 8]),
276-
]
277-
# For non-torch backends, test simultaneous top_k and top_p sampling.
278-
if backend != "torch":
279-
sampling_test_cases[3][0].top_p = 0.8
280-
281-
# Convert sampling params to non-readable format.
282-
rev_sampling_dict: List[SamplingParams] = [None] * batch_size
283-
for sampling_params, indices in sampling_test_cases:
284-
for idx in indices:
285-
rev_sampling_dict[idx] = sampling_params
286-
287-
# Prepare metadata for sample bookkeeping.
288-
temp_values = torch.Tensor([s.temperature for s in rev_sampling_dict])
289-
top_k_values = torch.Tensor([s.top_k for s in rev_sampling_dict]).to(torch.int32)
290-
top_p_values = torch.Tensor([s.top_p for s in rev_sampling_dict])
293+
controller = self.text_generation_controller
294+
295+
# Build per-request sampling params. The torch backend doesn't support
296+
# simultaneous top_k + top_p, so the 4th mixed case varies by backend.
297+
if sampling_profile == "mixed":
298+
sampling_test_cases: List[Tuple[SamplingParams, List[int]]] = [
299+
(SamplingParams(temperature=0.1, top_p=0.01), [9, 6, 10]),
300+
(SamplingParams(temperature=5.0, top_k=15), [0, 3, 2]),
301+
(SamplingParams(top_p=0.8), [4, 1, 7]),
302+
(
303+
SamplingParams(
304+
temperature=10.0, top_k=5, top_p=0.8 if backend != "torch" else 0.0
305+
),
306+
[11, 5, 8],
307+
),
308+
(SamplingParams(), [12, 13, 14]), # plain (no filtering)
309+
]
310+
rev_sampling_dict: List[SamplingParams] = [None] * batch_size
311+
for sampling_params, indices in sampling_test_cases:
312+
for idx in indices:
313+
rev_sampling_dict[idx] = sampling_params
314+
temp_values = torch.tensor([s.temperature for s in rev_sampling_dict])
315+
top_k_values = torch.tensor(
316+
[s.top_k for s in rev_sampling_dict], dtype=torch.int32
317+
)
318+
top_p_values = torch.tensor([s.top_p for s in rev_sampling_dict])
319+
else: # all_default
320+
temp_values = torch.ones(batch_size, dtype=torch.float32)
321+
top_k_values = torch.zeros(batch_size, dtype=torch.int32)
322+
top_p_values = torch.zeros(batch_size, dtype=torch.float32)
323+
324+
# Sampling params are written in user form (top_k=0 / top_p=0.0 mean
325+
# "no filter"); the FlashInfer encoding happens inside the sampler.
291326
context.active_request_metadata["temperature"][:batch_size].copy_(temp_values)
292327
context.active_request_metadata["top_k"][:batch_size].copy_(top_k_values)
293328
context.active_request_metadata["top_p"][:batch_size].copy_(top_p_values)
294-
self.text_generation_controller._sampling_backend = backend
295329

296330
context.padded_active_token_count = batch_size
297331
context.request_query_lengths = torch.ones(batch_size, dtype=torch.int32)
@@ -305,45 +339,95 @@ def test_sample_from_dynamic_logits(
305339
)
306340
context.paused_request_count = 0
307341
context.total_request_count = batch_size
342+
context.num_prefill_requests = 0
343+
context.padded_active_request_count = padded_n
344+
context._using_cuda_graph_this_step = use_cuda_graph
308345

309-
# Bookkeeping.
310-
self.text_generation_controller._dynamic_step_sample_bookkeeping()
311-
312-
# Sampling.
346+
# Set up logits: ascending [0, 1, ..., vocab_size-1] per request.
313347
logits = torch.arange(0, self.vocab_size).repeat(batch_size, 1).unsqueeze(0).float().cuda()
314-
self.text_generation_controller._all_logits_cuda = logits
315-
self.text_generation_controller._dynamic_step_sample_logits()
316-
sampled_logits = self.text_generation_controller._sampled_tokens_cuda[:batch_size]
317-
vocab_indices = torch.arange(self.vocab_size).cuda()
318-
319-
# Move tensors to GPU for assertion checks.
320-
temp_values = temp_values.cuda()
321-
top_k_values = top_k_values.cuda()
322-
top_p_values = top_p_values.cuda()
348+
if use_cuda_graph:
349+
controller._all_logits_cuda[:, :batch_size, :].copy_(logits)
350+
elif controller._sampling_backend == "flashinfer":
351+
controller._all_logits_cuda = logits.contiguous()
352+
else:
353+
controller._all_logits_cuda = logits
354+
355+
expected_any_filtered = sampling_profile == "mixed"
356+
357+
# Two passes so graph mode exercises both capture and replay. Eager mode
358+
# runs twice harmlessly.
359+
for _ in range(2):
360+
controller._dynamic_step_sample_bookkeeping()
361+
if backend == "flashinfer":
362+
controller._pre_forward_bookkeeping_event.synchronize()
363+
actual_any_filtered = bool(controller._fi_any_filtered_pinned.item())
364+
assert actual_any_filtered == expected_any_filtered, (
365+
f"any-filtered flag: expected {expected_any_filtered}, "
366+
f"got {actual_any_filtered}"
367+
)
368+
controller._dynamic_step_sample_logits()
369+
sampled = controller._sampled_tokens_cuda[:batch_size]
370+
371+
# All sampled tokens must be valid vocab indices.
372+
assert torch.all(sampled >= 0) and torch.all(
373+
sampled < self.vocab_size
374+
), f"Sampled tokens out of range: {sampled}"
375+
376+
# top_k constraint.
377+
top_k_gpu = top_k_values.cuda()
378+
top_k_gpu[top_k_gpu == 0] = self.vocab_size
379+
assert torch.all(
380+
sampled >= self.vocab_size - top_k_gpu
381+
), f"top_k violated: sampled {sampled}, min allowed {self.vocab_size - top_k_gpu}"
382+
383+
# Combined top_k + top_p constraint: compute the minimum token value
384+
# that passes both filters given temperature-scaled probabilities.
385+
l = logits.squeeze(0)
386+
scaled_probs = l.div(temp_values.cuda().unsqueeze(1)).softmax(dim=-1)
387+
vocab_indices = torch.arange(self.vocab_size, device=device)
388+
top_k_mask = vocab_indices.unsqueeze(0) < (self.vocab_size - top_k_gpu.unsqueeze(1))
389+
scaled_probs.masked_fill_(top_k_mask, 0.0)
390+
391+
top_p_gpu = top_p_values.cuda()
392+
top_p_mask = scaled_probs.cumsum(dim=-1) > top_p_gpu.unsqueeze(1)
393+
first_excluded = torch.where(
394+
top_p_mask.any(dim=-1),
395+
top_p_mask.float().argmax(dim=-1),
396+
torch.full((batch_size,), self.vocab_size, device=device),
397+
)
398+
last_included = torch.clamp(first_excluded - 1, min=0)
399+
start_idx = torch.clamp(self.vocab_size - top_k_gpu, min=0).long()
400+
last_included = torch.max(last_included, start_idx)
401+
expected_min_values = l.gather(1, last_included.unsqueeze(1)).squeeze(1)
402+
assert torch.all(
403+
sampled >= expected_min_values
404+
), f"Sampled below expected min: sampled {sampled}, expected_min {expected_min_values}"
405+
406+
if use_cuda_graph:
407+
# After two passes exactly one graph should be captured for this
408+
# (padded_n, filtered) pair; the second pass hits the cache.
409+
cache = controller._sampling_cuda_graphs
410+
expected_key = (padded_n, expected_any_filtered)
411+
assert len(cache) == 1, (
412+
f"Expected exactly one captured sampling graph, got {len(cache)}"
413+
)
414+
assert expected_key in cache, (
415+
f"Expected key {expected_key} not found in graph cache"
416+
)
323417

324-
# Assert correct sampled values.
325-
top_k_values[top_k_values == 0] = self.vocab_size
326-
assert torch.all(
327-
sampled_logits >= self.vocab_size - top_k_values
328-
), f"The sampled logits should all be greater than {self.vocab_size - top_k_values} but its {sampled_logits}"
329-
l = logits.squeeze(0)
330-
sampled_l = l.div(temp_values.unsqueeze(1)).softmax(dim=-1)
331-
top_k_mask = vocab_indices.unsqueeze(0) < (self.vocab_size - top_k_values.unsqueeze(1))
332-
sampled_l.masked_fill_(top_k_mask, 0.0)
333-
top_p_mask = sampled_l.cumsum(dim=-1) > top_p_values.unsqueeze(1)
334-
335-
first_excluded = torch.where(
336-
top_p_mask.any(dim=-1),
337-
top_p_mask.float().argmax(dim=-1),
338-
torch.full((batch_size,), self.vocab_size, device=top_p_mask.device),
418+
@pytest.mark.parametrize("backend", ["torch", "flashinfer"])
419+
def test_add_request_metadata_compatibility(self, backend: str):
420+
"""Verify that add_request's metadata assertion passes for all backends."""
421+
if backend == "flashinfer":
422+
pytest.importorskip("flashinfer")
423+
self.setup_model(torch.float32, batch_size=4, static=False, sampling_backend=backend)
424+
context = self.text_generation_controller.inference_wrapped_model.inference_context
425+
req = DynamicInferenceRequest(
426+
request_id=0,
427+
prompt_tokens=torch.zeros(4, dtype=torch.long, device='cuda'),
428+
sampling_params=SamplingParams(num_tokens_to_generate=1, termination_id=-1),
339429
)
340-
last_included = torch.clamp(first_excluded - 1, min=0)
341-
start_idx = torch.clamp(self.vocab_size - top_k_values, min=0).long()
342-
last_included = torch.max(last_included, start_idx)
343-
expected_min_values = l.gather(1, last_included.unsqueeze(1)).squeeze(1)
344-
assert torch.all(
345-
sampled_logits >= expected_min_values
346-
), f"The sampled logits should all be greater than {expected_min_values} but its {sampled_logits}"
430+
context.add_request(req)
347431

348432
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
349433
@pytest.mark.parametrize(
@@ -1415,10 +1499,12 @@ def mock_compute_mtp_single_step(hidden_states, next_token_ids, position_ids, de
14151499
side_effect=mock_compute_mtp_single_step
14161500
)
14171501

1418-
# Mock _sample_from_logits_2d to return arbitrary dummy tokens
1419-
self.text_generation_controller._sample_from_logits_2d = mock.MagicMock(
1420-
return_value=torch.tensor([101, 201], device='cuda')
1421-
)
1502+
# Set up real sampling: populate metadata with default params and run
1503+
# bookkeeping so _sample_logits uses the real torch sampling path.
1504+
ctx.active_request_metadata["temperature"][:2].fill_(1.0)
1505+
ctx.active_request_metadata["top_k"][:2].fill_(1)
1506+
ctx.active_request_metadata["top_p"][:2].fill_(0.0)
1507+
self.text_generation_controller._dynamic_step_sample_bookkeeping()
14221508

14231509
self.text_generation_controller._compute_serial_mtp_and_sample()
14241510

0 commit comments

Comments
 (0)