Skip to content

LMSDiscreteScheduler uses the wrong multistep order when sampling starts part-way through the schedule (img2img, inpainting) #15108

Open

Description

@mottopanikeiku

Describe the bug

LMSDiscreteScheduler.step sets the multistep order from step_index:

# 3. Compute linear multistep coefficients
order = min(self.step_index + 1, order)
lms_coeffs = [self.get_lms_coefficient(order, self.step_index, curr_order) for curr_order in range(order)]
# 4. Compute previous sample based on the derivatives path
prev_sample = sample + sum(
coeff * derivative for coeff, derivative in zip(lms_coeffs, reversed(self.derivatives))
)

step_index is the position in the full schedule. Image-to-image and inpainting with strength < 1 (and SDXL img2img with denoising_start) start part-way through the schedule with set_begin_index:

def get_timesteps(self, num_inference_steps, strength, device):
# get the original timestep using init_timestep
init_timestep = min(int(num_inference_steps * strength), num_inference_steps)
t_start = max(num_inference_steps - init_timestep, 0)
timesteps = self.scheduler.timesteps[t_start * self.scheduler.order :]
if hasattr(self.scheduler, "set_begin_index"):
self.scheduler.set_begin_index(t_start * self.scheduler.order)
return timesteps, num_inference_steps - t_start

So the first steps use 2nd to 4th order coefficients while only one derivative is stored, and zip(lms_coeffs, reversed(self.derivatives)) drops the coefficients that have no derivative. The first update becomes sample + c0 * d instead of sample + (sigma_next - sigma) * d, and the next steps are off in the same way. The latents then sit at a different noise level than the timestep the model gets next, which leaves noise in the result. k-diffusion's sample_lms counts the order from the steps taken on the remaining sigmas.

This looks like the cause of #5630 (LMS with SDXL img2img leaves noise at low strength), which was closed by the stale bot.

I found this by reading and testing the code. I'd like to open a PR if this approach is fine: order = min(len(self.derivatives), order). Text-to-image is unchanged, because there len(self.derivatives) equals min(step_index + 1, order).

Reproduction

I didn't run a real checkpoint (CPU only here). The closest I have runs the real StableDiffusionImg2ImgPipeline with LMSDiscreteScheduler, but with a stand-in UNet that gives the exact noise prediction when the only training sample is the input latents. With an exact model, a correct sampler returns the input latents.

import math

import torch

from diffusers import LMSDiscreteScheduler, StableDiffusionImg2ImgPipeline
from diffusers.configuration_utils import ConfigMixin, register_to_config
from diffusers.models.modeling_utils import ModelMixin


class ExactUNet(ModelMixin, ConfigMixin):
    # exact noise prediction when every training sample equals `self.x0`
    @register_to_config
    def __init__(self, in_channels: int = 4, time_cond_proj_dim=None):
        super().__init__()
        self.dummy = torch.nn.Parameter(torch.zeros(1))

    def forward(self, sample, timestep, encoder_hidden_states=None, return_dict=False, **kwargs):
        ac = self.alphas_cumprod.double()
        log_sigmas = 0.5 * torch.log((1 - ac) / ac)
        t = float(timestep)
        lo = min(int(t), len(ac) - 1)
        hi = min(lo + 1, len(ac) - 1)
        sigma = torch.exp((1 - (t - lo)) * log_sigmas[lo] + (t - lo) * log_sigmas[hi]).item()
        alpha = 1 / math.sqrt(1 + sigma**2)
        return ((sample - alpha * self.x0) / (sigma * alpha),)


scheduler = LMSDiscreteScheduler(beta_start=0.00085, beta_end=0.012, beta_schedule="scaled_linear", steps_offset=1)
unet = ExactUNet()
pipe = StableDiffusionImg2ImgPipeline(
    vae=None,
    text_encoder=None,
    tokenizer=None,
    unet=unet,
    scheduler=scheduler,
    safety_checker=None,
    feature_extractor=None,
    requires_safety_checker=False,
)
pipe.set_progress_bar_config(disable=True)
unet.alphas_cumprod = pipe.scheduler.alphas_cumprod

torch.manual_seed(1)
x0 = torch.randn(1, 4, 32, 32) * 0.8  # passed to the pipeline as latents
unet.x0 = x0
for steps, strength in [(10, 0.3), (10, 0.5), (25, 0.3), (25, 0.5), (50, 0.3)]:
    out = pipe(
        prompt_embeds=torch.zeros(1, 77, 32),
        image=x0,
        strength=strength,
        num_inference_steps=steps,
        guidance_scale=1.0,
        output_type="latent",
        generator=torch.Generator().manual_seed(0),
    ).images
    print(f"steps={steps} strength={strength} relative error={((out - x0).norm() / x0.norm()).item():.4f}")
steps strength main (73faca3) with order = min(len(self.derivatives), order)
10 0.3 0.0824 0.0000
10 0.5 0.0321 0.0000
25 0.3 0.0630 0.0000
25 0.5 0.0019 0.0000
50 0.3 0.0002 0.0000

I also compared the same scheduler loop with k-diffusion's sample_lms run on the remaining sigmas (Gaussian-data optimal denoiser, 10/25/50 steps, strength 0.3/0.5/0.75, SD 1.5 and SDXL scheduler settings). On main the outputs differ by 0.4% to 29% (max abs difference over max abs value); with the change they are identical.

Logs

main (73faca3):
steps=10 strength=0.3 relative error=0.0824
steps=10 strength=0.5 relative error=0.0321
steps=25 strength=0.3 relative error=0.0630
steps=25 strength=0.5 relative error=0.0019
steps=50 strength=0.3 relative error=0.0002

System Info

diffusers 0.42.0.dev0 (main @ 73faca3), Python 3.12.13, torch 2.14.1, Linux, CPU only.

Who can help?

@yiyixuxu @dg845

I made this report and directed the investigation, with AI assistance from Claude and GPT-6 Astra.

Activity

  1. mottopanikeiku commented on Oct 24, 2026

    @mottopanikeiku
    Author

    for reference, my fix has been ready since i opened this issue: mottopanikeiku@199d1d4 (committed oct 22). it's the change proposed above, a test_full_loop_with_begin_index regression test (max error 4.1e-2 before, 1.2e-7 after) and the updated test_full_loop_with_noise values. i held off on the pr because CONTRIBUTING asks for a maintainer go-ahead on the issue first. #15116 makes the same change and uses the reproducer numbers from this issue.

    @yiyixuxu @dg845 i can open mine as soon as you're ok with the approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions