PhotoGuider / space_runtime.py
NeilLeeNTU's picture
Fork the feature pool per call inside the daemonic ZeroGPU worker
dd0c9d7 verified
Raw History Blame Contribute Delete
7.08 kB
"""Single-process PhotoGuider runtime for the Hugging Face Space.
The release runs each extractor family in its own environment and process (envs/, workers.py).
Here every model lives in one environment and one process, loaded once and kept resident: the
extractor ops run in-process through the same load()/run(args) interface worker_serve.py uses, and
the explainer VLM is loaded once instead of once per call. On a Space (SPACE_ID set) the explainer
runs in bf16 instead of NF4, which the 48 GB ZeroGPU slice holds without bitsandbytes.
"""
import functools
import importlib
import os
import sys
import types
from pathlib import Path
ROOT = Path(os.environ.get("PHOTOGUIDER_ROOT", Path(__file__).resolve().parent)).resolve()
INF = ROOT / "inference"
LIB = INF / "_lib"
# one environment serves every role; require_env() only checks that the interpreter exists
for _role in ("MAIN", "LAION", "FGAESQ", "SAM", "EXPLAINER"):
os.environ.setdefault(f"CROPPER_ENV_{_role}", sys.executable)
sys.path[:0] = [str(INF), str(LIB), str(LIB / "extractors")]
# FG-IAA ships `utils/` as a namespace package; transparent-background appends a directory holding
# a regular `utils.py` to sys.path, which would shadow it. Bind the name to FG-IAA's package first.
from env_config import asset # noqa: E402
if "utils" not in sys.modules:
_u = types.ModuleType("utils")
_u.__path__ = [str(Path(asset("fgaesq_source", "envs/fgaesq/FGAesQ_Inference")) / "utils")]
sys.modules["utils"] = _u
import pandas as pd # noqa: E402
if os.environ.get("SPACE_ID"):
# ZeroGPU: the Gradio process has no GPU and forks a worker per GPU call. transformers 5 asks
# torch.cuda.is_current_stream_capturing() during a CPU forward, which initialises the CUDA
# runtime in that GPU-less parent, and every worker forked afterwards then sees no GPU
# ("No CUDA GPUs are available"). We never capture CUDA graphs, so answer without asking.
import transformers.utils.import_utils as _tiu
_tiu.is_cuda_stream_capturing = lambda: False
import _shared # noqa: E402
import feature_extractors # noqa: E402
import feat_saliency as FS # noqa: E402
import explain_vlm # noqa: E402
import explain as EX # noqa: E402
import photoguider # noqa: E402
class InProcessPool:
"""ExtractorPool interface; ops run in this process with their models resident."""
def __init__(self, verbose=False):
self.mods = {}
def module(self, op):
if op not in self.mods:
if op not in feature_extractors.OP_ENV:
raise ValueError(f"unknown extractor op '{op}'")
m = importlib.import_module(op)
m.load()
self.mods[op] = m
return self.mods[op]
def run_op(self, op, args=None):
self.module(op).run(args)
return True
def close(self): # models stay resident across requests
pass
def load_vlm_bf16(adapter_dir):
"""explain_vlm.load_vlm without the NF4 quantization."""
import torch
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
from peft import PeftModel
adapter_dir = Path(adapter_dir)
torch.manual_seed(1234)
proc = AutoProcessor.from_pretrained(explain_vlm.BASE_VLM, revision=explain_vlm.revision("qwen"))
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
explain_vlm.BASE_VLM, revision=explain_vlm.revision("qwen"), torch_dtype=torch.bfloat16).to("cuda")
model = PeftModel.from_pretrained(model, str(adapter_dir), is_trainable=False).eval()
return {"proc": proc, "model": model,
"bad": explain_vlm.cjk_bad_words_ids(proc.tokenizer, adapter_dir / "cjk_ids_cache.json")}
# ── two speed-ups that leave every number unchanged ──
# 1. The ~1,500 candidate boxes get their hand-crafted features in a per-row Python loop
# (consultant_features.stage_c_features); the rows are independent, so large pools are split
# across forked CPU workers and concatenated back in order.
import multiprocessing # noqa: E402
import infer # noqa: E402
_WORKERS = int(os.environ.get("PHOTOGUIDER_WORKERS") or max(1, min(8, (os.cpu_count() or 2) - 1))) # 1 = serial
_serial_features = infer.af_chain_features
def _chunk_features(args):
dfp, maps = args
return infer.M02.stage_c_features(dfp.reset_index(drop=True), maps)
def parallel_features(dfp, vmix_2d, v1_2d, mask_np, min_rows=200):
if len(dfp) < min_rows or _WORKERS == 1:
return _serial_features(dfp, vmix_2d, v1_2d, mask_np)
maps = infer.maps_for(dfp.photo_id.iloc[0], vmix_2d, v1_2d, mask_np)
dfp = dfp.reset_index(drop=True)
bounds = [round(i * len(dfp) / _WORKERS) for i in range(_WORKERS + 1)]
# The ZeroGPU worker is a daemonic process, which may not have children; it is forked fresh for
# every call, so lift that flag here and fork a pool for this call only (numpy only, never CUDA).
multiprocessing.current_process()._config["daemon"] = False
with multiprocessing.get_context("fork").Pool(_WORKERS) as pool:
parts = pool.map(_chunk_features, [(dfp.iloc[a:b], maps) for a, b in zip(bounds, bounds[1:]) if b > a])
out = pd.concat(parts, ignore_index=True)
assert len(out) == len(dfp), "parallel candidate features lost rows"
return out
infer.af_chain_features = parallel_features
# 2. The SAM image encoder sees the same full image several times per request (mean embedding,
# patch norms, the decoder's set_image, both extractor passes) and the same candidate batches
# in both passes. Memoise its forward on the exact input tensor; a hit returns the stored output.
def memoise_sam_encoder(sam, max_entries=48):
import hashlib
enc, cache = sam.image_encoder, {}
forward = enc.forward
def cached_forward(x):
key = (tuple(x.shape), str(x.dtype), hashlib.sha1(x.detach().cpu().numpy().tobytes()).hexdigest())
if key not in cache:
if len(cache) >= max_entries:
cache.pop(next(iter(cache)))
cache[key] = forward(x)
return cache[key]
enc.forward = cached_forward
return sam
_get_sam = _shared.get_sam
def get_sam_cached():
if "sam" not in _shared._CACHE:
memoise_sam_encoder(_get_sam())
return _shared._CACHE["sam"]
_shared.get_sam = get_sam_cached
explain_vlm.load_vlm = functools.cache(load_vlm_bf16 if os.environ.get("SPACE_ID") else explain_vlm.load_vlm)
EX.write_explanation = lambda crop_path, prompt: explain_vlm.generate_explanation(crop_path, prompt, str(EX.ADAPTER_DIR))
photoguider.ExtractorPool = InProcessPool
def preload(session, explain=True):
"""Load every model once (extractors, saliency, explainer) so requests only run inference."""
for op in feature_extractors.OP_ENV:
session.pool.module(op)
FS._model()
import feat_clip_refine
feat_clip_refine._load()
if "rem" not in FS._STATE:
from transparent_background import Remover
FS._STATE["rem"] = Remover()
if explain:
explain_vlm.load_vlm(str(EX.ADAPTER_DIR))