File size: 5,683 Bytes
20b15f3 945e356 20b15f3 945e356 20b15f3 945e356 20b15f3 945e356 20b15f3 a5e2450 945e356 20b15f3 a5e2450 945e356 a5e2450 20b15f3 a5e2450 945e356 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | """
ui/gpu.py
---------
ZeroGPU wiring — the only place in NOVA that touches a GPU.
HF's ZeroGPU hardware hands a Space a GPU *only* for the duration of a call to an
@spaces.GPU-decorated function, and refuses to boot at all if it can't find one
at import time ("No @spaces.GPU function detected during startup"). That single
constraint drives the whole design here.
NOVA is CPU-first and stays that way. Exactly one operation runs on the GPU: the
bulk embedding of a paper's chunks during vectorizing, which is by far the
slowest thing in the app (tens of seconds on CPU, a few on GPU). Everything else
— SPECTER reranking, query embedding, the cross-encoder — is pinned to CPU *on
purpose*, because those run outside any GPU window and a cuda-resident model
there would fail on first use. That's why the three backend call sites now take
an explicit `device` instead of auto-detecting.
Off ZeroGPU (local, or Spaces "CPU basic") @spaces.GPU is a transparent
passthrough and ON_ZEROGPU is False, so this module quietly degrades to plain
CPU work and nothing else in the app changes.
THE CUDA-VIRGINITY RULE
-----------------------
ZeroGPU forks its GPU worker from this process (`multiprocessing.get_context
('fork')`), and the very first thing the child does is:
os.environ['CUDA_VISIBLE_DEVICES'] = nvidia_uuid
torch.Tensor([0]).cuda()
CUDA_VISIBLE_DEVICES is honoured by the driver *exactly once per process*, at
first CUDA init. So if anything has really initialised CUDA in THIS process
before the fork, the child inherits a driver that already decided there are zero
devices, the env var is ignored, and the worker dies with
RuntimeError: No CUDA GPUs are available
`spaces` prevents that by monkey-patching torch at `import spaces`. But note how
it does it (spaces/zero/torch/patching.py::patch): the `torch.cuda.*` attribute
fakes are module-global, while the TorchFunctionMode/TorchDispatchMode that
intercept real tensor ops are **thread-local to the thread that called patch()**
— the main thread, at import. The library's own source carries the TODO
admitting the inconsistency. Consequence for us: heavy model loading must happen
on the MAIN THREAD, not on Gradio worker threads or threads we spawn ourselves.
See ui/agents.preload_all().
`cuda_state()` below is the telemetry that proves whether that rule is holding.
"""
import os
import threading
import spaces
# Safe: `spaces` imports torch itself (spaces/zero/torch/__init__.py) and calls
# torch.patch() immediately after, so by the time this line runs torch is already
# in sys.modules and already patched. This is NOT the import that decides order.
import torch
# Set by the ZeroGPU runtime; `spaces.config` reads the same variable.
ON_ZEROGPU = os.getenv("SPACES_ZERO_GPU", "").lower() in ("1", "t", "true")
# The device to use *inside* a GPU window. Outside one, always "cpu".
GPU_DEVICE = "cuda" if ON_ZEROGPU else "cpu"
# Generous but bounded. The window has to cover PDF text extraction and chunking
# (CPU work that unavoidably happens inside build_vectorstore) plus the encode
# itself. A long paper on a cold cache is the worst case.
_VECTORIZE_SECONDS = 120
def cuda_state(tag: str) -> str:
"""One-line snapshot of this process's CUDA state, for the Space logs.
Reads the two fields that actually decide whether a ZeroGPU fork will
succeed, neither of which `spaces` patches (so both tell the truth):
torch.cuda._initialized True once CUDA has REALLY been brought up here.
Must still be False in the parent at the moment
of the GPU call — otherwise the fork is doomed.
torch.cuda._is_in_bad_fork()
True when this process inherited an already-
initialised CUDA context across a fork.
Also reports the thread, because "which thread" is the whole ballgame: the
spaces Torch modes only cover the main thread.
"""
try:
bad_fork = torch.cuda._is_in_bad_fork()
except Exception as e: # pragma: no cover — never let telemetry break the app
bad_fork = f"<{type(e).__name__}>"
current = threading.current_thread()
return (
f"[cuda-state:{tag}] pid={os.getpid()}"
f" thread={current.name!r}"
f" is_main_thread={current is threading.main_thread()}"
f" torch.cuda._initialized={getattr(torch.cuda, '_initialized', '?')}"
f" _is_in_bad_fork={bad_fork}"
f" CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES')!r}"
f" ON_ZEROGPU={ON_ZEROGPU} GPU_DEVICE={GPU_DEVICE}"
)
@spaces.GPU(duration=_VECTORIZE_SECONDS)
def vectorize_on_gpu(pdf_path: str) -> None:
"""Build and persist this paper's vectorstore with the embedder on GPU.
Returns None deliberately. ZeroGPU runs this in its own GPU worker, so a
Chroma handle created here would carry a cuda-resident embedding model back
to a caller that no longer holds the GPU — useless at best, a crash at worst.
What crosses the boundary is the *persisted vectorstore on disk*, which is
device-independent.
The caller then re-opens it on CPU, which costs nothing: build_vectorstore
short-circuits to a plain load as soon as the persist dir exists.
"""
# Runs fresh on every call, inside the GPU worker — unlike a module-level
# print, this actually tells you what's true for *this* invocation. If you
# see this line at all, ZeroGPU's worker bootstrap succeeded.
print(cuda_state("gpu-worker"), flush=True)
from vectorizeer import build_vectorstore
build_vectorstore(pdf_path, device=GPU_DEVICE)
|