diff --git a/src/diffusers/schedulers/scheduling_flow_match_euler_discrete.py b/src/diffusers/schedulers/scheduling_flow_match_euler_discrete.py index 0e4f5c6a1f97..07ea7c1466ea 100644 --- a/src/diffusers/schedulers/scheduling_flow_match_euler_discrete.py +++ b/src/diffusers/schedulers/scheduling_flow_match_euler_discrete.py @@ -277,6 +277,10 @@ def stretch_shift_to_terminal(self, t: torch.Tensor) -> torch.Tensor: """ one_minus_z = 1 - t scale_factor = one_minus_z[-1] / (1 - self.config.shift_terminal) + if scale_factor == 0: + # The schedule already ends at 1.0 (e.g. a single-step schedule), so there is nothing to + # stretch and dividing by `scale_factor` would produce NaN/Inf. + return t stretched_t = 1 - (one_minus_z / scale_factor) return stretched_t @@ -350,7 +354,7 @@ def set_timesteps( else: sigmas = self.shift * sigmas / (1 + (self.shift - 1) * sigmas) - # 3. If required, stretch the sigmas schedule to terminate at the configured `shift_terminal` value + # 3. If required, stretch the sigmas schedule to terminate at the configured `shift_terminal` value. if self.config.shift_terminal: sigmas = self.stretch_shift_to_terminal(sigmas) diff --git a/src/diffusers/schedulers/scheduling_flow_match_lcm.py b/src/diffusers/schedulers/scheduling_flow_match_lcm.py index 97d4ebbc8e42..ffdfadc70d2b 100644 --- a/src/diffusers/schedulers/scheduling_flow_match_lcm.py +++ b/src/diffusers/schedulers/scheduling_flow_match_lcm.py @@ -286,6 +286,10 @@ def stretch_shift_to_terminal(self, t: np.ndarray | torch.Tensor) -> np.ndarray """ one_minus_z = 1 - t scale_factor = one_minus_z[-1] / (1 - self.config.shift_terminal) + if scale_factor == 0: + # The schedule already ends at 1.0 (e.g. a single-step schedule), so there is nothing to + # stretch and dividing by `scale_factor` would produce NaN/Inf. + return t stretched_t = 1 - (one_minus_z / scale_factor) return stretched_t @@ -359,7 +363,7 @@ def set_timesteps( else: sigmas = self.shift * sigmas / (1 + (self.shift - 1) * sigmas) # type: ignore - # 3. If required, stretch the sigmas schedule to terminate at the configured `shift_terminal` value + # 3. If required, stretch the sigmas schedule to terminate at the configured `shift_terminal` value. if self.config.shift_terminal: sigmas = self.stretch_shift_to_terminal(sigmas) # type: ignore diff --git a/src/diffusers/schedulers/scheduling_unipc_multistep.py b/src/diffusers/schedulers/scheduling_unipc_multistep.py index 5c2cbcc13ff1..71cd42711a14 100644 --- a/src/diffusers/schedulers/scheduling_unipc_multistep.py +++ b/src/diffusers/schedulers/scheduling_unipc_multistep.py @@ -521,6 +521,10 @@ def stretch_shift_to_terminal(self, t: torch.Tensor) -> torch.Tensor: """ one_minus_z = 1 - t scale_factor = one_minus_z[-1] / (1 - self.config.shift_terminal) + if scale_factor == 0: + # The schedule already ends at 1.0 (e.g. a single-step schedule), so there is nothing to + # stretch and dividing by `scale_factor` would produce NaN/Inf. + return t stretched_t = 1 - (one_minus_z / scale_factor) return stretched_t diff --git a/tests/schedulers/test_scheduler_shift_terminal_single_step.py b/tests/schedulers/test_scheduler_shift_terminal_single_step.py new file mode 100644 index 000000000000..39c1d3457ce0 --- /dev/null +++ b/tests/schedulers/test_scheduler_shift_terminal_single_step.py @@ -0,0 +1,53 @@ +# Copyright 2026 The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch + +from diffusers import FlowMatchEulerDiscreteScheduler, FlowMatchLCMScheduler, UniPCMultistepScheduler + + +class ShiftTerminalSingleStepTest(unittest.TestCase): + """ + Regression test for https://github.com/huggingface/diffusers/issues/14411. + + `stretch_shift_to_terminal()` rescales sigmas by `one_minus_z[-1] / (1 - shift_terminal)`. With + `num_inference_steps=1` the only sigma is 1.0, so `one_minus_z[-1]` is 0 and the rescale divides by + zero, producing a NaN sigma. Schedulers that support `shift_terminal` must skip the stretch when + there is only a single step instead of stretching into NaN. + """ + + def test_flow_match_euler_discrete_single_step_no_nan(self): + scheduler = FlowMatchEulerDiscreteScheduler(shift_terminal=0.1) + scheduler.set_timesteps(num_inference_steps=1) + self.assertFalse(torch.isnan(scheduler.sigmas).any()) + + def test_flow_match_lcm_single_step_no_nan(self): + scheduler = FlowMatchLCMScheduler(shift_terminal=0.1) + scheduler.set_timesteps(num_inference_steps=1) + self.assertFalse(torch.isnan(scheduler.sigmas).any()) + + def test_unipc_flow_sigmas_single_step_no_nan(self): + scheduler = UniPCMultistepScheduler(use_flow_sigmas=True, shift_terminal=0.1) + scheduler.set_timesteps(num_inference_steps=1) + self.assertFalse(torch.isnan(scheduler.sigmas).any()) + + def test_flow_match_euler_discrete_custom_schedule_ending_at_one_no_nan(self): + # A multi-step custom schedule whose last sigma is already 1.0 hits the same + # `one_minus_z[-1] == 0` division-by-zero case as the single-step schedule. + scheduler = FlowMatchEulerDiscreteScheduler(shift_terminal=0.1) + scheduler.set_timesteps(sigmas=[0.9, 1.0]) + self.assertFalse(torch.isnan(scheduler.sigmas).any()) + self.assertFalse(torch.isinf(scheduler.sigmas).any())