daKhosa commited on
Commit
afd702d
·
1 Parent(s): b840bd7

Fix ZeroGPU GPU access: switch from subprocess.Popen to multiprocessing.Process

Browse files

The spaces library only patches multiprocessing.Process.start() to register
child PIDs with the GPU broker. subprocess.Popen children are not registered
and get no GPU access (CUDA init fails with "No CUDA GPUs are available").

Changes:
- generate.py: extract run_generation() callable + _worker_entrypoint for
multiprocessing pickling; add immediate torch.zeros(1, device='cuda') right
after signal received to establish CUDA context before lease expires
- app.py: import _worker_entrypoint, use multiprocessing.get_context('spawn')
.Process instead of subprocess.Popen; sleep 0.8s in GPU lease to give
worker time to init CUDA; proc.join() instead of proc.wait()

Files changed (2) hide show
  1. app.py +26 -27
  2. generate.py +101 -72
app.py CHANGED
@@ -1,9 +1,11 @@
1
  """
2
  Sulphur — Image to Video (HF Spaces).
3
 
4
- POC-proven pattern: subprocess is pre-spawned from the main Gradio process,
5
- @spaces.GPU(duration=1) is used only to signal CUDA initialisation.
6
- The subprocess retains GPU access for the full generation duration.
 
 
7
  Billing cost: 1 second per generation regardless of inference time.
8
  """
9
 
@@ -11,8 +13,6 @@ import json
11
  import multiprocessing
12
  import os
13
  import shutil
14
- import subprocess
15
- import sys
16
  import tempfile
17
  import threading
18
  import time
@@ -21,13 +21,17 @@ from pathlib import Path
21
  import gradio as gr
22
  import spaces
23
 
 
 
24
  _HF_TOKEN = os.environ.get("HF_TOKEN")
25
  _PERSISTENT = Path("/data") if Path("/data").exists() else Path(tempfile.gettempdir())
26
  WAN2GP_ROOT = _PERSISTENT / "Wan2GP"
27
  CKPTS_DIR = WAN2GP_ROOT / "ckpts"
28
  LORAS_DIR = WAN2GP_ROOT / "loras" / "ltx2"
29
  FINETUNES_DIR = WAN2GP_ROOT / "finetunes"
30
- GENERATE_PY = Path(__file__).parent / "generate.py"
 
 
31
 
32
  SULPHUR_ASSETS = [
33
  ("SulphurAI/Sulphur-2-base", "sulphur_distil_bf16.safetensors", CKPTS_DIR),
@@ -86,6 +90,7 @@ def setup():
86
  if not (WAN2GP_ROOT / "shared" / "api.py").exists():
87
  WAN2GP_ROOT.mkdir(parents=True, exist_ok=True)
88
  print("[setup] Cloning Wan2GP...")
 
89
  subprocess.run(
90
  ["git", "clone", "--depth=1",
91
  "https://github.com/deepbeepmeep/Wan2GP.git", str(WAN2GP_ROOT)],
@@ -126,7 +131,7 @@ RESOLUTIONS = ["832x480", "480x832", "640x640", "1024x576", "576x1024"]
126
  def _signal_cuda_init(signal_path):
127
  """Acquire a 1-second GPU lease just long enough for the worker to init CUDA."""
128
  Path(signal_path).write_text("go")
129
- time.sleep(0.5)
130
 
131
 
132
  def generate_video(image, prompt, resolution, steps, guidance_scale, frames, seed):
@@ -140,25 +145,17 @@ def generate_video(image, prompt, resolution, steps, guidance_scale, frames, see
140
  out_dir = tempfile.mkdtemp()
141
  out_file = os.path.join(out_dir, "output.mp4")
142
 
143
- cmd = [
144
- sys.executable, str(GENERATE_PY),
145
- "--image", image,
146
- "--prompt", prompt,
147
- "--output", out_file,
148
- "--model", "sulphur-2",
149
- "--seed", str(int(seed)),
150
- "--resolution", resolution,
151
- "--steps", str(int(steps)),
152
- "--guidance_scale", str(float(guidance_scale)),
153
- "--frames", str(int(frames)),
154
- "--signal-path", signal_path,
155
- "--result-path", result_path,
156
- ]
157
-
158
- env = {**os.environ, "WAN2GP_ROOT": str(WAN2GP_ROOT)}
159
-
160
- # Spawn from main Gradio process (not inside ZeroGPU daemon — avoids daemon-child restriction)
161
- proc = subprocess.Popen(cmd, env=env)
162
 
163
  log_lines = ["Worker spawned, acquiring GPU lease..."]
164
  yield None, "\n".join(log_lines)
@@ -191,7 +188,9 @@ def generate_video(image, prompt, resolution, steps, guidance_scale, frames, see
191
  else:
192
  yield None, "\n".join(log_lines)
193
 
194
- proc.wait()
 
 
195
 
196
  for path in (signal_path, result_path):
197
  try:
 
1
  """
2
  Sulphur — Image to Video (HF Spaces).
3
 
4
+ POC-proven pattern: subprocess is pre-spawned from the main Gradio process via
5
+ multiprocessing.Process (NOT subprocess.Popen ZeroGPU only grants GPU access
6
+ to children spawned through the patched multiprocessing API).
7
+ @spaces.GPU(duration=1) signals CUDA initialisation; the worker retains the
8
+ context for the full generation.
9
  Billing cost: 1 second per generation regardless of inference time.
10
  """
11
 
 
13
  import multiprocessing
14
  import os
15
  import shutil
 
 
16
  import tempfile
17
  import threading
18
  import time
 
21
  import gradio as gr
22
  import spaces
23
 
24
+ from generate import _worker_entrypoint
25
+
26
  _HF_TOKEN = os.environ.get("HF_TOKEN")
27
  _PERSISTENT = Path("/data") if Path("/data").exists() else Path(tempfile.gettempdir())
28
  WAN2GP_ROOT = _PERSISTENT / "Wan2GP"
29
  CKPTS_DIR = WAN2GP_ROOT / "ckpts"
30
  LORAS_DIR = WAN2GP_ROOT / "loras" / "ltx2"
31
  FINETUNES_DIR = WAN2GP_ROOT / "finetunes"
32
+
33
+ # Propagate to spawned worker processes
34
+ os.environ["WAN2GP_ROOT"] = str(WAN2GP_ROOT)
35
 
36
  SULPHUR_ASSETS = [
37
  ("SulphurAI/Sulphur-2-base", "sulphur_distil_bf16.safetensors", CKPTS_DIR),
 
90
  if not (WAN2GP_ROOT / "shared" / "api.py").exists():
91
  WAN2GP_ROOT.mkdir(parents=True, exist_ok=True)
92
  print("[setup] Cloning Wan2GP...")
93
+ import subprocess
94
  subprocess.run(
95
  ["git", "clone", "--depth=1",
96
  "https://github.com/deepbeepmeep/Wan2GP.git", str(WAN2GP_ROOT)],
 
131
  def _signal_cuda_init(signal_path):
132
  """Acquire a 1-second GPU lease just long enough for the worker to init CUDA."""
133
  Path(signal_path).write_text("go")
134
+ time.sleep(0.8)
135
 
136
 
137
  def generate_video(image, prompt, resolution, steps, guidance_scale, frames, seed):
 
145
  out_dir = tempfile.mkdtemp()
146
  out_file = os.path.join(out_dir, "output.mp4")
147
 
148
+ # Use multiprocessing.Process — ZeroGPU patches this to grant GPU access to children.
149
+ # subprocess.Popen does NOT get GPU access (not patched by the spaces library).
150
+ ctx = multiprocessing.get_context("spawn")
151
+ proc = ctx.Process(
152
+ target=_worker_entrypoint,
153
+ args=(image, prompt, out_file, "sulphur-2",
154
+ int(steps), float(guidance_scale), int(frames),
155
+ resolution, int(seed), signal_path, result_path),
156
+ daemon=False,
157
+ )
158
+ proc.start()
 
 
 
 
 
 
 
 
159
 
160
  log_lines = ["Worker spawned, acquiring GPU lease..."]
161
  yield None, "\n".join(log_lines)
 
188
  else:
189
  yield None, "\n".join(log_lines)
190
 
191
+ proc.join(timeout=10)
192
+ if proc.is_alive():
193
+ proc.terminate()
194
 
195
  for path in (signal_path, result_path):
196
  try:
generate.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
- HF Spaces version of generate.pypaths adapted for /data/Wan2GP.
3
- Called as a subprocess from app.py.
4
  Waits for --signal-path before touching CUDA, writes incremental JSON to --result-path.
 
5
  """
6
 
7
  import argparse
@@ -30,7 +30,7 @@ _log_entries = []
30
  _result_path = None
31
 
32
 
33
- def p(msg):
34
  ts = time.strftime("%H:%M:%S")
35
  entry = f"[{ts}] {msg}"
36
  _log_entries.append(entry)
@@ -43,72 +43,70 @@ def p(msg):
43
  pass
44
 
45
 
46
- def parse_args():
47
- ap = argparse.ArgumentParser()
48
- ap.add_argument("--image", required=True)
49
- ap.add_argument("--prompt", required=True)
50
- ap.add_argument("--output", required=True)
51
- ap.add_argument("--model", default="sulphur-2")
52
- ap.add_argument("--steps", type=int, default=None)
53
- ap.add_argument("--guidance_scale", type=float, default=None)
54
- ap.add_argument("--frames", type=int, default=None)
55
- ap.add_argument("--resolution", default=None)
56
- ap.add_argument("--seed", type=int, default=-1)
57
- ap.add_argument("--signal-path", default=None, dest="signal_path")
58
- ap.add_argument("--result-path", default=None, dest="result_path")
59
- return ap.parse_args()
60
-
61
-
62
- def main():
63
- global _result_path
64
-
65
- args = parse_args()
66
- _result_path = args.result_path
67
-
68
- if args.signal_path:
69
- p("Waiting for GPU lease signal...")
70
- while not Path(args.signal_path).exists():
71
  time.sleep(0.05)
72
- p("Signal received — GPU lease active, initialising CUDA...")
73
-
74
- model_type = MODEL_SHORTHANDS.get(args.model, args.model)
 
 
 
 
 
 
 
 
75
  defaults = DEFAULTS.get(model_type, DEFAULTS["sulphur_2_base"])
76
 
77
- image_path = str(Path(args.image.strip()).resolve())
78
  if not Path(image_path).exists():
79
- p(f"Fatal: image not found: {image_path}")
80
- if _result_path:
81
- with open(_result_path, "w") as f:
82
- json.dump({"log": _log_entries, "done": True, "error": "image not found"}, f)
83
- sys.exit(1)
84
 
85
- resolution = args.resolution or defaults["resolution"]
86
- if not args.resolution:
87
  try:
88
  from PIL import Image as _PIL
89
  img = _PIL.open(image_path)
90
  iw, ih = img.size
91
  if ih > iw:
92
- tw = 480
93
- th = round(ih / iw * tw / 32) * 32
94
  else:
95
- th = 480
96
- tw = round(iw / ih * th / 32) * 32
97
- resolution = f"{tw}x{th}"
98
- p(f"Auto-detected resolution: {resolution} (from {iw}x{ih} input)")
99
  except Exception:
100
  pass
101
 
102
  task = {
103
  "model_type": model_type,
104
  "base_model_type": model_type,
105
- "prompt": args.prompt,
106
  "image_start": image_path,
107
- "num_inference_steps": args.steps or defaults["num_inference_steps"],
108
- "guidance_scale": args.guidance_scale or defaults["guidance_scale"],
109
- "resolution": resolution,
110
- "video_length": args.frames or defaults["video_length"],
111
- "seed": args.seed,
112
  "image_prompt_type": "S",
113
  "input_video_strength": 1.0,
114
  "activated_loras": [
@@ -118,28 +116,27 @@ def main():
118
  "loras_multipliers": ["0.5", "1.0"],
119
  }
120
 
121
- p(f"Model: {model_type}")
122
- p(f"Image: {image_path}")
123
- p(f"Steps: {task['num_inference_steps']} Guidance: {task['guidance_scale']}")
124
- p(f"Resolution: {task['resolution']} Frames: {task['video_length']}")
125
- p(f"Prompt: {args.prompt[:80]}")
126
 
127
  sys.path.insert(0, str(WAN2GP_ROOT))
128
  os.chdir(WAN2GP_ROOT)
129
 
130
  from shared.api import WanGPSession
131
 
132
- output_dir = Path(args.output).parent
133
  output_dir.mkdir(parents=True, exist_ok=True)
134
 
135
- p("Starting WanGPSession...")
136
  session = WanGPSession(root=WAN2GP_ROOT, output_dir=output_dir, console_output=True)
137
 
138
- p("Running generation...")
139
  result = session.run_task(task)
140
 
141
  output_file = None
142
-
143
  if result.artifacts:
144
  src = result.artifacts[0].path
145
  if src and Path(src).exists():
@@ -149,26 +146,58 @@ def main():
149
  candidates = sorted(output_dir.glob("**/*.mp4"), key=lambda f: f.stat().st_mtime, reverse=True)
150
  if candidates:
151
  output_file = str(candidates[0])
152
- p(f"Found output via dir scan: {output_file}")
153
 
154
  if output_file:
155
  import shutil
156
- shutil.copy2(output_file, args.output)
157
- p(f"Done: {args.output}")
158
  else:
159
- p(f"No output found in {output_dir}")
160
  if result.errors:
161
- p(f"Errors: {result.errors}")
162
- if _result_path:
163
- with open(_result_path, "w") as f:
164
- json.dump({"log": _log_entries, "done": True, "error": "no output"}, f)
165
- sys.exit(1)
166
 
167
  session.close()
 
168
 
169
- if _result_path:
170
- with open(_result_path, "w") as f:
171
- json.dump({"log": _log_entries, "done": True}, f)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
 
174
  if __name__ == "__main__":
 
1
  """
2
+ HF Spaces generate module called via multiprocessing.Process from app.py.
 
3
  Waits for --signal-path before touching CUDA, writes incremental JSON to --result-path.
4
+ Also usable as a CLI script.
5
  """
6
 
7
  import argparse
 
30
  _result_path = None
31
 
32
 
33
+ def _p(msg):
34
  ts = time.strftime("%H:%M:%S")
35
  entry = f"[{ts}] {msg}"
36
  _log_entries.append(entry)
 
43
  pass
44
 
45
 
46
+ def _done(error=None):
47
+ if _result_path:
48
+ with open(_result_path, "w") as f:
49
+ data = {"log": _log_entries, "done": True}
50
+ if error:
51
+ data["error"] = error
52
+ json.dump(data, f)
53
+
54
+
55
+ def run_generation(image, prompt, output, model="sulphur-2", steps=None,
56
+ guidance_scale=None, frames=None, resolution=None, seed=-1,
57
+ signal_path=None, result_path=None):
58
+ global _log_entries, _result_path
59
+ _log_entries = []
60
+ _result_path = result_path
61
+
62
+ if signal_path:
63
+ _p("Waiting for GPU lease signal...")
64
+ while not Path(signal_path).exists():
 
 
 
 
 
 
65
  time.sleep(0.05)
66
+ _p("Signal received — initialising CUDA context immediately...")
67
+ try:
68
+ import torch
69
+ _ = torch.zeros(1, device="cuda")
70
+ _p(f"CUDA ready: {torch.cuda.get_device_name(0)}")
71
+ except Exception as exc:
72
+ _p(f"CUDA init failed: {exc}")
73
+ _done(error=str(exc))
74
+ return
75
+
76
+ model_type = MODEL_SHORTHANDS.get(model, model)
77
  defaults = DEFAULTS.get(model_type, DEFAULTS["sulphur_2_base"])
78
 
79
+ image_path = str(Path(image.strip()).resolve())
80
  if not Path(image_path).exists():
81
+ _p(f"Fatal: image not found: {image_path}")
82
+ _done(error="image not found")
83
+ return
 
 
84
 
85
+ res = resolution or defaults["resolution"]
86
+ if not resolution:
87
  try:
88
  from PIL import Image as _PIL
89
  img = _PIL.open(image_path)
90
  iw, ih = img.size
91
  if ih > iw:
92
+ tw = 480; th = round(ih / iw * tw / 32) * 32
 
93
  else:
94
+ th = 480; tw = round(iw / ih * th / 32) * 32
95
+ res = f"{tw}x{th}"
96
+ _p(f"Auto-detected resolution: {res} (from {iw}x{ih} input)")
 
97
  except Exception:
98
  pass
99
 
100
  task = {
101
  "model_type": model_type,
102
  "base_model_type": model_type,
103
+ "prompt": prompt,
104
  "image_start": image_path,
105
+ "num_inference_steps": steps or defaults["num_inference_steps"],
106
+ "guidance_scale": guidance_scale or defaults["guidance_scale"],
107
+ "resolution": res,
108
+ "video_length": frames or defaults["video_length"],
109
+ "seed": seed,
110
  "image_prompt_type": "S",
111
  "input_video_strength": 1.0,
112
  "activated_loras": [
 
116
  "loras_multipliers": ["0.5", "1.0"],
117
  }
118
 
119
+ _p(f"Model: {model_type}")
120
+ _p(f"Image: {image_path}")
121
+ _p(f"Steps: {task['num_inference_steps']} Guidance: {task['guidance_scale']}")
122
+ _p(f"Resolution: {task['resolution']} Frames: {task['video_length']}")
123
+ _p(f"Prompt: {prompt[:80]}")
124
 
125
  sys.path.insert(0, str(WAN2GP_ROOT))
126
  os.chdir(WAN2GP_ROOT)
127
 
128
  from shared.api import WanGPSession
129
 
130
+ output_dir = Path(output).parent
131
  output_dir.mkdir(parents=True, exist_ok=True)
132
 
133
+ _p("Starting WanGPSession...")
134
  session = WanGPSession(root=WAN2GP_ROOT, output_dir=output_dir, console_output=True)
135
 
136
+ _p("Running generation...")
137
  result = session.run_task(task)
138
 
139
  output_file = None
 
140
  if result.artifacts:
141
  src = result.artifacts[0].path
142
  if src and Path(src).exists():
 
146
  candidates = sorted(output_dir.glob("**/*.mp4"), key=lambda f: f.stat().st_mtime, reverse=True)
147
  if candidates:
148
  output_file = str(candidates[0])
149
+ _p(f"Found output via dir scan: {output_file}")
150
 
151
  if output_file:
152
  import shutil
153
+ shutil.copy2(output_file, output)
154
+ _p(f"Done: {output}")
155
  else:
156
+ _p(f"No output found in {output_dir}")
157
  if result.errors:
158
+ _p(f"Errors: {result.errors}")
159
+ _done(error="no output produced")
160
+ return
 
 
161
 
162
  session.close()
163
+ _done()
164
 
165
+
166
+ # Top-level function required for multiprocessing.Process pickling
167
+ def _worker_entrypoint(image, prompt, output, model, steps, guidance_scale,
168
+ frames, resolution, seed, signal_path, result_path):
169
+ run_generation(
170
+ image=image, prompt=prompt, output=output, model=model,
171
+ steps=steps, guidance_scale=guidance_scale, frames=frames,
172
+ resolution=resolution, seed=seed,
173
+ signal_path=signal_path, result_path=result_path,
174
+ )
175
+
176
+
177
+ def parse_args():
178
+ ap = argparse.ArgumentParser()
179
+ ap.add_argument("--image", required=True)
180
+ ap.add_argument("--prompt", required=True)
181
+ ap.add_argument("--output", required=True)
182
+ ap.add_argument("--model", default="sulphur-2")
183
+ ap.add_argument("--steps", type=int, default=None)
184
+ ap.add_argument("--guidance_scale", type=float, default=None)
185
+ ap.add_argument("--frames", type=int, default=None)
186
+ ap.add_argument("--resolution", default=None)
187
+ ap.add_argument("--seed", type=int, default=-1)
188
+ ap.add_argument("--signal-path", default=None, dest="signal_path")
189
+ ap.add_argument("--result-path", default=None, dest="result_path")
190
+ return ap.parse_args()
191
+
192
+
193
+ def main():
194
+ args = parse_args()
195
+ run_generation(
196
+ image=args.image, prompt=args.prompt, output=args.output,
197
+ model=args.model, steps=args.steps, guidance_scale=args.guidance_scale,
198
+ frames=args.frames, resolution=args.resolution, seed=args.seed,
199
+ signal_path=args.signal_path, result_path=args.result_path,
200
+ )
201
 
202
 
203
  if __name__ == "__main__":