[Perf] Overlap result D2H copy with the next forward step (#29075)

This commit is contained in:
Liangsheng Yin
2026-06-24 01:34:02 -07:00
committed by GitHub
parent e04ed05193
commit 8e1988b746
4 changed files with 60 additions and 38 deletions
@@ -52,8 +52,10 @@ _OutputMode = Literal["file", "object"]
class ExpertDistributionMetrics: class ExpertDistributionMetrics:
eplb_balancedness: torch.Tensor eplb_balancedness: torch.Tensor
def copy_to_cpu(self): def map_device_tensors(self, fn):
self.eplb_balancedness = self.eplb_balancedness.to("cpu", non_blocking=True) # Device-tensor fields only; caller injects the copy+safety primitive
# (see GenerationBatchResult.copy_to_cpu).
self.eplb_balancedness = fn(self.eplb_balancedness)
class ExpertDistributionRecorder(ABC): class ExpertDistributionRecorder(ABC):
+9 -4
View File
@@ -3253,10 +3253,15 @@ class Scheduler(
else batch_result.next_token_ids else batch_result.next_token_ids
) )
self.future_map.stash(future_indices, stash_payload) self.future_map.stash(future_indices, stash_payload)
batch_result.copy_to_cpu( # Result D2H on copy_stream overlaps the next forward
return_logprob=batch.return_logprob, # instead of serializing on forward_stream; it's a leaf
return_hidden_states=batch.return_hidden_states, # gated by copy_done, so nothing on forward_stream waits.
) self.copy_stream.wait_stream(self.forward_stream)
with self.copy_stream_ctx:
batch_result.copy_to_cpu(
return_logprob=batch.return_logprob,
return_hidden_states=batch.return_hidden_states,
)
else: else:
batch_result.future_indices = future_indices batch_result.future_indices = future_indices
+40 -27
View File
@@ -22,6 +22,19 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _async_d2h(t: torch.Tensor) -> torch.Tensor:
"""Async D2H copy for overlap scheduling. On CUDA the dest is pinned (a D2H
to pageable host memory blocks the caller until done) and record_stream keeps
the source alive until the copy stream drains, so the caching allocator can't
recycle it early. Non-CUDA falls back to a plain copy."""
if not t.is_cuda:
return t.to("cpu", non_blocking=True)
cpu_t = torch.empty(t.shape, dtype=t.dtype, pin_memory=True)
cpu_t.copy_(t, non_blocking=True)
t.record_stream(torch.cuda.current_stream(t.device))
return cpu_t
@dataclasses.dataclass @dataclasses.dataclass
class GenerationBatchResult: class GenerationBatchResult:
logits_output: Optional[LogitsProcessorOutput] = None logits_output: Optional[LogitsProcessorOutput] = None
@@ -72,6 +85,7 @@ class GenerationBatchResult:
fpm_start_event: Optional[torch.cuda.Event] = None fpm_start_event: Optional[torch.cuda.Event] = None
fpm_end_event: Optional[torch.cuda.Event] = None fpm_end_event: Optional[torch.cuda.Event] = None
@torch.profiler.record_function("copy_result_to_cpu")
def copy_to_cpu(self, return_logprob: bool, return_hidden_states: bool = True): def copy_to_cpu(self, return_logprob: bool, return_hidden_states: bool = True):
"""Copy tensors to CPU in overlap scheduling. """Copy tensors to CPU in overlap scheduling.
Only the tensors which are needed for processing results are copied, Only the tensors which are needed for processing results are copied,
@@ -79,45 +93,47 @@ class GenerationBatchResult:
""" """
if return_logprob: if return_logprob:
if self.logits_output.next_token_logprobs is not None: if self.logits_output.next_token_logprobs is not None:
self.logits_output.next_token_logprobs = ( self.logits_output.next_token_logprobs = _async_d2h(
self.logits_output.next_token_logprobs.to("cpu", non_blocking=True) self.logits_output.next_token_logprobs
) )
if self.logits_output.input_token_logprobs is not None: if self.logits_output.input_token_logprobs is not None:
self.logits_output.input_token_logprobs = ( self.logits_output.input_token_logprobs = _async_d2h(
self.logits_output.input_token_logprobs.to("cpu", non_blocking=True) self.logits_output.input_token_logprobs
) )
if self.logits_output.next_token_top_logprobs_val is not None: if self.logits_output.next_token_top_logprobs_val is not None:
self.logits_output.next_token_top_logprobs_val = [ self.logits_output.next_token_top_logprobs_val = [
v.to("cpu", non_blocking=True) if torch.is_tensor(v) else v _async_d2h(v) if torch.is_tensor(v) else v
for v in self.logits_output.next_token_top_logprobs_val for v in self.logits_output.next_token_top_logprobs_val
] ]
if self.logits_output.next_token_top_logprobs_idx is not None: if self.logits_output.next_token_top_logprobs_idx is not None:
self.logits_output.next_token_top_logprobs_idx = [ self.logits_output.next_token_top_logprobs_idx = [
x.to("cpu", non_blocking=True) if torch.is_tensor(x) else x _async_d2h(x) if torch.is_tensor(x) else x
for x in self.logits_output.next_token_top_logprobs_idx for x in self.logits_output.next_token_top_logprobs_idx
] ]
if self.logits_output.next_token_token_ids_logprobs_val is not None: if self.logits_output.next_token_token_ids_logprobs_val is not None:
self.logits_output.next_token_token_ids_logprobs_val = [ self.logits_output.next_token_token_ids_logprobs_val = [
v.to("cpu", non_blocking=True) if torch.is_tensor(v) else v _async_d2h(v) if torch.is_tensor(v) else v
for v in self.logits_output.next_token_token_ids_logprobs_val for v in self.logits_output.next_token_token_ids_logprobs_val
] ]
if return_hidden_states and self.logits_output.hidden_states is not None: if return_hidden_states and self.logits_output.hidden_states is not None:
self.logits_output.hidden_states = self.logits_output.hidden_states.to( self.logits_output.hidden_states = _async_d2h(
"cpu", non_blocking=True self.logits_output.hidden_states
) )
self.next_token_ids = self.next_token_ids.to("cpu", non_blocking=True) self.next_token_ids = _async_d2h(self.next_token_ids)
if self.accept_lens is not None: if self.accept_lens is not None:
self.accept_lens = self.accept_lens.to("cpu", non_blocking=True) self.accept_lens = _async_d2h(self.accept_lens)
if self.routed_experts_output is not None: # Sub-objects only declare their device fields; the single copy+safety
self.routed_experts_output.copy_to_cpu() # primitive (_async_d2h: pinned D2H + record_stream) is injected here so
# all device->host copying and lifetime safety lives in one place.
if self.indexer_topk_output is not None: for holder in (
self.indexer_topk_output.copy_to_cpu() self.routed_experts_output,
self.indexer_topk_output,
if (x := self.expert_distribution_metrics) is not None: self.expert_distribution_metrics,
x.copy_to_cpu() ):
if holder is not None:
holder.map_device_tensors(_async_d2h)
self.copy_done.record() self.copy_done.record()
@@ -243,30 +259,27 @@ class EmbeddingBatchResult:
def can_run_cuda_graph(self) -> bool: def can_run_cuda_graph(self) -> bool:
return False return False
@torch.profiler.record_function("copy_embedding_to_cpu")
def copy_to_cpu(self): def copy_to_cpu(self):
"""Copy embeddings and pooled hidden states to CPU for overlap scheduling.""" """Copy embeddings and pooled hidden states to CPU for overlap scheduling."""
if isinstance(self.embeddings, torch.Tensor): if isinstance(self.embeddings, torch.Tensor):
self.copy_done = torch.get_device_module(self.embeddings.device).Event() self.copy_done = torch.get_device_module(self.embeddings.device).Event()
self.embeddings = self.embeddings.to("cpu", non_blocking=True) self.embeddings = _async_d2h(self.embeddings)
else: else:
assert isinstance(self.embeddings, list) assert isinstance(self.embeddings, list)
if len(self.embeddings) == 0: if len(self.embeddings) == 0:
return return
self.copy_done = torch.get_device_module(self.embeddings[0].device).Event() self.copy_done = torch.get_device_module(self.embeddings[0].device).Event()
self.embeddings = [ self.embeddings = [_async_d2h(emb) for emb in self.embeddings]
emb.to("cpu", non_blocking=True) for emb in self.embeddings
]
if self.pooled_hidden_states is not None: if self.pooled_hidden_states is not None:
if isinstance(self.pooled_hidden_states, list): if isinstance(self.pooled_hidden_states, list):
self.pooled_hidden_states = [ self.pooled_hidden_states = [
t.to("cpu", non_blocking=True) for t in self.pooled_hidden_states _async_d2h(t) for t in self.pooled_hidden_states
] ]
else: else:
self.pooled_hidden_states = self.pooled_hidden_states.to( self.pooled_hidden_states = _async_d2h(self.pooled_hidden_states)
"cpu", non_blocking=True
)
self.copy_done.record() self.copy_done.record()
+7 -5
View File
@@ -79,17 +79,19 @@ class BaseHostCache:
@dataclasses.dataclass @dataclasses.dataclass
class TopkCaptureOutput: class TopkCaptureOutput:
"""Holds GPU tensors captured during forward for overlap scheduling. """Holds GPU tensors captured during forward for overlap scheduling.
Call copy_to_cpu() inside forward stream (before copy_done.record()), map_device_tensors() D2H-copies them before copy_done.record() (may run on
then finalize() after copy_done.synchronize(). the dedicated result-copy stream); finalize() runs after copy_done.synchronize().
""" """
out_cache_loc: torch.Tensor out_cache_loc: torch.Tensor
topk: torch.Tensor topk: torch.Tensor
host_cache: BaseHostCache host_cache: BaseHostCache
def copy_to_cpu(self): def map_device_tensors(self, fn):
self.out_cache_loc = self.out_cache_loc.to("cpu", non_blocking=True) # Device-tensor fields only; caller injects the copy+safety primitive
self.topk = self.topk.to("cpu", non_blocking=True) # (see GenerationBatchResult.copy_to_cpu).
self.out_cache_loc = fn(self.out_cache_loc)
self.topk = fn(self.topk)
def finalize(self): def finalize(self):
self.host_cache.buffer[self.out_cache_loc] = self.topk self.host_cache.buffer[self.out_cache_loc] = self.topk