File size: 8,106 Bytes
1b17257 | 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 | # SPDX-FileCopyrightText: © 2026 Tenstorrent Inc.
# SPDX-License-Identifier: Apache-2.0
"""Device tests of the Python API (needs one Blackhole chip as device 0, the released weights in the HF cache).
1. ``test_api_equals_server``: boots the HTTP server (``uvicorn hamer.server.app:app`` subprocess, the ``tt-model.yaml``
serve env), takes ``/predict`` for the 8 demo hands of ``media/``, stops it, then
builds ``HamerModel.from_pretrained(device_id=0)`` and requires ``hand.to_dict()`` == the server response (all keys
except ``timing_ms`` / ``trace_active``) for every hand: the same numbers at the server's rounding.
2. ``test_api_call_time``: warm repeated calls, fresh crop every call (the 8 demo crops in turn), median / min of
- ``model.tt(u8)`` the bare ``TtHamer.__call__`` (= the published "model call" / served call),
- ``model.run_crop(u8)`` the same call through the API (lock + inference_mode),
- ``model(frame, bbox)`` the full API call (host crop + resize, model call, post-processing),
and requires ``run_crop`` not slower than the bare call (+3 %). Results go to ``.work/api_timing.json``.
source tools/chipenv.sh $CHIP models/hamer-p150/.venv
cd models/hamer-p150 && timeout -s INT 1200 python -m pytest code/tests/test_api_device.py -q -s
"""
from __future__ import annotations
import base64
import json
import os
import statistics
import time
from pathlib import Path
import pytest
if os.environ.get("TT_VISIBLE_DEVICES") == "none" or os.environ.get("HAMER_USE_TT") == "0":
pytest.skip("device test (TT_VISIBLE_DEVICES=none / HAMER_USE_TT=0)", allow_module_level=True)
import numpy as np # noqa: E402
import torch # noqa: E402
from PIL import Image # noqa: E402
REPO = Path(__file__).resolve().parents[2]
MEDIA = REPO / "media"
PUBLISHED_MODEL_CALL_MS = (3.480, 3.499) # OPT_REPORT.md round 15 headline, chip 5: served TtHamer.__call__ median
ITERS = int(os.environ.get("HAMER_API_ITERS", "200"))
def _hands():
boxes = json.loads((MEDIA / "bboxes.json").read_text())
return [(name, e["bbox"], bool(e.get("is_right", 1))) for name in sorted(boxes) for e in boxes[name]]
def _serve_env():
from hamer.served_config import SERVED_DISPATCH, SERVED_ENV, WEIGHTS_REVISION
env = dict(SERVED_ENV, HAMER_DISPATCH=SERVED_DISPATCH, HAMER_WARMUP_ITERS="3", TT_MESH_SHAPE="1x1",
TT_DEVICE_ID="0", TT_WEIGHTS_REVISION=WEIGHTS_REVISION)
for k, v in env.items():
os.environ[k] = v
return env
def _free_port() -> int:
import socket
with socket.socket() as so:
so.bind(("127.0.0.1", 0))
return so.getsockname()[1]
@pytest.fixture(scope="module")
def server_outputs():
"""The HTTP server as deployed (uvicorn subprocess, serve env), queried for every demo hand, then stopped."""
import signal
import subprocess
import sys
import urllib.request
pytest.importorskip("fastapi")
pytest.importorskip("uvicorn")
env = dict(os.environ, **_serve_env())
port = _free_port()
log = REPO / ".work" / "api_test_server.log"
log.parent.mkdir(exist_ok=True)
with open(log, "w") as fh:
proc = subprocess.Popen([sys.executable, "-m", "uvicorn", "--host", "127.0.0.1", "--port", str(port),
"--lifespan", "on", "hamer.server.app:app"], env=env, stdout=fh, stderr=subprocess.STDOUT)
url = f"http://127.0.0.1:{port}"
def get(path, body=None):
req = urllib.request.Request(url + path, data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
try:
for _ in range(600):
if proc.poll() is not None:
raise RuntimeError(f"server exited ({proc.returncode}); see {log}")
if "Application startup complete" in log.read_text():
break
time.sleep(1)
else:
raise RuntimeError(f"server did not become ready; see {log}")
outs = {}
for name, bbox, right in _hands():
img = base64.b64encode((MEDIA / name).read_bytes()).decode()
out = get("/predict", {"image": img, "bbox": bbox, "is_right": right})
assert out["trace_active"] is True
out.pop("timing_ms")
out.pop("trace_active")
outs[(name, tuple(bbox), right)] = out
info = get("/info")
finally:
proc.send_signal(signal.SIGINT)
try:
proc.wait(timeout=120)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
return outs, info
@pytest.fixture(scope="module")
def model(server_outputs):
from hamer import HamerModel
m = HamerModel.from_pretrained(device_id=0, verbose=True)
yield m
m.close()
def test_api_equals_server(server_outputs, model):
outs, info = server_outputs
assert model.trace_active and model.info["grid"] == "12x10" and model.info["dispatch"] == "eth"
print(f"\n[api] info: {json.dumps({k: v for k, v in model.info.items() if k != 'fused'})}")
print(f"[api] server warm-up: {info['warmup']}")
n = 0
for (name, bbox, right), want in outs.items():
hand = model(MEDIA / name, bbox=list(bbox), is_right=right)
got = hand.to_dict()
diff = [k for k in want if got.get(k) != want[k]]
assert not diff and set(got) == set(want), f"{name} {bbox}: keys differ from the server: {diff}"
n += 1
# the same hands through the list form (one decode per frame)
by_frame = {}
for (name, bbox, right) in outs:
by_frame.setdefault(name, []).append((list(bbox), right))
for name, items in by_frame.items():
hands = model(MEDIA / name, bbox=[b for b, _ in items], is_right=[r for _, r in items])
for (b, r), h in zip(items, hands):
assert h.to_dict() == outs[(name, tuple(b), r)]
print(f"[api] {n} demo hands: HamerModel output == server /predict output (all keys, server rounding)")
def _stats(v):
return {"median_ms": round(statistics.median(v), 4), "min_ms": round(min(v), 4), "n": len(v)}
def test_api_call_time(model):
from hamer.preprocess import preprocess_pil
hands = _hands()
frames = {name: Image.open(MEDIA / name).convert("RGB") for name, _, _ in hands}
crops = [preprocess_pil(frames[n], b, is_right=r, raw=True)[0] for n, b, r in hands]
tt = model.tt
def loop(fn, iters=ITERS):
ts = []
for i in range(iters):
t0 = time.perf_counter()
fn(i)
ts.append((time.perf_counter() - t0) * 1e3)
return ts
with torch.inference_mode():
loop(lambda i: tt(crops[i % len(crops)]), 20) # warm
res = {}
# alternate the two measurements (3 rounds each) so host-load drift hits both equally
bare, api = [], []
for _ in range(3):
with torch.inference_mode():
bare += loop(lambda i: tt(crops[i % len(crops)]))
api += loop(lambda i: model.run_crop(crops[i % len(crops)]))
res["bare_TtHamer_call"] = _stats(bare)
res["api_run_crop"] = _stats(api)
full, inner = [], []
for i in range(ITERS):
n, b, r = hands[i % len(hands)]
t0 = time.perf_counter()
h = model(frames[n], bbox=b, is_right=r)
full.append((time.perf_counter() - t0) * 1e3)
inner.append(h.timing_ms["model"])
res["api_call_full_frame"] = _stats(full)
res["api_call_full_frame_model_stage"] = _stats(inner)
res["published_model_call_ms"] = PUBLISHED_MODEL_CALL_MS
res["grid"], res["dispatch"] = model.info["grid"], model.info["dispatch"]
res["chip"] = os.environ.get("TT_CHIP")
res["host_load"] = [round(v, 1) for v in os.getloadavg()]
print("\n[api] timing " + json.dumps(res))
out = REPO / ".work"
out.mkdir(exist_ok=True)
(out / "api_timing.json").write_text(json.dumps(res, indent=2))
assert res["api_run_crop"]["median_ms"] <= res["bare_TtHamer_call"]["median_ms"] * 1.03
|