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