huggingface/transformers · #47975

[generate] stop synchronizing the accelerator on every decode step

SunMarc · merged Sep 4, 20265 files · 288 + / 4
src/transformers/cache_utils.py23 + / 0
@@ -28,6 +28,7 @@ class CacheLayerMixin(ABC):     """Base, abstract class for a single layer's cache."""      is_compileable = False+    is_croppable = False     supports_early_init = True     # Subclasses can set `_layer_type` to auto-register themselves in the mappings, if the class definition lives in a modeling     # file instead of this file. This allows to update the mapping only when the modeling file is imported, which simplifies imports@@ -117,6 +118,7 @@ class DynamicLayer(CacheLayerMixin):     """      is_sliding = False+    is_croppable = True      def lazy_initialization(self, key_states: torch.Tensor, value_states: torch.Tensor) -> None:         self.dtype, self.device = key_states.dtype, key_states.device@@ -960,6 +962,18 @@ def reorder_cache(self, beam_idx: torch.LongTensor):             if self.is_recurrent_states_initialized[i]:                 self.recurrent_states[i] = self.recurrent_states[i].index_select(0, beam_idx.to(self.device)) +    @property+    def is_croppable(self) -> bool:+        """+        Whether `crop` can put this layer back as it was. This is only supported when there are no recurrent states.+        """+        if any(self.is_recurrent_states_initialized.values()):+            return False+        # If nothing is initialized, return False as we don't yet know whether we will have any recurrent states or no, so let's be+        # extra careful. If a conv states is initialized but no recurrent states are, then we return True as we know that we will never+        # have any recurrent state (they are updated in the same forward)+        return any(self.is_conv_states_initialized.values())+     def activate_past_recording(self):         """         Calling this function will activate past state recording, meaning that a call to `update_conv_states` will@@ -1662,6 +1676,11 @@ def is_initialized(self) -> bool:         layers = [layer for layer in self.layers if layer.supports_early_init]         return len(layers) > 0 and all(layer.is_initialized for layer in layers) +    @property+    def is_croppable(self) -> bool:+        """Whether `crop` can put the whole cache back as it was, so a rollback leaves no trace."""+        return all(layer.is_croppable for layer in self.layers)+     @property     def is_sliding(self) -> list[bool]:         """Return whether the layers of the cache are sliding window"""@@ -2085,6 +2104,10 @@ def is_sliding(self):     def is_compileable(self) -> bool:         return self.self_attention_cache.is_compileable +    @property+    def is_croppable(self) -> bool:+        return self.self_attention_cache.is_croppable+     def activate_past_recording(self):         self.self_attention_cache.activate_past_recording() 
src/transformers/generation/utils.py154 + / 3
@@ -17,6 +17,7 @@ import inspect import os import warnings+from collections import deque from collections.abc import Callable from contextlib import contextmanager from dataclasses import dataclass@@ -356,6 +357,127 @@ class GenerateBeamEncoderDecoderOutput(ModelOutput): GenerateOutput = GenerateNonBeamOutput | GenerateBeamOutput  +def _undo_generation_steps(num_steps: int, input_ids: torch.LongTensor, *recorded: "tuple | None") -> tuple:+    """+    Undo the last `num_steps` decoding steps, so that they leave no trace in what `generate` returns.+    Note that the cache entries those steps wrote are dropped by `DeferredStopCheck.finish` instead.+    """+    # `[:-0]` is `[:0]`, which would empty everything rather than leave it alone+    if num_steps == 0:+        return (input_ids, *recorded)+    return (input_ids[..., :-num_steps], *(record[:-num_steps] if record else record for record in recorded))+++class StopCheck:+    """+    Decides when the decoding loop should stop, and hands each new token to a streamer.+    """++    def __init__(self, streamer: "BaseStreamer | None" = None):+        self.streamer = streamer++    def __call__(self, unfinished_sequences: torch.Tensor, tokens: torch.Tensor, length: int) -> bool:+        if self.streamer is not None:+            self.streamer.put(tokens.cpu())+        return bool(unfinished_sequences.max() == 0)++    def finish(self) -> int:+        """Flush whatever is still held back, and report how many decoding steps have to be undone."""+        return 0+++class DeferredStopCheck(StopCheck):+    """+    A deferred `StopCheck` that reports whether generation should stop, one step late, so the host never waits on the device.++    Reading `unfinished_sequences.max() == 0` blocks the host until the device has caught up, every step, leaving it unable to queue+    the next step meanwhile. Copying that flag asynchronously and reading it on the *following* step removes the stall. It costs one+    extra `forward` pass, whose results the caller undoes using the count returned by `finish`.+    Streaming needs the tokens themselves on the host, which is the same synchronization, so tokens are streamed one step behind.+    The extra token is never streamed: it is still in flight when the loop breaks, and `finish` drops it.+    """++    def __init__(+        self,+        input_ids: torch.LongTensor,+        max_length: int,+        cache: "Cache | None",+        cache_is_returned: bool,+        streamer: "BaseStreamer | None" = None,+    ):+        super().__init__(streamer)+        self.max_length = max_length+        # We only need to care about rollbacking the cache if we are going to return the Cache, i.e. if the user requested additional+        # outputs or if the user passed an explicit Cache object+        self.cache = cache if cache_is_returned else None+        if self.cache is not None:+            self.cache.activate_past_recording()+        pinned = input_ids.device.type == "cuda"+        self.slots = deque(+            (+                torch.zeros((), dtype=torch.bool, pin_memory=pinned),+                torch.zeros(input_ids.shape[0], dtype=torch.long, pin_memory=pinned),+                torch.Event(device=input_ids.device, blocking=True),+            )+            for _ in range(2)+        )+        self.is_first_step = True+        self.stop_reported = False++    @staticmethod+    def is_supported(device: torch.device, cache: "Cache | None", cache_is_returned: bool, is_assistant: bool) -> bool:+        """+        Whether the stop decision can safely be deferred by a step, in this decoding context.+        In general, we do not defer unless the device is `"mps"`, and if the user requests to return the `Cache`, we need this+        `Cache` to be able to rollback its states correctly, so that the last `forward` can be correctly reverted.+        """+        # Only mps for now, we should enable it for cuda if we observe perf gains - also skip if it's an assistant+        if device.type != "mps" or is_assistant:+            return False+        # Since this is called after prefill, if we still do not have any cache, it means we'll never have one+        if cache is None:+            return True+        # if we don't return the cache, we don't need to bother about activating past recording since it will be dropped anyway+        else:+            return cache.is_croppable or not cache_is_returned++    def __call__(self, unfinished_sequences: torch.Tensor, tokens: torch.Tensor, length: int) -> bool:+        should_stop, tokens_cpu, copy_done = self.slots[0]+        should_stop.copy_(unfinished_sequences.max() == 0, non_blocking=True)+        if self.streamer is not None:+            tokens_cpu.copy_(tokens, non_blocking=True)+        copy_done.record()++        self.slots.rotate()+        should_stop_before, tokens_cpu_before, copy_done_before = self.slots[0]+        copy_done_before.synchronize()+        if not self.is_first_step:+            if self.streamer is not None:+                self.streamer.put(tokens_cpu_before.clone())+            self.stop_reported = bool(should_stop_before)+        self.is_first_step = False+        stopping = self.stop_reported or (self.max_length is not None and length >= self.max_length)+        if not stopping and self.cache is not None:+            self.cache.crop(0)+        return stopping++    def finish(self) -> int:+        for *_, copy_done in self.slots:+            copy_done.synchronize()+        steps_to_undo = 1 if self.stop_reported else 0+        if self.streamer is not None and not steps_to_undo:+            _, tokens_cpu, _ = self.slots[1]+            self.streamer.put(tokens_cpu.clone())+        if self.cache is not None:+            self.cache.crop(-steps_to_undo)+            # We also need to deactivate past_recording, since we are giving the cache back to the user and it may lead to unneeded+            # memory spike on the next prefill+            for layer in self.cache.layers:+                if hasattr(layer, "record_past"):+                    layer.record_past = False+        return steps_to_undo++ class GenerationMixin(ContinuousMixin):     """     A class containing all functions for auto-regressive text generation, to be used as a mixin in model classes.@@ -1955,6 +2077,8 @@ def _prepare_cache_for_generation(                 raise ValueError(                     "Passing a tuple of `past_key_values` is not supported anymore. Please use a `Cache` instance."                 )+            # Marks the cache has user-defined for generate later on+            user_defined_cache._is_user_defined = True             return          # Quick escape route 2: if the user specifies no cache is to be used. (conflicting arguments are handled in@@ -2875,6 +2999,20 @@ def _sample(             is_first_iteration=not generation_config.is_assistant,         ) +        # Decides whether we can defer the stopping criteria to avoid a synchronization point in between every `forward`.+        # Note that it is very important to do this only after the prefill, as we may otherwise call `activate_past_recording` on the+        # Cache, which will cause a huge unneeded memory spike if the prefill is huge and the model would otherwise drop most of the+        # states, such as if it uses sliding window or linear attention+        cache = next((outputs[name] for name in ALL_CACHE_NAMES if name in outputs), None)+        # The cache outlives `generate` if the user asked to return it, or if it is one they passed in.+        cache_is_returned = generation_config.return_dict_in_generate or getattr(cache, "_is_user_defined", False)+        if DeferredStopCheck.is_supported(+            input_ids.device, cache, cache_is_returned, is_assistant=generation_config.is_assistant+        ):+            stop_check = DeferredStopCheck(input_ids, stopping_criteria.max_length, cache, cache_is_returned, streamer)+        else:+            stop_check = StopCheck(streamer)+         with self._optimize_model_for_decode():             while self._has_unfinished_sequences(this_peer_finished, synced_gpus, device=input_ids.device):                 if prefill_consumed:@@ -2933,16 +3071,29 @@ def _sample(                  # update generated ids, model inputs, and length for next step                 input_ids = torch.cat([input_ids, next_tokens[:, None]], dim=-1)-                if streamer is not None:-                    streamer.put(next_tokens.cpu())                  unfinished_sequences = unfinished_sequences & ~stopping_criteria(input_ids, scores)-                this_peer_finished = unfinished_sequences.max() == 0+                this_peer_finished = stop_check(unfinished_sequences, next_tokens, input_ids.shape[1])                  # This is needed to properly delete outputs.logits which may be very large for first iteration                 # Otherwise a reference to outputs is kept which keeps the logits alive in the next iteration                 del outputs +        steps_to_undo = stop_check.finish()+        # We may need to remove the last output if we deferred the stop checks+        if steps_to_undo:+            input_ids, scores, raw_logits, decoder_attentions, cross_attentions, decoder_hidden_states = (+                _undo_generation_steps(+                    steps_to_undo,+                    input_ids,+                    scores,+                    raw_logits,+                    decoder_attentions,+                    cross_attentions,+                    decoder_hidden_states,+                )+            )+         if streamer is not None:             streamer.end() 
src/transformers/models/minimax/modeling_minimax.py4 + / 0
@@ -71,6 +71,10 @@ def extra_repr(self):   class MiniMaxCache(DynamicCache):+    # `crop` raises below, so a rollback cannot be undone here. The inherited property only inspects+    # `self.layers`, which are all plain dynamic layers, and would otherwise wrongly report `True`.+    is_croppable = False+     def __init__(self):         super().__init__()         self.linear_cache: list[torch.Tensor] = []
src/transformers/models/minimax/modular_minimax.py4 + / 0
@@ -161,6 +161,10 @@ class MiniMaxRMSNorm(MixtralRMSNorm):   class MiniMaxCache(DynamicCache):+    # `crop` raises below, so a rollback cannot be undone here. The inherited property only inspects+    # `self.layers`, which are all plain dynamic layers, and would otherwise wrongly report `True`.+    is_croppable = False+     def __init__(self):         super().__init__()         self.linear_cache: list[torch.Tensor] = []
tests/generation/test_utils.py103 + / 1
@@ -114,7 +114,7 @@         AssistedCandidateGeneratorDifferentTokenizers,         DFlashTokenCandidateGenerator,     )-    from transformers.generation.utils import ALL_CACHE_NAMES, _speculative_sampling+    from transformers.generation.utils import ALL_CACHE_NAMES, DeferredStopCheck, _speculative_sampling     from transformers.modeling_layers import MtpModel  from unittest.mock import patch@@ -5700,3 +5700,105 @@ def assert_similar_generate_outputs(output_1, output_2, atol=1e-5, rtol=1e-5):     mismatch_message = _get_generate_outputs_mismatch_message(output_1, output_2, atol=atol, rtol=rtol)     if mismatch_message:         raise AssertionError(mismatch_message)+++class _CollectingStreamer:+    """Keeps every tensor it is handed, so a stream can be compared against the sequences that were returned."""++    def __init__(self):+        self.tokens = []++    def put(self, value):+        self.tokens.append(value.reshape(-1).cpu())++    def end(self):+        pass+++@require_torch_accelerator+class DeferredStopCheckIntegrationTest(unittest.TestCase):+    """Deferring the stop decision must not change a single token of what `generate` returns."""++    model_id = "hf-internal-testing/tiny-random-gpt2"++    def setUp(self):+        self.tokenizer = AutoTokenizer.from_pretrained(self.model_id)+        self.model = AutoModelForCausalLM.from_pretrained(self.model_id).to(torch_device).eval()++    def _generate(self, inputs, model=None, **kwargs):+        """Generate twice over the same inputs, once collecting the stream, and return both."""+        model = self.model if model is None else model+        kwargs = {"max_new_tokens": 12, "do_sample": False, **kwargs}+        outputs = model.generate(**inputs, return_dict_in_generate=True, output_scores=True, **kwargs)+        streamer = _CollectingStreamer()+        model.generate(**inputs, streamer=streamer, **kwargs)+        return outputs, torch.cat(streamer.tokens)++    def _generate_deferred_and_immediate(self, inputs, model=None, **kwargs):+        # Both halves are forced rather than left to `is_supported`, so this holds on any accelerator:+        # `is_supported` limits where the deferral is *enabled*, but the logic under test is not+        # device-specific and is worth checking wherever the suite runs.+        with patch.object(DeferredStopCheck, "is_supported", staticmethod(lambda *args, **kwargs: True)):+            deferred = self._generate(inputs, model=model, **kwargs)+        with patch.object(DeferredStopCheck, "is_supported", staticmethod(lambda *args, **kwargs: False)):+            immediate = self._generate(inputs, model=model, **kwargs)+        return deferred, immediate++    def _assert_matches(self, inputs, model=None, **kwargs):+        prompt_length = inputs["input_ids"].shape[1]+        (deferred, stream), (immediate, _) = self._generate_deferred_and_immediate(inputs, model=model, **kwargs)++        # The extra step leaves no trace: same tokens, and one score per token actually returned+        self.assertTrue(torch.equal(deferred.sequences, immediate.sequences))+        self.assertEqual(len(deferred.scores), len(immediate.scores))+        self.assertEqual(len(deferred.scores), deferred.sequences.shape[1] - prompt_length)+        # A streamer is one step behind, but still sees every token and never the one past the stop+        self.assertEqual(stream.tolist(), deferred.sequences[0].cpu().tolist())++    def test_matches_immediate_check_at_max_length(self):+        inputs = self.tokenizer(["Hello world, this is"], return_tensors="pt").to(torch_device)+        self._assert_matches(inputs)++    def test_matches_immediate_check_when_stopping_early(self):+        """The step taken past an eos is the one that has to be undone, so this is the interesting case."""+        inputs = self.tokenizer(["Hello world, this is"], return_tensors="pt").to(torch_device)+        prompt_length = inputs["input_ids"].shape[1]+        # Make a token the model does generate into an eos, so generation really stops before `max_new_tokens`+        generated = self.model.generate(**inputs, max_new_tokens=12, do_sample=False)+        eos_token_id = int(generated[0, prompt_length + 4])++        self._assert_matches(inputs, eos_token_id=eos_token_id)+        stopped = self.model.generate(**inputs, max_new_tokens=12, do_sample=False, eos_token_id=eos_token_id)+        self.assertLess(stopped.shape[1], generated.shape[1])++    def test_matches_immediate_check_with_a_sliding_window(self):+        """A sliding window layer has to hold an extra state for the rollback without widening its window.++        `update` hands attention whatever the layer is holding, so a cache recording past for `crop` must+        still return the working window - otherwise the model silently attends over `sliding_window + 1`+        positions and generates different tokens.+        """+        model_id = "hf-internal-testing/tiny-random-Gemma3ForCausalLM"+        config = AutoConfig.from_pretrained(model_id)+        # Narrow the window so that a short generation still runs past it, which is the only regime where the+        # states are trimmed, and so the only one where holding an extra one could widen what attention sees+        config.sliding_window = 4+        model = AutoModelForCausalLM.from_pretrained(model_id, config=config).to(torch_device).eval()+        tokenizer = AutoTokenizer.from_pretrained(model_id)+        inputs = tokenizer(["Hello world, this is"], return_tensors="pt").to(torch_device)++        self._assert_matches(inputs, model=model)++    def test_matches_immediate_check_without_a_cache(self):+        inputs = self.tokenizer(["Hello world, this is"], return_tensors="pt").to(torch_device)+        self._assert_matches(inputs, use_cache=False)++    def test_matches_immediate_check_on_a_batch(self):+        self.tokenizer.pad_token = self.tokenizer.eos_token+        inputs = self.tokenizer(+            ["Hello world, this is", "The capital of France is"], return_tensors="pt", padding=True+        ).to(torch_device)+        prompt_length = inputs["input_ids"].shape[1]+        (deferred, _), (immediate, _) = self._generate_deferred_and_immediate(inputs)+        self.assertTrue(torch.equal(deferred.sequences, immediate.sequences))+        self.assertEqual(len(deferred.scores), deferred.sequences.shape[1] - prompt_length)