File size: 4,943 Bytes
5fa71d5
 
 
1f74b41
 
 
 
 
5fa71d5
1f74b41
5fa71d5
585f232
5fa71d5
 
 
 
585f232
 
 
 
 
 
 
 
 
5fa71d5
4d32393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fa71d5
 
 
1f74b41
 
5fa71d5
 
 
 
1f74b41
5fa71d5
1f74b41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634fc07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
125
126
127
128
129
130
131
132
133
"""
Shared CPU scheduling for CPU-only hardware (e.g. HF "CPU Upgrade": 8 vCPU / 32 GB).

* At most CPU_JOBS model calls run at once (default cores // 2); further calls wait for a slot.
* PyTorch threads adapt to the load: a job running alone uses ALL cores, two jobs get half each, etc.
  (Before, every job was capped at cores // CPU_JOBS threads even when the server was idle.)
* quantize(model): int8 dynamic quantisation of the Linear layers for CPU inference (≈2x faster,
  near-identical output). Disable with CPU_INT8=0.
"""
import contextlib
import os
import sys
import threading

import torch

# A log line must never fail a job. The logs carry "→", "°C", emoji and customers' text; on a console that is not
# UTF-8 (Windows, a container with a C/POSIX locale) print() raised UnicodeEncodeError and the finished job was
# reported as "Processing failed". Characters the console cannot show are now written as escapes instead.
for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(errors="backslashreplace")
    except Exception:                                 # replaced / closed stream: nothing to configure
        pass

HAS_CUDA = torch.cuda.is_available()
# The checking models (meaning, fluency, clarity, vocabulary) ran on the CPU even on a GPU Space: with several
# versions of every sentence checked on every pass, they were most of the waiting time. On a dedicated GPU they now
# run there too. Not on ZeroGPU (the GPU may only be used inside a GPU job there). HELPERS_GPU=0 = keep on CPU.
_ZERO = os.getenv("SPACES_ZERO_GPU", "").strip().lower() in ("1", "true", "yes")
HELPERS_GPU = HAS_CUDA and not _ZERO and os.getenv("HELPERS_GPU", "1") != "0"


def to_gpu(model):
    """Move a small checking model to the GPU when there is a dedicated one (it stays on the CPU otherwise)."""
    if HELPERS_GPU:
        try:
            return model.to("cuda")
        except Exception as err:                       # out of GPU memory etc.: the CPU still works
            print(f"[runtime] model kept on CPU: {err}", flush=True)
    return model


def device_of(model):
    return next(model.parameters()).device
CORES = os.cpu_count() or 2
CPU_JOBS = max(1, int(os.getenv("CPU_JOBS", str(max(1, CORES // 2)))))
cpu_slots = threading.BoundedSemaphore(CPU_JOBS)
_active = 0
_lock = threading.Lock()

if not HAS_CUDA:
    try:
        torch.set_num_interop_threads(1)
    except Exception:
        pass


def _retune():
    try:
        torch.set_num_threads(max(1, CORES // max(1, _active)))
    except Exception:
        pass


@contextlib.contextmanager
def cpu_job():
    """Hold a CPU slot for one model call; threads = cores ÷ jobs running right now."""
    global _active
    cpu_slots.acquire()
    with _lock:
        _active += 1
        _retune()
    try:
        yield
    finally:
        with _lock:
            _active -= 1
            _retune()
        cpu_slots.release()


def quantize(model):
    """int8 dynamic quantisation for CPU (falls back to the original model on any problem)."""
    if HAS_CUDA or os.getenv("CPU_INT8", "1") == "0":
        return model
    try:
        q = torch.quantization.quantize_dynamic(model, {torch.nn.Linear}, dtype=torch.qint8)
        print("[runtime] int8 CPU quantisation enabled", flush=True)
        return q
    except Exception as err:
        print(f"[runtime] quantisation skipped: {err}", flush=True)
        return model


# ---------------------------------------------------------------------------------------------------------------
# Fast tokenizers are not thread-safe. Hugging Face's Rust tokenizer is re-configured (padding / truncation) on
# every call, so two jobs tokenizing with the same model at the same moment fail with "Already borrowed"
# (live Space, 2026-10-19). Every fast tokenizer now gets its own lock: one call at a time per tokenizer, any number
# of tokenizers in parallel. A call takes milliseconds, so jobs do not notice the wait.
def _guard_tokenizers():
    try:
        from transformers import PreTrainedTokenizerFast as T
    except Exception:                                 # transformers not installed: nothing to guard
        return
    if getattr(T, "_cs_guarded", False):
        return
    make = threading.Lock()

    def lock_of(tok):
        lk = tok.__dict__.get("_cs_lock")
        if lk is None:
            with make:
                lk = tok.__dict__.setdefault("_cs_lock", threading.RLock())
        return lk

    def guard(name):
        fn = getattr(T, name, None)
        if fn is None:
            return

        def wrapped(self, *a, **k):
            with lock_of(self):
                return fn(self, *a, **k)
        wrapped.__name__, wrapped.__doc__ = fn.__name__, fn.__doc__
        setattr(T, name, wrapped)
    for name in ("_batch_encode_plus", "_encode_plus", "_decode", "set_truncation_and_padding"):
        guard(name)
    T._cs_guarded = True


_guard_tokenizers()