Skip to content

Commit f099a89

Browse files
authored
Merge pull request #30 from listlessbird/perf-stuff
Perf stuff
2 parents d2543dc + 58c2c77 commit f099a89

69 files changed

Lines changed: 5015 additions & 2472 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend-api/.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"workbench.colorCustomizations": {
3+
"statusBar.background": "#335c99",
4+
"statusBar.foreground": "#ffffff",
5+
"sash.hoverBorder": "#335c99"
6+
}
7+
}

backend-api/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ USER appuser
5454
EXPOSE 8000
5555

5656
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
57-
CMD curl -f http://localhost:8000/live || exit 1
57+
CMD curl -f http://localhost:8000/ready || exit 1
5858

5959
CMD ["sh", "-c", "alembic upgrade head && uvicorn api.main:app --host 0.0.0.0 --port 8000"]
6060

backend-api/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ The worker must be running for ingest and rebuild jobs to execute.
9696

9797
- API: `http://localhost:8000`
9898
- API docs: `http://localhost:8000/docs`
99-
- Health check: `http://localhost:8000/live`
99+
- Health check: `http://localhost:8000/ready`
100100
- Temporal UI: `http://localhost:8233`
101101
- MinIO console: `http://localhost:9001`
102102

@@ -111,7 +111,7 @@ minioadmin / minioadmin
111111
Check service health:
112112

113113
```bash
114-
curl http://localhost:8000/live
114+
curl http://localhost:8000/ready
115115
```
116116

117117
Queue one image for ingestion:

backend-api/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ services:
110110
minio:
111111
condition: service_healthy
112112
healthcheck:
113-
test: [ "CMD", "curl", "-f", "http://localhost:8000/live" ]
113+
test: [ "CMD", "curl", "-f", "http://localhost:8000/ready" ]
114114
interval: 30s
115115
timeout: 10s
116116
retries: 3

backend-api/docker.compose.prod.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ services:
3131
memory: 2048M
3232
restart: unless-stopped
3333
healthcheck:
34-
test: [ "CMD", "curl", "-f", "http://localhost:8000/live" ]
34+
test: [ "CMD", "curl", "-f", "http://localhost:8000/ready" ]
3535
interval: 30s
3636
timeout: 10s
3737
retries: 3

backend-api/pyproject.toml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ dependencies = [
2424
"modal==1.3.4",
2525
"slowapi==0.1.9",
2626
"axiom-py==0.10.0",
27+
"asyncpg>=0.31.0",
28+
"aiobotocore>=3.7.0",
2729
]
2830

2931
# Extras only needed to run models locally (GPU_BACKEND=local) or to import
@@ -68,7 +70,11 @@ dev = [
6870
{ include-group = "hooks" },
6971
]
7072
lint = ["ruff==0.15.2"]
71-
typecheck = ["pyright"]
73+
typecheck = [
74+
"pyright",
75+
"types-aiobotocore>=3.7.0",
76+
"types-aiobotocore-s3>=3.7.0",
77+
]
7278
test = [
7379
"pytest==9.0.2",
7480
"pytest-asyncio==1.3.0",
@@ -131,6 +137,17 @@ executionEnvironments = [
131137
] },
132138
]
133139
typeCheckingMode = "basic"
140+
strict = [
141+
"src/shared/db.py",
142+
"src/shared/services/api_storage.py",
143+
"src/domain/job_rules.py",
144+
"src/domain/job_store.py",
145+
"src/domain/image_catalog.py",
146+
"src/domain/ingestion_browse.py",
147+
"src/domain/source_item_browse.py",
148+
"src/domain/source_registry.py",
149+
"src/domain/source_retry.py",
150+
]
134151
useLibraryCodeForTypes = true
135152
reportMissingTypeStubs = false
136153
reportPrivateImportUsage = false

backend-api/scripts/loadprobe.sh

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
cd "$(dirname "$0")/.."
5+
6+
DB_URL="${DB_URL:-postgresql://postgres:postgres@localhost:5432/mimeme}"
7+
PG_CONTAINER="${PG_CONTAINER:-mimeme-postgres}"
8+
API_PORT="${API_PORT:-8000}"
9+
DURATION="${DURATION:-30s}"
10+
CONCURRENCY="${CONCURRENCY:-200}"
11+
OVERLOAD_CONCURRENCY="${OVERLOAD_CONCURRENCY:-400}"
12+
SEED_COUNT="${SEED_COUNT:-5000}"
13+
OHA_BIN="${OHA_BIN:-oha}"
14+
OUT_DIR="${OUT_DIR:-data/loadprobe}"
15+
OVERLOAD_CPUS="${OVERLOAD_CPUS:-0.1}"
16+
17+
TARGET="http://127.0.0.1:${API_PORT}/images?limit=20"
18+
API_LOG="${OUT_DIR}/api.log"
19+
20+
mkdir -p "$OUT_DIR"
21+
22+
if ! docker ps --format '{{.Names}}' | grep -qx "$PG_CONTAINER"; then
23+
docker compose up -d postgres
24+
sleep 3
25+
fi
26+
27+
export DB_URL
28+
SEED_COUNT="$SEED_COUNT" uv run python - <<'PY'
29+
import os
30+
31+
from sqlalchemy import create_engine, func, select
32+
from sqlalchemy.orm import Session
33+
34+
from shared.config import settings
35+
from shared.models.orm import Base, Image
36+
37+
seed_count = int(os.environ["SEED_COUNT"])
38+
engine = create_engine(settings.db_url_str)
39+
Base.metadata.create_all(engine)
40+
with Session(engine) as session:
41+
existing = session.scalar(select(func.count()).select_from(Image)) or 0
42+
for i in range(existing, seed_count):
43+
session.add(
44+
Image(
45+
sha256=f"loadprobe-{i:056d}",
46+
dataset="loadprobe",
47+
s3_key=f"images/loadprobe/{i}.jpg",
48+
width=640,
49+
height=480,
50+
format="jpeg",
51+
file_size=12345,
52+
)
53+
)
54+
session.commit()
55+
total = session.scalar(select(func.count()).select_from(Image)) or 0
56+
print(f"images in db: {total}")
57+
PY
58+
59+
APP_ENV=development DEBUG=false LOG_LEVEL=INFO \
60+
RATE_LIMIT_ENABLED=false PRELOAD_TEXT_ENCODER_ON_STARTUP=false GPU_BACKEND=local \
61+
AXIOM_API_TOKEN= AXIOM_DATASET= S3_ENDPOINT_URL=http://127.0.0.1:9 \
62+
uv run uvicorn api.main:app --host 127.0.0.1 --port "$API_PORT" --log-level warning \
63+
>"$API_LOG" 2>&1 &
64+
API_PID=$!
65+
trap 'kill "$API_PID" 2>/dev/null || true; docker update --cpus 0 "$PG_CONTAINER" >/dev/null 2>&1 || true' EXIT
66+
67+
for _ in $(seq 1 60); do
68+
curl -fsS -o /dev/null "$TARGET" 2>/dev/null && break
69+
sleep 1
70+
done
71+
curl -fsS -o /dev/null "$TARGET"
72+
73+
echo "== warmup, c=50 for 5s"
74+
"$OHA_BIN" -z 5s -c 50 --no-tui --output-format quiet "$TARGET" >/dev/null
75+
LAG_AFTER_WARMUP=$(grep -c event_loop_lag "$API_LOG" || true)
76+
77+
echo "== phase (a): normal load, c=${CONCURRENCY} for ${DURATION}"
78+
"$OHA_BIN" -z "$DURATION" -c "$CONCURRENCY" --no-tui --output-format json "$TARGET" >"$OUT_DIR/normal.json"
79+
LAG_AFTER_NORMAL=$(grep -c event_loop_lag "$API_LOG" || true)
80+
81+
echo "== phase (b): overload, c=${OVERLOAD_CONCURRENCY}, postgres constrained to ${OVERLOAD_CPUS} cpus"
82+
docker update --cpus "$OVERLOAD_CPUS" "$PG_CONTAINER" >/dev/null
83+
"$OHA_BIN" -z "$DURATION" -c "$OVERLOAD_CONCURRENCY" --no-tui --output-format json "$TARGET" >"$OUT_DIR/overload.json"
84+
docker update --cpus 0 "$PG_CONTAINER" >/dev/null
85+
86+
kill "$API_PID"
87+
wait "$API_PID" 2>/dev/null || true
88+
89+
LAG_TOTAL=$(grep -c event_loop_lag "$API_LOG" || true)
90+
TRACEBACKS=$(grep -c Traceback "$API_LOG" || true)
91+
92+
LAG_AFTER_WARMUP="$LAG_AFTER_WARMUP" \
93+
LAG_AFTER_NORMAL="$LAG_AFTER_NORMAL" LAG_TOTAL="$LAG_TOTAL" TRACEBACKS="$TRACEBACKS" \
94+
OUT_DIR="$OUT_DIR" uv run python - <<'PY'
95+
import json
96+
import os
97+
98+
out_dir = os.environ["OUT_DIR"]
99+
for phase in ("normal", "overload"):
100+
with open(f"{out_dir}/{phase}.json") as f:
101+
report = json.load(f)
102+
summary = report["summary"]
103+
percentiles = report["latencyPercentiles"]
104+
print(f"-- {phase}")
105+
print(f" rps={summary['requestsPerSec']:.1f} total={report['statusCodeDistribution']}")
106+
for key in ("p50", "p90", "p99"):
107+
print(f" {key}={percentiles[key] * 1000:.1f}ms")
108+
print(f" errors={report['errorDistribution']}")
109+
warmup = int(os.environ["LAG_AFTER_WARMUP"])
110+
after_normal = int(os.environ["LAG_AFTER_NORMAL"])
111+
total = int(os.environ["LAG_TOTAL"])
112+
print(f"-- loop lag events: warmup={warmup} normal={after_normal - warmup} overload={total - after_normal}")
113+
print(f"-- tracebacks in api log: {os.environ['TRACEBACKS']}")
114+
PY
115+
116+
echo "raw reports in ${OUT_DIR}/, api log in ${API_LOG}"

backend-api/src/activities/indexing/activities.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import numpy as np
88
import structlog
99
from botocore.exceptions import ClientError
10+
from sqlalchemy import select
1011
from temporalio import activity
1112

1213
from activities.indexing.faiss_manager import FaissIndexManager
@@ -40,14 +41,12 @@ def build_index_activity(input: BuildIndexInput) -> BuildIndexOutput:
4041
index_manager = FaissIndexManager.get_instance()
4142
try:
4243
with session_scope() as session:
43-
done_procs = (
44-
session.query(Processing.image_id, Processing.embed_s3_key)
45-
.filter(
44+
done_procs = session.execute(
45+
select(Processing.image_id, Processing.embed_s3_key).where(
4646
Processing.embed_status == ProcessingStatus.DONE,
4747
Processing.embed_s3_key.isnot(None),
4848
)
49-
.all()
50-
)
49+
).all()
5150

5251
total_candidates = len(done_procs)
5352
candidates = [

backend-api/src/activities/indexing/index_catalog.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import Sequence
44
from typing import Any, Protocol
55

6+
from sqlalchemy import select, update
67
from sqlalchemy.orm import Session
78

89
from shared.models import IndexBuild
@@ -20,10 +21,10 @@ def delete_cached_version(self, version: str) -> None: ...
2021

2122
class ActiveIndexCatalog:
2223
def active_build(self, db: Session) -> IndexBuild | None:
23-
return db.query(IndexBuild).filter(IndexBuild.is_active).first()
24+
return db.scalars(select(IndexBuild).where(IndexBuild.is_active.is_(True))).first()
2425

2526
def list_builds_newest_first(self, db: Session) -> Sequence[IndexBuild]:
26-
return db.query(IndexBuild).order_by(IndexBuild.created_at.desc()).all()
27+
return db.scalars(select(IndexBuild).order_by(IndexBuild.created_at.desc())).all()
2728

2829
def add_inactive_build(
2930
self,
@@ -57,12 +58,15 @@ def mark_latest_available(
5758
metadata: dict[str, Any],
5859
artifacts: StoredIndexArtifacts,
5960
) -> None:
60-
db.query(IndexBuild).filter(
61-
IndexBuild.is_active,
62-
IndexBuild.version != version,
63-
).update({"is_active": False})
6461

65-
build = db.query(IndexBuild).filter(IndexBuild.version == version).first()
62+
db.execute(
63+
update(IndexBuild)
64+
.where(IndexBuild.is_active.is_(True), IndexBuild.version != version)
65+
.values(is_active=False)
66+
)
67+
68+
build = db.scalars(select(IndexBuild).where(IndexBuild.version == version)).first()
69+
6670
if build is None:
6771
db.add(
6872
IndexBuild(
@@ -91,8 +95,9 @@ def mark_latest_available(
9195
db.commit()
9296

9397
def swap_to_version(self, version: str, db: Session) -> None:
94-
db.query(IndexBuild).filter(IndexBuild.is_active).update({"is_active": False})
95-
db.query(IndexBuild).filter(IndexBuild.version == version).update({"is_active": True})
98+
db.execute(update(IndexBuild).where(IndexBuild.is_active.is_(True)).values(is_active=False))
99+
db.execute(update(IndexBuild).where(IndexBuild.version == version).values(is_active=True))
100+
96101
db.commit()
97102

98103
def garbage_collect(

0 commit comments

Comments
 (0)