File size: 38,084 Bytes
c5c9a10
f4f3734
 
c5c9a10
 
 
 
 
 
f4f3734
 
 
 
 
 
 
 
 
 
 
 
 
 
3eb7396
f4f3734
 
 
 
 
 
 
 
 
 
3eb7396
 
 
 
f4f3734
 
 
 
 
 
 
d18e0a7
 
 
 
b117dbc
 
 
 
 
 
 
c5c9a10
 
 
 
b9db84c
f4f3734
c5c9a10
f4f3734
c5c9a10
b9db84c
f4f3734
 
b9db84c
 
c5c9a10
f4f3734
 
c5c9a10
 
 
f4f3734
d18e0a7
 
c5c9a10
 
 
b9db84c
b117dbc
f4f3734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b621ef
f4f3734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b117dbc
3eb7396
f4f3734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b117dbc
3eb7396
f4f3734
c5c9a10
 
f4f3734
 
 
 
 
 
 
 
 
 
 
c5c9a10
 
f4f3734
 
 
 
 
c5c9a10
f4f3734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3eb7396
 
f4f3734
 
c5c9a10
f4f3734
 
c5c9a10
b117dbc
 
3eb7396
 
b117dbc
3eb7396
 
 
c5c9a10
 
f4f3734
 
 
 
 
c5c9a10
 
 
 
f4f3734
c5c9a10
f4f3734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c5c9a10
f4f3734
b117dbc
 
f4f3734
 
 
 
 
 
 
 
 
 
 
 
 
b117dbc
 
 
 
 
 
 
 
 
 
 
b9db84c
 
f4f3734
 
 
b9db84c
f4f3734
 
 
 
 
 
 
 
c5c9a10
 
 
 
f4f3734
 
 
b117dbc
 
 
 
 
f4f3734
 
 
 
 
c5c9a10
 
f4f3734
c5c9a10
 
f4f3734
 
 
c5c9a10
f4f3734
c5c9a10
f4f3734
 
 
 
 
c5c9a10
 
b9db84c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d18e0a7
 
 
 
b9db84c
 
 
 
 
 
 
c5c9a10
b9db84c
d18e0a7
 
 
 
 
 
 
 
b9db84c
 
 
f4f3734
 
b9db84c
 
 
 
f4f3734
c5c9a10
 
 
b9db84c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4f3734
 
 
b9db84c
 
 
 
 
 
 
 
 
 
 
 
 
f4f3734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b9db84c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4f3734
 
 
b9db84c
f4f3734
 
 
 
 
c5c9a10
 
f4f3734
 
 
 
 
 
c5c9a10
 
 
 
f4f3734
c5c9a10
f4f3734
 
 
 
3eb7396
 
b9db84c
 
f4f3734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b117dbc
 
 
 
d18e0a7
 
f4f3734
d18e0a7
 
f4f3734
 
 
 
 
 
 
 
 
c5c9a10
 
 
d18e0a7
 
 
 
 
 
 
c5c9a10
d18e0a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4f3734
 
 
d18e0a7
 
 
 
 
 
 
 
 
 
 
c5c9a10
f4f3734
b9db84c
 
d18e0a7
 
 
f4f3734
 
 
 
c5c9a10
d18e0a7
 
b9db84c
 
f4f3734
 
 
 
 
d18e0a7
 
 
 
 
 
 
 
f4f3734
 
 
 
 
 
 
d18e0a7
f4f3734
 
 
 
 
 
d18e0a7
 
f4f3734
 
 
 
 
 
 
 
 
 
d18e0a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
# SPDX-License-Identifier: Apache-2.0
"""ASGI serving app for SAM3 (text-prompted open-vocabulary instance segmentation) on a
single Tenstorrent Blackhole p150a.

Served by tt-model-manager as ``kind: tt-dit-server``::

    runtime:
      app: tt_sam3.server.app:app

uvicorn runs this module with ``--lifespan on``. Everything that touches the chip or the
weights happens inside the ASGI **lifespan**, so uvicorn's ``Application startup complete``
-- the line ``tt-model serve`` waits for -- means the device is claimed, the checkpoint is
loaded and the pipeline has already run its warm-up forwards.

Import-time this module only needs numpy / torch / fastapi / pydantic / PIL and the
port's own side-effect-free ``tt_sam3.tt.ttnn_sam3_model`` (``ttnn`` and ``sam3`` are
imported lazily in the lifespan), so the image's build-time ``verify.sh`` can import it
with no device, no weights and no ``HF_MODEL``.

Recipe (the one the port validated -- ``tests/benchmark_sam3_e2e.py``):

    _patch_pin_memory()
    model = build_sam3_model(checkpoint_path=<sam3.pt from HF_MODEL @ TT_WEIGHTS_REVISION>)
    device = open_device(cfg)    # ETH dispatch + 1 CQ + 12x10 grid (SAM3_DISPATCH=auto), single chip
    pipeline = TtSam3ImagePipeline(model, device, torch_compile=False)
    pipeline.forward_image(px_1x3x1008x1008, ["object"])       # x SAM3_WARMUP_RUNS

Configuration (environment, read in the lifespan -- never at import):

    HF_MODEL              weights repo id            (default facebook/sam3; tt-model sets it)
    TT_WEIGHTS_REVISION   commit sha of the weights  (default None = repo default branch)
    SAM3_WEIGHTS_DIR      local dir holding sam3.pt  (offline / host override; skips the Hub)
    TT_MESH_SHAPE         "1x1" | "(1, 1)" | "1,1"   (anything but a 1-chip mesh is refused)
    TT_DEVICE_ID          chip to open               (default 0)
    SAM3_DISPATCH         "auto" (default) | "eth" | "worker". auto = ETH dispatch + 1 command queue + 12x10
                          compute grid (the p150 target) when tt-metal has the Blackhole ETH-dispatch patch, else
                          the stock dispatch with a warning. "worker" = explicit opt-in to the stock Tensix-column
                          dispatch (11x10 on a p150; 12x10 only on a Galaxy chip). Outputs are the same.
    SAM3_BPE_PATH         CLIP BPE vocab .txt.gz     (default: vendored tt_sam3/assets copy)
    SAM3_TORCH_COMPILE    "1" to torch.compile the decoder (default "0": no C++ compiler in the image)
    SAM3_NUM_THREADS      torch CPU threads          (default 17, capped at os.cpu_count())
    SAM3_WARMUP_RUNS      warm forwards before READY (default 2)
    SAM3_L1_SMALL_SIZE    ttnn l1_small_size         (default 0 = ttnn default, as validated)
    SAM3_MAX_PROMPTS      prompts per request        (default 4)
    SAM3_MAX_SIDE         longest accepted image side (default 4096)
    SAM3_IMG_CACHE        "1" (default; needs the optional xxhash package) = image-result cache: a request whose
                          image bytes equal the previous request's (xxh3_128 key) reuses the decoded image and the
                          image-trace outputs still resident on the device (no decode, upload or image trace; same
                          output bits). "0" = every request runs the whole forward. Read at import.
    TT_FUSED              fused device paths (default ON since the device pass of 2026-09-13; "0" =
                          the legacy graph, bit-for-bit). Read once here (device open needs the trace
                          region) and passed to the pipeline. With it: SAM3_TRACE_REGION_SIZE ->
                          ttnn.open_device(trace_region_size=), and the encoder trace is captured
                          during warm-up run 1, so READY implies a captured trace AND zero legacy
                          fallbacks in warm-up (startup fails otherwise; /info.runtime reports both).
                          Sub-knobs and their validated defaults: tt_sam3/tt/fused_config.py.
"""
from __future__ import annotations

import base64
import binascii
import gc
import io
import logging
import os
import struct
import threading
import time
import zlib
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional, Tuple

import numpy as np
import torch
import torch.nn.functional as F
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from PIL import Image
from pydantic import BaseModel, Field

from tt_sam3.server import maskpost as _maskpost
from tt_sam3.tt.fused_config import FusedConfig, device_open_kwargs
from tt_sam3.tt.ttnn_sam3_model import (
    TtSam3ImagePipeline,
    _patch_pin_memory,
    build_sam3_model,
    preprocess_image,
    resolve_bpe_path,
)

log = logging.getLogger("tt_sam3.server")
if not log.handlers:
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")

# ---------------------------------------------------------------------------------------
# Constants (what the port validated)
# ---------------------------------------------------------------------------------------
MODEL_NAME = "sam3-p150"
DEFAULT_WEIGHTS_REPO = "facebook/sam3"
WEIGHTS_FILENAME = "sam3.pt"
INPUT_SIZE = 1008  # fixed: pos_embed / SDPA chunking are precomputed for the 72x72 patch grid
NUM_QUERIES = 200
MASK_RES = 288  # pred_masks are 288x288 logits (stride 3.5 of the 1008 input)
SOURCE_REPO = "https://github.com/changh95/tt-sam3"
SOURCE_COMMIT = "cb728e85275334973b39d5acf6ae376ff2a94a68"
DEFAULT_WARMUP_PROMPT = "object"

STATE: Dict[str, Any] = {}
LOCK = threading.Lock()  # one chip, one globally monkey-patched torch model: serialize


# ---------------------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------------------
@dataclass
class Config:
    weights_repo: str
    weights_revision: Optional[str]
    weights_dir: Optional[str]
    mesh_shape: str
    device_id: int
    bpe_path: str
    torch_compile: bool
    warmup_runs: int
    l1_small_size: int
    max_prompts: int
    max_side: int
    fused: FusedConfig = FusedConfig.disabled()
    dispatch: str = "auto"


def _env_flag(name: str, default: str) -> bool:
    return os.environ.get(name, default).strip().lower() not in ("0", "false", "no", "off", "")


def read_config() -> Config:
    return Config(
        weights_repo=os.environ.get("HF_MODEL") or DEFAULT_WEIGHTS_REPO,
        weights_revision=os.environ.get("TT_WEIGHTS_REVISION") or None,
        weights_dir=os.environ.get("SAM3_WEIGHTS_DIR") or None,
        mesh_shape=os.environ.get("TT_MESH_SHAPE", "1x1"),
        device_id=int(os.environ.get("TT_DEVICE_ID", "0")),
        bpe_path=resolve_bpe_path(),
        torch_compile=_env_flag("SAM3_TORCH_COMPILE", "0"),
        warmup_runs=int(os.environ.get("SAM3_WARMUP_RUNS", "2")),
        l1_small_size=int(os.environ.get("SAM3_L1_SMALL_SIZE", "0")),
        max_prompts=int(os.environ.get("SAM3_MAX_PROMPTS", "4")),
        max_side=int(os.environ.get("SAM3_MAX_SIDE", "4096")),
        fused=FusedConfig.from_env(),  # TT_FUSED read once for the whole process
        dispatch=os.environ.get("SAM3_DISPATCH", "auto").strip().lower() or "auto",
    )


def parse_mesh_shape(value: str) -> Tuple[int, int]:
    """Accept the launcher's ``1x1`` as well as ``(1, 1)`` / ``1,1``; refuse anything else."""
    text = value.strip().lower().replace("(", "").replace(")", "").replace(" ", "")
    for sep in ("x", ","):
        if sep in text:
            parts = text.split(sep)
            if len(parts) == 2 and all(p.isdigit() for p in parts):
                return int(parts[0]), int(parts[1])
    raise RuntimeError(
        f"TT_MESH_SHAPE={value!r} is not understood; expected a shape like '1x1', '(1, 1)' or '1,1'."
    )


# ---------------------------------------------------------------------------------------
# Lifespan pieces
# ---------------------------------------------------------------------------------------
def resolve_checkpoint(cfg: Config) -> str:
    """Path of ``sam3.pt``: a local dir override, else the HF cache at the pinned revision.

    ``hf_hub_download(revision=<sha>)`` resolves from the cache without any network call when
    tt-model pre-downloaded the snapshot; without a sha it must resolve ``main`` on the Hub.
    """
    if cfg.weights_dir:
        path = os.path.join(cfg.weights_dir, WEIGHTS_FILENAME)
        if not os.path.isfile(path):
            raise FileNotFoundError(f"SAM3_WEIGHTS_DIR={cfg.weights_dir!r} does not contain {WEIGHTS_FILENAME}")
        return path
    from huggingface_hub import hf_hub_download

    if not cfg.weights_revision:
        log.warning("TT_WEIGHTS_REVISION is not set: resolving %s@main on the Hub (network needed)", cfg.weights_repo)
    return hf_hub_download(repo_id=cfg.weights_repo, filename=WEIGHTS_FILENAME, revision=cfg.weights_revision)


def open_device(cfg: Config):
    """Open the single chip: ``fused_config.device_open_kwargs`` (l1_small / trace region) + the dispatch of
    ``SAM3_DISPATCH`` (default auto = ETH dispatch, 1 CQ, 12x10 grid; ``tt_sam3.tt.device_open``)."""
    rows, cols = parse_mesh_shape(cfg.mesh_shape)
    if rows * cols != 1:
        raise RuntimeError(
            f"TT_MESH_SHAPE={cfg.mesh_shape!r} is a {rows * cols}-chip mesh; this port runs on exactly one "
            "Blackhole chip (mesh_device P150). Use ttnn.open_mesh_device and shard the model first."
        )
    # shared with benchmark.py / conftest.py: bare open for the legacy path, l1_small_size only when set
    # (SAM3_L1_SMALL_SIZE; the E2E runs used the default), trace_region_size when the fused encoder trace is on
    from tt_sam3.tt.device_open import open_device as _open

    kwargs: Dict[str, Any] = device_open_kwargs(cfg.fused, device_id=cfg.device_id, l1_small_size=cfg.l1_small_size)
    device, mode, grid = _open(kwargs, cfg.dispatch)
    STATE["dispatch"] = {"dispatch": mode, "num_command_queues": 1, "grid": f"{grid[0]}x{grid[1]}"}
    return device


def warmup_input() -> torch.Tensor:
    """The deterministic structured pattern the benchmark warms with (not random noise)."""
    x = torch.linspace(-1, 1, INPUT_SIZE).view(1, 1, 1, INPUT_SIZE).expand(1, 1, INPUT_SIZE, INPUT_SIZE)
    y = torch.linspace(-1, 1, INPUT_SIZE).view(1, 1, INPUT_SIZE, 1).expand(1, 1, INPUT_SIZE, INPUT_SIZE)
    return torch.cat([x, y, torch.sin(x * 3.14) * torch.cos(y * 3.14)], dim=1).contiguous()


@asynccontextmanager
async def lifespan(_app: FastAPI):
    cfg = read_config()
    torch.set_grad_enabled(False)
    _patch_pin_memory()  # sam3's geometry encoder calls Tensor.pin_memory(), which raises on CPU-only torch

    # 1) weights + reference model on the host (no device yet)
    t0 = time.perf_counter()
    log.info("Loading weights %s@%s (%s)", cfg.weights_repo, cfg.weights_revision or "main", WEIGHTS_FILENAME)
    ckpt_path = resolve_checkpoint(cfg)
    if not os.path.isfile(cfg.bpe_path):
        raise FileNotFoundError(f"BPE vocabulary not found at {cfg.bpe_path!r} (set SAM3_BPE_PATH)")
    log.info("Loading checkpoint %s (bpe=%s)", ckpt_path, cfg.bpe_path)
    model = build_sam3_model(use_pretrained=True, checkpoint_path=ckpt_path, bpe_path=cfg.bpe_path)
    n_params = sum(p.numel() for p in model.parameters()) / 1e6
    t_load = time.perf_counter() - t0
    log.info("Reference model ready: %.1fM params in %.1fs", n_params, t_load)

    # 2) device + ttnn pipeline (weights converted to bfloat8_b/bf16 and uploaded)
    device = open_device(cfg)
    pipeline = None
    try:
        t1 = time.perf_counter()
        log.info("Loading pipeline (torch_compile=%s, fused=%s)", cfg.torch_compile, cfg.fused.describe())
        pipeline = TtSam3ImagePipeline(model, device, torch_compile=cfg.torch_compile, fused_config=cfg.fused)
        log.info("Pipeline built in %.1fs (torch threads=%d)", time.perf_counter() - t1, torch.get_num_threads())

        # 3) warm-up: JIT-compiles the ttnn kernels (minutes cold, cached under TT_METAL_CACHE)
        px = warmup_input()
        t2 = time.perf_counter()
        for i in range(max(0, cfg.warmup_runs)):
            log.info("Warming up (%d/%d) ...", i + 1, cfg.warmup_runs)
            with torch.inference_mode():
                out = pipeline.forward_image(px, [DEFAULT_WARMUP_PROMPT])
            if out is None or "pred_masks" not in out:
                raise RuntimeError("warm-up forward returned no predictions")
            log.info("Warmup run %d took %.2fs", i + 1, time.perf_counter() - t2)
            t2 = time.perf_counter()
        if cfg.fused.enabled and cfg.fused.enc_trace and cfg.warmup_runs > 0:
            # READY contract under TT_FUSED=1: the encoder trace is captured before startup completes AND
            # every warm-up forward ran through it (a fallback to the legacy eager encoder means the served
            # shape is not the traced one; refuse READY rather than serve the un-traced path silently).
            if not pipeline.encoder_trace_captured or pipeline.encoder_fallbacks != 0:
                raise RuntimeError(
                    "TT_FUSED=1: warm-up did not run entirely through the fused encoder trace "
                    f"(captured={pipeline.encoder_trace_captured}, legacy fallbacks={pipeline.encoder_fallbacks}); "
                    "refusing to report READY"
                )
            log.info("Encoder trace captured (%s)", pipeline.encoder_stats)
        _fast_mask_ok()  # one-time bit-for-bit self check of the crop post-processing against F.interpolate
        STATE["rgb_input"] = _fused_input_ok(pipeline)
        log.info("Warmup complete")

        STATE.update(
            rgb_input=STATE.get("rgb_input", False),
            config=cfg,
            device=device,
            pipeline=pipeline,
            checkpoint_path=ckpt_path,
            num_params_m=n_params,
            device_arch=str(device.arch()),
            ready_at=time.time(),
        )
        yield
    finally:
        import ttnn

        STATE.clear()
        # drop the device tensors held by the pipeline before closing the chip
        if pipeline is not None:
            try:
                pipeline.release()  # TT_FUSED=1: release the encoder trace + persistent buffers first
            except Exception:  # noqa: BLE001 - shutdown must reach close_device
                log.exception("pipeline.release() failed")
            for attr in ("backbone_params", "_enc_params", "_fused_encoder"):
                setattr(pipeline, attr, None)
        del pipeline
        gc.collect()
        log.info("Closing device")
        ttnn.close_device(device)


app = FastAPI(title="SAM3 on Tenstorrent Blackhole", lifespan=lifespan)


# ---------------------------------------------------------------------------------------
# Request / response
# ---------------------------------------------------------------------------------------
class PredictRequest(BaseModel):
    """One image, one to four noun-phrase prompts. Each prompt is an independent query."""

    image: str = Field(..., description="base64 PNG/JPEG (any size, RGB or RGBA; squashed to 1008x1008)")
    prompts: List[str] = Field(..., min_length=1, max_length=8, description="noun phrases, e.g. ['car']")
    threshold: float = Field(0.5, ge=0.0, le=1.0, description="keep instances with score > threshold (upstream default 0.5)")
    max_instances: int = Field(50, ge=1, le=NUM_QUERIES, description="per-prompt cap, highest scores first")
    mask_format: Literal["png", "rle", "none"] = Field("png", description="per-instance mask encoding at original resolution")


_B64_ALPHABET = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
# host worker pool for the per-instance mask upsample + PNG encode (torch / zlib release the GIL)
_POOL = ThreadPoolExecutor(max_workers=int(os.environ.get("SAM3_POST_THREADS", "4")), thread_name_prefix="sam3-post")


try:  # SIMD base64 (validate=True has the same strictness as base64.b64decode(validate=True)): 4.8 MB demo 0.2 ms
    import pybase64 as _pybase64
except ImportError:  # optional dependency
    _pybase64 = None


def _b64decode_strict(b64: str) -> bytes:
    """``base64.b64decode(b64, validate=True)`` semantics (only the base64 alphabet, '=' only as trailing padding,
    binascii padding checks) without the regex pass over the whole payload (4.8 MB demo: 11.9 -> ~8 ms with
    binascii, 0.2 ms with pybase64)."""
    if _pybase64 is not None:
        return _pybase64.b64decode(b64, validate=True)
    raw = b64.encode("ascii")  # non-ASCII -> UnicodeEncodeError (a client error)
    if raw.translate(None, _B64_ALPHABET):
        raise binascii.Error("Non-base64 digit found")
    eq = raw.find(b"=")
    if eq != -1 and (len(raw) - eq > 2 or raw[eq:] not in (b"=", b"==")):
        raise binascii.Error("Non-base64 digit found")
    return binascii.a2b_base64(raw)


# DecodedImage, the PNG fast path and the 1008x1008 preprocessing live in tt_sam3/imaging.py (shared with the
# Python API, which must not need fastapi); these are the same objects under their old names.
from tt_sam3.imaging import _PNG_SIG, DecodedImage, _libdeflate, _png_fast_decode  # noqa: E402,F401
from tt_sam3.imaging import to_pixel_values as _to_pixel_values  # noqa: E402

_PNG_FAST = os.environ.get("SAM3_FAST_PNG", "1") == "1"


def _decode_image(b64: str) -> DecodedImage:
    """base64 PNG/JPEG -> RGB uint8.  RGB / RGBA decode without PIL's convert pass: RGBA -> RGB in PIL drops the
    alpha channel (no compositing), so the RGB channels of the RGBA buffer are the same bytes."""
    try:
        raw = _b64decode_strict(b64)
    except Exception as e:  # noqa: BLE001 - any decode failure is a client error
        raise HTTPException(status_code=400, detail=f"bad image: {e}") from None
    return _decode_bytes(raw)


def _decode_bytes(raw: bytes) -> DecodedImage:
    """PNG/JPEG bytes -> RGB uint8 (see ``_decode_image``)."""
    try:
        arr = _png_fast_decode(raw) if _PNG_FAST else None
        if arr is not None:
            return DecodedImage(arr[..., :3] if arr.shape[2] == 4 else arr)
        im = Image.open(io.BytesIO(raw))
        im.load()
        if im.mode in ("RGB", "RGBA"):
            arr = np.asarray(im)
            return DecodedImage(arr[..., :3] if im.mode == "RGBA" else arr)
        return DecodedImage(np.asarray(im.convert("RGB")))
    except Exception as e:  # noqa: BLE001 - any decode failure is a client error
        raise HTTPException(status_code=400, detail=f"bad image: {e}") from None



def _fused_input_ok(pipeline) -> bool:
    """SAM3_FUSED_INPUT=1 (default): the decoded image goes to the device runner as uint8 RGB and is resized,
    normalised and im2col'ed per window-row band straight into the upload buffers (``server/hostinput.py``), each
    band's upload overlapping the next band's host work.  Enabled only after a bit-for-bit self check against
    ``_to_pixel_values`` + im2col on this torch build (else the reference path)."""
    if os.environ.get("SAM3_FUSED_INPUT", "1") != "1" or not pipeline.rgb_input_supported():
        log.info("image input: reference path (_to_pixel_values + im2col)")
        return False
    from tt_sam3.server import hostinput

    hostinput.configure(torch.get_num_threads())
    ok = hostinput.self_check()
    if ok:
        with torch.inference_mode():  # warm the band path through the traces once
            pipeline.forward_rgb_selected_multi(np.zeros((720, 1280, 3), dtype=np.uint8), [DEFAULT_WARMUP_PROMPT], 0.5, 1)
    log.info("image input: %s", "fused band path (hostinput)" if ok else "reference path (hostinput self check FAILED)")
    return ok


def _png_chunk(tag: bytes, data: bytes) -> bytes:
    return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)


def _encode_mask_png(mask: np.ndarray) -> str:
    """(H, W) bool -> base64 8-bit grayscale PNG (0 / 255), written directly: filter type 0 on every row and zlib
    with the RLE strategy.  Decodes to exactly the same pixels as the previous PIL encode (``Image.fromarray(m *
    255, "L")``, checked in perf/dev/host_path_check.py); 1080p demo-size mask: PIL level 1 ~15 ms -> ~3 ms, and
    about half the bytes (the adaptive PNG filters PIL tries are what made it slow)."""
    h, w = mask.shape
    raw = np.empty((h, w + 1), dtype=np.uint8)
    raw[:, 0] = 0  # filter type None
    np.multiply(mask, np.uint8(255), out=raw[:, 1:], dtype=np.uint8, casting="unsafe")
    z = zlib.compressobj(1, zlib.DEFLATED, 15, 9, zlib.Z_RLE)
    idat = z.compress(raw.tobytes()) + z.flush()
    png = (b"\x89PNG\r\n\x1a\n" + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 0, 0, 0, 0))
           + _png_chunk(b"IDAT", idat) + _png_chunk(b"IEND", b""))
    return base64.b64encode(png).decode("ascii")


def _encode_mask_rle(mask: np.ndarray) -> Dict[str, Any]:
    """COCO-style uncompressed RLE: column-major runs, starting with the number of zeros."""
    flat = np.asarray(mask, dtype=np.uint8).flatten(order="F")
    if flat.size == 0:
        return {"size": [int(mask.shape[0]), int(mask.shape[1])], "counts": []}
    change = np.flatnonzero(np.diff(flat)) + 1
    starts = np.concatenate([[0], change])
    lengths = np.diff(np.concatenate([starts, [flat.size]])).tolist()
    if flat[0] == 1:
        lengths = [0] + lengths
    return {"size": [int(mask.shape[0]), int(mask.shape[1])], "counts": [int(v) for v in lengths]}


def select_instances(out: Dict[str, torch.Tensor], threshold: float, max_instances: int):
    """Upstream Sam3Processor scoring: sigmoid(logit) * sigmoid(presence), keep > threshold.

    Returns (scores (N,), boxes_cxcywh (N,4) normalized, mask_logits (N,288,288), presence float)
    sorted by score descending and capped at ``max_instances``. Runs under the lock, cheap.
    """
    logits = out["pred_logits"][0, :, 0].float()  # (200,)
    presence_logit = out["presence_logit_dec"].reshape(-1)[0].float()
    presence = torch.sigmoid(presence_logit)
    scores = torch.sigmoid(logits) * presence
    keep = torch.nonzero(scores > threshold, as_tuple=False).flatten()
    if keep.numel() > 0:
        order = torch.argsort(scores[keep], descending=True)
        keep = keep[order][:max_instances]
    return (
        scores[keep].clone(),
        out["pred_boxes"][0, keep].float().clone(),
        out["pred_masks"][0, keep].float().clone(),
        float(presence),
    )


def _boxes_to_xyxy_pixels(boxes_cxcywh: torch.Tensor, width: int, height: int) -> List[List[float]]:
    if boxes_cxcywh.numel() == 0:
        return []
    cx, cy, w, h = boxes_cxcywh.unbind(-1)
    xyxy = torch.stack([cx - 0.5 * w, cy - 0.5 * h, cx + 0.5 * w, cy + 0.5 * h], dim=-1)
    xyxy = xyxy * torch.tensor([width, height, width, height], dtype=torch.float32)
    xyxy[:, 0::2] = xyxy[:, 0::2].clamp(0, width)
    xyxy[:, 1::2] = xyxy[:, 1::2].clamp(0, height)
    return [[round(float(v), 2) for v in row] for row in xyxy.tolist()]


def _upsample_mask(mask_logits: torch.Tensor, width: int, height: int) -> np.ndarray:
    """(288, 288) logits -> (H, W) bool mask; bilinear, align_corners=False, > 0 (== sigmoid > 0.5).  One mask per
    call (the interpolation of each output pixel only reads its own mask, so batching does not change the values)."""
    up = F.interpolate(mask_logits[None, None], size=(height, width), mode="bilinear", align_corners=False)
    return (up[0, 0] > 0).numpy()


_FAST_MASK = os.environ.get("SAM3_FAST_MASK", "1") == "1"
_FAST_MASK_OK: Optional[bool] = None


def _fast_mask_ok() -> bool:
    """maskpost reproduces this torch build's bilinear arithmetic (bit-for-bit self check, once); else the full path."""
    global _FAST_MASK_OK
    if _FAST_MASK_OK is None:
        _FAST_MASK_OK = bool(_FAST_MASK and _maskpost.self_check())
        log.info("mask post-processing: %s", "bounding-box crop (maskpost)" if _FAST_MASK_OK else "full upsample")
    return _FAST_MASK_OK


def _mask_payload(mask_logits: torch.Tensor, width: int, height: int, mask_format: str) -> Dict[str, Any]:
    if _fast_mask_ok():  # same pixels as the full path below, computed only where the mask can be non-zero
        if mask_format == "png":
            png, area = _maskpost.mask_png_and_area(mask_logits.numpy(), width, height)
            return {"mask_area": area, "mask_png": png}
        m = _maskpost.upsample_mask(mask_logits.numpy(), width, height)
        return {"mask_area": int(np.count_nonzero(m)), "mask_rle": _encode_mask_rle(m)}
    m = _upsample_mask(mask_logits, width, height)
    out: Dict[str, Any] = {"mask_area": int(m.sum())}
    if mask_format == "png":
        out["mask_png"] = _encode_mask_png(m)
    else:
        out["mask_rle"] = _encode_mask_rle(m)
    return out


def _postprocess_all(prompts: List[str], selected: List[Any], width: int, height: int, mask_format: str) -> List[Dict[str, Any]]:
    """Per-instance work of every prompt (upsample to the original size, binarize, PNG / RLE encode).  The crop path
    (maskpost, ~0.3-1.5 ms per mask, mostly GIL-holding numpy) runs serially on the request thread, which measured
    faster than the pool; the full-upsample fallback runs on the pool with the request thread taking the first job
    (it keeps that core clocked up).  Outputs are byte-identical to a serial loop either way."""
    jobs = [(k, i) for k, sel in enumerate(selected) for i in range(sel[0].numel())] if mask_format != "none" else []
    job = lambda ki: _mask_payload(selected[ki[0]][2][ki[1]], width, height, mask_format)  # noqa: E731
    if _fast_mask_ok():
        payloads = [job(ki) for ki in jobs]
    else:
        futs = [_POOL.submit(job, ki) for ki in jobs[1:]]
        payloads = ([job(jobs[0])] if jobs else []) + [f.result() for f in futs]
    return _postprocess_results(prompts, selected, width, height, dict(zip(jobs, payloads)))


def _postprocess_results(prompts, selected, width: int, height: int, by_job) -> List[Dict[str, Any]]:
    results = []
    for k, (prompt, (scores, boxes, _mask_logits, presence)) in enumerate(zip(prompts, selected)):
        boxes_px = _boxes_to_xyxy_pixels(boxes, width, height)
        instances: List[Dict[str, Any]] = []
        for i in range(scores.numel()):
            inst: Dict[str, Any] = {"score": round(float(scores[i]), 4), "box_xyxy": boxes_px[i]}
            inst.update(by_job.get((k, i), {}))
            instances.append(inst)
        results.append({"prompt": prompt, "presence": round(presence, 4), "num_instances": len(instances), "instances": instances})
    return results


def _postprocess_prompt(prompt: str, selected, width: int, height: int, mask_format: str) -> Dict[str, Any]:
    return _postprocess_all([prompt], [selected], width, height, mask_format)[0]


# ---------------------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------------------
@app.get("/health")
def health() -> dict:
    ready = "pipeline" in STATE
    return {
        "status": "ok" if ready else "starting",
        "model": MODEL_NAME,
        "device": {"arch": STATE.get("device_arch"), "device_id": STATE["config"].device_id} if ready else None,
    }


@app.get("/info")
def info() -> dict:
    cfg: Optional[Config] = STATE.get("config")
    return {
        "model": MODEL_NAME,
        "description": "SAM3 (Segment Anything with Concepts, Meta) - text-prompted open-vocabulary instance segmentation",
        "task": "image-segmentation",
        "io": "image + text prompts -> per-prompt instance masks (original resolution), boxes and scores",
        "device_open": STATE.get("dispatch"),
        "hardware": "Tenstorrent Blackhole (single chip; default ETH dispatch, 1 CQ, 12x10 grid: see device_open) via tt-nn; whole image graph on device as two metal "
        "traces (image: ViT-H/14 + FPN neck + geometry encoder; per prompt: fusion encoder + decoder + seg head); "
        "text encoder on CPU (cached per prompt)",
        "weights": {
            "repo": cfg.weights_repo if cfg else DEFAULT_WEIGHTS_REPO,
            "revision": cfg.weights_revision if cfg else None,
            "file": WEIGHTS_FILENAME,
            "checkpoint_path": STATE.get("checkpoint_path"),
            "num_params_m": STATE.get("num_params_m"),
            "license": "Meta SAM License (gated repo; request access on the Hub)",
        },
        "source": {"repo": SOURCE_REPO, "commit": SOURCE_COMMIT, "package": "tt_sam3"},
        "limits": {
            "input_size": INPUT_SIZE,
            "resize": "bilinear squash to 1008x1008 (non-square images are squashed, as upstream)",
            "batch": 1,
            "max_prompts": cfg.max_prompts if cfg else None,
            "max_side": cfg.max_side if cfg else None,
            "num_queries": NUM_QUERIES,
            "mask_logit_resolution": MASK_RES,
        },
        "runtime": {
            "torch_compile": cfg.torch_compile if cfg else None,
            "torch_threads": torch.get_num_threads(),
            "warmup_runs": cfg.warmup_runs if cfg else None,
            "mesh_shape": cfg.mesh_shape if cfg else None,
            # the pipeline's copy: it may downgrade sub-knobs the loaded model cannot use (bf16 neck hand-off)
            "fused": (STATE["pipeline"].fused.describe() if "pipeline" in STATE else cfg.fused.describe()) if cfg else None,
            "encoder_trace_captured": bool(STATE["pipeline"].encoder_trace_captured) if "pipeline" in STATE else None,
            "encoder_fallbacks": int(STATE["pipeline"].encoder_fallbacks) if "pipeline" in STATE else None,
            "image_cache": bool(_IMG_CACHE and STATE.get("rgb_input")),
            "image_cache_hits": _image_cache_hits(),
        },
        "endpoints": {"predict": "POST /predict", "predict_raw": "POST /predict_raw", "health": "GET /health",
                      "models": "GET /v1/models"},
    }


@app.get("/v1/models")
def v1_models() -> dict:
    cfg: Optional[Config] = STATE.get("config")
    return {
        "object": "list",
        "data": [{"id": cfg.weights_repo if cfg else DEFAULT_WEIGHTS_REPO, "object": "model", "owned_by": "changh95"}],
    }


try:  # image-result cache key (SAM3_IMG_CACHE): xxh3_128 of the request's image bytes, 4.8 MB in ~0.2 ms
    import xxhash as _xxhash
except ImportError:  # optional dependency: no cache
    _xxhash = None
_IMG_CACHE = os.environ.get("SAM3_IMG_CACHE", "1") == "1" and _xxhash is not None
_LAST_DECODED: List[Any] = [None, None]  # [key, DecodedImage] of the last decoded image (one entry)


def _image_key(raw, tag: str) -> Optional[Tuple]:
    if not _IMG_CACHE:
        return None
    return (tag, len(raw), _xxhash.xxh3_128_digest(raw))


def _decode_cached(raw: bytes, key: Optional[Tuple], decode) -> DecodedImage:
    """Same bytes as the last request -> the same decoded pixels (skip the inflate)."""
    last = _LAST_DECODED[:]  # one consistent snapshot
    if key is not None and last[0] == key:
        return last[1]
    im = decode(raw)
    if key is not None:
        _LAST_DECODED[:] = [key, im]
    return im


def _invalidate_image_cache(pipeline) -> None:
    """Drop both cache levels (call with LOCK held): the decoded image and the device runner's resident-image key."""
    _LAST_DECODED[:] = [None, None]
    runner = getattr(pipeline, "_full", None)
    if runner is not None:
        runner.img_key = None


def _image_cache_hits() -> Optional[int]:
    runner = getattr(STATE.get("pipeline"), "_full", None)
    return int(runner.stats.get("image_cache_hits", 0)) if runner is not None else None


def _check_prompts(cfg, prompts_in: List[str]) -> List[str]:
    prompts = [p.strip() for p in prompts_in]
    if not prompts or any(not p for p in prompts):
        raise HTTPException(status_code=400, detail="prompts must be non-empty strings")
    if len(prompts) > cfg.max_prompts:
        raise HTTPException(status_code=400, detail=f"at most {cfg.max_prompts} prompts per request")
    return prompts


def _predict_core(raw: bytes, prompts_in: List[str], threshold: float, max_instances: int, mask_format: str,
                  decode, tag: str) -> dict:
    """raw: the image bytes (PNG/JPEG, or raw RGB uint8 for /predict_raw); decode(raw) -> DecodedImage."""
    pipeline: Optional[TtSam3ImagePipeline] = STATE.get("pipeline")
    cfg: Optional[Config] = STATE.get("config")
    if pipeline is None or cfg is None:
        raise HTTPException(status_code=503, detail="model is still starting")
    prompts = _check_prompts(cfg, prompts_in)

    t_start = time.perf_counter()
    # text-cache misses: the host text encoder starts now, overlapping the decode / preprocess / image trace
    pipeline.prefetch_text(prompts)
    rgb_input = STATE.get("rgb_input")
    key = _image_key(raw, tag) if rgb_input else None
    im = _decode_cached(raw, key, decode)
    width, height = im.size
    if max(width, height) > cfg.max_side:
        raise HTTPException(status_code=400, detail=f"image side {max(width, height)} exceeds SAM3_MAX_SIDE={cfg.max_side}")
    t_decoded = time.perf_counter()

    if rgb_input:  # resize / normalise / im2col run inside the forward, banded with the upload
        px, fwd = im.rgb, (lambda *a: pipeline.forward_rgb_selected_multi(*a, image_key=key))
    else:
        px, fwd = _to_pixel_values(im), pipeline.forward_image_selected_multi
    t_pre = time.perf_counter()

    selected = []
    try:
        with LOCK:
            try:
                with torch.inference_mode():
                    # full-device path: the image part runs once per request, the prompt part once per prompt, and
                    # only the kept instances' masks are read back from the device
                    selected = fwd(px, prompts, threshold, max_instances)
            except BaseException:
                _invalidate_image_cache(pipeline)  # a failed forward never leaves a cache entry behind
                raise
    except HTTPException:
        raise
    except Exception as e:  # noqa: BLE001 - surface the failure text to the client
        log.exception("inference failed")
        raise HTTPException(status_code=500, detail=f"inference failed: {type(e).__name__}: {e}") from None
    t_fwd = time.perf_counter()

    results = _postprocess_all(prompts, selected, width, height, mask_format)
    t_end = time.perf_counter()

    return {
        "width": width,
        "height": height,
        "input_size": INPUT_SIZE,
        "threshold": threshold,
        "mask_format": mask_format,
        "results": results,
        "timing_ms": {
            "decode": round((t_decoded - t_start) * 1000, 1),
            "preprocess": round((t_pre - t_decoded) * 1000, 1),
            "forward": round((t_fwd - t_pre) * 1000, 1),
            "forward_per_prompt": round((t_fwd - t_pre) * 1000 / max(1, len(prompts)), 1),
            "postprocess": round((t_end - t_fwd) * 1000, 1),
            "total": round((t_end - t_start) * 1000, 1),
        },
    }


@app.post("/predict")
def predict(req: PredictRequest) -> dict:
    if STATE.get("pipeline") is None or STATE.get("config") is None:
        raise HTTPException(status_code=503, detail="model is still starting")
    try:
        raw = _b64decode_strict(req.image)
    except Exception as e:  # noqa: BLE001 - any decode failure is a client error
        raise HTTPException(status_code=400, detail=f"bad image: {e}") from None
    return _predict_core(raw, req.prompts, req.threshold, req.max_instances, req.mask_format, _decode_bytes, "enc")


def _raw_rgb_decoder(width: int, height: int):
    def decode(raw: bytes) -> DecodedImage:
        if width <= 0 or height <= 0 or width * height * 3 != len(raw):
            raise HTTPException(status_code=400, detail=f"raw RGB body is {len(raw)} bytes, expected {width}x{height}x3")
        return DecodedImage(np.frombuffer(raw, dtype=np.uint8).reshape(height, width, 3))
    return decode


@app.post("/predict_raw")
async def predict_raw(
    request: Request,
    prompts: List[str] = Query(..., min_length=1, max_length=8),
    threshold: float = Query(0.5, ge=0.0, le=1.0),
    max_instances: int = Query(50, ge=1, le=NUM_QUERIES),
    mask_format: Literal["png", "rle", "none"] = Query("png"),
) -> dict:
    """Binary input, same pipeline and same response JSON as /predict without the JSON / base64 request layer.
    Body: the PNG/JPEG file (any Content-Type other than application/x-rgb24, e.g. image/png | image/jpeg), or raw
    RGB uint8 pixels, row-major H x W x 3 (Content-Type application/x-rgb24 with integer X-Width / X-Height headers;
    the body must be exactly X-Width * X-Height * 3 bytes).  Prompts etc. are query parameters
    (``?prompts=car&prompts=tree&threshold=0.5``)."""
    if STATE.get("pipeline") is None or STATE.get("config") is None:
        raise HTTPException(status_code=503, detail="model is still starting")
    ctype = request.headers.get("content-type", "").split(";")[0].strip().lower()
    if ctype == "application/x-rgb24":
        try:
            w, h = int(request.headers["x-width"]), int(request.headers["x-height"])
        except (KeyError, ValueError):
            raise HTTPException(status_code=400, detail="application/x-rgb24 needs integer X-Width and X-Height") from None
        max_side = STATE["config"].max_side
        if max(w, h) > max_side:  # refuse before reading a large body
            raise HTTPException(status_code=400, detail=f"image side {max(w, h)} exceeds SAM3_MAX_SIDE={max_side}")
        decode, tag = _raw_rgb_decoder(w, h), f"rgb{w}x{h}"
    else:
        decode, tag = _decode_bytes, "enc"
    raw = await request.body()
    if not raw:
        raise HTTPException(status_code=400, detail="empty body")
    return await run_in_threadpool(_predict_core, raw, prompts, threshold, max_instances, mask_format, decode, tag)