diffusers-workflow 0.11.0__py3-none-any.whl → 0.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffusers-workflow
3
- Version: 0.11.0
3
+ Version: 0.11.1
4
4
  Summary: Declarative workflow engine and web UI for Hugging Face Diffusers
5
5
  Author: dkackman
6
6
  License: Apache-2.0
@@ -1,4 +1,4 @@
1
- diffusers_workflow-0.11.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
1
+ diffusers_workflow-0.11.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
2
2
  dw/__init__.py,sha256=YKwvXdngbW8iY6xFcfVwDCeiD_oFyCdWb-rRVUogp0Y,19969
3
3
  dw/adapter_compatibility.py,sha256=MzNfxK-yOBrn6Mp90m_i_QY-N4zz4C0ZCvKIByUpXAw,10674
4
4
  dw/argument_media.py,sha256=fIsvlDRCuTz_DPH4MXFxBZiKTQfgR1vY0-wTU5P9p-I,15145
@@ -30,13 +30,13 @@ dw/log_setup.py,sha256=ATwSO0CCDtgEA-VoPsS8HVeuDwL2jBg--4QXSZwecQs,1478
30
30
  dw/lora_catalog.py,sha256=B6RRgdyY0NSCyAwMVoDS3_wxyqF9i3Q3JjEx-hCMVAI,4893
31
31
  dw/lora_catalog_schema.json,sha256=i-oh9JR8DbKoNxZ2ufnyPaTaNT8hpeY_I6I3Zrhrl_w,2120
32
32
  dw/lora_hub.py,sha256=Px2cV9yAQsKve6WZuvuQCOfgLWjJ1bJx0uDbxeK2kmQ,8500
33
- dw/media.py,sha256=rRxEDLvj1u_KU9XyLrXihVbbumXSJLvMGzAnWNksf5I,42281
33
+ dw/media.py,sha256=bfMHKcqE-STfyS6szwOR0jwpGZjgfwOONqEQF5X9Ul4,43047
34
34
  dw/media_frames.py,sha256=2WLLU4Z1NgN4rTxSVDHDs5VZVQqbHXMtOz-ekvonoAA,12789
35
35
  dw/media_types.py,sha256=GFnWr8Eq6jU1Y-wccXDKFO5MX-oePAjj8RsE23wvsp8,9770
36
36
  dw/outbound.py,sha256=Noaerzmzz-n23JGe9ciMuvo8QQZO-SBOK_gyFXB22Do,20508
37
37
  dw/output_extraction.py,sha256=m_uppccZeGPF2lb8UQLCrLHH1PQxmSniB8fuT5TMm_E,10341
38
38
  dw/path_redaction.py,sha256=GnZ154Q4X_bvmQEjTVvgcMmlnz1g8N6VkDLj91lLREM,4324
39
- dw/pipeline_ownership.py,sha256=rmdsYvbp7JqoHcZYl2HLNDgzSOUUozGjqWBLJTrnIKk,13078
39
+ dw/pipeline_ownership.py,sha256=wFzGE6H6tq-2Bzg0EcHkVaNgUU8f_V3HbMMfs1X5woM,13964
40
40
  dw/plan.py,sha256=PMu9bRE2tmY69ux6TOwa61f6WHsnC55blS95wZr9r8g,44509
41
41
  dw/previous_results.py,sha256=0ReOCNwkHVFhP9VIErvsREVVu3a9RItC33vxhqp4tYw,17361
42
42
  dw/probe_paths.py,sha256=iJgy4G256zELyzi4Aol4_qifejjZsUtdeEEDWryAB0o,2683
@@ -60,7 +60,7 @@ dw/shot_span_preflight.py,sha256=AZP1VY_J9KdjoGdgr3k-H149ojIQr00e6um3XXO3Ga8,431
60
60
  dw/shots.py,sha256=mLDI_FXHvrG8gIMcY_gJGDLMSUAB8xAomogjA66gcno,21616
61
61
  dw/slice_preflight.py,sha256=IJ1NWZPfFBgz_YKU8CvHilasAa6nEIUGu3XW8Vf4gHY,7492
62
62
  dw/step.py,sha256=ReYYvfQUKDVtSJ2bcShHDJEmga0YunRIYQV0k1ySSKc,10259
63
- dw/step_cache.py,sha256=aT51amck-da2_mOwYRCQXHkJAJJHLh0XPdivn2P8PWc,29096
63
+ dw/step_cache.py,sha256=VDJSH5W6ItzKzFzy0e5rEu7_Rt08WpzXJoyAgaZVbhU,33264
64
64
  dw/step_value_checks.py,sha256=60yn46QKVCq4uYwFwkT7z1LB-1C4PpnttlB3m-Yjiag,14212
65
65
  dw/subfolders.py,sha256=ZRRxMhHk1Yha9yt_5C_kJfe1YBWJYRba5n2w6Nx30xw,3738
66
66
  dw/task_domains.py,sha256=cxfTaECzTOCrFNZQdzgVme6yYk5Hr-P-nHnO8n_zTBo,35379
@@ -78,46 +78,46 @@ dw/video_size_errors.py,sha256=X6F0LukghxPbOonc7iKVfMNPig2Oiq4sghhhZsR1a4Y,4465
78
78
  dw/vram_estimate.py,sha256=eTOE9B_9G6sVjZj7mBp0HyxPi09C_uEjQm-qV-hfVOU,22790
79
79
  dw/vram_inheritance.py,sha256=JryUJ9k8DduQLKfX2MUkQDea7GsuRYdOty5_qHve2Fc,8369
80
80
  dw/window_count_errors.py,sha256=GIifaclu65kV0eiQFP1cscUuHJab80S3KFdP4Beho9k,4876
81
- dw/worker.py,sha256=I0KyzP5aHzubZFwkUd42iEtdMbruoFlc--JVNe-GzJc,35252
81
+ dw/worker.py,sha256=uh2DIBYxFIOT6vL8j3bxgdsmBUp-jqlZ-NOPmU8cMXA,37032
82
82
  dw/worker_manager.py,sha256=MgGZKf_49R8VbyVc5iQrGvCB2PM-rw-J44TyggaTp1Y,12046
83
83
  dw/worker_protocol.py,sha256=lz1__T2l6RB72_F7D9atLhEmKOtP7ae0Hydc0TrYTFE,6585
84
- dw/workflow.py,sha256=DGnzzh3mMiop3stp8-y4CqymIYHnUq0mJM6fwnReMGY,40176
85
- dw/workflow_run.py,sha256=FiiOu03NXq0w50w-DO1Pw5YIIqP_NP3BWT0vHYaTw0c,41042
84
+ dw/workflow.py,sha256=OyGzzZwtMeF4TLsHuqMTJXsSF1sKNqqNN4sizUjroQc,40640
85
+ dw/workflow_run.py,sha256=PzLhl-4ScKydyAwKtSeKnyYgtSOwpevwW2HrGjBw4cI,42778
86
86
  dw/workflow_schema.json,sha256=45oJbjnf3VDor5ZJi00-JJHDKSIduGO9OgD9gFNTjkc,71690
87
87
  dw/workspace.py,sha256=8OdAqHjIZGX8MLpuNqzEZcowFXNnHC9kKZot4KNUyyE,25831
88
88
  dw/writers.py,sha256=S2-c1zitCHrH7UY8r_O5AmpNFc1eB2IErbAOeSNeWI0,15427
89
89
  dw/community_pipelines/pipeline_flux_rf_inversion.py,sha256=XjLO58h5wQn16qP8wEIZKg2Y46mxzQ8qkUu29iQnrrA,50587
90
90
  dw/community_pipelines/pipeline_ltx2_refine.py,sha256=kvDBeiaDvt_BX2AjbN6nnYN0EZsEvMzvSC6WzorA_48,6619
91
- dw/docs/ACCELERATION.md,sha256=UxPUcxPWniJ0zN2Xn3HaPnV9DNhOGZGEswPNS0901-0,18509
91
+ dw/docs/ACCELERATION.md,sha256=qXqaOROm1RTjYATf1l3kEAnTdcy8aFM6bFCBmRmbI9c,19832
92
92
  dw/docs/AGENT_LOOP.md,sha256=rMb_c_r2ucsB7djwtQdp9_VVRagvhevWo-Iep7emmS8,4729
93
93
  dw/docs/ARCHITECTURE.md,sha256=WxoTTCSf1oTHdKGij27vL8Irghn5gKvCQ87981lDCUs,62415
94
94
  dw/docs/DEPENDENCIES.md,sha256=FKkPm98UeXYBcf26MUpaXkOeoeu3ADyMsC3oP3B4bn4,4455
95
95
  dw/docs/IP_ADAPTER.md,sha256=AAHaUIfbnOojHfT0noIeypbhdYPluR7sbZeVn5FpbjU,3730
96
- dw/docs/LORAS.md,sha256=lwug-7yQp7O1AHJfzv5jzvH8Vta4KMAt5G8m6khyMH8,6895
96
+ dw/docs/LORAS.md,sha256=iIdMH50hnZtspzG6fXkCkm5-wVmEncb2SvX8SfZyLik,7093
97
97
  dw/docs/MCP.md,sha256=_mja-bGds4S4bXHuasTxON1kttOE2RCp86wlRw7Iaok,57495
98
98
  dw/docs/PROMPT_WEIGHTING.md,sha256=Pvr25d3yvS8SlnYwX9qc61VvTa8yFMxK_1zMLvrEigI,3031
99
99
  dw/docs/QUANTIZATION.md,sha256=NZCL-09bDM-JhzV1WqK2JGkL1la8VoH9fie1X39arsE,7678
100
- dw/docs/RECIPES_24GB.md,sha256=Wy0clDoGhALMbYzO4nWxt-A63rqy7OppU5I-h77EPDo,16134
101
- dw/docs/RELEASING.md,sha256=KUA9N0LotzP-EnrUltF9OAlUin4q9lG3rvp1yTr7GtU,51857
100
+ dw/docs/RECIPES_24GB.md,sha256=Xu0JNK-PkTnoK0of_0CbjFn-oteVWg2OHNkJ8wLFifE,18867
101
+ dw/docs/RELEASING.md,sha256=7F5be8jR8u_cIa0TvDgJLSFwHVLpxG-9VbaM7Xuu9Tc,54218
102
102
  dw/docs/REMOTE.md,sha256=quum2Y54g2bp2rgXg9WkSPY3zhVGHEpMkEAiL6oAQDU,6650
103
103
  dw/docs/SECURITY.md,sha256=Y8eIMvJrYPmK6KsMQ9TY6UGE_IjwTU-8EuE13Ikc2uU,20002
104
104
  dw/docs/SECURITY_QUICKREF.md,sha256=-xZNIV6j1oWcoy5B-oHKqh3ko2zJgB2fuNMiDV4f1zY,3537
105
- dw/docs/SERVER.md,sha256=LDZXkxvg-DiRMgCxtvtpa8obeQVprNHpVn5X0VmAN78,57277
106
- dw/docs/TASKS.md,sha256=afCTS8bdw4r4KsBxjaGeHT1R9H0-Iunn1-gR8eJrJDc,176225
105
+ dw/docs/SERVER.md,sha256=UQodcMi5mHkuVQa9qGk2hfPP8bXUsDZD-jCb3OTDD48,57452
106
+ dw/docs/TASKS.md,sha256=gfSzqEJrSspxaewwol5GP0oTknKRqn0vegHpcehF43s,176358
107
107
  dw/docs/TESTING.md,sha256=D4zmQp0iVmREM9J1tq4RxTmmIxvxsp5dRtuRGovP96s,3973
108
- dw/docs/WORKER_GUIDE.md,sha256=FqzXVuH91XtonpBeXj_7WhMVz5JiB4Rqu2lnGBHchE4,2065
109
- dw/docs/WORKFLOW_GUIDE.md,sha256=YFlW0IIadffzL3pABM-w3ZAJawb9IMLL8hADiHLsx1Q,148366
108
+ dw/docs/WORKER_GUIDE.md,sha256=DHcOrk10Qbb_HBzPzXKsF5lULR4gZJQEWWaa9udx7Mc,2492
109
+ dw/docs/WORKFLOW_GUIDE.md,sha256=wbEvT96wUmOqTDdO0d_nsqzt5o9UjwyBa1D0uUpGodo,149357
110
110
  dw/docs/WORKSPACES.md,sha256=PBAuB4zSdPDkkNf8n2D2fc79qClBILpOnzx-CB63FWo,16672
111
- dw/pipeline_processors/adapters.py,sha256=HIDYa20vWTuD4XK8q4rntr9zZAHgbxkIPPQqeUVa0oQ,6638
111
+ dw/pipeline_processors/adapters.py,sha256=2i9XIbxeDfHWsB5RD4oUbExdg9WJnLTy4VFO8s8lcPE,9705
112
112
  dw/pipeline_processors/chain.py,sha256=2KscX3jQttJL6NmEfiQquPGmBjYIGPOaueHbDv0OUHw,41721
113
- dw/pipeline_processors/components.py,sha256=S7SjUIg7MOxM0fG67zZe5xJm7WBNfZzdkaTlsMdoaqo,36292
113
+ dw/pipeline_processors/components.py,sha256=6NNHoanDpQdiKbNVQ34Aw-B9coOlF8aDPTzXsGsaQho,37001
114
114
  dw/pipeline_processors/config_objects.py,sha256=nQYKx00544UzJFlEZ-th45b-8sav1hz3tKJaaBDNJns,10911
115
115
  dw/pipeline_processors/h3_guide_steps.py,sha256=wsaV7jFqcoZAEfjjyKYAMust3XgotqMkftlA-Ov5WrE,21580
116
116
  dw/pipeline_processors/h3_guides.py,sha256=DAhNOzOUD9i33Iopd-9nG3H5jaJPbDnEtqkP8-_GJDk,12445
117
117
  dw/pipeline_processors/h3_hold.py,sha256=dwpUuT8NuAVo77_4YVBAvn3G5oBW32e6M6i-cds66oU,9912
118
118
  dw/pipeline_processors/h3_hold_steps.py,sha256=mjoWAYHrVhQbpcXCzDVHhOXtCH50wXSUJjTmRhAGfzw,16437
119
119
  dw/pipeline_processors/h3_rules.py,sha256=Kpm4xeO7oiM2hdECjvnPg_NZTgCO-vBxs2jHtNW08iA,9244
120
- dw/pipeline_processors/pipeline.py,sha256=d8zXrmjrp99B44uglhOvjgloagvpMeMlJBtFtmWnF04,44789
120
+ dw/pipeline_processors/pipeline.py,sha256=93ABX25UQ3EfZg4eSk8DonfMv2z94Ww4rmyzLIkefKA,46141
121
121
  dw/pipeline_processors/placement.py,sha256=D-7ZMNs2P8t81CTtwc_APVrlk_8bz7Ek0M-Hb8wFhV4,16342
122
122
  dw/pipeline_processors/progress.py,sha256=uSKPsMug1AZqDoaFXsF5sRcJNiNLzbBuX3C3H3xVzms,9196
123
123
  dw/pipeline_processors/remote.py,sha256=eZl6fPEKUrd1ieFgX94QNKAR4ziPH9JXTV_Hse4VsqY,2450
@@ -293,7 +293,7 @@ dw/tasks/model_handlers.py,sha256=JbAVIZVQUr3r4y3p8HyQHKw0dUksRPpCPKsw13qwkX4,11
293
293
  dw/tasks/pair_audio.py,sha256=5q56WfKh5srvjLx_sjXBpl-Pk_0qvdMKULT-CJXKkY8,17842
294
294
  dw/tasks/qr_code.py,sha256=xyckWTixcMYasJPI5N7-co4jmyHwfScGWD2D1RbFzuM,548
295
295
  dw/tasks/registry.py,sha256=nk8mcqkpg-OBdiN4K7Go9lpJVR2mQREmxv4wTzB7k1c,9107
296
- dw/tasks/restore_faces.py,sha256=0gRhEofAlWw-e3X6mpMPzzEQsHQ5krtocOKPvsuBBMY,6440
296
+ dw/tasks/restore_faces.py,sha256=Q9TzuwWdT5H7kZxcgTCitCCLc8clI2T6Qd6azfaoKx0,6926
297
297
  dw/tasks/rife_model.py,sha256=eO77oSH4OGY5pXBp6UDaXIDbGPlOHPIiXMrf1RMzYjo,6387
298
298
  dw/tasks/script_check.py,sha256=S8YdJ1bPKdiYh-m5YmIXQ_r_aJGGel8TZquMHDYWXm0,28478
299
299
  dw/tasks/segment.py,sha256=2g-jndKDlIcVMH4OApWNlWzt-8DzQvhK82_TNMSXoTI,4267
@@ -333,8 +333,8 @@ dw_mcp/tools_catalog.py,sha256=kpa75deedQEFI90RHKjjqrNY9pFA4E5KtbtOogGwFtY,21962
333
333
  dw_mcp/tools_jobs.py,sha256=Hk7MNtd-GA-he6VIepOJHkLnHD594IEMNuriqj88MEk,12015
334
334
  dw_mcp/tools_media.py,sha256=YovyYnkRzhAjIQ54Sl12c-lOy9Jq5vrQWVRxd015ehU,16909
335
335
  dw_mcp/workspaces.py,sha256=OF6oVMQx_pkTuRMLvbOYkpVWwcA6GQwaEf5k0oxqBnI,8449
336
- diffusers_workflow-0.11.0.dist-info/METADATA,sha256=P7Mj4vsFRqR08lcGSSgaf0tdwSAW0ZW64ROEkzQDNnM,14939
337
- diffusers_workflow-0.11.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
338
- diffusers_workflow-0.11.0.dist-info/entry_points.txt,sha256=Dem_AOzV-Kcpr9AASlSvJ-S5sn3f6TQu6ABb6BbApho,148
339
- diffusers_workflow-0.11.0.dist-info/top_level.txt,sha256=o-uFAN_oUOrhXfMKO39HsMs2r19L9LykxoVKj2S9q7U,10
340
- diffusers_workflow-0.11.0.dist-info/RECORD,,
336
+ diffusers_workflow-0.11.1.dist-info/METADATA,sha256=_ghxhXxXWRSceZi1J8ZmUtvdVSWhep5QWSvAP4Mqwbw,14939
337
+ diffusers_workflow-0.11.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
338
+ diffusers_workflow-0.11.1.dist-info/entry_points.txt,sha256=Dem_AOzV-Kcpr9AASlSvJ-S5sn3f6TQu6ABb6BbApho,148
339
+ diffusers_workflow-0.11.1.dist-info/top_level.txt,sha256=o-uFAN_oUOrhXfMKO39HsMs2r19L9LykxoVKj2S9q7U,10
340
+ diffusers_workflow-0.11.1.dist-info/RECORD,,
dw/docs/ACCELERATION.md CHANGED
@@ -136,6 +136,18 @@ Select the attention implementation diffusers uses for the duration of each pipe
136
136
 
137
137
  Common values: `"flash"`, `"flash_hub"`, `"sage"`, `"sage_hub"`, `"native"`, `"flex"`. The full set is diffusers' `AttentionBackendName` enum - availability depends on what's installed (`flash-attn`, `sageattention`, etc.) and the platform. `_hub`-suffixed backends are fetched from the Hugging Face Hub kernel registry on first use, which needs the `kernels` package installed (`pip install kernels`) - it is not a dw dependency, and no bundled workflow sets a backend, so each runs on a plain install. diffusers also refuses a Hub kernel whose publisher it does not list as trusted - `sage_hub` among them, since SageAttention 2 comes from `SageAttention/sage-attention` - until `DIFFUSERS_TRUST_REMOTE_KERNELS=true`, which allows remote code from any such publisher. On an RTX 3090 `sage_hub` measured about 30% faster per MiniMax-H3 step (13.8 s against 19.2 s at 960x544x124, 54.7 s against 81.2 s at 345 frames); the local `sage` backend needs `sageattention>=2.1.1`, which PyPI does not carry.
138
138
 
139
+ The MiniMax-H3 baselines expose this as a variable: `attention_backend` on
140
+ [video-with-audio.json](../workflows/templates/minimax/video-with-audio.json),
141
+ [video-with-audio-768p.json](../workflows/templates/minimax/video-with-audio-768p.json),
142
+ [image-to-video.json](../workflows/templates/minimax/image-to-video.json) and
143
+ [reference-to-video.json](../workflows/templates/minimax/reference-to-video.json), pinned
144
+ on the transformer and `null` by default. Pass `attention_backend=sage_hub` to a box with
145
+ the `kernels` package and `DIFFUSERS_TRUST_REMOTE_KERNELS=true` in its `.env`; on MPS
146
+ leave it null. The other H3 templates take the same key under `components.transformer`
147
+ (`transformer_ref` for a ref2va workflow). LTX-2.5 cannot use it: its transformer
148
+ passes an attention mask, and sage attention refuses one ("`attn_mask` is not supported
149
+ for sage attention", measured 2026-10-10), so the LTX templates expose no backend.
150
+
139
151
  A component can also pin its backend persistently instead, via `set_attention_backend`:
140
152
 
141
153
  ```json
@@ -232,7 +244,11 @@ Three older per-component knobs, set in the pipeline `configuration` beside
232
244
 
233
245
  ## Memory Offloading
234
246
 
235
- `offload` (`"model"` or `"sequential"`) and `group_offload` trade speed for VRAM by streaming weights between system memory and the accelerator instead of keeping everything resident. `"model"` moves whole submodules and costs the least speed; `"sequential"` moves individual layers and is the slowest but uses the least memory; block/leaf-level `group_offload` sits between the two and is what a modular pipeline's self-loaded components use, since they aren't reachable in time for `offload`. Full configuration syntax is in [WORKFLOW_GUIDE.md](WORKFLOW_GUIDE.md#memory-offloading). Omit both for the fastest run, when VRAM allows it.
247
+ `offload` (`"model"` or `"sequential"`) and `group_offload` trade speed for VRAM by streaming weights between system memory and the accelerator instead of keeping everything resident. `"model"` moves whole submodules and costs the least speed; `"sequential"` moves individual layers and is the slowest but uses the least memory; block/leaf-level `group_offload` sits between the two and is what a modular pipeline's self-loaded components use, since they aren't reachable in time for `offload`. Full configuration syntax is in [WORKFLOW_GUIDE.md](WORKFLOW_GUIDE.md#memory-offloading). Omit both for the fastest run, when VRAM allows it. Measured on an RTX 3090 (RECIPES_24GB.md): sequential
248
+ offload costs about 6x per step on FLUX dev and 3.4x on Z-Image against model offload;
249
+ a block-level, streamed `group_offload` of the transformer from pinned host memory
250
+ matches the resident per-step cost while holding one block on the card, at ~22 GB of
251
+ pinned host RAM per FLUX pipeline - the placement the unquantized FLUX templates use.
236
252
 
237
253
  `"residency": "on_demand"` on a component is the cheap case of the same trade: the model rests in system memory and is moved to the device whole around each of its own calls. That is a bad deal for anything called once per step, and a good one for a VAE called twice a run - it frees the VAE's VRAM for the denoise loop at the cost of two transfers, where group offloading the same VAE would restream it once per decode tile. See [On-demand components](WORKFLOW_GUIDE.md#on-demand-components).
238
254
 
dw/docs/LORAS.md CHANGED
@@ -48,6 +48,10 @@ LoRA (Low-Rank Adaptation) models apply lightweight style or subject modificatio
48
48
 
49
49
  Any other property (e.g. `revision`) is forwarded as-is to the underlying `load_lora_weights()` call.
50
50
 
51
+ `scale` and `alpha` are set in place on the loaded adapter, so a server run that changes
52
+ only them reuses the warm pipeline instead of reloading it; changing `model_name` or
53
+ `weight_name` reloads.
54
+
51
55
  ## Multiple LoRAs
52
56
 
53
57
  Stack multiple LoRAs. They are blended via weighted adapter composition:
dw/docs/RECIPES_24GB.md CHANGED
@@ -12,23 +12,79 @@ The general recipe, in order of impact:
12
12
 
13
13
  ## Flux dev (12B)
14
14
 
15
- The bf16 transformer is ~24GB - it does not fit alongside the T5 encoder, and it does
16
- not fit alongside the VAE either: a resident bf16 transformer runs the denoise and then
17
- fails at decode. `offload: "model"` is what makes bf16 work on 24GB, and on an RTX 3090
18
- it is also the fastest configuration measured (1024x1024, 28 steps, pipeline loaded):
19
-
20
- | Approach | Config | Measured on RTX 3090 |
21
- | -------- | ------ | -------------------- |
22
- | **bf16 + model offload** | [flux-dev.json](../workflows/models/flux-dev.json) | 55s per image (72s cold) |
23
- | **bf16 + model offload + compile** | [flux-dev-compile.json](../workflows/models/flux-dev-compile.json) - `compile: {repeated_blocks: true}` on the transformer | 52s per image (64s cold) |
24
- | **int8 TorchAO** | transformer `quant_type: "torchao.quantization.Int8WeightOnlyConfig"` + `compile`, with or without `cache: first_block` | over 60s per *denoising step* - do not use on Ampere |
25
- | **float8 TorchAO** (RTX 40-series+) | `Float8DynamicActivationFloat8WeightConfig` + `compile` | needs compute capability 8.9+ (Ada); unmeasured |
26
- | **GGUF Q8** | [flux-gguf.json](../workflows/models/flux-gguf.json) - `from_single_file` Q8_0 transformer + `offload: "model"` | unmeasured; smallest VRAM, best quality retention |
27
-
28
- Measured 2026-09-07. A number in this table came from a run on the named card; a row
29
- without one is a configuration that loads, not a speed claim.
30
-
31
- **Examples:** [flux-dev-compile.json](../workflows/models/flux-dev-compile.json), [flux-gguf.json](../workflows/models/flux-gguf.json), [step-caching.json](../workflows/templates/step-caching.json)
15
+ The bf16 transformer is ~22 GB, so on 24 GB it cannot sit beside its own activations
16
+ with any margin, and it cannot sit beside the T5 encoder or the VAE at all. Three
17
+ placements, measured on an RTX 3090 (2026-10-10, cold load included):
18
+
19
+ | Placement | 1024x1024, 25 steps, 1 image, dev + LoRA | 768x768, 25 steps, 4 images, Krea | 1232x1632, 50 steps, Fill |
20
+ | --------- | ----------------------------------------- | --------------------------------- | ------------------------- |
21
+ | `offload: "sequential"` | 273 s (10.8 s/step) | not measured - same per-step cost | not measured |
22
+ | `offload: "model"` | 61 s (1.7 s/step) at **23.3 GB reserved, 491 MB free** | **OOM** at 23.45 GiB | not attempted |
23
+ | **transformer `group_offload`, block-level, streamed, pinned host copies** | 78 s (1.5 s/step), 17.7 GB peak reserved, 6 GB free | 131 s (3.2 s/step) | 152 s denoise (3.0 s/step), 1 s decode |
24
+
25
+ Model offload is the fastest placement that loads, and the one #580 (9c708a12) retired:
26
+ at 1024x1024 it runs with a few hundred MB to spare and fails on anything larger -
27
+ a batch, a second encoder resident for prompt weighting, a cache's state. Sequential
28
+ offload is safe everywhere and 6x slower per step. The streamed group offload is both:
29
+ the transformer's blocks are copied in one at a time from pinned host memory under a
30
+ CUDA stream, so each step costs the same as the resident model's, and the card holds
31
+ one block plus the activations. It is what every unquantized FLUX template now uses:
32
+
33
+ ```json
34
+ "configuration": {
35
+ "component_type": "FluxPipeline",
36
+ "components": {
37
+ "transformer": {
38
+ "group_offload": {
39
+ "offload_type": "block_level",
40
+ "num_blocks_per_group": 1,
41
+ "use_stream": true,
42
+ "record_stream": true
43
+ }
44
+ },
45
+ "text_encoder": { "device": "cuda" },
46
+ "text_encoder_2": { "device": "cuda" },
47
+ "vae": { "device": "cuda" }
48
+ }
49
+ }
50
+ ```
51
+
52
+ Two things the shape depends on. The non-offloaded components are placed explicitly:
53
+ with no pipeline-level `offload`, nothing else moves them, and a T5 or VAE left on the
54
+ CPU costs a minute per prompt encode and per decode (measured: 70 s before the first
55
+ step, 185 s decoding). And the host copies are pinned - diffusers' default,
56
+ `low_cpu_mem_usage: false` - which holds ~22 GB of unswappable host memory per loaded
57
+ FLUX pipeline and takes ~35 s once per load; with `low_cpu_mem_usage: true` the copies
58
+ come from pageable memory at 8 s/step, barely ahead of sequential. A host with less
59
+ than 32 GB free should take `offload: "sequential"` instead.
60
+
61
+ Compile and caching stack on top as before: `compile: {repeated_blocks: true}` measured
62
+ 52 s vs 55 s per image on the old model-offload placement; `cache: first_block` is
63
+ [step-caching.json](../workflows/templates/step-caching.json). TorchAO int8 is over 60 s
64
+ per *denoising step* on Ampere - do not use it there; float8 needs compute capability
65
+ 8.9+ (Ada) and is unmeasured; GGUF Q8 ([flux-gguf.json](../workflows/models/flux-gguf.json))
66
+ loads with the least VRAM and is unmeasured for speed.
67
+
68
+ **Examples:** [lora.json](../workflows/templates/lora.json), [flux-dev-compile.json](../workflows/models/flux-dev-compile.json), [flux-gguf.json](../workflows/models/flux-gguf.json), [step-caching.json](../workflows/templates/step-caching.json)
69
+
70
+ ## Z-Image Turbo (6B)
71
+
72
+ Fits a 24 GB card in bf16 with model offload, and that is the configuration to use.
73
+ Measured on an RTX 3090 (960x544, 9 steps, 4 images per prompt, cold load included,
74
+ 2026-10-10):
75
+
76
+ | Offload | Load | Denoise | Decode | Total | Peak VRAM reserved |
77
+ | ------- | ---- | ------- | ------ | ----- | ------------------ |
78
+ | `sequential` | 18 s | 84 s | 2 s | 107 s | 1.1 GB |
79
+ | **`model`** | 4 s | 25 s | 9 s | 41 s | 13.0 GB |
80
+ | none (resident) | 11 s | 17 s | 2 s | 33 s | 23.7 GB, 95 MB free at decode |
81
+
82
+ Sequential offload streams the transformer's layers every step and costs 3.4x on the
83
+ denoise for a model that does not need the room. Resident is faster still but decodes
84
+ four images with nothing to spare, so [z-image.json](../workflows/models/z-image.json)
85
+ and the Z-Image portrait steps of the MiniMax templates use `"offload": "model"`. The
86
+ decode's 9 s under model offload is the VAE swapping in; `vae.enable_slicing` would
87
+ trade it for per-image decodes.
32
88
 
33
89
  ## Qwen-Image (20B)
34
90
 
dw/docs/RELEASING.md CHANGED
@@ -7,6 +7,47 @@ notes from commits at tag time (see below). This section is a scratch pad
7
7
  for items a branch's author wants the next release note to name; clear it
8
8
  when a release ships.
9
9
 
10
+ ### 0.11.1
11
+
12
+ <!-- Drafted from v0.11.0..develop. Paste into the GitHub release body once the tag has published: gh release edit v0.11.1 --notes-file ... -->
13
+
14
+ A speed release. The unquantized FLUX templates run about 3.5x faster on
15
+ a 24 GB card, Z-Image Turbo about 2.6x, a LoRA scale or scheduler shift
16
+ change no longer reloads the model, and switching workflows keeps the
17
+ models the next one shares. Every number below was measured on an RTX 3090;
18
+ the placement tables are in docs/RECIPES_24GB.md.
19
+
20
+ **Workflow speed**
21
+
22
+ - The 14 unquantized FLUX templates and `models/flux-krea` stream the
23
+ transformer block by block from pinned host memory (`group_offload`)
24
+ instead of `offload: "sequential"`: a 25-step 1024x1024 image takes 78 s
25
+ instead of 273 s (1.5 s/step against 10.8), with 6 GB of VRAM to spare -
26
+ enough for the 4-image batches `offload: "model"` runs out of memory on.
27
+ It holds ~22 GB of pinned host RAM per loaded FLUX pipeline.
28
+ - Z-Image Turbo (`models/z-image` and the MiniMax portrait steps) takes
29
+ `offload: "model"`: four 960x544 images in 41 s instead of 107 s.
30
+ - The four MiniMax-H3 family baselines expose `attention_backend` (null by
31
+ default; `sage_hub` measured about 30% faster per step on a 3090; it needs
32
+ the `kernels` package and `DIFFUSERS_TRUST_REMOTE_KERNELS=true`, see
33
+ docs/ACCELERATION.md). LTX-2.5 cannot use sage attention (its attention
34
+ mask is refused).
35
+ - A LoRA `scale`/`alpha` change or a scheduler `shift` change re-applies on
36
+ the warm pipeline instead of reloading it. Removing an alpha or shift still
37
+ reloads, and a `pipeline_reference` runs at the referenced step's own values.
38
+ - Switching workflows in the worker keeps the warm pipelines the next
39
+ workflow loads anyway, instead of releasing everything first.
40
+ - `restore_faces` builds its face helper once per run instead of once per
41
+ frame, and picture frames decode with libav threading, so video inputs
42
+ process faster.
43
+ - Catalog defaults corrected: compose-workflows video steps run 5 steps
44
+ (was 20) and the prompt-weighting schnell workflow runs 4 steps at guidance
45
+ 0 (was 20).
46
+ - The six 9-step minimax ref2va templates drop a dead `first_block` cache,
47
+ and minimax/shots-batch drops its `release_pipeline`: a no-op inside the
48
+ run, but it unloaded H3 at job end, so H3 now stays resident after a
49
+ shots-batch job, which is the win.
50
+
10
51
  ### 0.11.0
11
52
 
12
53
  <!-- Drafted from v0.10.0..develop. Paste into the GitHub release body once the tag has published: gh release edit v0.11.0 --notes-file ... -->
dw/docs/SERVER.md CHANGED
@@ -222,7 +222,9 @@ Among the free cards a job fits, dispatch prefers the one it has an affinity
222
222
  for: a rerun goes to the card that ran the original, and any job to the card
223
223
  whose worker last ran the same workflow (the same file, or an inline
224
224
  definition's `id`) - that worker's pipelines and step cache are warm, and a
225
- worker frees both when the workflow changes. A busy preferred card is not
225
+ worker's step cache and any model the next workflow does not load are freed
226
+ when the workflow changes, while a model both workflows load (same weights
227
+ identity) stays warm. `clear_memory` still empties the card by hand. A busy preferred card is not
226
228
  waited for. A validation's cache probe and its `plan.estimate` ask about the
227
229
  card the job would be dispatched to now, and the estimate names it in
228
230
  `priced_for`. Memory is per card: `GET /api/memory` and `POST
dw/docs/TASKS.md CHANGED
@@ -2632,7 +2632,7 @@ Restore and enhance faces in images using spandrel-compatible face restoration m
2632
2632
  | `eye_dist_threshold` | No | Skip faces with eye distance below this (default: 5) |
2633
2633
  | `upsample_img` | No | Pre-upscaled background image (e.g., from a prior upscale step) |
2634
2634
 
2635
- Models are loaded via spandrel, so any `.pth`/`.safetensors` face restoration weights work. CodeFormer requires `pip install spandrel-extra-arches` (non-commercial license).
2635
+ Models are loaded via spandrel, so any `.pth`/`.safetensors` face restoration weights work. CodeFormer requires `pip install spandrel-extra-arches` (non-commercial license). The detector and parser are loaded once per settings/device and reused across frames; `release_models` drops them with the restorer.
2636
2636
 
2637
2637
  **Example:** [restore-faces.json](../workflows/templates/restore-faces.json) — Generate a portrait, then restore faces with GFPGAN v1.4.
2638
2638
 
dw/docs/WORKER_GUIDE.md CHANGED
@@ -28,6 +28,13 @@ The worker cleans up automatically between runs (garbage collection + GPU
28
28
  cache clearing). If memory grows unexpectedly, `GET /api/memory` reports the
29
29
  current reading and `POST /api/memory/clear` resets it.
30
30
 
31
+ Switching to a different workflow releases only the models the next workflow
32
+ does not load: the worker prepares the incoming definition, and a pipeline
33
+ both workflows load (same weights identity) stays warm. The step cache and
34
+ the task model cache are still emptied, and if the incoming definition cannot
35
+ be prepared everything is released. `clear_memory` (`POST /api/memory/clear`)
36
+ remains the way to empty the card by hand.
37
+
31
38
  ## Troubleshooting
32
39
 
33
40
  **Worker crashes**: `JobManager` detects it and starts a fresh worker on the
dw/docs/WORKFLOW_GUIDE.md CHANGED
@@ -112,6 +112,10 @@ inverted latents:
112
112
  ```
113
113
 
114
114
  `reference_name` must name a step earlier in the same workflow that has a `pipeline`.
115
+ Two steps whose pipelines differ only in a LoRA's `scale` or `alpha` or a scheduler's
116
+ `shift` share one warm model, and a reference always runs at the referenced step's own
117
+ values; `release_pipeline` on either step frees the shared model, as it does for two
118
+ identical steps.
115
119
  See [workflows/templates/community-pipeline.json](../workflows/templates/community-pipeline.json) for a full example.
116
120
 
117
121
  ### Task Steps
@@ -1173,7 +1177,7 @@ Control how models use memory:
1173
1177
  ```
1174
1178
 
1175
1179
  - `"model"` — Moves entire models between CPU and GPU. Good balance of speed and memory.
1176
- - `"sequential"` — Moves individual layers. Slowest but uses least GPU memory. On MPS it is downgraded to `"model"` with a warning: with unified memory there is no separate pool to keep small, so the per-layer copies cost speed and save nothing.
1180
+ - `"sequential"` — Moves individual layers. Slowest but uses least GPU memory (measured at 6x the per-step cost of model offload on FLUX dev and 3.4x on Z-Image; a streamed block-level `group_offload` of the transformer is the usual better answer - see [RECIPES_24GB.md](RECIPES_24GB.md#flux-dev-12b)). On MPS it is downgraded to `"model"` with a warning: with unified memory there is no separate pool to keep small, so the per-layer copies cost speed and save nothing.
1177
1181
  `exclude_from_cpu_offload` names components the sweep should leave alone.
1178
1182
  - Omit for no offloading (fastest, requires enough VRAM).
1179
1183
 
@@ -1325,6 +1329,13 @@ The step-level `release_pipeline` flag unloads the step's pipeline after its res
1325
1329
  saved. A later `pipeline_reference` to a released step is an error, and the process-wide
1326
1330
  step cache will not retain it.
1327
1331
 
1332
+ What keeps a pipeline warm: the server's worker keeps a pipeline loaded across runs for as
1333
+ long as what it *loads* is unchanged. A LoRA's `scale` and `alpha` and a scheduler's (or
1334
+ `audio_scheduler`'s) `shift` are applied in place on the warm pipeline, so iterating on
1335
+ them costs no reload; removing (nulling) an alpha or a shift reloads, so the checkpoint
1336
+ default comes back. Changing a LoRA's `model_name` or `weight_name`, the quantization,
1337
+ the placement or the scheduler type reloads it.
1338
+
1328
1339
  #### Releasing task models mid-workflow
1329
1340
 
1330
1341
  Task models - the checkpoints behind `text_generation`, `segment`, `depth_estimator` and
dw/media.py CHANGED
@@ -33,6 +33,15 @@ from .dsp import SILENCE_DBFS, dbfs, integrated_lufs, layout_name, true_peak_dbf
33
33
 
34
34
  logger = logging.getLogger("dw")
35
35
 
36
+
37
+ def _threaded(stream):
38
+ """A video stream set to decode on every core. libav defaults a
39
+ software h264 decode to one thread; AUTO lets it pick frame and
40
+ slice threading for the codec. Header-only reads do not call this."""
41
+ stream.thread_type = "AUTO"
42
+ return stream
43
+
44
+
36
45
  # The most a soundtrack may be as base64 before the gallery route refuses to
37
46
  # extract it whole - the twin of dw_mcp/media.py's MAX_RETURNED_BYTES (the
38
47
  # MCP package's cap on any inline payload). Two constants because dw_mcp
@@ -345,7 +354,7 @@ def video_shape(path):
345
354
  fps = float(stream.average_rate) if stream.average_rate else None
346
355
  count = int(stream.frames) if stream.frames else None
347
356
  if count is None:
348
- count = sum(1 for _ in container.decode(stream))
357
+ count = sum(1 for _ in container.decode(_threaded(stream)))
349
358
  return {
350
359
  "frame_count": count,
351
360
  "fps": fps,
@@ -383,6 +392,8 @@ def read_frames(path, indexes, fit=None):
383
392
  wanted = sorted(set(int(i) for i in indexes))
384
393
  found = {}
385
394
  with av.open(path) as container:
395
+ # Not _threaded: frame threads hold frames in flight, and a backward
396
+ # seek after a decoded frame then yields stale ones (tests/test_media_decode_threads.py)
386
397
  stream = container.streams.video[0]
387
398
  fps = float(stream.average_rate) if stream.average_rate else None
388
399
  start_pts = stream.start_time if stream.start_time is not None else 0
@@ -479,6 +490,8 @@ def read_frame_range(path, start, stop):
479
490
  )
480
491
  out = None
481
492
  with av.open(path) as container:
493
+ # Not _threaded: frame threads hold frames in flight, and a backward
494
+ # seek after a decoded frame then yields stale ones (tests/test_media_decode_threads.py)
482
495
  stream = container.streams.video[0]
483
496
  fps = float(stream.average_rate) if stream.average_rate else None
484
497
  start_pts = stream.start_time if stream.start_time is not None else 0
@@ -513,7 +526,7 @@ def count_video_frames(path):
513
526
  """The picture frame count of `path`, by decoding every frame rather
514
527
  than reading the header. Nothing is held but the frame in hand."""
515
528
  with av.open(path) as container:
516
- return sum(1 for _ in container.decode(container.streams.video[0]))
529
+ return sum(1 for _ in container.decode(_threaded(container.streams.video[0])))
517
530
 
518
531
 
519
532
  def decode_audio_video(handle):
@@ -531,7 +544,7 @@ def decode_audio_video(handle):
531
544
  sample_rate = None
532
545
 
533
546
  with av.open(handle) as container:
534
- video_stream = container.streams.video[0]
547
+ video_stream = _threaded(container.streams.video[0])
535
548
  frame_rate = (
536
549
  float(video_stream.average_rate) if video_stream.average_rate else None
537
550
  )
@@ -559,7 +572,10 @@ def decode_rgb_frames(path):
559
572
  """Every picture frame of `path` as an (height, width, 3) uint8 array,
560
573
  in order."""
561
574
  with av.open(path) as container:
562
- return [frame.to_ndarray(format="rgb24") for frame in container.decode(video=0)]
575
+ return [
576
+ frame.to_ndarray(format="rgb24")
577
+ for frame in container.decode(_threaded(container.streams.video[0]))
578
+ ]
563
579
 
564
580
 
565
581
  def read_thumbnails_and_track(path, thumb_width, thumb_height):
@@ -575,7 +591,9 @@ def read_thumbnails_and_track(path, thumb_width, thumb_height):
575
591
  thumbs = []
576
592
  chunks = []
577
593
  with av.open(path) as container:
578
- video = container.streams.video[0] if container.streams.video else None
594
+ video = (
595
+ _threaded(container.streams.video[0]) if container.streams.video else None
596
+ )
579
597
  audio = container.streams.audio[0] if container.streams.audio else None
580
598
  if video is None and audio is None:
581
599
  raise ValueError(f"{path} has neither a video nor an audio stream")
dw/pipeline_ownership.py CHANGED
@@ -62,6 +62,11 @@ class PipelineOwnership:
62
62
  # pipeline_reference still address pipelines by the step that made
63
63
  # them
64
64
  self.keys_by_step = {}
65
+ # Step name -> the step's pipeline definition as the workflow holds
66
+ # it (never a loading copy), so a pipeline_reference can put the
67
+ # referenced step's own scale, alpha and shift back on a model a
68
+ # step differing only in those shares with it
69
+ self.definitions_by_step = {}
65
70
  # Step name -> _Deferred for each cache hit whose pipeline was not
66
71
  # resident and so was not loaded
67
72
  self.deferred = {}
@@ -92,9 +97,16 @@ class PipelineOwnership:
92
97
  )
93
98
  return self.running[step_name]
94
99
 
95
- def record(self, step_name, key):
96
- """Record which cache key a step's pipeline lives under this run."""
100
+ def record(self, step_name, key, pipeline_definition=None):
101
+ """Record which cache key a step's pipeline lives under this run,
102
+ and the definition it was loaded or wrapped from."""
97
103
  self.keys_by_step[step_name] = key
104
+ if pipeline_definition is not None:
105
+ self.definitions_by_step[step_name] = pipeline_definition
106
+
107
+ def definition_for(self, step_name):
108
+ """The pipeline definition `step_name` recorded this run, or None."""
109
+ return self.definitions_by_step.get(step_name)
98
110
 
99
111
  def key_for(self, step_name):
100
112
  """The cache key `step_name` recorded this run, or None."""
@@ -286,6 +298,10 @@ def wrap_resident(
286
298
  new_pipeline_wrapper.pipeline_definition.get("seed", default_seed)
287
299
  )
288
300
 
301
+ # The key the hit matched on is the weights identity: a scale, alpha or
302
+ # shift may differ from the values the resident model last ran with
303
+ new_pipeline_wrapper.apply_runtime_settings()
304
+
289
305
  # A cache hit and a cold load look identical from the outside -
290
306
  # same step, same dot - and they differ by minutes
291
307
  emit_phase("cached", detail=new_pipeline_wrapper.name)
@@ -101,11 +101,103 @@ def active_loras(loras):
101
101
  ]
102
102
 
103
103
 
104
+ def adapter_settings(loras):
105
+ """(adapter_names, adapter_weights, alphas) for the active entries of
106
+ `loras`. An adapter is named by its own adapter_name or its ORIGINAL
107
+ index - a nulled entry keeps its slot, so the name a hit re-applies to
108
+ is the name the load gave. Reads the entries, never pops them.
109
+
110
+ `or` on adapter_name, because a variable nulled by the caller arrives as
111
+ a present None; float() on scale, because the schema takes a 'variable:'
112
+ reference there, and a variable declared as a string default substitutes
113
+ as one."""
114
+ names, weights, alphas = [], [], {}
115
+ for i, lora in enumerate(loras or []):
116
+ if not isinstance(lora, dict) or lora.get("model_name") is None:
117
+ continue
118
+ name = lora.get("adapter_name") or str(i)
119
+ names.append(name)
120
+ scale = lora.get("scale")
121
+ weights.append(1.0 if scale is None else float(scale))
122
+ if lora.get("alpha") is not None:
123
+ alphas[name] = lora["alpha"]
124
+ return names, weights, alphas
125
+
126
+
127
+ def set_adapter_scales(pipeline, names, weights):
128
+ """Set each named adapter's scale on every loaded layer that carries it,
129
+ without re-activating the adapters.
130
+
131
+ The warm-pipeline path. diffusers' set_adapters() activates as well as
132
+ scales, and activation goes through peft's set_adapter(), which sets
133
+ requires_grad on the adapter weights - after a run under model offload
134
+ those are inference tensors, and torch refuses ("Setting requires_grad=True
135
+ on inference tensor outside InferenceMode" - seen on a 3090 the first time
136
+ a scale changed on a warm Flux pipeline, 2026-10-10).
137
+ A pipeline that already ran holds the adapters active, so a hit only
138
+ needs each layer's set_scale(), which recomputes scale * alpha / rank and
139
+ touches no tensor.
140
+
141
+ Raises:
142
+ ValueError: if an adapter is carried by no loaded layer - a scale
143
+ that applied to nothing would silently run the old value
144
+ """
145
+ touched = {name: 0 for name in names}
146
+ for module in _lora_layers(pipeline):
147
+ scaling = getattr(module, "scaling", None)
148
+ if not isinstance(scaling, dict):
149
+ continue
150
+ for name, weight in zip(names, weights):
151
+ if name in scaling:
152
+ module.set_scale(name, weight)
153
+ touched[name] += 1
154
+ missing = [name for name, count in touched.items() if not count]
155
+ if missing:
156
+ raise ValueError(
157
+ f"Cannot set the scale of adapter(s) {missing} - no loaded layer "
158
+ "carries them. Nothing would have been scaled."
159
+ )
160
+
161
+
162
+ def apply_adapter_settings(loras, pipeline, activate=False):
163
+ """Set each active LoRA's alpha, then every scale, on a pipeline that
164
+ already holds the adapters. Alpha first: the scale pass is what recomputes
165
+ each layer's scaling from it. No-op with no active entry.
166
+
167
+ `activate` is the cold path, right after load_lora_weights: diffusers'
168
+ set_adapters() activates the adapters and sets their scales in one call.
169
+ A warm pipeline (a cache hit) has them active already and takes the
170
+ scale-only path - see set_adapter_scales for why it must.
171
+ """
172
+ names, weights, alphas = adapter_settings(loras)
173
+ if not names:
174
+ return
175
+ for name, alpha in alphas.items():
176
+ set_adapter_alpha(pipeline, name, alpha)
177
+ logger.info(f"Setting adapter weights: {list(zip(names, weights))}")
178
+ if activate:
179
+ # Positionally - diffusers' mixin calls the second parameter
180
+ # 'adapter_weights' while custom pipelines that delegate to the model
181
+ # (ostris/Krea2OstrisEdit) call it 'weights'
182
+ pipeline.set_adapters(names, weights)
183
+ else:
184
+ set_adapter_scales(pipeline, names, weights)
185
+
186
+
104
187
  def load_loras(loras, pipeline):
105
- """Load and configure LoRA models."""
106
- adapter_names = []
107
- adapter_weights = []
108
- alphas = {}
188
+ """Load and configure LoRA models.
189
+
190
+ Names, scales and alphas come from adapter_settings, read before the
191
+ entries are popped, so a load and a later cache hit's
192
+ apply_adapter_settings set the same values on the same names."""
193
+ # A snapshot of each entry's runtime keys, taken before the pops below
194
+ settings = [
195
+ {k: lora.get(k) for k in ("model_name", "adapter_name", "scale", "alpha")}
196
+ if isinstance(lora, dict)
197
+ else lora
198
+ for lora in loras or []
199
+ ]
200
+ names = iter(adapter_settings(settings)[0])
109
201
 
110
202
  for i, lora in enumerate(loras or []):
111
203
  if isinstance(lora, dict) and lora.get("model_name") is None:
@@ -117,40 +209,15 @@ def load_loras(loras, pipeline):
117
209
  logger.info(f"Loading LoRA: {model_name}")
118
210
  emit_phase("loading", detail=f"LoRA: {model_name}")
119
211
 
120
- # Use provided adapter_name or generate from index - `or`, because a
121
- # variable nulled by the caller arrives as a present None
122
- adapter_name = lora.pop("adapter_name", None) or str(i)
123
- adapter_names.append(adapter_name)
212
+ # Popped before the load: everything left in the dict is a keyword
213
+ # argument to load_lora_weights; the scale and alpha are applied to
214
+ # the layers afterwards rather than passed to it
215
+ for key in ("adapter_name", "scale", "alpha"):
216
+ lora.pop(key, None)
124
217
 
125
- # Extract scale for adapter weights - float() because the schema takes a
126
- # 'variable:' reference here, and a variable declared as a string default
127
- # substitutes as one
128
- scale = lora.pop("scale", None)
129
- scale = 1.0 if scale is None else float(scale)
130
- adapter_weights.append(scale)
218
+ pipeline.load_lora_weights(model_name, adapter_name=next(names), **lora)
131
219
 
132
- # Popped before the load: everything left in the dict is a keyword
133
- # argument to load_lora_weights, and the alpha is applied to the layers
134
- # afterwards rather than passed to it
135
- alpha = lora.pop("alpha", None)
136
- if alpha is not None:
137
- alphas[adapter_name] = alpha
138
-
139
- # Load the LoRA with the adapter name
140
- pipeline.load_lora_weights(model_name, adapter_name=adapter_name, **lora)
141
-
142
- for adapter_name, alpha in alphas.items():
143
- set_adapter_alpha(pipeline, adapter_name, alpha)
144
-
145
- # Set adapter weights for all loaded LoRAs
146
- if adapter_names:
147
- logger.info(
148
- f"Setting adapter weights: {list(zip(adapter_names, adapter_weights))}"
149
- )
150
- # Positionally - diffusers' mixin calls the second parameter 'adapter_weights'
151
- # while custom pipelines that delegate to the model (ostris/Krea2OstrisEdit)
152
- # call it 'weights'
153
- pipeline.set_adapters(adapter_names, adapter_weights)
220
+ apply_adapter_settings(settings, pipeline, activate=True)
154
221
 
155
222
 
156
223
  def load_ip_adapter(ip_adapter_definition, pipeline):
@@ -447,6 +447,23 @@ def load_and_configure_scheduler(
447
447
  ),
448
448
  )
449
449
 
450
+ apply_scheduler_shift(scheduler_definition, pipeline, component_name)
451
+
452
+
453
+ def apply_scheduler_shift(scheduler_definition, pipeline, component_name="scheduler"):
454
+ """Set the shift a scheduler definition names on an already-loaded
455
+ scheduler; nothing when the definition is None or names no shift.
456
+
457
+ A load sets it after any scheduler replacement, and a pipeline cache hit
458
+ sets it again (Pipeline.apply_runtime_settings), because the cache key
459
+ leaves the shift out - a run that only moves it reuses the warm pipeline.
460
+
461
+ Raises:
462
+ ValueError: if the scheduler is registered but not loaded, or has no
463
+ set_shift()
464
+ """
465
+ if scheduler_definition is None:
466
+ return
450
467
  shift = scheduler_definition.get("shift", None)
451
468
  if shift is None:
452
469
  return
@@ -9,8 +9,14 @@ from .config_objects import (
9
9
  get_quantization_configuration,
10
10
  get_cache_configuration,
11
11
  )
12
- from .adapters import active_loras, load_ip_adapter, load_loras
12
+ from .adapters import (
13
+ active_loras,
14
+ apply_adapter_settings,
15
+ load_ip_adapter,
16
+ load_loras,
17
+ )
13
18
  from .components import (
19
+ apply_scheduler_shift,
14
20
  apply_sdnq_optimizations,
15
21
  configure_components,
16
22
  enable_cache_on_transformer,
@@ -135,6 +141,22 @@ def _loading_copy(pipeline_definition):
135
141
  return copied
136
142
 
137
143
 
144
+ def apply_runtime_settings(pipeline_definition, model):
145
+ """Set what a pipeline definition states that changes no weights -
146
+ scheduler and audio_scheduler shift, LoRA scales and alphas - on a model
147
+ that already holds those weights.
148
+
149
+ Takes the definition as the workflow holds it, never a Pipeline's
150
+ loading copy: load_loras pops each entry's model_name from that copy,
151
+ and an entry without one reads as switched off and would be skipped.
152
+ """
153
+ apply_scheduler_shift(pipeline_definition.get("scheduler"), model)
154
+ apply_scheduler_shift(
155
+ pipeline_definition.get("audio_scheduler"), model, "audio_scheduler"
156
+ )
157
+ apply_adapter_settings(pipeline_definition.get("loras"), model)
158
+
159
+
138
160
  class Pipeline:
139
161
  """
140
162
  Manages pipeline initialization, configuration, and execution.
@@ -303,6 +325,15 @@ class Pipeline:
303
325
  for entry in value:
304
326
  Pipeline._check_trusted_block(entry, key)
305
327
 
328
+ def apply_runtime_settings(self):
329
+ """Re-apply what a run may have changed without changing the weights:
330
+ scheduler and audio_scheduler shift, LoRA scales and alphas. The
331
+ pipeline cache keys on weights_identity, so a hit can carry any of
332
+ these at a new value; load() applies the same values on its way
333
+ through, so a cold load and a hit end in the same state. Only for a
334
+ wrapper that has not loaded (a hit): load() pops from this copy."""
335
+ apply_runtime_settings(self.pipeline_definition, self.pipeline)
336
+
306
337
  def load(self, shared_components):
307
338
  """
308
339
  Load and configure the pipeline with all components.
dw/step_cache.py CHANGED
@@ -121,26 +121,101 @@ def component_names(pipeline_definition, key):
121
121
  return list(pipeline_definition.get(key, [])) + list(configuration.get(key, []))
122
122
 
123
123
 
124
+ RUNTIME_LORA_KEYS = ("scale", "alpha")
125
+
126
+
127
+ def weights_identity(pipeline_definition):
128
+ """The pipeline definition with every runtime-mutable setting removed.
129
+
130
+ A LoRA's scale and alpha (set_adapters / set_adapter_alpha) and a
131
+ scheduler's shift (set_shift) change nothing about what is loaded, so a
132
+ run that only moves one of them must hit the warm pipeline and re-apply
133
+ the value (Pipeline.apply_runtime_settings), not reload the stack.
134
+ Everything else - the model, the LoRA files, quantization, placement,
135
+ the scheduler type - still changes the key. Returns a new dict; the
136
+ definition is the workflow's and is not edited.
137
+
138
+ An alpha's or a shift's *presence* stays in the identity (as True, never
139
+ the value). A hit re-applies only what the definition states, and nothing
140
+ sets an alpha or shift back to the checkpoint's own: a run that drops one
141
+ (or nulls the variable behind it) must reload to get the default back,
142
+ where 16 -> 8 just re-applies. A scale needs no marker - an absent scale
143
+ re-applies as 1.0.
144
+ """
145
+ identity = {
146
+ k: v
147
+ for k, v in pipeline_definition.items()
148
+ if k not in ("arguments", "seed", "chain")
149
+ }
150
+ loras = identity.get("loras")
151
+ if isinstance(loras, list):
152
+ identity["loras"] = [
153
+ _without_runtime(lora, RUNTIME_LORA_KEYS, "alpha")
154
+ if isinstance(lora, dict)
155
+ else lora
156
+ for lora in loras
157
+ ]
158
+ for name in ("scheduler", "audio_scheduler"):
159
+ scheduler = identity.get(name)
160
+ if isinstance(scheduler, dict):
161
+ identity[name] = _without_runtime(scheduler, ("shift",), "shift")
162
+ return identity
163
+
164
+
165
+ def _without_runtime(block, runtime_keys, marked):
166
+ """`block` without its runtime keys, but with `marked` as True when the
167
+ block states it - see weights_identity for why its presence counts."""
168
+ stripped = {k: v for k, v in block.items() if k not in runtime_keys}
169
+ if block.get(marked) is not None:
170
+ stripped[marked] = True
171
+ return stripped
172
+
173
+
174
+ def pipeline_definition_key(pipeline_definition):
175
+ """A hash of everything in a pipeline definition but what varies per call
176
+ (arguments, seed, chain) - runtime settings included, unlike
177
+ pipeline_cache_key.
178
+
179
+ The step cache keys a borrowing step on this, not on the pipeline cache
180
+ key: a step that runs on another step's pipeline (pipeline_reference,
181
+ reused_components) produces a different result when only its source's
182
+ LoRA scale or shift moves, though the loaded weights - and so the warm
183
+ pipeline - are the same.
184
+ """
185
+ serialized = json.dumps(
186
+ {
187
+ k: v
188
+ for k, v in pipeline_definition.items()
189
+ if k not in ("arguments", "seed", "chain")
190
+ },
191
+ sort_keys=True,
192
+ default=str,
193
+ )
194
+ return hashlib.sha256(serialized.encode()).hexdigest()
195
+
196
+
124
197
  def pipeline_cache_key(pipeline_definition):
125
198
  """Stable identity for a loaded pipeline.
126
199
 
127
- Hashes everything that shapes loading - configuration, components,
128
- quantization, loras - and excludes what varies per call (arguments, seed,
129
- chain), so a cache hit means "this exact model stack is already loaded".
130
- Keying the cache by identity instead of step name means two workflows
131
- whose steps happen to share a name can no longer collide, and a rerun of
132
- an edited workflow keeps every pipeline whose definition did not change.
200
+ Hashes the weights identity - everything that shapes loading:
201
+ configuration, components, quantization, the LoRA files, the scheduler
202
+ type - and excludes what varies per call (arguments, seed, chain), so a
203
+ cache hit means "this exact model stack is already loaded". A LoRA's
204
+ scale and alpha and a scheduler's shift are excluded too: each is set in
205
+ place on the loaded model, and a hit re-applies them
206
+ (Pipeline.apply_runtime_settings), so nudging one between runs costs no
207
+ reload. Keying the cache by identity instead of step name means two
208
+ workflows whose steps happen to share a name can no longer collide, and
209
+ a rerun of an edited workflow keeps every pipeline whose definition did
210
+ not change.
133
211
 
134
212
  Computed after variable substitution but the excluded keys keep realized
135
213
  per-run values (images, generators) out of the hash; realized types and
136
214
  dtypes stringify stably via default=str.
137
215
  """
138
- load_definition = {
139
- k: v
140
- for k, v in pipeline_definition.items()
141
- if k not in ("arguments", "seed", "chain")
142
- }
143
- serialized = json.dumps(load_definition, sort_keys=True, default=str)
216
+ serialized = json.dumps(
217
+ weights_identity(pipeline_definition), sort_keys=True, default=str
218
+ )
144
219
  return hashlib.sha256(serialized.encode()).hexdigest()
145
220
 
146
221
 
@@ -170,14 +245,34 @@ def step_pipeline_keys(steps):
170
245
  loaded - a deferred cache hit, or the cache_hits probe. A key that has to
171
246
  agree across those cases is read from this table, not re-hashed.
172
247
  """
248
+ return _closure_keys(steps, pipeline_cache_key)
249
+
250
+
251
+ def step_definition_keys(steps):
252
+ """Step name -> effective definition key for every pipeline step, taken
253
+ before any step runs: step_pipeline_keys over pipeline_definition_key.
254
+
255
+ What the step cache folds in for a borrowed pipeline
256
+ (borrowed_pipeline_keys), so a source step whose only change is a LoRA
257
+ scale, alpha or shift - the same warm pipeline, a different result - is
258
+ a miss for the steps that borrow it. Pipeline ownership keeps to
259
+ step_pipeline_keys.
260
+ """
261
+ return _closure_keys(steps, pipeline_definition_key)
262
+
263
+
264
+ def _closure_keys(steps, own_key_of):
265
+ """Step name -> the step's own key (`own_key_of` its pipeline
266
+ definition), or, for a step that reuses components, the hash of the
267
+ sorted own keys over its reuse closure - see step_pipeline_keys."""
173
268
  keys = {}
174
- # Step name -> the own pipeline_cache_keys of its reuse closure
269
+ # Step name -> the own keys of its reuse closure
175
270
  closures = {}
176
271
  for index, step_data in enumerate(steps):
177
272
  if "pipeline" not in step_data:
178
273
  continue
179
274
  pipeline_definition = step_data["pipeline"]
180
- own_key = pipeline_cache_key(pipeline_definition)
275
+ own_key = own_key_of(pipeline_definition)
181
276
  closure = {own_key}
182
277
  reused = component_names(pipeline_definition, "reused_components")
183
278
  for name in reused:
@@ -215,10 +310,15 @@ def borrowed_pipeline_keys(steps, index, pipeline_keys):
215
310
  step's model changes - its own definition never names the model. Folding
216
311
  the source steps' keys into the lookup snapshot makes a source model
217
312
  change a miss here too, rather than a stale hit that republishes a
218
- now-wrong pipeline. The keys are effective keys (step_pipeline_keys), so
219
- a change further up a borrow chain than the direct source is a miss too.
313
+ now-wrong pipeline. The keys are effective keys, so a change further up
314
+ a borrow chain than the direct source is a miss too.
315
+
316
+ The step cache passes step_definition_keys, which also moves with a
317
+ source's LoRA scale, alpha or shift - a change that keeps the warm
318
+ pipeline but changes what the borrower produces. Callers that only need
319
+ which steps are borrowed may pass step_pipeline_keys.
220
320
 
221
- The keys come from `pipeline_keys` (step_pipeline_keys), computed from
321
+ The keys come from `pipeline_keys` (one of those tables), computed from
222
322
  the definition before any step ran, never from `steps[:index]` as they
223
323
  stand now: a load edits its own definition in place, and a source step
224
324
  that loaded before this lookup (a cold run) and one that did not (a
dw/tasks/restore_faces.py CHANGED
@@ -66,15 +66,25 @@ def restore_faces(image, model_name, device="cpu", **kwargs):
66
66
  ("restore_faces", model_name, filename, str(device)), load_descriptor
67
67
  )
68
68
 
69
- # Set up facexlib helper
70
- face_helper = FaceRestoreHelper(
71
- upscale_factor=upscale_factor,
72
- face_size=face_size,
73
- crop_ratio=(1, 1),
74
- det_model="retinaface_resnet50",
75
- use_parse=use_parse,
76
- device=torch.device(device),
69
+ # facexlib's helper loads RetinaFace (and ParseNet with use_parse) in
70
+ # its constructor. per_frame calls this once per frame, so a video was
71
+ # reloading detector weights for every frame. The helper keeps per-image
72
+ # state between calls; clean_all() resets it before each read
73
+ def build_helper():
74
+ return FaceRestoreHelper(
75
+ upscale_factor=upscale_factor,
76
+ face_size=face_size,
77
+ crop_ratio=(1, 1),
78
+ det_model="retinaface_resnet50",
79
+ use_parse=use_parse,
80
+ device=torch.device(device),
81
+ )
82
+
83
+ face_helper = cached_model(
84
+ ("restore_faces_helper", upscale_factor, face_size, use_parse, str(device)),
85
+ build_helper,
77
86
  )
87
+ face_helper.clean_all()
78
88
 
79
89
  # Convert PIL to BGR numpy (facexlib format)
80
90
  input_bgr = np.array(image.convert("RGB"))[:, :, ::-1].copy()
dw/worker.py CHANGED
@@ -357,15 +357,38 @@ class WorkflowWorker:
357
357
  # what runs out of memory
358
358
  if identity != self.workflow_identity:
359
359
  if self.workflow_identity is not None:
360
- self._reply(
361
- Output(message="Workflow changed - releasing cached models...")
362
- )
363
- self._reply(Output(message=self._cleanup_all()))
360
+ keep = self._keys_worth_keeping(command, job.workflow)
361
+ if keep:
362
+ self._reply(
363
+ Output(
364
+ message=f"Workflow changed - keeping {len(keep)} warm "
365
+ "model(s) the new workflow loads, releasing the rest..."
366
+ )
367
+ )
368
+ else:
369
+ self._reply(
370
+ Output(message="Workflow changed - releasing cached models...")
371
+ )
372
+ self._reply(Output(message=self._cleanup_all(keep=keep)))
364
373
  self.workflow_identity = identity
365
374
 
366
375
  self._reply(WorkflowLoaded(workflow_name=job.workflow.name))
367
376
  self._reply(Output(message=f"Executing workflow: {job.workflow.name}"))
368
377
 
378
+ def _keys_worth_keeping(self, command, workflow):
379
+ """The resident pipeline keys the next workflow loads anyway. Any
380
+ failure to prepare the definition answers the empty set - the run
381
+ itself will report what is wrong, and a full release is the safe
382
+ default on a card that cannot hold two stacks."""
383
+ try:
384
+ wanted = workflow_run.pipeline_keys(
385
+ workflow, command.get("arguments") or {}
386
+ )
387
+ except Exception as e:
388
+ logger.debug(f"Could not take the next workflow's pipeline keys: {e}")
389
+ return set()
390
+ return wanted & set(self.loaded_pipelines)
391
+
369
392
  def _run_job(self, command: Dict[str, Any], job: "_Job"):
370
393
  """Phase 3: run the workflow, streaming its events, then reclaim."""
371
394
  workflow = job.workflow
@@ -635,10 +658,11 @@ class WorkflowWorker:
635
658
 
636
659
  logger.debug("Inter-run cleanup complete")
637
660
 
638
- def _cleanup_all(self):
661
+ def _cleanup_all(self, keep=frozenset()):
639
662
  """
640
663
  Complete cleanup - clear all cached models and components.
641
- Called when workflow changes or on shutdown.
664
+ Called when workflow changes or on shutdown. Pipelines whose key is
665
+ in `keep` survive; with `keep` empty everything goes.
642
666
 
643
667
  Returns:
644
668
  One line saying what host memory looks like on the other side of
@@ -652,11 +676,20 @@ class WorkflowWorker:
652
676
  logger.info("Performing full cleanup")
653
677
 
654
678
  # Clear pipeline cache and any models task handlers cached
679
+ kept = {k: v for k, v in self.loaded_pipelines.items() if k in keep}
680
+ dropped = len(self.loaded_pipelines) - len(kept)
681
+ # Cleared and refilled in place, so the kept entries keep their
682
+ # identity
655
683
  self.loaded_pipelines.clear()
684
+ self.loaded_pipelines.update(kept)
685
+ # Every run republishes shared components from the pipelines it hits
686
+ # (workflow.py: publish_shared_components on a cache hit)
656
687
  self.shared_components.clear()
657
- # The keys addressed entries that are now gone; keeping them would
658
- # have a later step chase a release that has already happened
659
- self.prior_step_keys.clear()
688
+ # A step's prior key addresses an entry that is now gone - unless it
689
+ # was kept, in which case a redefinition next run still releases it
690
+ self.prior_step_keys = {
691
+ name: key for name, key in self.prior_step_keys.items() if key in kept
692
+ }
660
693
  clear_model_cache()
661
694
  # Drop cached step results too - stale results would otherwise
662
695
  # survive a memory clear and keep getting served for steps whose
@@ -703,9 +736,10 @@ class WorkflowWorker:
703
736
  f"Released cached models: host RSS {_mb(stats.get('rss_mb'))}, "
704
737
  f"{_mb(stats.get('available_mb'))} available"
705
738
  + (f" ({released:.0f} MB returned to the OS)" if released else "")
739
+ + (f", kept {len(kept)} warm" if kept else "")
706
740
  )
707
741
 
708
- logger.info(f"Full cleanup complete. {summary}")
742
+ logger.info(f"Full cleanup complete ({dropped} pipeline(s) dropped). {summary}")
709
743
  return summary
710
744
 
711
745
  def _parent_is_dead(self) -> bool:
dw/workflow.py CHANGED
@@ -27,7 +27,7 @@ from .variables import (
27
27
  resolve_variable_values,
28
28
  set_variables,
29
29
  )
30
- from .pipeline_processors.pipeline import Pipeline
30
+ from .pipeline_processors.pipeline import Pipeline, apply_runtime_settings
31
31
  from .tasks.task import Task
32
32
  from . import get_device
33
33
  from .pipeline_ownership import (
@@ -730,7 +730,7 @@ class Workflow:
730
730
  # Pipelines are cached by what they load, not what step loads them
731
731
  ownership = self.pipeline_ownership
732
732
  cache_key = ownership.load_key(step_definition)
733
- ownership.record(step_name, cache_key)
733
+ ownership.record(step_name, cache_key, step_definition["pipeline"])
734
734
  get_context().touch_pipeline(cache_key)
735
735
 
736
736
  if cache_hit and cache_key not in previous_pipelines:
@@ -798,6 +798,12 @@ class Workflow:
798
798
  "earlier pipeline step in this run (or it was released)"
799
799
  )
800
800
  previous_pipeline = previous_pipelines[referenced_key]
801
+ # Steps whose pipelines differ only in scale, alpha or shift share
802
+ # one model, and the last of them to run left its values on it: put
803
+ # the referenced step's own back
804
+ referenced_definition = self.pipeline_ownership.definition_for(reference_name)
805
+ if referenced_definition is not None:
806
+ apply_runtime_settings(referenced_definition, previous_pipeline.pipeline)
801
807
  return Pipeline(
802
808
  pipeline_reference,
803
809
  default_seed,
dw/workflow_run.py CHANGED
@@ -62,6 +62,7 @@ from .step_cache import (
62
62
  reference_resolves_to,
63
63
  referenced_result_names,
64
64
  step_cache,
65
+ step_definition_keys,
65
66
  step_pipeline_keys,
66
67
  )
67
68
  from .subfolders import step_subfolder
@@ -138,6 +139,9 @@ class StepLoop:
138
139
  hits: set = field(default_factory=set)
139
140
  # Final result is the workflow return value
140
141
  last_result: object = None
142
+ # step_definition_keys of `steps`, taken before any step loaded - what
143
+ # the step cache folds in for a borrowed pipeline (cache_lookup)
144
+ definition_keys: dict = field(default_factory=dict)
141
145
 
142
146
 
143
147
  def run_base_dir(workflow):
@@ -367,14 +371,16 @@ def cache_lookup(
367
371
  step_seed,
368
372
  hits_this_run,
369
373
  cache_enabled,
370
- pipeline_keys,
374
+ definition_keys,
371
375
  ):
372
376
  """Whether the step cache serves step `index`, as (cached_result or
373
377
  None, the step_data snapshot the entry is keyed on or None, whether
374
378
  a later step still reads this one's result, the names later steps
375
379
  still reference). Shared by run() and cache_hits() - see
376
- prepare_definition for why. `pipeline_keys` is step_pipeline_keys
377
- of `steps`, taken before the first step ran.
380
+ prepare_definition for why. `definition_keys` is step_definition_keys
381
+ of `steps`, taken before the first step ran - not the pipeline
382
+ ownership table, whose keys leave out a source's LoRA scale, alpha and
383
+ shift, each of which changes what a borrowing step produces.
378
384
  """
379
385
  # What later steps still read, which decides both whether this
380
386
  # step's result has to be kept alive after the step (release_unreferenced_results
@@ -411,7 +417,7 @@ def cache_lookup(
411
417
  # model - only the source step's does - so without this a
412
418
  # source model change would leave the borrowing step's
413
419
  # snapshot unchanged and serve a stale hit
414
- borrowed = borrowed_pipeline_keys(steps, index, pipeline_keys)
420
+ borrowed = borrowed_pipeline_keys(steps, index, definition_keys)
415
421
  if borrowed:
416
422
  step_data_snapshot["__borrowed_pipelines__"] = borrowed
417
423
  except Exception as ex:
@@ -465,7 +471,7 @@ def cache_hits(workflow, arguments):
465
471
  realize_args(steps, base_dir)
466
472
  # The same table run() takes at the same point, so a borrowed
467
473
  # key here is the key the run stored its entry under
468
- pipeline_keys = step_pipeline_keys(steps)
474
+ definition_keys = step_definition_keys(steps)
469
475
  hits_this_run = set()
470
476
  hits = []
471
477
  for index, step_data in enumerate(steps):
@@ -479,7 +485,7 @@ def cache_hits(workflow, arguments):
479
485
  step_seed,
480
486
  hits_this_run,
481
487
  True,
482
- pipeline_keys,
488
+ definition_keys,
483
489
  )
484
490
  if cached_result is not None:
485
491
  hits_this_run.add(step_data["name"])
@@ -489,6 +495,33 @@ def cache_hits(workflow, arguments):
489
495
  deactivate_output_root(output_root_token)
490
496
 
491
497
 
498
+ def pipeline_keys(workflow, arguments):
499
+ """The set of pipeline cache keys a run of `workflow` with `arguments`
500
+ loads its steps under - the table run() and cache_hits() take
501
+ (step_pipeline_keys over the prepared definition). Prepares the
502
+ definition exactly as a run does and executes nothing: the worker asks
503
+ this before releasing the previous workflow's models, so a model the
504
+ next workflow loads anyway stays warm. A sub-workflow step loads its
505
+ own pipelines later and is not in the set; an unseeded workflow still
506
+ answers, since warmth does not depend on the step cache. The probe
507
+ realizes the steps because the run keys on the realized definition, at
508
+ the price of loading step media once per switch (the price cache_hits
509
+ already pays).
510
+ """
511
+ output_root_token = activate_output_root(workflow.output_dir)
512
+ try:
513
+ workflow_def = copy.deepcopy(workflow.workflow_definition)
514
+ base_dir = run_base_dir(workflow)
515
+ workflow_def, _, _ = prepare_definition(
516
+ workflow, workflow_def, arguments or {}, base_dir
517
+ )
518
+ steps = workflow_def.get("steps", [])
519
+ realize_args(steps, base_dir)
520
+ return set(step_pipeline_keys(steps).values())
521
+ finally:
522
+ deactivate_output_root(output_root_token)
523
+
524
+
492
525
  def prepare_run(workflow, record):
493
526
  """Phase B: the definition as this run works from it, and its seed.
494
527
 
@@ -678,6 +711,7 @@ def begin_steps(workflow, prepared, previous_pipelines, run_context):
678
711
  prepared.cache_enabled,
679
712
  run_context,
680
713
  pipelines,
714
+ definition_keys=step_definition_keys(steps),
681
715
  )
682
716
 
683
717
 
@@ -875,7 +909,7 @@ def run_step(workflow, loop, index, step_data, record):
875
909
  step_seed,
876
910
  loop.hits,
877
911
  loop.cache_enabled,
878
- workflow.pipeline_ownership.running,
912
+ loop.definition_keys,
879
913
  )
880
914
  # The last step of a composed child whose parent does the
881
915
  # saving (#92) - its files are written once, by the parent