dpm_solver.py 在 VibeVoice 里做什么:扩散采样的调度那一段

2026-08-18

翻 VibeVoice 仓库的时候,vibevoice/schedule/ 这个目录很容易被跳过——它只有三个文件,__init__.py 是空的,剩下 dpm_solver.pytimestep_sampler.py。但如果你想搞清楚”一段语音潜变量是怎么从纯噪声里被一步步解出来的”,绕不开这里。

先说清楚本文的边界:我们没有下载权重、没有跑过推理,也没有做过训练。下面全部是把仓库里的代码读出来讲一遍,不涉及任何运行表现。

从调用点倒着看:谁在用这个调度器

直接 grep 仓库,DPMSolverMultistepScheduler 的引用点只有三处,都在 vibevoice/modular/ 下:modeling_vibevoice.pymodeling_vibevoice_streaming.pymodeling_vibevoice_streaming_inference.py

真正把整条采样循环写出来的是 modeling_vibevoice_streaming_inference.py 里的 sample_speech_tokens

@torch.no_grad()
def sample_speech_tokens(self, condition, neg_condition, cfg_scale=3.0):
    self.model.noise_scheduler.set_timesteps(self.ddpm_inference_steps)
    condition = torch.cat([condition, neg_condition], dim=0).to(self.model.prediction_head.device)
    speech = torch.randn(condition.shape[0], self.config.acoustic_vae_dim).to(condition)
    for t in self.model.noise_scheduler.timesteps:
        half = speech[: len(speech) // 2]
        combined = torch.cat([half, half], dim=0)
        eps = self.model.prediction_head(combined, t.repeat(combined.shape[0]).to(combined), condition=condition)
        cond_eps, uncond_eps = torch.split(eps, len(eps) // 2, dim=0)
        half_eps = uncond_eps + cfg_scale * (cond_eps - uncond_eps)
        eps = torch.cat([half_eps, half_eps], dim=0)
        speech = self.model.noise_scheduler.step(eps, t, speech).prev_sample
    return speech[: len(speech) // 2]

这段是仓库代码原文。读法是:正条件和负条件在 batch 维拼在一起,prediction_head(也就是 diffusion head)一次前向同时算出两份输出,再按 classifier-free guidance 的写法合成一份 eps,最后交给 noise_scheduler.step。调度器在这里的角色很单纯——它不碰模型,只负责”走几步”和”这一步从 x_t 走到哪个 x_{t-1}“。签名里的 cfg_scale=3.0 是仓库当前代码里的默认值,随版本可能变动;它的实际取值由上游 generate 一路传下来(modeling_vibevoice_streaming_inference.py 里那个函数签名上写的是 cfg_scale: float = 1.0,两处默认值本身就不一致,这一点代码里没有解释)。

调度器是在哪儿被 new 出来的

VibeVoiceStreamingModel.__init__ 里只传了三个参数:

self.noise_scheduler = DPMSolverMultistepScheduler(
    num_train_timesteps=config.diffusion_head_config.ddpm_num_steps,
    beta_schedule=config.diffusion_head_config.ddpm_beta_schedule,
    prediction_type=config.diffusion_head_config.prediction_type
)

modeling_vibevoice.py 里是同样的三行。也就是说,dpm_solver.py 那个长长的 __init__ 里除了这三项,其余全部吃默认值——solver_orderalgorithm_typesolver_typelower_order_finalfinal_sigmas_typetimestep_spacing 这些都没有从 config 走。想知道它们是什么,只能去 vibevoice/schedule/dpm_solver.py 的构造函数签名里看,那是仓库当前代码里的默认值,随版本可能变动。

三个来源字段定义在 vibevoice/modular/configuration_vibevoice.pyVibeVoiceDiffusionHeadConfig 里:ddpm_num_stepsddpm_beta_scheduleprediction_type,同一个类里还有 ddpm_num_inference_stepsddpm_batch_mul。注意 ddpm_num_inference_steps 并不流向调度器构造,它是被 VibeVoiceStreamingForConditionalGenerationInference.__init__ 读走存成 self.ddpm_inference_steps,再由 set_ddpm_inference_steps(num_steps=None) 覆盖,最后才在 sample_speech_tokens 里传给 set_timesteps。构造期和推理期的两个”步数”是两回事,混起来看会很晕。

set_timesteps 到底改了什么

set_timesteps(num_inference_steps, device, timesteps) 的文档串写明”要在推理前运行”,而 step() 开头有一段硬检查:num_inference_stepsNone 时直接抛 ValueError,提示你先跑 set_timesteps。所以顺序是死的。

这个方法里值得记住的几件事:

  • num_inference_steps 和自定义 timesteps 只能二选一,两个都传或都不传都会抛 ValueError;传了自定义 timesteps 又开着 use_karras_sigmasuse_lu_lambdas,也会各自抛错。
  • 时间步的排布由 timestep_spacing 决定,代码里只接受 linspaceleadingtrailing 三种,其它值抛 ValueError
  • final_sigmas_type 只接受 zerosigma_min,前者把最后一个 sigma 设成 0。
  • 收尾时它会把 model_outputs 重置成 [None] * solver_orderlower_order_nums 归零、_step_index_begin_indexNone,并把 sigmas 搬到 CPU(注释写的是避免过多 CPU/GPU 通信)。

把最后这条和上面那段采样循环放在一起看:sample_speech_tokens 每进来一次就重新调一次 set_timesteps,于是队列里上一段留下的 model_outputs 与两个下标计数器都被清成初始状态——多步求解器是带状态的,这一点代码里写得很清楚,至于为什么把清理动作放在这个位置,仓库里没有写明理由。

step() 内部走了哪几层

step(model_output, timestep, sample, generator=None, variance_noise=None, return_dict=True) 大致是四段:

第一段,定位。 self.step_indexNone 时调 _init_step_index(timestep);如果 begin_index 没被 set_begin_index() 设过,就走 index_for_timestep()self.timesteps 里找这个 timestep 的下标,命中多个时取第二个(注释说明这是为了从去噪中途起步的场景不漏掉 sigma)。之后每步结尾 self._step_index += 1传进来的 timestep 实际上只在第一次调用时起作用。与之呼应的是,convert_model_outputdpm_solver_first_order_update 这几个方法里旧的位置参数 timestep / prev_timestep / timestep_list 都挂了 deprecate(...),提示信息逐字写明这些参数”已废弃且没有效果”,改由内部计数器 self.step_index 接管。

第二段,转换。 convert_model_outputalgorithm_type 分两大支:dpmsolver++ / sde-dpmsolver++ 这支要的是数据预测(x0),dpmsolver / sde-dpmsolver 这支要的是噪声预测。每支内部再按 prediction_typeepsilonsamplev_prediction 三种,其它值抛 ValueError。VibeVoice 的 VibeVoiceDiffusionHeadConfigprediction_type 的默认值是 "v_prediction"(这是仓库当前代码里的默认值,随版本可能变动),走的是第三支。文档串里还挂了一个 <Tip>,写明”算法类型和模型类型是解耦的”。

第三段,滑窗与降阶。 转换后的输出被推进 self.model_outputs 这个长度等于 solver_order 的队列(老的往前挪,新的放队尾)。然后判定该用几阶:

lower_order_final = (self.step_index == len(self.timesteps) - 1) and (
    self.config.euler_at_final
    or (self.config.lower_order_final and len(self.timesteps) < 15)
    or self.config.final_sigmas_type == "zero"
)

这里有一处把两段代码放在一起才看得出来的东西:final_sigmas_type 在构造函数签名里的默认值是 "zero"(同样是仓库当前代码里的默认值,随版本可能变动),而这个条件里 final_sigmas_type == "zero" 是个独立的 or 分支——只要不改这个默认值,最后一步就一定走一阶更新,跟推理步数是多是少无关len(self.timesteps) < 15 那个条件在这种配置下并不是必要条件。代码就写在那里,仓库里没有对此做进一步说明。

第四段,更新。solver_orderlower_order_nums 和上面两个降阶标志,分发到 dpm_solver_first_order_update(文档串写明等价于 DDIM)、multistep_dpm_solver_second_order_updatemultistep_dpm_solver_third_order_update。二阶那支还会再按 solver_typemidpointheun 两种写法。SDE 那两种 algorithm_type 需要额外的 noisestep() 里会用 randn_tensor 生成,或者直接用你传进来的 variance_noise;两个 update 方法里对应位置是 assert noise is not None。返回值默认包成 SchedulerOutput(prev_sample=...)return_dict=False 时返回单元素 tuple——sample_speech_tokens 取的是 .prev_sample

demo 里把调度器换了一遍

demo/web/app.py 加载完模型之后有这么一段:

self.model.model.noise_scheduler = self.model.model.noise_scheduler.from_config(
    self.model.model.noise_scheduler.config,
    algorithm_type="sde-dpmsolver++",
    beta_schedule="squaredcos_cap_v2",
)
self.model.set_ddpm_inference_steps(num_steps=self.inference_steps)

两点值得对着 dpm_solver.py 读。一是 beta_schedule 换成了 squaredcos_cap_v2,而 VibeVoiceDiffusionHeadConfigddpm_beta_schedule 当前默认给的是 "cosine"(默认值随版本可能变动)——回到 dpm_solver.py 的构造函数,这两个字符串命中的是同一个分支elif beta_schedule == "squaredcos_cap_v2" or beta_schedule == "cosine"),走的都是 betas_for_alpha_bar(..., alpha_transform_type="cosine")。二是 algorithm_type 换成 SDE 版本,于是每步会走上面说的 randn_tensor 那条路。

顺带一个文档与代码不一致的点:DPMSolverMultistepScheduler 的类文档串里 beta_schedule 只写了 linearscaled_linearsquaredcos_cap_v2 三个可选值,但构造函数实际还接受 cosinecauchylaplace,后两者对应 betas_for_alpha_bar 里的 alpha_transform_type 分支。以代码为准。

另外必须照实标出来的:构造函数开头对 algorithm_type in ["dpmsolver", "sde-dpmsolver"] 调了 deprecate(...),消息逐字写明这两个类型已废弃、将在未来版本移除,让你改用 dpmsolver++sde-dpmsolver++。还有 deis 这个值不会报错,而是被静默 register_to_config(algorithm_type="dpmsolver++") 改掉;solver_typelogrho / bh1 / bh2 同样被静默改成 midpoint。这类”看着接受了、其实换了一个”的行为,调参时不看源码是发现不了的。

训练那一侧,以及 timestep_sampler.py 的位置

同一个调度器还有两个方法给训练用:add_noise(original_samples, noise, timesteps)get_velocity(...)modeling_vibevoice.py 里算 diffusion loss 时先用 add_noise 造带噪样本,再按 prediction_type 选目标——epsilon 时目标就是 noisev_prediction 时目标由 get_velocity 算,其它值 raise NotImplementedError,最后取 F.mse_loss

那里的时间步是现场抽的:

timesteps = torch.multinomial(
    torch.ones(self.config.diffusion_head_config.ddpm_num_steps),
    speech_len * ddpm_batch_mul,
    replacement=True,
).to(hidden_states.device)

vibevoice/schedule/timestep_sampler.py 里定义了两个类,接口都是 sample(batch_size, device)UniformSamplertorch.randint(0, self.timesteps, ...)LogitNormalSampler 先在 [0, 1] 上取 linspace 算 logit、构造一个正态形状的概率向量 self.prob,再用 torch.multinomial(..., replacement=True) 抽索引。

把两处放在一起:这两个类在仓库里我们没有找到任何其它引用点,训练路径用的是上面那段就地写死的 multinomial。两者在形式上做的是同一类事(在 ddpm_num_steps 范围内抽一批时间步),但走的是两条独立实现。原因代码里没有写,这里就到此为止。

读这一块时容易踩的地方

  • vibevoice/schedule/__init__.py 是空文件,导入要写全路径 from vibevoice.schedule.dpm_solver import DPMSolverMultistepScheduler,仓库里三个引用点都是这么写的。
  • 这两个文件是纯 Python + PyTorch 计算,里面没有任何平台相关分支,Windows 与 Linux/macOS 在这一层没有差别;平台差异要看的是环境与依赖那一层,不在本文范围。
  • 文件顶部的 DISCLAIMER 注释写明它强烈参考了 github.com/LuChengTHU/dpm-solver,多处方法上还带着 # Copied from diffusers.schedulers... 的标记。你在别的扩散项目里见过几乎一样的代码不是错觉。
  • 想改采样行为,改点在三个地方:config 里那几个 ddpm_* 字段、set_ddpm_inference_steps 的入参、以及像 demo 那样用 from_config 重建调度器。绕开这三处直接改 self.config.xxx 未必生效——register_to_config 的配置读的是 self.config,而 sigmaslambda_t 这些张量是在 __init__ 里一次性算好的。

以上代码片段均摘自仓库文件原文(长函数有截断);未经实测,以仓库最新代码为准。


本文依据 github.com/microsoft/VibeVoice 仓库与 Hugging Face 模型卡于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有下载权重、没有跑过推理、也没有做过训练, 因此不涉及显存占用、推理速度、识别准确率与音质的任何描述,也不与其它模型做比较或排名。 该项目持续更新,文中涉及的模块路径、配置字段与接口写法随版本变动,请以仓库最新内容为准。

需要说明的是:仓库 README 记载,2025-09-05 微软因发现有与既定意图不符的使用方式, 基于负责任 AI 原则从该仓库移除了 VibeVoice-TTS 代码; 当前 vibevoice/modular/modeling_vibevoice.py 首行注释标明其来自社区 fork, 且该模块未被 vibevoice/modular/__init__.py__all__ 导出。 本文只讲代码与架构,不构成 TTS 推理的可用性保证。

仓库 README 的风险与限制一节写明:该模型仅供研究与开发用途, 未经进一步测试与开发不建议用于商业或真实场景,并特别提示了合成语音被用于伪造与虚假信息的风险。 使用合成语音时应遵守所在司法辖区的法律法规,并在分享 AI 生成内容时主动披露。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。