Skip to content

Commit dd26f3c

Browse files
committed
Add tests
1 parent 58acf4a commit dd26f3c

1 file changed

Lines changed: 175 additions & 51 deletions

File tree

tests/unit_tests/inference/text_generation_controllers/test_text_generation_controller.py

Lines changed: 175 additions & 51 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
):
6869
Utils.initialize_model_parallel(
6970
tensor_model_parallel_size=tensor_model_parallel_size,
@@ -142,6 +143,7 @@ def setup_model(
142143
block_size_tokens=block_size_tokens,
143144
enable_prefix_caching=enable_prefix_caching,
144145
max_requests=max_requests,
146+
sampling_backend=sampling_backend,
145147
),
146148
)
147149

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

252-
@pytest.mark.parametrize("backend", ["torch"])
254+
@pytest.mark.parametrize("use_cuda_graph", [False, True])
255+
@pytest.mark.parametrize("backend", ["torch", "flashinfer"])
253256
@pytest.mark.parametrize("materialize_only_last_token_logits", [True, False])
254257
def test_sample_from_dynamic_logits(
255-
self, backend: str, materialize_only_last_token_logits: bool
258+
self, backend: str, materialize_only_last_token_logits: bool, use_cuda_graph: bool
256259
):
257-
batch_size = 12
260+
if backend == "flashinfer":
261+
pytest.importorskip("flashinfer")
262+
if use_cuda_graph and backend != "flashinfer":
263+
pytest.skip("CUDA graph sampling only applies to the flashinfer backend")
264+
batch_size = 15
258265
self.setup_model(
259266
torch.float32,
260267
batch_size=batch_size,
261268
static=False,
262269
materialize_only_last_token_logits=materialize_only_last_token_logits,
270+
sampling_backend=backend,
263271
)
264272
self.mock_tokenizer.eod = self.vocab_size
273+
device = torch.cuda.current_device()
265274

266275
context = self.text_generation_controller.inference_wrapped_model.inference_context
276+
controller = self.text_generation_controller
267277

268-
# Prepare sampling params in human-readable format, to aid with test maintenance.
278+
# Prepare sampling params. The torch backend doesn't support simultaneous
279+
# top_k + top_p, so the 4th case varies by backend.
269280
sampling_test_cases: List[Tuple[SamplingParams, List[int]]] = [
270281
(SamplingParams(temperature=0.1, top_p=0.01), [9, 6, 10]),
271282
(SamplingParams(temperature=5.0, top_k=15), [0, 3, 2]),
272283
(SamplingParams(top_p=0.8), [4, 1, 7]),
273-
(SamplingParams(temperature=10.0, top_k=5), [11, 5, 8]),
284+
(
285+
SamplingParams(temperature=10.0, top_k=5, top_p=0.8 if backend != "torch" else 0.0),
286+
[11, 5, 8],
287+
),
288+
(SamplingParams(), [12, 13, 14]), # plain (no filtering)
274289
]
275-
# For non-torch backends, test simultaneous top_k and top_p sampling.
276-
if backend != "torch":
277-
sampling_test_cases[3][0].top_p = 0.8
278290

279-
# Convert sampling params to non-readable format.
280291
rev_sampling_dict: List[SamplingParams] = [None] * batch_size
281292
for sampling_params, indices in sampling_test_cases:
282293
for idx in indices:
283294
rev_sampling_dict[idx] = sampling_params
284295

285-
# Prepare metadata for sample bookkeeping.
286-
temp_values = torch.Tensor([s.temperature for s in rev_sampling_dict])
287-
top_k_values = torch.Tensor([s.top_k for s in rev_sampling_dict]).to(torch.int32)
288-
top_p_values = torch.Tensor([s.top_p for s in rev_sampling_dict])
296+
# Prepare metadata for sample bookkeeping. The original values are
297+
# kept around for the assertion math below; we write the
298+
# backend-encoded form into the context (simulating what add_request
299+
# would do if we were going through the real request lifecycle).
300+
temp_values = torch.tensor([s.temperature for s in rev_sampling_dict])
301+
top_k_values = torch.tensor([s.top_k for s in rev_sampling_dict], dtype=torch.int32)
302+
top_p_values = torch.tensor([s.top_p for s in rev_sampling_dict])
289303
context.active_request_metadata["temperature"][:batch_size].copy_(temp_values)
290-
context.active_request_metadata["top_k"][:batch_size].copy_(top_k_values)
291-
context.active_request_metadata["top_p"][:batch_size].copy_(top_p_values)
292-
self.text_generation_controller._sampling_backend = backend
304+
context.active_request_metadata["top_k"][:batch_size].copy_(
305+
context._encode_sampling_metadata("top_k", top_k_values)
306+
)
307+
context.active_request_metadata["top_p"][:batch_size].copy_(
308+
context._encode_sampling_metadata("top_p", top_p_values)
309+
)
293310

294311
context.padded_active_token_count = batch_size
295312
context.request_query_lengths = torch.ones(batch_size, dtype=torch.int32)
@@ -303,45 +320,152 @@ def test_sample_from_dynamic_logits(
303320
)
304321
context.paused_request_count = 0
305322
context.total_request_count = batch_size
323+
context.num_prefill_requests = 0
324+
context.padded_active_request_count = batch_size
325+
context._using_cuda_graph_this_step = use_cuda_graph
306326

307-
# Bookkeeping.
308-
self.text_generation_controller._dynamic_step_sample_bookkeeping()
309-
310-
# Sampling.
327+
# Set up logits: ascending [0, 1, ..., vocab_size-1] per request.
311328
logits = torch.arange(0, self.vocab_size).repeat(batch_size, 1).unsqueeze(0).float().cuda()
312-
self.text_generation_controller._all_logits_cuda = logits
313-
self.text_generation_controller._dynamic_step_sample_logits()
314-
sampled_logits = self.text_generation_controller._sampled_tokens_cuda[:batch_size]
315-
vocab_indices = torch.arange(self.vocab_size).cuda()
329+
if controller._sampling_backend == "flashinfer":
330+
# Static buffer; copy in place so the address stays stable for
331+
# any graph capture that follows.
332+
controller._all_logits_cuda[:, :batch_size, :].copy_(logits)
333+
else:
334+
controller._all_logits_cuda = logits
335+
# _enable_cuda_graph now only gates sampling-graph capture/replay (the
336+
# static buffer is allocated regardless); flip it post-init to exercise
337+
# graph mode without reconstructing the model with cuda_graph_impl.
338+
controller._enable_cuda_graph = use_cuda_graph
339+
340+
# Two passes so graph mode exercises both capture and replay. Eager mode
341+
# runs twice harmlessly.
342+
for _ in range(2):
343+
controller._dynamic_step_sample_bookkeeping()
344+
controller._dynamic_step_sample_logits()
345+
sampled = controller._sampled_tokens_cuda[:batch_size]
346+
347+
# All sampled tokens must be valid vocab indices.
348+
assert torch.all(sampled >= 0) and torch.all(
349+
sampled < self.vocab_size
350+
), f"Sampled tokens out of range: {sampled}"
351+
352+
# top_k constraint.
353+
top_k_gpu = top_k_values.cuda()
354+
top_k_gpu[top_k_gpu == 0] = self.vocab_size
355+
assert torch.all(
356+
sampled >= self.vocab_size - top_k_gpu
357+
), f"top_k violated: sampled {sampled}, min allowed {self.vocab_size - top_k_gpu}"
358+
359+
# Combined top_k + top_p constraint: compute the minimum token value
360+
# that passes both filters given temperature-scaled probabilities.
361+
l = logits.squeeze(0)
362+
scaled_probs = l.div(temp_values.cuda().unsqueeze(1)).softmax(dim=-1)
363+
vocab_indices = torch.arange(self.vocab_size, device=device)
364+
top_k_mask = vocab_indices.unsqueeze(0) < (self.vocab_size - top_k_gpu.unsqueeze(1))
365+
scaled_probs.masked_fill_(top_k_mask, 0.0)
366+
367+
top_p_gpu = top_p_values.cuda()
368+
top_p_mask = scaled_probs.cumsum(dim=-1) > top_p_gpu.unsqueeze(1)
369+
first_excluded = torch.where(
370+
top_p_mask.any(dim=-1),
371+
top_p_mask.float().argmax(dim=-1),
372+
torch.full((batch_size,), self.vocab_size, device=device),
373+
)
374+
last_included = torch.clamp(first_excluded - 1, min=0)
375+
start_idx = torch.clamp(self.vocab_size - top_k_gpu, min=0).long()
376+
last_included = torch.max(last_included, start_idx)
377+
expected_min_values = l.gather(1, last_included.unsqueeze(1)).squeeze(1)
378+
assert torch.all(
379+
sampled >= expected_min_values
380+
), f"Sampled below expected min: sampled {sampled}, expected_min {expected_min_values}"
381+
382+
if use_cuda_graph:
383+
# First pass should have populated the graph cache; second should have replayed.
384+
assert len(controller._sampling_cuda_graphs._graphs) >= 1, (
385+
"Expected at least one captured sampling graph, got "
386+
f"{len(controller._sampling_cuda_graphs._graphs)}"
387+
)
316388

317-
# Move tensors to GPU for assertion checks.
318-
temp_values = temp_values.cuda()
319-
top_k_values = top_k_values.cuda()
320-
top_p_values = top_p_values.cuda()
389+
@pytest.mark.parametrize("use_cuda_graph", [False, True])
390+
def test_flashinfer_plain_bucket(self, use_cuda_graph: bool):
391+
"""Exercise the plain (no top_k/top_p) FlashInfer sampling path.
321392
322-
# Assert correct sampled values.
323-
top_k_values[top_k_values == 0] = self.vocab_size
324-
assert torch.all(
325-
sampled_logits >= self.vocab_size - top_k_values
326-
), f"The sampled logits should all be greater than {self.vocab_size - top_k_values} but its {sampled_logits}"
327-
l = logits.squeeze(0)
328-
sampled_l = l.div(temp_values.unsqueeze(1)).softmax(dim=-1)
329-
top_k_mask = vocab_indices.unsqueeze(0) < (self.vocab_size - top_k_values.unsqueeze(1))
330-
sampled_l.masked_fill_(top_k_mask, 0.0)
331-
top_p_mask = sampled_l.cumsum(dim=-1) > top_p_values.unsqueeze(1)
332-
333-
first_excluded = torch.where(
334-
top_p_mask.any(dim=-1),
335-
top_p_mask.float().argmax(dim=-1),
336-
torch.full((batch_size,), self.vocab_size, device=top_p_mask.device),
337-
)
338-
last_included = torch.clamp(first_excluded - 1, min=0)
339-
start_idx = torch.clamp(self.vocab_size - top_k_values, min=0).long()
340-
last_included = torch.max(last_included, start_idx)
341-
expected_min_values = l.gather(1, last_included.unsqueeze(1)).squeeze(1)
342-
assert torch.all(
343-
sampled_logits >= expected_min_values
344-
), f"The sampled logits should all be greater than {expected_min_values} but its {sampled_logits}"
393+
The mixed-params test above always has at least one filtered request,
394+
so _fi_any_filtered_gpu is always True and the plain kernel never
395+
runs. This test uses all-default SamplingParams across the batch so
396+
the plain kernel graph is exercised.
397+
"""
398+
pytest.importorskip("flashinfer")
399+
batch_size = 8
400+
self.setup_model(
401+
torch.float32,
402+
batch_size=batch_size,
403+
static=False,
404+
materialize_only_last_token_logits=True,
405+
sampling_backend="flashinfer",
406+
)
407+
self.mock_tokenizer.eod = self.vocab_size
408+
device = torch.cuda.current_device()
409+
410+
context = self.text_generation_controller.inference_wrapped_model.inference_context
411+
controller = self.text_generation_controller
412+
413+
# All requests use default SamplingParams (top_k=0, top_p=0.0 -> both
414+
# disabled). After add_request-style encoding this becomes the
415+
# flashinfer passthrough state where _fi_any_filtered_gpu is False.
416+
temp_values = torch.ones(batch_size, dtype=torch.float32)
417+
top_k_values = torch.zeros(batch_size, dtype=torch.int32)
418+
top_p_values = torch.zeros(batch_size, dtype=torch.float32)
419+
context.active_request_metadata["temperature"][:batch_size].copy_(temp_values)
420+
context.active_request_metadata["top_k"][:batch_size].copy_(
421+
context._encode_sampling_metadata("top_k", top_k_values)
422+
)
423+
context.active_request_metadata["top_p"][:batch_size].copy_(
424+
context._encode_sampling_metadata("top_p", top_p_values)
425+
)
426+
427+
context.padded_active_token_count = batch_size
428+
context.request_query_lengths = torch.ones(batch_size, dtype=torch.int32)
429+
context.active_request_query_lengths[:batch_size].fill_(1)
430+
context.active_request_last_token_idxs[:batch_size].copy_(
431+
torch.arange(
432+
batch_size,
433+
dtype=context.active_request_last_token_idxs.dtype,
434+
device=context.active_request_last_token_idxs.device,
435+
)
436+
)
437+
context.paused_request_count = 0
438+
context.total_request_count = batch_size
439+
context.num_prefill_requests = 0
440+
context.padded_active_request_count = batch_size
441+
context._using_cuda_graph_this_step = use_cuda_graph
442+
443+
logits = torch.arange(0, self.vocab_size).repeat(batch_size, 1).unsqueeze(0).float().cuda()
444+
controller._all_logits_cuda[:, :batch_size, :].copy_(logits)
445+
controller._enable_cuda_graph = use_cuda_graph
446+
447+
for _ in range(2):
448+
controller._dynamic_step_sample_bookkeeping()
449+
# With all-default params the filtered check must be False so we
450+
# actually hit the plain kernel path.
451+
assert not controller._fi_any_filtered_gpu.item(), (
452+
"Expected _fi_any_filtered_gpu to be False for all-default params"
453+
)
454+
controller._dynamic_step_sample_logits()
455+
sampled = controller._sampled_tokens_cuda[:batch_size]
456+
assert torch.all(sampled >= 0) and torch.all(
457+
sampled < self.vocab_size
458+
), f"Sampled tokens out of range: {sampled}"
459+
460+
if use_cuda_graph:
461+
# Exactly the plain graph (filtered=False) should be captured.
462+
graphs = controller._sampling_cuda_graphs._graphs
463+
assert len(graphs) == 1, f"Expected 1 captured graph (plain), got {len(graphs)}"
464+
# Cache key is (n, filtered=False).
465+
assert (batch_size, False) in graphs, (
466+
f"Expected (batch_size={batch_size}, filtered=False) in graph cache, "
467+
f"got keys {list(graphs.keys())}"
468+
)
345469

346470
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
347471
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)