From 494eace7d429ede71672cb255773126141c1c341 Mon Sep 17 00:00:00 2001 From: rjsgud49 Date: Thu, 22 Oct 2026 01:00:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=EA=B0=9C=EB=B0=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=8A=94=20YOLO=20=EC=98=88=EC=97=B4=EC=9D=84=20?= =?UTF-8?q?=EB=81=84=EA=B3=A0=20=EC=B2=AB=20=EC=9A=94=EC=B2=AD=EC=97=90=20?= =?UTF-8?q?=EC=98=AC=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI_WARMUP이 없으면 개발은 가중치를 기동 때 올리지 않고, 운영(NODE_ENV=production)은 예전처럼 예열한다. 개발에서만 torch 스레드를 1로 제한한다. Co-authored-by: park gun hyung --- fishing/ai/.env.example | 5 ++ fishing/ai/app/runtime_tuning.py | 92 +++++++++++++++++++++++++ fishing/ai/app/species_clip.py | 3 + fishing/ai/app/species_yolo.py | 9 ++- fishing/ai/main.py | 16 ++++- fishing/ai/production.env.example | 5 ++ fishing/ai/run.py | 4 ++ fishing/ai/tests/test_runtime_tuning.py | 49 +++++++++++++ fishing/docs/06-deployment.md | 8 ++- fishing/docs/12-ai-server-structure.md | 2 + 10 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 fishing/ai/app/runtime_tuning.py create mode 100644 fishing/ai/production.env.example create mode 100644 fishing/ai/tests/test_runtime_tuning.py diff --git a/fishing/ai/.env.example b/fishing/ai/.env.example index 1ef2bf6..370fdf9 100644 --- a/fishing/ai/.env.example +++ b/fishing/ai/.env.example @@ -1,6 +1,11 @@ PORT=8000 # NODE_ENV, ENV, ENVIRONMENT 중 하나가 production 이면 이 기본값으로 서버가 기동되지 않습니다. INTERNAL_SECRET=fishrank_ai_internal_secret +# YOLO 가중치는 개발에서 첫 /analyze 요청 때 올립니다. 운영(NODE_ENV=production)은 기동 시 예열합니다. +# 개발에서 미리 올리려면 AI_WARMUP=true. 운영에서 끄려면 AI_WARMUP=false. +# AI_WARMUP=false +# 개발 기본 스레드는 1입니다. 운영은 PyTorch 기본값을 유지합니다. 바꾸려면 숫자를 지정하세요. +# TORCH_NUM_THREADS=1 UPLOADS_DIR=../apps/api/uploads SPECIES_CONFIDENCE_S_THRESHOLD=0.7 SPECIES_CLASSIFIER=auto diff --git a/fishing/ai/app/runtime_tuning.py b/fishing/ai/app/runtime_tuning.py new file mode 100644 index 0000000..14d9ce9 --- /dev/null +++ b/fishing/ai/app/runtime_tuning.py @@ -0,0 +1,92 @@ +"""Dev-safe model startup. Production defaults stay eager and uncapped.""" + +from __future__ import annotations + +import os + +from .production_guard import runtime_env + +_TRUE = {"1", "true", "yes", "on"} +_THREAD_ENV_KEYS = ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS") + + +def _raw(source: dict[str, str], key: str) -> str | None: + value = source.get(key) + if value is None: + return None + text = str(value).strip() + if text == "": + return None + return text + + +def _truthy(value: str) -> bool: + return value.strip().lower() in _TRUE + + +def warmup_enabled(environ: dict[str, str] | None = None) -> bool: + """YOLO weights load at startup only when this is true. + + Unset follows the process environment: production warms up, everything else + waits for the first inference request. AI_WARMUP always wins when set. + """ + source = environ if environ is not None else os.environ + raw = _raw(source, "AI_WARMUP") + if raw is None: + return runtime_env(source) == "production" + return _truthy(raw) + + +def torch_num_threads(environ: dict[str, str] | None = None) -> int | None: + """Thread cap passed to torch.set_num_threads. + + None means leave PyTorch's default alone (production). Dev defaults to 1. + TORCH_NUM_THREADS overrides both. + """ + source = environ if environ is not None else os.environ + raw = _raw(source, "TORCH_NUM_THREADS") + if raw is not None: + try: + return max(1, int(raw)) + except ValueError: + return 1 + if runtime_env(source) == "production": + return None + return 1 + + +def prepare_process_threads(environ: dict[str, str] | None = None) -> dict[str, str]: + """Set OpenMP/BLAS thread env before native libraries load. + + When environ is passed, nothing is written to os.environ (tests). + Existing values are kept. + """ + source = environ if environ is not None else os.environ + count = torch_num_threads(source) + if count is None: + return {} + updates: dict[str, str] = {} + for key in _THREAD_ENV_KEYS: + if _raw(source, key) is None: + updates[key] = str(count) + if environ is None: + os.environ.update(updates) + return updates + + +def apply_torch_thread_cap() -> int | None: + """Cap torch threads on the dev model-load path. No-op in production.""" + count = torch_num_threads() + if count is None: + return None + prepare_process_threads() + try: + import torch + except ImportError: + return count + torch.set_num_threads(count) + try: + torch.set_num_interop_threads(1) + except RuntimeError: + pass + return count diff --git a/fishing/ai/app/species_clip.py b/fishing/ai/app/species_clip.py index 47bebd8..38656d9 100644 --- a/fishing/ai/app/species_clip.py +++ b/fishing/ai/app/species_clip.py @@ -39,6 +39,9 @@ def _get_classifier(): if _classifier is not None: return _classifier, _label_to_slug or {} + from .runtime_tuning import apply_torch_thread_cap + + apply_torch_thread_cap() from transformers import pipeline import torch diff --git a/fishing/ai/app/species_yolo.py b/fishing/ai/app/species_yolo.py index 64bf9d3..d9b5f80 100644 --- a/fishing/ai/app/species_yolo.py +++ b/fishing/ai/app/species_yolo.py @@ -36,10 +36,10 @@ def is_yolo_available() -> bool: def get_yolo_class_count() -> int | None: - model = _load_model() - if model is None: + """Class count for an already-loaded model. Never loads weights.""" + if _model is None: return None - return len(model.names) + return len(_model.names) def warmup_yolo() -> bool: @@ -108,6 +108,9 @@ def _load_model(): if not path.is_file(): return None + from .runtime_tuning import apply_torch_thread_cap + + apply_torch_thread_cap() from ultralytics import YOLO logger.info("Loading YOLO classifier: %s", path) diff --git a/fishing/ai/main.py b/fishing/ai/main.py index 053c798..e0b9ac2 100644 --- a/fishing/ai/main.py +++ b/fishing/ai/main.py @@ -1,3 +1,6 @@ +import logging + +from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException from app.analyzer import analyze_image, resolve_image_path @@ -5,6 +8,7 @@ from app.config import settings from app.production_guard import assert_production_secret, runtime_env from app.keypoints import detect_keypoints +from app.runtime_tuning import prepare_process_threads, warmup_enabled from app.schemas import ( AnalyzeRequest, AnalyzeResponse, @@ -16,20 +20,30 @@ from app.species_yolo import INFERENCE_VERSION, get_yolo_class_count, is_yolo_available, warmup_yolo import cv2 +logger = logging.getLogger(__name__) + app = FastAPI(title="FishRank AI Server", version="1.0.0") @app.on_event("startup") def _startup_warmup() -> None: + load_dotenv() assert_production_secret(runtime_env(), settings.internal_secret) - if is_yolo_available(): + prepare_process_threads() + if not is_yolo_available(): + return + if warmup_enabled(): + logger.info("YOLO warmup enabled") warmup_yolo() + return + logger.info("YOLO warmup skipped; weights load on the first inference request") @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: yolo_ready = is_yolo_available() model_name = "yolo+opencv" if yolo_ready else "opencv+clip" + # Do not load weights here. Class count stays empty until warmup or /analyze. yolo_class_count = get_yolo_class_count() if yolo_ready else None return HealthResponse( status="ok", diff --git a/fishing/ai/production.env.example b/fishing/ai/production.env.example new file mode 100644 index 0000000..48d5c13 --- /dev/null +++ b/fishing/ai/production.env.example @@ -0,0 +1,5 @@ +# 운영과 Docker 프로덕션 AI 프로세스에 넣습니다. +# 개발용 ai/.env.example 를 운영에 그대로 복사하지 마세요. +# 이 값이 있으면 예전과 같이 기동 시 YOLO 가중치를 올리고, 스레드 수는 제한하지 않습니다. +NODE_ENV=production +AI_WARMUP=true diff --git a/fishing/ai/run.py b/fishing/ai/run.py index 33ca449..4faa55b 100644 --- a/fishing/ai/run.py +++ b/fishing/ai/run.py @@ -11,6 +11,10 @@ load_dotenv(AI_DIR / ".env") +from app.runtime_tuning import prepare_process_threads # noqa: E402 + +prepare_process_threads() + from app.config import settings # noqa: E402 if __name__ == "__main__": diff --git a/fishing/ai/tests/test_runtime_tuning.py b/fishing/ai/tests/test_runtime_tuning.py new file mode 100644 index 0000000..19de922 --- /dev/null +++ b/fishing/ai/tests/test_runtime_tuning.py @@ -0,0 +1,49 @@ +import unittest + +from app.runtime_tuning import prepare_process_threads, torch_num_threads, warmup_enabled + + +class RuntimeTuningTest(unittest.TestCase): + def test_dev_warmup_defaults_off(self) -> None: + self.assertFalse(warmup_enabled({})) + self.assertFalse(warmup_enabled({"NODE_ENV": "development"})) + self.assertFalse(warmup_enabled({"AI_WARMUP": ""})) + + def test_production_warmup_defaults_on(self) -> None: + self.assertTrue(warmup_enabled({"NODE_ENV": "production"})) + self.assertTrue(warmup_enabled({"ENV": "Production"})) + self.assertTrue(warmup_enabled({"ENVIRONMENT": "production"})) + + def test_explicit_warmup_flag_wins(self) -> None: + self.assertFalse(warmup_enabled({"NODE_ENV": "production", "AI_WARMUP": "false"})) + self.assertFalse(warmup_enabled({"AI_WARMUP": "0"})) + self.assertFalse(warmup_enabled({"AI_WARMUP": "no"})) + self.assertFalse(warmup_enabled({"AI_WARMUP": "off"})) + self.assertTrue(warmup_enabled({"AI_WARMUP": "true"})) + self.assertTrue(warmup_enabled({"AI_WARMUP": " YES "})) + self.assertTrue(warmup_enabled({"NODE_ENV": "development", "AI_WARMUP": "1"})) + + def test_torch_threads_default_small_in_dev(self) -> None: + self.assertEqual(torch_num_threads({}), 1) + self.assertEqual(torch_num_threads({"NODE_ENV": "development"}), 1) + self.assertEqual(torch_num_threads({"TORCH_NUM_THREADS": "2"}), 2) + self.assertEqual(torch_num_threads({"TORCH_NUM_THREADS": "0"}), 1) + self.assertEqual(torch_num_threads({"TORCH_NUM_THREADS": "nope"}), 1) + + def test_production_does_not_cap_threads_unless_asked(self) -> None: + self.assertIsNone(torch_num_threads({"NODE_ENV": "production"})) + self.assertEqual( + torch_num_threads({"NODE_ENV": "production", "TORCH_NUM_THREADS": "4"}), + 4, + ) + + def test_prepare_keeps_existing_omp_and_skips_production(self) -> None: + updates = prepare_process_threads({"TORCH_NUM_THREADS": "1", "OMP_NUM_THREADS": "8"}) + self.assertNotIn("OMP_NUM_THREADS", updates) + self.assertEqual(updates["MKL_NUM_THREADS"], "1") + self.assertEqual(updates["OPENBLAS_NUM_THREADS"], "1") + self.assertEqual(prepare_process_threads({"NODE_ENV": "production"}), {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/fishing/docs/06-deployment.md b/fishing/docs/06-deployment.md index f6790d8..4404af3 100644 --- a/fishing/docs/06-deployment.md +++ b/fishing/docs/06-deployment.md @@ -171,6 +171,10 @@ S3_BUCKET_NAME=fishrank-images # 내부 통신 인증 INTERNAL_SECRET=내부_통신용_시크릿 +# 개발은 비우거나 false. 운영/Docker 프로덕션은 true (예: ai/production.env.example). +# NODE_ENV=production 이면 AI_WARMUP 을 비워도 기동 시 YOLO 를 올립니다. +AI_WARMUP=true + # 모델 경로 YOLO_MODEL_PATH=models/fish_classifier.pt YOLO_CONFIDENCE=0.5 @@ -227,9 +231,9 @@ pm2 start apps/api/dist/main.js --name fishrank-api npm run build --prefix apps/web pm2 start "npm run start --prefix apps/web" --name fishrank-web -# FastAPI 배포 +# FastAPI 배포. AI_WARMUP=true 는 기동 시 YOLO 예열(기존 운영 동작)을 유지합니다. cd ai -uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2 +NODE_ENV=production AI_WARMUP=true uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2 ``` --- diff --git a/fishing/docs/12-ai-server-structure.md b/fishing/docs/12-ai-server-structure.md index 50365cb..2b5951d 100644 --- a/fishing/docs/12-ai-server-structure.md +++ b/fishing/docs/12-ai-server-structure.md @@ -242,6 +242,8 @@ npm run dev:all npm run dev ``` +로컬 `npm run ai` 는 `AI_WARMUP` 이 없으면 YOLO 가중치를 기동 때 올리지 않습니다. 첫 `/analyze` 에서 읽고, 개발 중 PyTorch 스레드는 기본 1개입니다 (`TORCH_NUM_THREADS`). `NODE_ENV=production` 이거나 `AI_WARMUP=true` 이면 예전처럼 기동 시 예열하고 스레드를 제한하지 않습니다. 운영 env 예시는 `ai/production.env.example` 입니다. `/health` 는 가중치를 로드하지 않으므로, 예열 전에는 `yoloClassCount` 가 비어 있을 수 있습니다. + | 서비스 | 포트 | URL | |---|---|---| | Web | 3000 | http://localhost:3000 |