diffusers-workflow 0.4.0__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.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-Bcn70HdC.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-D1HmNnby.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- dw_mcp/workspaces.py +212 -0
dw/worker.py
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent worker process for workflow execution.
|
|
3
|
+
Keeps models loaded in GPU memory across multiple runs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import queue
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
import traceback
|
|
12
|
+
from typing import Dict, Any
|
|
13
|
+
|
|
14
|
+
# Add parent directory to path for imports
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
16
|
+
|
|
17
|
+
from dw.workflow import workflow_from_file, workflow_from_definition
|
|
18
|
+
from dw.step_cache import step_cache
|
|
19
|
+
from dw.assets import activate_asset_dir, deactivate_asset_dir
|
|
20
|
+
from dw.log_setup import setup_logging, set_log_level
|
|
21
|
+
from dw.settings import load_settings, resolve_path
|
|
22
|
+
from dw.events import RunContext, WorkflowCancelled
|
|
23
|
+
from dw import get_device_type, empty_device_cache, device_memory_stats
|
|
24
|
+
from dw.host_memory import (
|
|
25
|
+
host_memory_fields,
|
|
26
|
+
host_memory_stats,
|
|
27
|
+
pinned_host_memory_fields,
|
|
28
|
+
release_host_caches,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _mb(value):
|
|
33
|
+
"""A host memory reading for a message, or 'unknown' where the platform
|
|
34
|
+
could not take one."""
|
|
35
|
+
return f"{value:.0f} MB" if value is not None else "unknown"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _job_scoped_peak_rss_mb(info, baseline_peak_mb):
|
|
39
|
+
"""This job's own contribution to the process-lifetime peak (#272).
|
|
40
|
+
|
|
41
|
+
`host_memory_peak_rss_mb` is `ru_maxrss` - process-lifetime, never reset -
|
|
42
|
+
so in the persistent worker a small job run right after a heavy one
|
|
43
|
+
inherits the heavy job's high-water mark as its own. `baseline_peak_mb`
|
|
44
|
+
is that same process-lifetime figure read at this job's first phase
|
|
45
|
+
boundary; because ru_maxrss is monotonic non-decreasing, growth since
|
|
46
|
+
then is unambiguously this job's doing. A job that caused no growth at
|
|
47
|
+
all (it rode an earlier, larger peak) has no meaningful "job peak" to
|
|
48
|
+
report as a delta, so this floors at the job's current rss instead of
|
|
49
|
+
reporting a number that undersells what is resident right now.
|
|
50
|
+
"""
|
|
51
|
+
peak = info.get("host_memory_peak_rss_mb")
|
|
52
|
+
if peak is None or baseline_peak_mb is None:
|
|
53
|
+
return None
|
|
54
|
+
growth = peak - baseline_peak_mb
|
|
55
|
+
if growth > 0:
|
|
56
|
+
return growth
|
|
57
|
+
return info.get("host_memory_rss_mb")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger("dw.worker")
|
|
61
|
+
|
|
62
|
+
# Memory management constants
|
|
63
|
+
MEMORY_GROWTH_THRESHOLD_MB = 500 # Warn if GPU memory grows by more than this
|
|
64
|
+
|
|
65
|
+
# How often (in seconds) the main loop wakes up to check whether the parent
|
|
66
|
+
# process is still alive when no command has arrived. Short enough that an
|
|
67
|
+
# orphaned worker exits promptly, long enough to avoid busy-waiting.
|
|
68
|
+
COMMAND_POLL_TIMEOUT_SECONDS = 5
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WorkflowWorker:
|
|
72
|
+
"""
|
|
73
|
+
Persistent worker that keeps workflows and models loaded in memory.
|
|
74
|
+
Monitors workflow file for changes and reloads when necessary.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, command_queue, result_queue, log_level="INFO"):
|
|
78
|
+
"""
|
|
79
|
+
Initialize the worker with communication queues.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
command_queue: Queue for receiving commands from REPL
|
|
83
|
+
result_queue: Queue for sending results back to REPL
|
|
84
|
+
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
|
|
85
|
+
"""
|
|
86
|
+
self.command_queue = command_queue
|
|
87
|
+
self.result_queue = result_queue
|
|
88
|
+
|
|
89
|
+
# Capture the parent PID at startup so the main loop can detect
|
|
90
|
+
# orphaning (parent died/was killed without sending "shutdown") and
|
|
91
|
+
# exit cleanly instead of blocking forever on the command queue.
|
|
92
|
+
self.parent_pid = os.getppid()
|
|
93
|
+
|
|
94
|
+
# Log to the same file the rest of dw uses - the worker is a separate
|
|
95
|
+
# process, and ConcurrentRotatingFileHandler exists to share it safely
|
|
96
|
+
settings = load_settings()
|
|
97
|
+
setup_logging(resolve_path(settings.log_filename), log_level)
|
|
98
|
+
|
|
99
|
+
# Workflow state. Identity (path, or id for inline definitions) decides
|
|
100
|
+
# when the model cache is dropped wholesale - switching workflows frees
|
|
101
|
+
# the old one's models before the new one loads
|
|
102
|
+
self.workflow_identity = None
|
|
103
|
+
self.pending_shutdown = False
|
|
104
|
+
|
|
105
|
+
# Pipeline cache - persists across runs
|
|
106
|
+
self.loaded_pipelines = {}
|
|
107
|
+
self.shared_components = {}
|
|
108
|
+
# Last run's step name -> pipeline cache key. Carried across commands
|
|
109
|
+
# so a rerun of the SAME workflow whose arguments changed what a step
|
|
110
|
+
# loads (a different LoRA scale, canvas or step count all change the
|
|
111
|
+
# identity) releases the old stack before the new one loads. Held on
|
|
112
|
+
# the worker rather than on the Workflow because a command builds a
|
|
113
|
+
# fresh Workflow every time, which is why the release never fired
|
|
114
|
+
# between jobs (#150)
|
|
115
|
+
self.prior_step_keys = {}
|
|
116
|
+
|
|
117
|
+
# Memory tracking
|
|
118
|
+
self.run_count = 0
|
|
119
|
+
self.last_memory_mb = 0
|
|
120
|
+
|
|
121
|
+
logger.info("Worker process initialized")
|
|
122
|
+
|
|
123
|
+
def run(self):
|
|
124
|
+
"""
|
|
125
|
+
Main worker loop - processes commands until shutdown.
|
|
126
|
+
"""
|
|
127
|
+
logger.info("Worker entering command loop")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
# Wait for a command from the REPL, but poll with a
|
|
133
|
+
# timeout rather than blocking forever. If nothing
|
|
134
|
+
# arrives, check whether the parent process is still
|
|
135
|
+
# alive - if it has died (e.g. crashed or was killed)
|
|
136
|
+
# without sending "shutdown", we'd otherwise sit here
|
|
137
|
+
# forever as an orphaned, unkillable-by-normal-means
|
|
138
|
+
# child. Exit cleanly instead.
|
|
139
|
+
try:
|
|
140
|
+
command = self.command_queue.get(
|
|
141
|
+
timeout=COMMAND_POLL_TIMEOUT_SECONDS
|
|
142
|
+
)
|
|
143
|
+
except queue.Empty:
|
|
144
|
+
if self._parent_is_dead():
|
|
145
|
+
logger.info(
|
|
146
|
+
f"Parent process (pid {self.parent_pid}) is gone - "
|
|
147
|
+
"worker exiting"
|
|
148
|
+
)
|
|
149
|
+
break
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
command_type = command.get("type")
|
|
153
|
+
|
|
154
|
+
logger.debug(f"Received command: {command_type}")
|
|
155
|
+
|
|
156
|
+
if command_type == "execute":
|
|
157
|
+
self._handle_execute(command)
|
|
158
|
+
if self.pending_shutdown:
|
|
159
|
+
self._handle_shutdown()
|
|
160
|
+
break
|
|
161
|
+
elif command_type == "cancel":
|
|
162
|
+
# Nothing running - a cancel that raced the run's end
|
|
163
|
+
logger.debug("Ignoring cancel with no workflow running")
|
|
164
|
+
elif command_type == "shutdown":
|
|
165
|
+
self._handle_shutdown()
|
|
166
|
+
break
|
|
167
|
+
elif command_type == "ping":
|
|
168
|
+
self._handle_ping()
|
|
169
|
+
elif command_type == "clear_memory":
|
|
170
|
+
self._handle_clear_memory()
|
|
171
|
+
elif command_type == "memory_status":
|
|
172
|
+
self._handle_memory_status()
|
|
173
|
+
elif command_type == "probe_cache":
|
|
174
|
+
self._handle_probe_cache(command)
|
|
175
|
+
else:
|
|
176
|
+
self.result_queue.put(
|
|
177
|
+
{
|
|
178
|
+
"type": "error",
|
|
179
|
+
"message": f"Unknown command type: {command_type}",
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
except KeyboardInterrupt:
|
|
184
|
+
logger.info("Worker interrupted by keyboard")
|
|
185
|
+
break
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(f"Error processing command: {e}", exc_info=True)
|
|
188
|
+
self.result_queue.put(
|
|
189
|
+
{
|
|
190
|
+
"type": "error",
|
|
191
|
+
"message": f"Command processing error: {str(e)}",
|
|
192
|
+
"traceback": traceback.format_exc(),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
finally:
|
|
197
|
+
logger.info("Worker shutting down")
|
|
198
|
+
self._cleanup_all()
|
|
199
|
+
|
|
200
|
+
def _handle_execute(self, command: Dict[str, Any]):
|
|
201
|
+
"""
|
|
202
|
+
Execute a workflow, reusing loaded models if possible.
|
|
203
|
+
|
|
204
|
+
The command names the workflow either by path (workflow_path) or as an
|
|
205
|
+
inline definition (workflow, with an optional base_dir that relative
|
|
206
|
+
paths inside it resolve against). Models stay cached between runs of
|
|
207
|
+
the same workflow identity; pipelines are cached by what they load, so
|
|
208
|
+
an edited workflow keeps every pipeline whose definition is unchanged.
|
|
209
|
+
A {"type": "cancel"} command sent during execution stops the run at
|
|
210
|
+
the next step boundary or diffusion step.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
command: Dictionary with workflow_path or workflow (+ base_dir),
|
|
214
|
+
arguments, output_dir, log_level, and optionally asset_dir -
|
|
215
|
+
the workspace library 'asset:' resolves against
|
|
216
|
+
"""
|
|
217
|
+
arguments = command["arguments"]
|
|
218
|
+
output_dir = command["output_dir"]
|
|
219
|
+
log_level = command.get("log_level", "INFO")
|
|
220
|
+
# Bound before the load, so a failure or a cancellation still reports
|
|
221
|
+
# whatever the run had written by then - the steps that did complete
|
|
222
|
+
# are the first thing a failed long run is asked about
|
|
223
|
+
workflow = None
|
|
224
|
+
# Bound for the same reason: the failure path evicts against what the
|
|
225
|
+
# run touched, and a run can fail before it has a context at all
|
|
226
|
+
context = None
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
set_log_level(log_level)
|
|
230
|
+
|
|
231
|
+
workflow, identity = self._load_workflow(command, output_dir)
|
|
232
|
+
workflow.validate(arguments=arguments)
|
|
233
|
+
|
|
234
|
+
# Switching to a different workflow frees the old one's models
|
|
235
|
+
# before the new one loads - on one accelerator, holding both is
|
|
236
|
+
# what runs out of memory
|
|
237
|
+
if identity != self.workflow_identity:
|
|
238
|
+
if self.workflow_identity is not None:
|
|
239
|
+
self.result_queue.put(
|
|
240
|
+
{
|
|
241
|
+
"type": "output",
|
|
242
|
+
"message": "Workflow changed - releasing cached models...",
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
self.result_queue.put(
|
|
246
|
+
{"type": "output", "message": self._cleanup_all()}
|
|
247
|
+
)
|
|
248
|
+
self.workflow_identity = identity
|
|
249
|
+
|
|
250
|
+
self.result_queue.put(
|
|
251
|
+
{"type": "workflow_loaded", "workflow_name": workflow.name}
|
|
252
|
+
)
|
|
253
|
+
self.result_queue.put(
|
|
254
|
+
{
|
|
255
|
+
"type": "output",
|
|
256
|
+
"message": f"Executing workflow: {workflow.name}",
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Progress events stream to the client as they happen; the
|
|
261
|
+
# watcher thread keeps the command queue live so cancel works
|
|
262
|
+
# mid-run. Each phase boundary also gets its own memory_info
|
|
263
|
+
# message (#273), so get_memory answers freshly mid-run instead
|
|
264
|
+
# of refusing with job_running for the run's whole duration; the
|
|
265
|
+
# first such reading is this job's baseline for the job-scoped
|
|
266
|
+
# peak field carried on every memory_info from here on (#272)
|
|
267
|
+
job_baseline = {"peak_rss_mb": None}
|
|
268
|
+
|
|
269
|
+
def _on_event(event):
|
|
270
|
+
self.result_queue.put({"type": "progress", **event})
|
|
271
|
+
if event.get("event") == "phase":
|
|
272
|
+
memory_info = self._get_memory_info()
|
|
273
|
+
if job_baseline["peak_rss_mb"] is None:
|
|
274
|
+
job_baseline["peak_rss_mb"] = memory_info.get(
|
|
275
|
+
"host_memory_peak_rss_mb"
|
|
276
|
+
)
|
|
277
|
+
memory_info["host_memory_job_peak_rss_mb"] = (
|
|
278
|
+
_job_scoped_peak_rss_mb(
|
|
279
|
+
memory_info, job_baseline["peak_rss_mb"]
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
self.result_queue.put({"type": "memory_info", "info": memory_info})
|
|
283
|
+
|
|
284
|
+
context = RunContext(on_event=_on_event)
|
|
285
|
+
watcher = self._watch_commands(context)
|
|
286
|
+
# Which workspace's assets this job's 'asset:' references resolve
|
|
287
|
+
# against. A server holds several workspaces and each has its own
|
|
288
|
+
# library, so the root travels with the job rather than being
|
|
289
|
+
# pinned in the environment the way the shared prompt library is
|
|
290
|
+
asset_token = (
|
|
291
|
+
activate_asset_dir(command["asset_dir"])
|
|
292
|
+
if command.get("asset_dir")
|
|
293
|
+
else None
|
|
294
|
+
)
|
|
295
|
+
try:
|
|
296
|
+
workflow.run(
|
|
297
|
+
arguments,
|
|
298
|
+
self.loaded_pipelines,
|
|
299
|
+
context=context,
|
|
300
|
+
prior_step_keys=self.prior_step_keys,
|
|
301
|
+
)
|
|
302
|
+
finally:
|
|
303
|
+
self._record_step_keys(workflow)
|
|
304
|
+
watcher.stop()
|
|
305
|
+
if asset_token is not None:
|
|
306
|
+
deactivate_asset_dir(asset_token)
|
|
307
|
+
|
|
308
|
+
self._evict_untouched_pipelines(context)
|
|
309
|
+
|
|
310
|
+
self.run_count += 1
|
|
311
|
+
|
|
312
|
+
# Aggressive memory cleanup after execution
|
|
313
|
+
self._cleanup_between_runs()
|
|
314
|
+
|
|
315
|
+
# Report memory status - the job's final reading, carrying the
|
|
316
|
+
# same job-scoped delta the phase-boundary readings above do
|
|
317
|
+
# (#272). A run with no phase events at all (job_baseline never
|
|
318
|
+
# set) reports host_memory_job_peak_rss_mb as null rather than
|
|
319
|
+
# guessing a baseline after the fact.
|
|
320
|
+
memory_info = self._get_memory_info()
|
|
321
|
+
memory_info["host_memory_job_peak_rss_mb"] = _job_scoped_peak_rss_mb(
|
|
322
|
+
memory_info, job_baseline["peak_rss_mb"]
|
|
323
|
+
)
|
|
324
|
+
self.result_queue.put({"type": "memory_info", "info": memory_info})
|
|
325
|
+
|
|
326
|
+
self.result_queue.put(
|
|
327
|
+
{
|
|
328
|
+
"type": "success",
|
|
329
|
+
"message": "Workflow completed successfully",
|
|
330
|
+
"run_count": self.run_count,
|
|
331
|
+
"manifest": getattr(workflow, "manifest", []),
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
except WorkflowCancelled:
|
|
336
|
+
self._cleanup_between_runs()
|
|
337
|
+
self.result_queue.put(
|
|
338
|
+
{
|
|
339
|
+
"type": "cancelled",
|
|
340
|
+
"message": "Workflow run cancelled",
|
|
341
|
+
"manifest": getattr(workflow, "manifest", []),
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logger.error(f"Error executing workflow: {e}", exc_info=True)
|
|
346
|
+
failure = {
|
|
347
|
+
"type": "error",
|
|
348
|
+
"message": f"Workflow execution error: {str(e)}",
|
|
349
|
+
"traceback": traceback.format_exc(),
|
|
350
|
+
# The files the steps before the failure wrote are on
|
|
351
|
+
# disk; reporting them is what keeps a run that died at
|
|
352
|
+
# step five from looking like one that produced nothing
|
|
353
|
+
"manifest": getattr(workflow, "manifest", []),
|
|
354
|
+
}
|
|
355
|
+
# The exception's traceback reaches every frame between here and
|
|
356
|
+
# the failure, and those frames hold whatever a half-finished load
|
|
357
|
+
# had built - so a collection that runs while the exception is
|
|
358
|
+
# still live frees none of it. The report above is a formatted
|
|
359
|
+
# string by now, so nothing is lost by letting the traceback go
|
|
360
|
+
e.__traceback__ = None
|
|
361
|
+
del e
|
|
362
|
+
# Success and cancellation both reclaim; failure did neither, so a
|
|
363
|
+
# half-loaded pipeline and any variant the attempt superseded
|
|
364
|
+
# stayed resident and the next attempt loaded on top of them
|
|
365
|
+
self._evict_untouched_pipelines(context)
|
|
366
|
+
self._cleanup_between_runs()
|
|
367
|
+
self.result_queue.put(failure)
|
|
368
|
+
|
|
369
|
+
def _load_workflow(self, command: Dict[str, Any], output_dir: str):
|
|
370
|
+
"""Build the Workflow a command names, and its cache identity."""
|
|
371
|
+
workflow_dir = command.get("workflow_dir")
|
|
372
|
+
if "workflow_path" in command and command["workflow_path"] is not None:
|
|
373
|
+
workflow_path = command["workflow_path"]
|
|
374
|
+
workflow = workflow_from_file(workflow_path, output_dir, workflow_dir)
|
|
375
|
+
return workflow, ("path", workflow_path)
|
|
376
|
+
|
|
377
|
+
workflow_data = command["workflow"]
|
|
378
|
+
workflow = workflow_from_definition(
|
|
379
|
+
workflow_data, output_dir, command.get("base_dir"), workflow_dir
|
|
380
|
+
)
|
|
381
|
+
return workflow, ("inline", workflow_data.get("id"))
|
|
382
|
+
|
|
383
|
+
def _watch_commands(self, context):
|
|
384
|
+
"""Watch the command queue during a run so cancel and ping still work.
|
|
385
|
+
|
|
386
|
+
Returns an object with stop(); anything that is not cancel, ping or
|
|
387
|
+
shutdown is refused, since one workflow runs at a time.
|
|
388
|
+
"""
|
|
389
|
+
stop_event = threading.Event()
|
|
390
|
+
worker = self
|
|
391
|
+
|
|
392
|
+
def watch():
|
|
393
|
+
while not stop_event.is_set():
|
|
394
|
+
try:
|
|
395
|
+
command = worker.command_queue.get(timeout=0.25)
|
|
396
|
+
except queue.Empty:
|
|
397
|
+
continue
|
|
398
|
+
command_type = command.get("type")
|
|
399
|
+
if command_type == "cancel":
|
|
400
|
+
logger.info("Cancel requested")
|
|
401
|
+
context.cancel()
|
|
402
|
+
worker.result_queue.put(
|
|
403
|
+
{"type": "output", "message": "Cancelling..."}
|
|
404
|
+
)
|
|
405
|
+
elif command_type == "ping":
|
|
406
|
+
worker._handle_ping()
|
|
407
|
+
elif command_type == "shutdown":
|
|
408
|
+
# Stop the run, then let the main loop see the shutdown
|
|
409
|
+
context.cancel()
|
|
410
|
+
worker.pending_shutdown = True
|
|
411
|
+
else:
|
|
412
|
+
worker.result_queue.put(
|
|
413
|
+
{
|
|
414
|
+
"type": "error",
|
|
415
|
+
"message": f"Cannot handle '{command_type}' while a "
|
|
416
|
+
"workflow is running",
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
thread = threading.Thread(target=watch, daemon=True, name="command-watcher")
|
|
421
|
+
thread.start()
|
|
422
|
+
|
|
423
|
+
class _Watcher:
|
|
424
|
+
def stop(self):
|
|
425
|
+
stop_event.set()
|
|
426
|
+
thread.join()
|
|
427
|
+
|
|
428
|
+
return _Watcher()
|
|
429
|
+
|
|
430
|
+
def _handle_shutdown(self):
|
|
431
|
+
"""Handle graceful shutdown request."""
|
|
432
|
+
logger.info("Shutdown requested")
|
|
433
|
+
self._cleanup_all()
|
|
434
|
+
self.result_queue.put({"type": "shutdown_complete"})
|
|
435
|
+
|
|
436
|
+
def _handle_ping(self):
|
|
437
|
+
"""Respond to ping to prove worker is alive."""
|
|
438
|
+
self.result_queue.put({"type": "pong", "run_count": self.run_count})
|
|
439
|
+
|
|
440
|
+
def _handle_clear_memory(self):
|
|
441
|
+
"""Handle explicit memory clear request."""
|
|
442
|
+
logger.info("Memory clear requested")
|
|
443
|
+
self._cleanup_all()
|
|
444
|
+
memory_info = self._get_memory_info()
|
|
445
|
+
self.result_queue.put({"type": "memory_cleared", "info": memory_info})
|
|
446
|
+
|
|
447
|
+
def _handle_memory_status(self):
|
|
448
|
+
"""Report current memory usage."""
|
|
449
|
+
memory_info = self._get_memory_info()
|
|
450
|
+
self.result_queue.put({"type": "memory_status", "info": memory_info})
|
|
451
|
+
|
|
452
|
+
def _handle_probe_cache(self, command: Dict[str, Any]):
|
|
453
|
+
"""Which steps the step cache would serve for a run of this command
|
|
454
|
+
- the plan's cached_steps (#85). Same fields as an execute command;
|
|
455
|
+
loads the workflow, executes nothing. A failure answers
|
|
456
|
+
cached: null with the reason rather than an error message, since
|
|
457
|
+
an unknown answer is a valid plan and a crashed probe is not. The
|
|
458
|
+
command's probe_id is echoed so a reply that arrives after its
|
|
459
|
+
caller gave up is not read as the answer to the next probe.
|
|
460
|
+
"""
|
|
461
|
+
probe_id = command.get("probe_id")
|
|
462
|
+
try:
|
|
463
|
+
workflow, _ = self._load_workflow(command, command["output_dir"])
|
|
464
|
+
asset_token = (
|
|
465
|
+
activate_asset_dir(command["asset_dir"])
|
|
466
|
+
if command.get("asset_dir")
|
|
467
|
+
else None
|
|
468
|
+
)
|
|
469
|
+
try:
|
|
470
|
+
cached = workflow.cache_hits(command.get("arguments") or {})
|
|
471
|
+
finally:
|
|
472
|
+
if asset_token is not None:
|
|
473
|
+
deactivate_asset_dir(asset_token)
|
|
474
|
+
self.result_queue.put(
|
|
475
|
+
{"type": "probe_cache", "probe_id": probe_id, "cached": cached}
|
|
476
|
+
)
|
|
477
|
+
except Exception as e:
|
|
478
|
+
logger.debug(f"Cache probe failed: {e}")
|
|
479
|
+
self.result_queue.put(
|
|
480
|
+
{
|
|
481
|
+
"type": "probe_cache",
|
|
482
|
+
"probe_id": probe_id,
|
|
483
|
+
"cached": None,
|
|
484
|
+
"error": str(e),
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def _record_step_keys(self, workflow):
|
|
489
|
+
"""Remember which pipeline each step loaded, for the next command.
|
|
490
|
+
|
|
491
|
+
Merged rather than replaced: a run that failed at step two never
|
|
492
|
+
reached step five, and forgetting step five's key would leave the
|
|
493
|
+
variant it loaded last time with nothing to release it. Merged means
|
|
494
|
+
never pruned, though, so this map outlives the workflow that wrote a
|
|
495
|
+
given entry - a name here can belong to an earlier, different job's
|
|
496
|
+
step. A stale entry is harmless because Workflow.create_step_action
|
|
497
|
+
judges "still shared" on the running steps' CURRENT keys
|
|
498
|
+
(Workflow._running_pipeline_keys), never on this map's other
|
|
499
|
+
entries: a key is held only while another step of the executing run
|
|
500
|
+
loads under it now. A name this map remembers from another workflow
|
|
501
|
+
is not a running step, and a sibling whose key moved with this one's
|
|
502
|
+
no longer claims the old key, so neither saves it from release.
|
|
503
|
+
"""
|
|
504
|
+
keys = getattr(workflow, "_pipeline_keys_by_step", None)
|
|
505
|
+
if keys:
|
|
506
|
+
self.prior_step_keys.update(keys)
|
|
507
|
+
|
|
508
|
+
def _evict_untouched_pipelines(self, context):
|
|
509
|
+
"""Drop cached pipelines this run no longer touched.
|
|
510
|
+
|
|
511
|
+
An edited workflow that removed or redefined a step leaves those
|
|
512
|
+
behind, and so does a failed attempt whose superseded variant nothing
|
|
513
|
+
will ever load again. A run that failed before it had a context
|
|
514
|
+
touched nothing this can be judged against, so nothing is dropped -
|
|
515
|
+
the caller's own cleanup still runs.
|
|
516
|
+
"""
|
|
517
|
+
if context is None:
|
|
518
|
+
return
|
|
519
|
+
for cache_key in list(self.loaded_pipelines):
|
|
520
|
+
if cache_key not in context.touched_pipelines:
|
|
521
|
+
logger.info("Evicting cached pipeline no longer in workflow")
|
|
522
|
+
del self.loaded_pipelines[cache_key]
|
|
523
|
+
|
|
524
|
+
def _cleanup_between_runs(self):
|
|
525
|
+
"""
|
|
526
|
+
Aggressive memory cleanup between workflow runs.
|
|
527
|
+
Keeps models loaded but cleans up intermediate tensors and garbage.
|
|
528
|
+
"""
|
|
529
|
+
import gc
|
|
530
|
+
|
|
531
|
+
logger.debug("Performing inter-run memory cleanup")
|
|
532
|
+
|
|
533
|
+
# Force garbage collection
|
|
534
|
+
gc.collect()
|
|
535
|
+
|
|
536
|
+
# Clean up GPU cache if available (CUDA or MPS). Don't synchronize
|
|
537
|
+
# here as it's expensive and unnecessary.
|
|
538
|
+
try:
|
|
539
|
+
empty_device_cache()
|
|
540
|
+
except Exception as e:
|
|
541
|
+
logger.warning(f"Could not clean GPU cache: {e}")
|
|
542
|
+
|
|
543
|
+
# release_host_caches() only touches blocks nothing is using - the
|
|
544
|
+
# pinned-host staging buffers of a step's group_offload and the
|
|
545
|
+
# glibc arenas a released pipeline's weights were read into - so it
|
|
546
|
+
# is safe here even though loaded_pipelines/shared_components are
|
|
547
|
+
# still warm for the next run. Without it those two caches are the
|
|
548
|
+
# gap between what a job's own cleanup releases and what an explicit
|
|
549
|
+
# clear_memory does (#368): a released pipeline's GPU memory drops at
|
|
550
|
+
# `release_pipeline`, but the host arenas it staged through, plus any
|
|
551
|
+
# SDNQ/group_offload residue from steps that never released at all
|
|
552
|
+
# because the job ended first, stay resident until something calls
|
|
553
|
+
# this.
|
|
554
|
+
released = release_host_caches()
|
|
555
|
+
if released:
|
|
556
|
+
logger.info(f"Inter-run cleanup returned {released:.0f} MB to the OS")
|
|
557
|
+
|
|
558
|
+
# Check for memory growth
|
|
559
|
+
current_memory = self._get_gpu_memory_mb()
|
|
560
|
+
if current_memory > 0:
|
|
561
|
+
if self.last_memory_mb > 0:
|
|
562
|
+
growth = current_memory - self.last_memory_mb
|
|
563
|
+
if growth > MEMORY_GROWTH_THRESHOLD_MB:
|
|
564
|
+
logger.warning(
|
|
565
|
+
f"GPU memory grew by {growth:.1f}MB "
|
|
566
|
+
f"({self.last_memory_mb:.1f}MB -> {current_memory:.1f}MB)"
|
|
567
|
+
)
|
|
568
|
+
self.last_memory_mb = current_memory
|
|
569
|
+
|
|
570
|
+
logger.debug("Inter-run cleanup complete")
|
|
571
|
+
|
|
572
|
+
def _cleanup_all(self):
|
|
573
|
+
"""
|
|
574
|
+
Complete cleanup - clear all cached models and components.
|
|
575
|
+
Called when workflow changes or on shutdown.
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
One line saying what host memory looks like on the other side of
|
|
579
|
+
it. The caller decides whether that reaches the client: on a
|
|
580
|
+
workflow switch it is the answer to "what did the last job leave
|
|
581
|
+
behind", which is the question a later OOM is asked (#98).
|
|
582
|
+
"""
|
|
583
|
+
import gc
|
|
584
|
+
from .tasks.model_cache import clear_model_cache
|
|
585
|
+
|
|
586
|
+
logger.info("Performing full cleanup")
|
|
587
|
+
|
|
588
|
+
# Clear pipeline cache and any models task handlers cached
|
|
589
|
+
self.loaded_pipelines.clear()
|
|
590
|
+
self.shared_components.clear()
|
|
591
|
+
# The keys addressed entries that are now gone; keeping them would
|
|
592
|
+
# have a later step chase a release that has already happened
|
|
593
|
+
self.prior_step_keys.clear()
|
|
594
|
+
clear_model_cache()
|
|
595
|
+
# Drop cached step results too - stale results would otherwise
|
|
596
|
+
# survive a memory clear and keep getting served for steps whose
|
|
597
|
+
# models/components were just evicted
|
|
598
|
+
step_cache.clear()
|
|
599
|
+
|
|
600
|
+
# Reset state
|
|
601
|
+
self.run_count = 0
|
|
602
|
+
self.last_memory_mb = 0
|
|
603
|
+
|
|
604
|
+
# Force garbage collection multiple times
|
|
605
|
+
for _ in range(3):
|
|
606
|
+
gc.collect()
|
|
607
|
+
|
|
608
|
+
# Aggressive GPU cleanup (CUDA or MPS) - empty cache and synchronize
|
|
609
|
+
# to ensure all operations complete before we go on to reset stats.
|
|
610
|
+
try:
|
|
611
|
+
empty_device_cache(synchronize=True)
|
|
612
|
+
|
|
613
|
+
# Try to reset CUDA memory stats - no MPS equivalent exists
|
|
614
|
+
if get_device_type() == "cuda":
|
|
615
|
+
import torch
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
torch.cuda.reset_peak_memory_stats()
|
|
619
|
+
torch.cuda.reset_accumulated_memory_stats()
|
|
620
|
+
except (RuntimeError, AttributeError) as e:
|
|
621
|
+
logger.debug(f"Could not reset memory stats: {e}")
|
|
622
|
+
|
|
623
|
+
except Exception as e:
|
|
624
|
+
logger.warning(f"Could not perform GPU cleanup: {e}")
|
|
625
|
+
|
|
626
|
+
# Dropping the references above frees the weights inside this
|
|
627
|
+
# process; it does not hand the arenas they were read into back to
|
|
628
|
+
# the kernel. On a template that needs 96% of host RAM the residue of
|
|
629
|
+
# the *previous* job is what the OOM killer arrives for, minutes into
|
|
630
|
+
# a run that is itself perfectly legal (#98) - so ask for it back
|
|
631
|
+
# here, where a different model family is about to be loaded, and say
|
|
632
|
+
# what came back rather than leaving it to be inferred from a later
|
|
633
|
+
# reading
|
|
634
|
+
released = release_host_caches()
|
|
635
|
+
stats = host_memory_stats()
|
|
636
|
+
summary = (
|
|
637
|
+
f"Released cached models: host RSS {_mb(stats.get('rss_mb'))}, "
|
|
638
|
+
f"{_mb(stats.get('available_mb'))} available"
|
|
639
|
+
+ (f" ({released:.0f} MB returned to the OS)" if released else "")
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
logger.info(f"Full cleanup complete. {summary}")
|
|
643
|
+
return summary
|
|
644
|
+
|
|
645
|
+
def _parent_is_dead(self) -> bool:
|
|
646
|
+
"""
|
|
647
|
+
Check whether the process that spawned this worker is still around.
|
|
648
|
+
|
|
649
|
+
On POSIX, a process gets reparented to init (traditionally pid 1,
|
|
650
|
+
though some systems use a subreaper) once its original parent exits,
|
|
651
|
+
so a changed getppid() is the standard signal that we've been
|
|
652
|
+
orphaned.
|
|
653
|
+
|
|
654
|
+
Returns:
|
|
655
|
+
True if the parent appears to be gone, False otherwise.
|
|
656
|
+
"""
|
|
657
|
+
current_ppid = os.getppid()
|
|
658
|
+
return current_ppid != self.parent_pid or current_ppid == 1
|
|
659
|
+
|
|
660
|
+
def _get_gpu_memory_mb(self) -> float:
|
|
661
|
+
"""
|
|
662
|
+
Get current GPU memory usage in MB.
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
Memory usage in MB, or 0 if not available
|
|
666
|
+
"""
|
|
667
|
+
try:
|
|
668
|
+
return device_memory_stats()["allocated_mb"]
|
|
669
|
+
except (RuntimeError, AttributeError) as e:
|
|
670
|
+
logger.debug(f"Could not get GPU memory: {e}")
|
|
671
|
+
return 0.0
|
|
672
|
+
|
|
673
|
+
def _get_memory_info(self) -> Dict[str, Any]:
|
|
674
|
+
"""
|
|
675
|
+
Get detailed memory information.
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
Dictionary with memory statistics
|
|
679
|
+
"""
|
|
680
|
+
info = {
|
|
681
|
+
"run_count": self.run_count,
|
|
682
|
+
"gpu_available": False,
|
|
683
|
+
"gpu_memory_allocated_mb": 0.0,
|
|
684
|
+
"gpu_memory_reserved_mb": 0.0,
|
|
685
|
+
"gpu_memory_free_mb": 0.0,
|
|
686
|
+
"gpu_device_name": None,
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
# Host memory beside the device figures: the offloading these
|
|
690
|
+
# workflows use keeps weights in RAM by design, so a leak - or a run
|
|
691
|
+
# that simply has not let go of a model - shows here and nowhere
|
|
692
|
+
# else. Measured inside the worker, so the process figures are the
|
|
693
|
+
# worker's own
|
|
694
|
+
info.update(host_memory_fields())
|
|
695
|
+
# And what torch's pinned-host allocator is sitting on, which
|
|
696
|
+
# `rss_mb` includes without saying so: on a group-offloaded workflow
|
|
697
|
+
# it is GB of staging buffers, and it is the half of a worker's host
|
|
698
|
+
# residency no figure named before (#98)
|
|
699
|
+
info.update(pinned_host_memory_fields())
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
stats = device_memory_stats()
|
|
703
|
+
info["gpu_available"] = stats["available"]
|
|
704
|
+
info["gpu_device_name"] = stats["device_name"]
|
|
705
|
+
info["gpu_memory_allocated_mb"] = stats["allocated_mb"]
|
|
706
|
+
info["gpu_memory_reserved_mb"] = stats["reserved_mb"]
|
|
707
|
+
# free/total are only ever None when CUDA's mem_get_info call
|
|
708
|
+
# itself failed - leave gpu_memory_free_mb at its 0.0 default and
|
|
709
|
+
# gpu_memory_total_mb unset in that case, same as before.
|
|
710
|
+
if stats["free_mb"] is not None:
|
|
711
|
+
info["gpu_memory_free_mb"] = stats["free_mb"]
|
|
712
|
+
if stats["total_mb"] is not None:
|
|
713
|
+
info["gpu_memory_total_mb"] = stats["total_mb"]
|
|
714
|
+
except (ImportError, RuntimeError, AttributeError) as e:
|
|
715
|
+
logger.debug(f"Could not access GPU: {e}")
|
|
716
|
+
|
|
717
|
+
# The step cache's own eviction accounting (#418) - otherwise a
|
|
718
|
+
# phantom-bytes regression in it has no symptom short of the cache
|
|
719
|
+
# collapsing to one entry, thousands of stale drops away
|
|
720
|
+
info["step_cache"] = step_cache.stats()
|
|
721
|
+
|
|
722
|
+
return info
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def worker_main(command_queue, result_queue, log_level="INFO"):
|
|
726
|
+
"""
|
|
727
|
+
Entry point for worker process.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
command_queue: Queue for receiving commands
|
|
731
|
+
result_queue: Queue for sending results
|
|
732
|
+
log_level: Logging level
|
|
733
|
+
"""
|
|
734
|
+
try:
|
|
735
|
+
worker = WorkflowWorker(command_queue, result_queue, log_level)
|
|
736
|
+
worker.run()
|
|
737
|
+
except Exception as e:
|
|
738
|
+
logger.error(f"Worker crashed: {e}", exc_info=True)
|
|
739
|
+
try:
|
|
740
|
+
result_queue.put(
|
|
741
|
+
{
|
|
742
|
+
"type": "worker_crashed",
|
|
743
|
+
"message": str(e),
|
|
744
|
+
"traceback": traceback.format_exc(),
|
|
745
|
+
}
|
|
746
|
+
)
|
|
747
|
+
except (OSError, RuntimeError) as queue_error:
|
|
748
|
+
logger.error(f"Failed to send crash notification to queue: {queue_error}")
|
|
749
|
+
sys.exit(1)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
if __name__ == "__main__":
|
|
753
|
+
# For testing - won't normally be run directly
|
|
754
|
+
import multiprocessing
|
|
755
|
+
|
|
756
|
+
# Set spawn method for CUDA compatibility
|
|
757
|
+
if multiprocessing.get_start_method(allow_none=True) != "spawn":
|
|
758
|
+
multiprocessing.set_start_method("spawn", force=True)
|
|
759
|
+
|
|
760
|
+
cmd_queue = multiprocessing.Queue()
|
|
761
|
+
res_queue = multiprocessing.Queue()
|
|
762
|
+
|
|
763
|
+
print("Starting worker in test mode...")
|
|
764
|
+
worker_main(cmd_queue, res_queue, "DEBUG")
|