运行时
引擎怎样在 Mizuoto 的进程里运行:运行时的各部分(设备、模型句柄与管理器、层、注意力、采样),请求上下文,引擎不得改动全局状态的规则,LocalEngine,内存的各个层次,取消与超时,流式,远程引擎,以及查看这一切的命令。
每个引擎都在服务器自己的进程里运行(在命令行上则在命令自己的进程里),基于 Mizuoto 自己的运行时:mizuoto/runtime/,一个仿照 ComfyUI 模型管理方式写的小框架。核心只通过 mizuoto/core/inference/ 接触它。本页讲两边:引擎从运行时得到什么,以及核心怎样调用一个已加载的模型。怎样编写引擎见接入引擎。
谁在哪里
| 部分 | 代码 | 作用 |
|---|---|---|
InferenceService | mizuoto/core/inference/service.py | 第一次用到时按 compute 设置建立 Runtime;回答本机实现了哪些引擎、某个引擎缺什么(torch、mizuoto-fishspeech 等)、它的附加依赖和训练器 |
RuntimeService | mizuoto/core/inference/runtime.py | 每个(引擎,设备)一个引擎,按需创建;loaded() 给出模型的 EngineHandle;max_resident_models、空闲回收、runtime_changed、doctor |
LocalEngine / LocalHandle | mizuoto/core/inference/local.py | 本进程中一个设备上的一个引擎,以及它某个已加载模型的句柄 |
RemoteEngine / RemoteEngineHandle | mizuoto/core/remote/client.py | 同上,用于名为 <主机>/<设备> 的设备:与另一台机器上 mizuoto agent 的会话 |
EngineHandle、EngineRequest、ModelRef | mizuoto/core/inference/engine.py | 核心持有和传递的东西:模块顶层只用标准库 |
| 任务类型 | mizuoto/core/inference/tasks.py | SynthesisTask、VoiceSpec、Reference、LoadSpec、AudioChunk、Description 等(标准库数据类) |
Engine、LoadedEngine、注册表 | mizuoto/runtime/engine.py | 引擎契约;BUILTIN 和 mizuoto.engines 入口点 |
RequestContext | mizuoto/runtime/context.py | 每次调用一个:取消、生成器、进度、文本片段、临时目录 |
Runtime | mizuoto/runtime/runtime.py | Engine.load 收到的东西:devices(DeviceService)、manager(ModelManager)、options(RuntimeOptions) |
TtsService、AsrService 和音色服务从不直接接触 LocalEngine:它们调用 RuntimeService.loaded(ModelRef, device, dtype, options, request),使用它返回的句柄。本地还是远程对它们没有区别。
运行时
InferenceService.runtime 在第一次加载模型时为每个进程建立一个 Runtime,所以从不加载模型的命令不会导入其中任何部分。
设备:device.py
DeviceService 相当于 ComfyUI model_management.py 中设备相关的那一半,只是做成了对象而不是模块全局变量。设备名用 Mizuoto 的写法:cpu、cuda:N(NVIDIA,或 ROCm 上的 AMD)、xpu:N、mps;解析 auto 是核心的事(ComputeService.resolve_device)。引擎拿到的是真正的 torch.device;没有任何东西去收窄 CUDA_VISIBLE_DEVICES。
memory(device):(空闲,总量)字节数;在 CUDA 上,torch 占着但没用的显存也算空闲。empty_cache(device)、synchronize(device)。supports(device, dtype)和pick_dtype(device, requested, supported, cpu):组件用哪种精度运行,依据用户的engines.<id>.dtype和引擎给出的偏好顺序(CPU 上除非指定都用 fp32;GTX 16xx 显卡不用 fp16)。is_oom(error):一个异常是否表示设备内存不足,不导入 torch。float64_device(device):以 float64 计算的模块在哪里运行——该设备,或在没有 float64 的设备(MPS、不支持 fp64 的 XPU)上改用 CPU。pin_to_cpu(module)让这样的模块在父模块移动时留在 CPU 上;CosyVoice 3 的 F0 预测器用到这两者。probe_devices():torch 看到的加速器(CUDA、通过 HIP 的 ROCm、XPU、MPS)及其显存。核心的设备探测(mizuoto/core/compute/probe.py)先询问nvidia-smi、rocm-smi/amd-smi和平台,只有在已安装的构建可能找到它们没找到的 GPU 时,才在子进程中询问 torch(python -m mizuoto.runtime.device);运行时加载了 torch 之后则在本进程中询问。- 引擎的
DEVICES(默认为所有设备类型)与其清单的devices一致;在 MPS 上torch.compile不生效,按原样运行。 torch_build():已安装的 torch 是为什么构建的、本机哪些加速器可用(mizuoto doctor用它)。
模型句柄与模型管理器
ModelHandle(handle.py,相当于去掉扩散部分的 ComfyUI ModelPatcher)包装一个带权重的 nn.Module:name(显示在已加载模型列表中,如 omnivoice/omnivoice:model)、load_device、offload_device(CPU)、kind(自回归语言模型为 sequence,其他为 oneshot)以及各项大小(size_bytes、loaded_bytes、streamed_bytes、host_bytes)。引擎只读取 module;LoadedEngine 在 self.handles 中列出它的句柄。
ModelManager(manager.py,相当于 ComfyUI 的 load_models_gpu / free_memory)记录每个已注册句柄的所有者(<model id>@<device>)和最后使用时间,并在请求的每个阶段(ctx.use、ctx.run)把句柄整体或部分放到设备上:空闲组件逐个单元让出显存,放不下的模型把部分权重留在内存中、在各层运行时流式加载到设备,显存不足的阶段以更多权重流式加载的方式精确地重新运行。策略(policy.py,VramPolicy)在每次加载时由 compute 设置得出。
| 方法 | 用途 |
|---|---|
register(owner, handles, mode, pinned) / unregister(owner) | 加载之后;卸载模型时(直接释放,不复制回来) |
use(handles, memory_required) | 包在每个阶段外;没有 PHASED 的引擎则包住整个调用 |
after_oom(handles) | ctx.run 重试前的清理 |
release_idle(device)、drop_host_copies() | 腾空一块 GPU(微调前、显存不足后);内存不足时 |
resident()、owner_usage(owner)、owner_placements(owner) | 已加载模型列表、meta.memory 及其 partial 报告 |
阶段、预算与显存模式、单元与流式加载、主机副本、让出显存、精确重试、工作显存估算及其校准,以及引擎要怎样配合:见显存与内存。
在 CPU 上什么都不移动、不计数;除非 DeviceService(simulate={"cpu": bytes}) 让它扮演一块 GPU(测试与一致性验证工具:compute.simulate_vram_mb)。
层、注意力、加载
ops.py:每个引擎都用ops命名空间而不是torch.nn来搭建模块。ops.default里是同样的层,但不做随机初始化:构造一个大模型不花时间,也不抽随机数,所以加载永远不会碰到任何随机数生成器。ops.cast还会在每次前向时把权重转换成输入的精度和设备,用于以一种精度存储、以另一种精度计算的组件。init_random(module, generator)在测试里给default模块填上真正的权重。accel/:原生代码中的每一次注意力计算都写作accel.attention(q, k, v, *, site=SITE, mask=…, causal=…, scale=…, enable_gqa=…, dropout_p=…, mask_info=…),从不直接调用 SDPA(架构测试禁止在runtime/accel之外这样做)。每个Site保存它的参照计算,即上游在该处的精确计算;compute.attention = auto(默认)时运行参照计算,结果逐位相同。其他取值会绑定一个策略,按调用在 SDPA 的各内核、FlashAttention、xFormers 和 SageAttention 之间选择,带兼容性检查和回退;features.apply设置主机的compute.fast开关,也是原生代码中唯一设置torch.backends标志的地方。见加速。loading.py:load_state_dict(safetensors,或以weights_only=True读取的 pickle)、strip_prefix、replace_prefixes、fold_weight_norm,以及默认严格的load_into:缺少或多出的键都是错误,并写明是哪个键,除非引擎把它列为可忽略。sampling.py:hf_sample/hf_process,按 transformers 的顺序执行它的 logits 处理器(重复惩罚、最少新 token、屏蔽、温度、top-k、top-p),然后是multinomial1:在请求的生成器上执行torch.multinomial自己的算法,但不等待设备,所以带种子的抽样结果一致(无同步采样)。lm/:QwenDecoder(带 transformers 数值行为和参数名的 Qwen2/Qwen3 解码器,以及mask_for、hf_sdpa)、DynamicCache、需主动开启的BufferCache(compute.kv_cache = static),以及用于工作显存估算的peak_bytes。graphs.py:Graphs、LayerSteps、DecoderSteps:把解码步骤捕获为 CUDA Graph,重放即时执行的内核(compute.cuda_graphs),见 CUDA Graph。convert/onnx.py:直接从 protobuf 线格式读出 ONNX 图的节点和张量,不需要 onnx 包(CosyVoice 的语音分词器和 CAM++、GPT-SoVITS 的 G2PW 用到)。compile.py:compute.compile打开时,Compiled/compile_module(module, enabled, name)把一个热点模块包进torch.compile(dynamic=True);引擎调用compiled or module,编译器出错时改为即时执行。见torch.compile。policy.py、units.py、stream.py:显存策略、模型可流式加载的单元,以及每个设备一个的Streamer(显存与内存)。snapshot.py:snapshot()对引擎调用必须保持不变的进程级状态做摘要;契约套件在请求前后比较它。
引擎共用的构件放在运行时旁边:mizuoto/nn(与 torchaudio、librosa、Whisper 逐位一致的 DSP,流模型,transformer,声码器,编解码器,SSL 和说话人编码器)和 mizuoto/text(对 Mizuoto 运行的模型与 transformers 结果一致的 BPE 分词器、Whisper 词表、wetext 规范化器)。
请求上下文
对引擎的每次调用都有一个 RequestContext(mizuoto/runtime/context.py):
| 成员 | 用途 |
|---|---|
check_cancel()、cancelled() | 协作式取消。check_cancel() 抛出 Cancelled,它是 BaseException,模型代码里的 except Exception 吞不掉它。引擎在每个 token 或每个 ODE 步调用它 |
bind() | 把上下文设为当前线程的请求;层里于是可以直接调用模块级的 check_cancel(),不必传参 |
generator_for(device)、generator | 请求在某个设备上的 torch.Generator,每个设备一个,用请求的种子初始化(没有种子则用新的熵) |
rng | 请求的 random.Random,同样方式初始化 |
reseed(seed) | 让请求的随机流从头开始(像 GPT-SoVITS 上游那样每段重新播种的引擎会用) |
progress(stage, value, message) | loading:<部分>、generating 等 |
applied、warn(message) | 引擎实际使用的参数值和它的警告:两者都随结果返回 |
text_pieces() | 客户端发来的流式文本(SynthesisTask.text_stream):一个真正的生成器,可以在别的线程上消费,取消时提前结束 |
tmpdir | <cache>/scratch/<引擎>/ 下的临时目录,调用结束时删除 |
use(handles, memory)、run(name, handles, fn, memory, generators, fallback) | 请求的一个阶段:放上它的组件并为其工作显存留出空间;run 另加显存不足后的精确重试和可选的回退(显存与内存) |
accel | 模型的注意力策略,随上下文绑定(加速) |
log、device、seed、request_id |
全局状态
引擎与服务器、彼此以及测试共用一个进程,所以原生代码不改动任何进程级状态。test_native_code_leaves_global_state_alone 在 runtime、nn、text、各引擎和需要接受许可的包里禁止:
- 给全局随机数生成器播种:
torch.manual_seed、torch.cuda.manual_seed(_all)、random.seed、np.random.seed; os.chdir、sys.path.append/insert、os.putenv、给os.environ赋值;logging.basicConfig、torch.set_default_dtype、torch.use_deterministic_algorithms、给torch.backends.*和torch._inductor.config赋值。
随机数改从 ctx.generator_for(device) 和 ctx.rng 取。这也是对照得以成立的前提:一个用种子 s 初始化的全新 torch.Generator,与 torch.manual_seed(s) 之后 torch 的默认生成器产出完全相同的序列,在 CPU 和 CUDA 上都是如此(scripts/parity/check_rng.py)。所以一个引擎只要在请求的生成器上按上游的顺序做上游的抽样,就能逐次复现上游带种子的运行。上游在构建或准备某个东西时从全局生成器抽过随机数的地方,移植版会在私有生成器上重放这些抽样,而不去碰全局生成器。
训练阶段是唯一的例外:它们在自己的进程里运行,并在那里给进程级生成器播种(见训练器)。
LocalEngine:在本进程中调用模型
RuntimeService.engine(engine_id, device) 在第一次用到时创建一个 LocalEngine,并把它作为显存持有者 engine:<引擎>:<设备> 登记到 ComputeService。它的状态是 stopped(什么都没加载)、ready、busy,或者引擎实现导入失败时的 crashed。
加载(LocalEngine.load):
start()用get_engine(id)找到实现(导入它的模块,不导入 torch)。make_room(model_id):运行时服务卸载超出compute.max_resident_models的、最久未用的空闲模型(固定的模型保留)。- 构造
LoadSpec(model_id, model_dir, variant, device, dtype, options);options合并了引擎的加载选项和model_options(model_id),后者把模型所需共享资源的目录放进options["assets"]。 - 在绑定了上下文、不在
torch.inference_mode()里的情况下运行Engine.load(spec, runtime, ctx);句柄登记到管理器,describe()的结果保存为句柄的description。
调用(LocalHandle.prepare_voice、synthesize、convert、edit、call):每次都在调用线程(某个任务的线程)上、在 torch.inference_mode() 里运行,上下文已绑定,模型的句柄由 manager.use(...) 占住。种子取自任务的 params["seed"]。
为什么加载不在推理模式里
这是对照 E2-TTS 与上游时发现的一个 CPU 内核陷阱:F.linear 作用于跨步(非连续)的三维输入时,若权重需要梯度,或权重是推理张量(在 torch.inference_mode() 里创建的),就会走另一个内核,即使在 no_grad 下也是如此。上游的权重是普通的、需要梯度的参数,所以原生引擎保持 nn.Module 创建参数时的样子(不调用 requires_grad_(False)),模型也在推理模式之外加载。请求本身仍在推理模式里运行。两个陷阱的细节见引擎参考。
流就是生成器
synthesize、convert 和 edit 返回产出 AudioChunk 的生成器。LocalEngine.stream 惰性地驱动引擎自己的生成器:只有调用方索取下一块时引擎才运行,也只有这时它才绑定到线程、处在推理模式里。每一块都会被检查(必须是 AudioChunk),转成 float32 单声道 numpy 采样再交出去;第一块设定 timings["first_chunk"]。调用方关闭流(contextlib.closing)就能提前停止,引擎的生成器也随之关闭。TtsService 逐块取出,客户端流式接收时再把每块转给 StreamSink。
取消与超时
调用方传入一个 EngineRequest,带着它的 cancel 事件(任务的 cancel_event)、可选的 timeout、流式文本队列(text_input,由 TextFeed 填充)和 progress 回调。一个小的监视线程每 20 毫秒检查一次:把调用方的取消转到上下文的取消事件上;超时则把这次调用标为超时并取消它。引擎在下一次 check_cancel() 时停下;调用随之以 CancelledError 失败,超时则是 OperationTimeoutError。调用结束时,句柄把 applied、warnings 和 timings["total"] 写回 EngineRequest。
错误
引擎抛出的东西会变成核心的错误:
EngineError(message, detail, code)保留它的错误码(error_from_code),并在 detail 里加上引擎和设备;Unsupported是capability_not_supported。- 内存不足(
is_oom)时,把该设备上所有空闲组件移走(release_idle),并以compute_unavailable失败,detail.reason = "out_of_memory";任务服务随后卸载所有 GPU 上未在使用的模型(release_after_oom)。 - 其他任何异常都是
engine_error,detail 里(以及日志里)有异常名和回溯。
内存
内存在三个层次上管理:
| 层次 | 谁 | 做什么 |
|---|---|---|
| 组件 | ModelManager | 调用的每个阶段之前,同一设备上的空闲组件逐个单元让出显存腾地方;放不下的部分从内存流式加载(显存与内存) |
| 模型 | RuntimeService | 所有引擎合计最多保留 compute.max_resident_models(默认 3)个已加载模型;加载前卸载最久未用的空闲模型。服务器的回收线程(每 10 秒)还会卸载空闲超过 compute.unload_after_minutes(默认 20;0 表示从不)或 engines.<id>.idle_minutes 的模型。engines.<id>.pinned_models 既不会为腾地方而卸载,也不会因空闲而卸载 |
| 进程 | ComputeService | 每个 LocalEngine 都是一个显存持有者:释放显存(POST /api/v1/compute/release)卸载所有空闲持有者的全部模型并移走剩下的东西;有持有者忙时拒绝 |
不会因为对显存的估算而拒绝加载模型:模型总是尝试加载,只有显存分配真正失败才算错误。任务因显存不足失败时(精确重试之后),ComputeService.release_after_oom 卸载所有 GPU 上未在使用的模型(即 ComfyUI 的 unload_all_models);CPU 上的模型保留。在这一层之下组件怎样共用一个设备,见显存与内存。
任何 engines.<id>.* 设置的改动都会卸载该引擎的模型(unload_engine),下一个请求用新的设备、精度或加载选项重新加载。
远程引擎
当一个引擎的设备是 <主机>/<设备>(例如 engines.qwen3tts.device = "gpu-box/cuda:0")时,RuntimeService.engine 向 RemoteService 要一个 RemoteEngine:与那台主机上的 mizuoto agent 之间、针对一个设备上一个引擎的会话。agent 是一个完整的 Mizuoto,有自己的数据目录、模型和运行时;它在自己的进程里运行引擎,和主机完全一样。RemoteEngineHandle 与 LocalHandle 回应同样的调用。面向用户的配置见远程引擎。
协议 2(mizuoto/core/remote/wire.py 的 AGENT_PROTOCOL)在“已加载引擎”这一层工作。帧格式来自 mizuoto/core/remote/frames.py(4 字节长度、一个类型字节、JSON 正文,或 JSON 头加原始字节),走 TCP 或 TLS:
- 主机连上后发送
hello,带令牌(用hmac.compare_digest比较)和协议版本。协议不同的 agent 会拒绝主机,提示在两台机器上安装相同版本的 Mizuoto;主机也同样拒绝这样的 agent。否则 agent 回复它的AgentInfo:版本、设备、那里哪些引擎可用,以及已安装的模型。 open {engine, device},然后依次执行load、prepare_voice、synthesize、convert、edit、call、unload和status;cancel和text(流式文本)随时可以发。- agent 对每个操作先回
progress、chunk(二进制帧:float32 小端单声道)和file帧,最后是result或error,并每 5 秒发一次心跳。超过remote.heartbeat_timeout(60 秒)收不到任何帧,主机就以worker_failed(during = "heartbeat")关闭会话;下一个请求重新连接。
按内容传文件。 路径在另一台机器上没有意义。请求里提到的每个文件都变成 mizuoto-file:<sha256>/<文件名>:have 询问 agent 缺哪些,put 以 8 MiB 的帧发送缺的那些,并核对 SHA-256。agent 把它们存在 <cache>/remote/files/,一个月没有主机要过的文件会被删除。prepare_voice 写入的目录变成 mizuoto-out:…,其中的文件作为 file 帧传回主机自己的目录。
主机依据自己的模型目录和 agent 的模型列表做规划;agent 自己处理它的显存,训练始终在本机进行。
查看:mizuoto runtime 与 mizuoto doctor
mizuoto runtime # 正在运行的服务器加载了哪些模型:引擎、设备、字节数、忙或空闲
mizuoto runtime --unload # 卸载所有没在用的模型
mizuoto runtime qwen3tts/1.7b-base --unload
mizuoto doctor # torch 与硬件是否匹配、每个引擎、附加依赖
mizuoto doctor gptsovits --jsonmizuoto runtime询问正在运行的服务器(通过数据目录里的server.json找到它),因为模型在服务器进程里;没有服务器时报告命令自身进程的情况。对应GET /api/v1/runtime和POST /api/v1/runtime/unload,以及 Web UI 的内存中的模型面板(LoadedModelsSheet)。mizuoto doctor [ENGINE]就是RuntimeService.doctor:已安装的 torch 构建与本机硬件(CUDA、ROCm、XPU、MPS、CPU)是否匹配,并给出安装正确构建的命令(mizuoto/core/compute/torch_index.py);每个引擎可用还是缺失(以及缺什么);导入不了的可选附加依赖(Engine.EXTRAS);以及训练器缺的依赖。对应GET /api/v1/runtime/doctor和GET /api/v1/engines/{id}/doctor。