File size: 4,480 Bytes
889ebe8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The NCII prompt guard, in its own process.

[`hfmlsoc/ncii-guard-v02`](https://huggingface.co/hfmlsoc/ncii-guard-v02) is a 270M CPU text classifier
scoring the NCII risk of an edit prompt. It cannot live in the main process: with it loaded there, every
subsequent `@spaces.GPU` worker dies at `worker_init` with `RuntimeError: No CUDA GPUs are available` — the
fork inherits whatever CUDA driver state the classifier's torch activity left behind, and a restart does not
clear it. It cannot be a `multiprocessing.spawn` child either: spawn re-imports the parent's main module, and
on a Space that main module is `app.py` — the child would re-run the whole startup, model load included. So
the classifier runs this file as a plain subprocess — a fresh interpreter that never sees `spaces` — and
answers over stdin/stdout, one JSON object per line.
"""

from __future__ import annotations

import json
import os
import select
import subprocess
import sys
import threading

GUARD_REPO = "hfmlsoc/ncii-guard-v02"
# Prompts are short; the model's own max_position_embeddings is 32768, and cost
# grows with length (43ms at 16 tokens, 267ms at 256), so cap it.
MAX_TOKENS = 128
# A 270M model over many threads is pathologically slow — 6.1s per prompt on 30
# threads vs 41ms on 8 (OpenMP oversubscription on small tensors).
CPU_THREADS = 8

_lock = threading.Lock()
_process: subprocess.Popen | None = None


def _read(timeout: float) -> dict:
    readable, _, _ = select.select([_process.stdout], [], [], timeout)
    if not readable:
        raise TimeoutError(f"the guard did not answer within {timeout}s")
    line = _process.stdout.readline()
    if not line:
        raise EOFError("the guard process died")
    return json.loads(line)


def _spawn() -> None:
    global _process
    env = dict(os.environ)
    # Belt and braces: this child must never touch a GPU.
    env["CUDA_VISIBLE_DEVICES"] = ""
    env["OMP_NUM_THREADS"] = str(CPU_THREADS)
    _process = subprocess.Popen(
        [sys.executable, os.path.abspath(__file__)],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        text=True,
        bufsize=1,
        env=env,
    )
    # Generous: a cold cache downloads the 1.07GB checkpoint first.
    assert _read(300.0) == {"status": "ready"}


def start() -> None:
    """Launch the worker and block until its model is up.

    Called once at startup, before the main model is loaded so the fork is cheap.
    `classify` revives it if it dies.
    """
    with _lock:
        _spawn()


def classify(prompt: str, timeout: float = 60.0) -> dict:
    """`{'p_ncii': float}` for one prompt, replacing a dead or wedged worker once."""
    with _lock:
        for attempt in (0, 1):
            try:
                if _process is None or _process.poll() is not None:
                    _spawn()
                _process.stdin.write(json.dumps({"prompt": prompt}) + "\n")
                _process.stdin.flush()
                return _read(timeout)
            except Exception:
                if attempt:
                    raise
                if _process is not None and _process.poll() is None:
                    _process.kill()


def _serve() -> None:
    """The child: plain torch on CPU. The protocol keeps the real stdout to itself — everything else
    (download progress, warnings) is pushed over to stderr so it cannot corrupt a reply."""
    protocol = os.fdopen(os.dup(1), "w", buffering=1)
    os.dup2(2, 1)

    import torch
    from transformers import pipeline

    torch.set_num_threads(min(CPU_THREADS, os.cpu_count() or CPU_THREADS))
    # The tokenizer must come from this repo: it carries the confusable/bidi
    # normalizer that folds homoglyph obfuscation ("rеmove" with a Cyrillic е).
    classifier = pipeline("text-classification", model=GUARD_REPO, device="cpu")
    protocol.write(json.dumps({"status": "ready"}) + "\n")
    for line in sys.stdin:
        prompt = json.loads(line)["prompt"]
        # top_k=None returns every label, so the caller can threshold on the
        # ncii probability itself rather than on the argmax label.
        scores = classifier(prompt, truncation=True, max_length=MAX_TOKENS, top_k=None)
        p_ncii = 0.0
        for entry in scores:
            if str(entry["label"]).lower() == "ncii":
                p_ncii = float(entry["score"])
                break
        protocol.write(json.dumps({"p_ncii": p_ncii}) + "\n")


if __name__ == "__main__":
    _serve()