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)