File size: 11,766 Bytes
7b5e901
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""WeMM-JEV text and vision inference."""

from __future__ import annotations

import contextlib
import gc
import os
import sys
import threading
from pathlib import Path

from ._compat import Engine, Unsupported
from . import _images as vimg
from ._images import resize_policy

os.environ.setdefault("TRITON_CACHE_AUTOTUNING", "1")
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")

_LOAD_LOCK = threading.Lock()


class D3Engine(Engine):
    name = "d3"
    latency = (
        "Device-synchronized in-process request wall time including prompt rendering and tokenization (and, "
        "for image and video requests, their decoding and preprocessing); one request per call, its questions in "
        "request order in batches of batch_size; excludes model loading."
    )

    def __init__(
        self,
        model: str | None = None,
        revision: str | None = None,
        device: str | None = None,
        batch_size: int = 8,
        verify: str = "fast",
        model_name: str | None = "WeMM-JEV",
        permutation_average: str | bool = False,
        max_pixels: int | None = vimg.MAX_PIXELS,
        image_root: str | None = None,
        replicas: int | None = None,
        full_warmup: str | bool = False,
        enable_videos: str | bool = False,
        max_batch_tokens: int = 8192,
        **options,
    ):
        if options:
            raise TypeError(f"unknown engine options {sorted(options)}")
        if not model:
            model = str(Path(__file__).absolute().parent.parent)
        flag = str(permutation_average).strip().lower()
        if flag not in ("0", "1", "false", "true"):
            raise ValueError("permutation_average must be 0 or 1")
        averaged = flag in ("1", "true")
        fw_flag = str(full_warmup).strip().lower()
        if fw_flag not in ("0", "1", "false", "true"):
            raise ValueError("full_warmup must be 0 or 1")
        self.full_warmup = fw_flag in ("1", "true")
        video_flag = str(enable_videos).strip().lower()
        if video_flag not in ("0", "1", "false", "true"):
            raise ValueError("enable_videos must be 0 or 1")
        self.enable_videos = video_flag in ("1", "true")

        import torch

        self.torch = torch
        self.model_id = str(model)
        self.revision = revision
        self.batch_size = int(batch_size)
        self.max_batch_tokens = int(max_batch_tokens)
        if self.batch_size < 1 or self.max_batch_tokens < 0:
            raise ValueError("batch_size must be positive and max_batch_tokens nonnegative")
        self.verify = verify
        self.model_name = model_name
        self.permutation_average = averaged
        self.max_pixels = max_pixels
        self.image_root = image_root

        gpus = torch.cuda.device_count() if torch.cuda.is_available() else 0
        if device in (None, "cuda"):
            primary_device = "cuda:0" if gpus > 0 else "cpu"
            self._multi_gpu = gpus > 1
            self._num_gpus = max(1, gpus)
        else:
            primary_device = str(device)
            self._multi_gpu = False
            self._num_gpus = 1

        super().__init__(
            model=self.model_id,
            revision=revision,
            device=primary_device,
            batch_size=self.batch_size,
            verify=verify,
            model_name=model_name,
            max_pixels=max_pixels,
            enable_videos=self.enable_videos,
            max_batch_tokens=self.max_batch_tokens,
            **({"permutation_average": 1} if averaged else {}),
        )

        hf_model, decision = self._load_replica(primary_device)
        self.model = hf_model
        self.decision = decision
        self._slots = {0: (hf_model, decision, threading.Lock())}
        self._local = threading.local()
        self._next_slot = 0
        self._slots_lock = threading.Lock()

        if replicas is not None:
            n_rep = int(replicas)
            if n_rep > 1:
                if gpus < n_rep:
                    raise RuntimeError(f"replicas={n_rep} needs {n_rep} CUDA devices, found {gpus}")
                self._multi_gpu = True
                self._num_gpus = n_rep
                for idx in range(1, n_rep):
                    m_i, d_i = self._load_replica(f"cuda:{idx}")
                    self._slots[idx] = (m_i, d_i, threading.Lock())

        self.provenance = {
            **self.decision.provenance(),
            "max_pixels": self.max_pixels,
            "max_batch_tokens": self.max_batch_tokens,
            "image_resize": resize_policy(self.max_pixels) + "; then the checkpoint's own processor",
        }

    def _load_replica(self, device_str: str):
        with _LOAD_LOCK:
            local = Path(os.fspath(self.model_id)).expanduser()
            if local.is_dir():
                pkg_dir = os.path.abspath(os.fspath(local))
                if pkg_dir not in sys.path:
                    sys.path.insert(0, pkg_dir)
            from transformers import AutoModel

            if device_str.startswith("cuda"):
                self.torch.cuda.set_device(device_str)
            hf_model = AutoModel.from_pretrained(
                self.model_id,
                trust_remote_code=True,
                revision=self.revision,
                device=device_str,
                batch_size=self.batch_size,
                verify=self.verify,
            )
            decision = getattr(hf_model, "runtime", None)
            if decision is None:
                raise ValueError(
                    f"{self.model_id!r} did not load as a d3 decision package (missing decision_config.json)"
                )
            if self.model_name:
                decision.model_name = self.model_name
            if self.permutation_average:
                decision.permutation_average = True
            # Text/image evaluation uses PIL and needs no video decoder. This also
            # prevents provenance().video_contract() from importing optional cv2.
            if not self.enable_videos and getattr(decision, "video_unavailable", None) is None:
                decision.video_unavailable = "disabled for text/image evaluation (enable_videos=0)"
            return hf_model, decision

    def _acquire_slot(self):
        slot = getattr(self._local, "slot", None)
        if slot is None:
            with self._slots_lock:
                slot = self._next_slot if self._multi_gpu else 0
                self._next_slot = (self._next_slot + 1) % max(1, self._num_gpus)
            self._local.slot = slot
        entry = self._slots.get(slot)
        if entry is None:
            with self._slots_lock:
                entry = self._slots.get(slot)
                if entry is None:
                    hf_model, decision = self._load_replica(f"cuda:{slot}")
                    entry = (hf_model, decision, threading.Lock())
                    self._slots[slot] = entry
        return entry

    @contextlib.contextmanager
    def _device_ctx(self, decision):
        dev = getattr(decision, "device", None)
        if dev is not None and getattr(dev, "type", None) == "cuda":
            with self.torch.cuda.device(dev):
                yield
        else:
            yield

    def warmup(self):
        with self._device_ctx(self.decision):
            super().warmup()
            if self.full_warmup or getattr(self.decision, "fast", None) is not None:
                self.warmup_seconds = self.decision.warmup()

    def runtime(self):
        return self.decision.runtime_info()

    def synchronize(self):
        for _, decision, _ in list(self._slots.values()):
            decision.synchronize()

    def _prepare_images(self, images, decision):
        if not images:
            return None, None
        from PIL import Image

        pil_images = []
        image_pixels = []
        for item in images:
            if isinstance(item, Image.Image):
                im = item.convert("RGB")
                if self.max_pixels and im.width * im.height > self.max_pixels:
                    im = im.resize(vimg.fit(im.width, im.height, self.max_pixels), Image.LANCZOS)
            else:
                im = vimg.pil(item, self.image_root, self.max_pixels).convert("RGB")
            pil_images.append(im)
            image_pixels.append(im.width * im.height)
        image_tokens = None
        proc = getattr(decision, "processor", None)
        if proc is not None and getattr(proc, "image_processor", None) is not None:
            ip = proc.image_processor
            merge = getattr(ip, "merge_size", 2) ** 2
            try:
                image_tokens = sum(
                    ip.get_number_of_image_patches(im.height, im.width, {}) // merge
                    for im in pil_images
                )
            except Exception:
                image_tokens = None
        raw = {"image_pixels": image_pixels}
        if image_tokens is not None:
            raw["image_tokens"] = image_tokens
        return pil_images, raw

    def __call__(self, state, questions, images=None, videos=None, prompt=None):
        if videos and not self.enable_videos:
            raise Unsupported("video evaluation requires --option enable_videos=1 and a working video decoder")
        _, decision, lock = self._acquire_slot()
        pil_images, raw = self._prepare_images(images, decision)
        with lock, self._device_ctx(decision):
            prepared = decision.prepare(state, questions, pil_images, videos)
            over = [
                e["message"]
                for e in prepared.errors.values()
                if e["error"] == "max_length_exceeded"
            ]
            if over:
                raise Unsupported(over[0])
            if prepared.errors:
                raise ValueError(
                    "invalid questions: "
                    + "; ".join(f"{k}: {e['message']}" for k, e in prepared.errors.items())
                )
            probabilities, tokens = self._run_prepared(decision, prepared)
            response = decision.respond(prepared, probabilities, tokens)
        failed = {
            k: a["message"] for k, a in response["answers"].items() if "error" in a
        }
        if failed:
            raise ValueError(f"invalid model output: {failed}")
        return response, raw

    def _run_prepared(self, decision, prepared):
        """Bound padded tokens per forward without truncating questions or context."""
        original = decision.batch_size
        sizes = list(getattr(prepared, "lengths", {}).values())
        sizes.extend(len(s) for s in getattr(prepared, "sequences", {}).values())
        sizes.extend(len(s) for s in getattr(prepared, "reversed_sequences", {}).values())
        sizes.extend(getattr(prepared, "reversed_lengths", {}).values())
        batch = original
        if self.max_batch_tokens and sizes:
            width = max(sizes) * (2 if self.permutation_average else 1)
            batch = min(batch, max(1, self.max_batch_tokens // max(1, width)))
        try:
            while True:
                decision.batch_size = batch
                try:
                    return decision.run(prepared)
                except self.torch.OutOfMemoryError:
                    if batch == 1:
                        raise
                # Leave the except block first to release the failed forward's traceback/tensors.
                batch = max(1, batch // 2)
                gc.collect()
                self.torch.cuda.empty_cache()
        finally:
            decision.batch_size = original

    def close(self):
        with self._slots_lock:
            self._slots.clear()
        self.model = None
        self.decision = None
        if self.torch.cuda.is_available():
            self.torch.cuda.empty_cache()