@@ -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