J.B-Lin commited on
Commit
aa40303
·
1 Parent(s): 765f642

fix(deploy): 使用预编译CUDA wheel替代源码编译

Browse files
Files changed (2) hide show
  1. .gitignore +6 -1
  2. modal_deploy/deploy.py +157 -101
.gitignore CHANGED
@@ -38,4 +38,9 @@ debug*.txt
38
  # 检查脚本(一次性使用)
39
  _check_hf.py
40
  _download_models.py
41
- "cookbook_ref/"
 
 
 
 
 
 
38
  # 检查脚本(一次性使用)
39
  _check_hf.py
40
  _download_models.py
41
+ "cookbook_ref/"
42
+
43
+ # MiniCPM-V-Cookbook 参考仓库(仅供本地参考,不提交)
44
+ MiniCPM-V-Cookbook/
45
+ temp_minicpm/
46
+ cookbook_ref/
modal_deploy/deploy.py CHANGED
@@ -1,64 +1,55 @@
1
  """
2
- Modal deployment for MiniCPM-o-4_5 via llama-cpp-python (CUDA).
3
- Compiles with GGML_CUDA=ON during image build (~5min, within Modal's 15min limit).
4
-
5
- Usage:
6
- modal deploy modal_deploy.deploy # Deploy (~5min build)
7
- modal run modal_deploy.deploy::test_inference # Test
8
-
9
- Architecture:
10
- User FastAPI (ASGI, OpenAI-compatible) → llama-cpp-python (CUDA)
11
-
12
- Modal Volume (GGUF models)
 
 
 
 
 
 
 
 
 
 
 
13
  """
 
14
  import os
15
  import modal
16
  from modal import Image, App, Volume, asgi_app
17
 
18
- # ═══════════════════════════════════════════════════════════════
19
- # 1. BASE IMAGE
20
- # ═══════════════════════════════════════════════════════════════
21
- # Compile llama-cpp-python with CUDA support at image build time.
22
- # Modal provides 15min build timeout — sufficient for CUDA compilation.
23
- # ═══════════════════════════════════════════════════════════════
24
 
25
  _image = (
26
  Image.debian_slim(python_version="3.11")
27
- .apt_install("build-essential", "cmake", "curl", "wget", "git",
28
- "libcurl4-openssl-dev")
29
- # Install CUDA toolkit (needed for llama.cpp CUDA compilation)
30
- .run_commands(
31
- "wget -q https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-keyring_1.1-1_all.deb",
32
- "dpkg -i cuda-keyring_1.1-1_all.deb",
33
- "apt-get update -qq",
34
- "apt-get install -y -qq cuda-compiler-12-1 cuda-cudart-dev-12-1 2>&1 | tail -3",
35
- )
36
- .env({
37
- "CUDA_HOME": "/usr/local/cuda-12.1",
38
- "PATH": "/usr/local/cuda-12.1/bin:${PATH}",
39
- "LD_LIBRARY_PATH": "/usr/local/cuda-12.1/lib64:${LD_LIBRARY_PATH}",
40
- "CMAKE_ARGS": "-DGGML_CUDA=ON -DGGML_CUDA_ARCHS=sm_80",
41
- "FORCE_CMAKE": "1",
42
- })
43
  .pip_install(
44
- "fastapi",
45
- "uvicorn[standard]",
46
- "httpx",
47
- "numpy",
48
- "Pillow",
49
- # Install llama-cpp-python with CUDA (compiles at build time)
50
  "llama-cpp-python",
51
- extra_args=["--force-reinstall", "--no-cache-dir"],
 
52
  )
 
53
  .run_commands(
54
- # Verify CUDA is available
55
- "python -c 'from llama_cpp import Llama; print(f\"CUDA available: {Llama.supports_gpu()}\")'",
56
  )
57
  )
58
 
59
- # ═══════════════════════════════════════════════════════════════
60
  # 2. CONSTANTS
61
- # ═══════════════════════════════════════════════════════════════
62
 
63
  MODEL_DIR = "/models"
64
  MODEL_SUBDIR = f"{MODEL_DIR}/MiniCPM-o-4_5-gguf"
@@ -68,12 +59,9 @@ VISION_MMPROJ = "vision/MiniCPM-o-4_5-vision-F16.gguf"
68
  model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
69
  app = App("prego-pal-minicpm")
70
 
71
- # ═══════════════════════════════════════════════════════════════
72
- # 3. MODEL HELPER
73
- # ═══════════════════════════════════════════════════════════════
74
 
75
  def get_model_paths(base_dir: str) -> dict:
76
- """Return validated model paths."""
77
  main_path = os.path.join(base_dir, MAIN_GGUF)
78
  vision_path = os.path.join(base_dir, VISION_MMPROJ)
79
  paths = {"main": main_path, "vision": vision_path}
@@ -81,24 +69,27 @@ def get_model_paths(base_dir: str) -> dict:
81
  print(f"[PregoPal] {key}: {path} (exists={os.path.isfile(path)})")
82
  return paths
83
 
84
- # ═══════════════════════════════════════════════════════════════
85
- # 4. ASGI APP (OpenAI-compatible API)
86
- # ═══════════════════════════════════════════════════════════════
 
87
 
88
  @app.function(
89
  image=_image,
90
  volumes={MODEL_DIR: model_volume},
91
- scaledown_window=300, # Keep warm 5min after last request
92
- gpu="A100", # Use A100 GPU via Modal
93
- timeout=1200, # Max function execution time
94
- allow_concurrent_inputs=10, # Up to 10 parallel requests
95
- container_idle_timeout=60, # Keep container 60s between requests
96
  )
97
  @asgi_app()
98
  def serve():
99
  import asyncio
100
  import json
101
  import logging
 
 
102
  from pathlib import Path
103
  from fastapi import FastAPI, Request
104
  from fastapi.responses import StreamingResponse, JSONResponse
@@ -117,27 +108,68 @@ def serve():
117
  allow_headers=["*"],
118
  )
119
 
120
- # ── Model Loading ──
121
  paths = get_model_paths(MODEL_SUBDIR)
122
  model_path = paths["main"]
123
  vision_path = paths["vision"]
124
 
125
  kwargs: dict = dict(
126
  model_path=model_path,
127
- n_gpu_layers=-1, # Offload ALL layers to GPU
128
- n_ctx=8192, # Context window
129
- verbose=False, # Keep logs clean
130
  n_threads=os.cpu_count() or 4,
131
  )
132
  if os.path.isfile(vision_path):
133
  kwargs["mmproj"] = vision_path
134
- logger.info("[PregoPal] Vision mmproj enabled")
135
-
136
- logger.info("[PregoPal] Loading model (this may take 30-90s)...")
137
- llm = Llama(**kwargs)
138
- logger.info("[PregoPal] Model loaded! Ready for inference.")
139
-
140
- # ── Endpoints ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  @web_app.post("/v1/chat/completions")
143
  async def chat_completions(request: Request):
@@ -147,7 +179,6 @@ def serve():
147
  max_tokens = body.get("max_tokens", 512)
148
  temperature = body.get("temperature", 0.7)
149
  top_p = body.get("top_p", 0.9)
150
- model = body.get("model", "MiniCPM-o-4_5")
151
 
152
  if stream:
153
  async def event_stream():
@@ -176,12 +207,11 @@ def serve():
176
  body = await request.json()
177
  prompt = body.get("prompt", "")
178
  max_tokens = body.get("max_tokens", 256)
179
- temperature = body.get("temperature", 0.7)
180
 
181
  result = llm.create_completion(
182
  prompt=prompt,
183
  max_tokens=max_tokens,
184
- temperature=temperature,
185
  stream=False,
186
  )
187
  return JSONResponse(result)
@@ -195,13 +225,39 @@ def serve():
195
  )
196
  return JSONResponse(result)
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  @web_app.get("/health")
199
  async def health():
200
- # Quick check: model loaded
201
  return {
202
  "status": "ok",
203
  "model": "MiniCPM-o-4_5",
204
  "cuda": True,
 
205
  }
206
 
207
  @web_app.get("/v1/models")
@@ -213,30 +269,31 @@ def serve():
213
  "object": "model",
214
  "created": 1,
215
  "owned_by": "prego-pal",
216
- "permission": [],
217
  }],
218
  }
219
 
220
  @web_app.get("/")
221
  async def root():
222
  return {
223
- "service": "PregoPal MiniCPM-o-4_5 API (CUDA)",
224
- "version": "1.2.0",
225
- "model": "MiniCPM-o-4_5-Q4_K_M",
226
  "endpoints": {
227
- "chat": "/v1/chat/completions (POST, streaming+non-streaming)",
228
- "completions": "/v1/completions (POST)",
229
- "embeddings": "/v1/embeddings (POST)",
230
- "models": "/v1/models (GET)",
231
- "health": "/health (GET)",
232
- }
 
233
  }
234
 
235
  return web_app
236
 
237
- # ═══════════════════════════════════════════════════════════════
238
- # 5. MODEL UPLOAD HELPER
239
- # ═══════════════════════════════════════════════════════════════
 
240
 
241
  @app.function(
242
  image=_image,
@@ -244,21 +301,25 @@ def serve():
244
  timeout=3600,
245
  )
246
  def upload_models():
247
- """Print instructions for uploading models to Modal Volume."""
248
  print("=" * 60)
249
- print("Upload model files to Modal Volume:")
250
  print()
251
  print(" # From your local models directory:")
252
  print(" modal volume put minicpm-o-4_5-models \\")
253
- print(" ../models/MiniCPM-o-4_5-gguf /")
254
  print()
255
  print(" # Verify:")
256
  print(" modal volume ls minicpm-o-4_5-models /")
 
 
 
257
  print("=" * 60)
258
 
259
- # ═══════════════════════════════════════════════════════════════
260
- # 6. TEST INFERENCE (One-shot, not as ASGI)
261
- # ═══════════════════════════════════════════════════════════════
 
262
 
263
  @app.function(
264
  image=_image,
@@ -267,32 +328,28 @@ def upload_models():
267
  timeout=600,
268
  )
269
  def test_inference():
270
- """Test inference on Modal (downloads model from Volume)."""
271
  import time
272
  import json
273
  from llama_cpp import Llama
274
 
275
  print("[PregoPal] ========== TEST INFERENCE ==========")
276
- print(f"[PregoPal] Timestamp: {time.strftime('%Y-%m-%d %H:%M:%S')}")
277
  print(f"[PregoPal] GPU: A100 (via Modal)")
278
- print(f"[PregoPal] Model base: {MODEL_SUBDIR}")
279
 
280
  paths = get_model_paths(MODEL_SUBDIR)
281
  main_path = paths["main"]
282
  vision_path = paths["vision"]
283
 
284
  if not os.path.isfile(main_path):
285
- print(f"[PregoPal] ERROR: Model not found at {main_path}")
286
- print("[PregoPal] Upload models first: modal run modal_deploy.deploy::upload_models")
287
  return
288
 
289
- # Load model
290
  t0 = time.time()
291
  kwargs = dict(
292
  model_path=main_path,
293
  n_gpu_layers=-1,
294
  n_ctx=4096,
295
- verbose=True,
296
  )
297
  if os.path.isfile(vision_path):
298
  kwargs["mmproj"] = vision_path
@@ -300,9 +357,9 @@ def test_inference():
300
  print("[PregoPal] Loading model...")
301
  llm = Llama(**kwargs)
302
  load_time = time.time() - t0
303
- print(f"[PregoPal] Model loaded in {load_time:.1f}s")
304
 
305
- # ── Test 1: Text generation ──
306
  print("\n[Test 1] Chinese greeting...")
307
  t0 = time.time()
308
  result = llm.create_chat_completion(
@@ -314,7 +371,7 @@ def test_inference():
314
  content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
315
  print(f"Response ({elapsed:.1f}s): {content}")
316
 
317
- # ── Test 2: English text ──
318
  print("\n[Test 2] English instruction...")
319
  t0 = time.time()
320
  result = llm.create_chat_completion(
@@ -328,5 +385,4 @@ def test_inference():
328
 
329
  print(f"\n{'='*50}")
330
  print(f"✅ Test complete! Loading: {load_time:.1f}s")
331
- print(f"✅ Inference speed: {elapsed:.1f}s per response (CUDA)")
332
  print(f"{'='*50}")
 
1
  """
2
+ PregoPal × MiniCPM-o-4_5 Modal 部署 (预编译 llama-cpp-python)
3
+
4
+ 架构:
5
+ FastAPI (ASGI) ←→ llama-cpp-python (CUDA via pre-built wheel)
6
+
7
+ Modal Volume: GGUF models
8
+
9
+ 用法:
10
+ pip install modal # 安装 Modal CLI
11
+ modal token new # 登录 Modal
12
+ modal deploy modal_deploy.deploy # 部署 (2-3 min)
13
+
14
+ 测试:
15
+ modal run modal_deploy.deploy::test_inference
16
+
17
+ API:
18
+ POST /v1/chat/completions — OpenAI 兼容 (支持 streaming)
19
+ POST /v1/completions — Text completion
20
+ POST /v1/embeddings — Embeddings
21
+ POST /v1/vision — 多模态 (图片+文字)
22
+ GET /health — 健康检查
23
+ GET /v1/models — 模型列表
24
  """
25
+
26
  import os
27
  import modal
28
  from modal import Image, App, Volume, asgi_app
29
 
30
+ # ════════════════════════════════════════════════════════════════════
31
+ # 1. IMAGE — 预编译 CUDA wheel (不从头编译)
32
+ # ════════════════════════════════════════════════════════════════════
 
 
 
33
 
34
  _image = (
35
  Image.debian_slim(python_version="3.11")
36
+ # 只安装 Python 依赖,不安装 cmake/gcc/CUDA toolkit
37
+ .pip_install("fastapi", "uvicorn[standard]", "httpx", "numpy", "Pillow")
38
+ # ggml-org 官方索引安装预编译 CUDA wheel(几秒完成)
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  .pip_install(
 
 
 
 
 
 
40
  "llama-cpp-python",
41
+ extra_index_url="https://ggml-org.github.io/llama-cpp-python/whl/cu121",
42
+ force_build=True,
43
  )
44
+ # → 验证安装
45
  .run_commands(
46
+ "python -c 'from llama_cpp import Llama; print(f\"llama-cpp OK, GPU: {Llama.supports_gpu()}\")'",
 
47
  )
48
  )
49
 
50
+ # ════════════════════════════════════════════════════════════════════
51
  # 2. CONSTANTS
52
+ # ════════════════════════════════════════════════════════════════════
53
 
54
  MODEL_DIR = "/models"
55
  MODEL_SUBDIR = f"{MODEL_DIR}/MiniCPM-o-4_5-gguf"
 
59
  model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
60
  app = App("prego-pal-minicpm")
61
 
 
 
 
62
 
63
  def get_model_paths(base_dir: str) -> dict:
64
+ """返回经验证的模型路径."""
65
  main_path = os.path.join(base_dir, MAIN_GGUF)
66
  vision_path = os.path.join(base_dir, VISION_MMPROJ)
67
  paths = {"main": main_path, "vision": vision_path}
 
69
  print(f"[PregoPal] {key}: {path} (exists={os.path.isfile(path)})")
70
  return paths
71
 
72
+
73
+ # ════════════════════════════════════════════════════════════════════
74
+ # 3. ASGI APP — 多模态 API
75
+ # ════════════════════════════════════════════════════════════════════
76
 
77
  @app.function(
78
  image=_image,
79
  volumes={MODEL_DIR: model_volume},
80
+ scaledown_window=300,
81
+ gpu="A100",
82
+ timeout=1200,
83
+ container_idle_timeout=300,
84
+ allow_concurrent_inputs=10,
85
  )
86
  @asgi_app()
87
  def serve():
88
  import asyncio
89
  import json
90
  import logging
91
+ import base64
92
+ from io import BytesIO
93
  from pathlib import Path
94
  from fastapi import FastAPI, Request
95
  from fastapi.responses import StreamingResponse, JSONResponse
 
108
  allow_headers=["*"],
109
  )
110
 
111
+ # ── Model Loading ──────────────────────────────────────────────
112
  paths = get_model_paths(MODEL_SUBDIR)
113
  model_path = paths["main"]
114
  vision_path = paths["vision"]
115
 
116
  kwargs: dict = dict(
117
  model_path=model_path,
118
+ n_gpu_layers=-1,
119
+ n_ctx=8192,
120
+ verbose=False,
121
  n_threads=os.cpu_count() or 4,
122
  )
123
  if os.path.isfile(vision_path):
124
  kwargs["mmproj"] = vision_path
125
+ logger.info("[PregoPal] Vision mmproj enabled")
126
+ else:
127
+ logger.warning(f"[PregoPal] ⚠️ mmproj not found at {vision_path} — vision disabled")
128
+
129
+ logger.info("[PregoPal] Loading model (30-90s)...")
130
+ try:
131
+ llm = Llama(**kwargs)
132
+ logger.info("[PregoPal] ✅ Model loaded!")
133
+ except Exception as e:
134
+ logger.error(f"[PregoPal] ❌ Failed to load model: {e}")
135
+ raise
136
+
137
+ # ── Helpers ────────────────────────────────────────────────────
138
+
139
+ def _parse_messages(messages: list) -> str:
140
+ """Convert messages list to a prompt string."""
141
+ texts = []
142
+ for msg in messages:
143
+ role = msg.get("role", "user")
144
+ content = msg.get("content", "")
145
+ if isinstance(content, list):
146
+ parts = []
147
+ for part in content:
148
+ if isinstance(part, dict):
149
+ if part.get("type") == "text":
150
+ parts.append(part.get("text", ""))
151
+ elif part.get("type") == "image_url":
152
+ parts.append("[IMAGE]")
153
+ else:
154
+ parts.append(str(part))
155
+ content = " ".join(parts)
156
+ texts.append(f"<|{role}|>\n{content}\n<|assistant|>\n")
157
+ return "".join(texts)
158
+
159
+ def _extract_image(messages: list) -> bytes | None:
160
+ """Extract the first base64 image from messages."""
161
+ for msg in messages:
162
+ content = msg.get("content", "")
163
+ if isinstance(content, list):
164
+ for part in content:
165
+ if isinstance(part, dict) and part.get("type") == "image_url":
166
+ url = part.get("image_url", {}).get("url", "")
167
+ if url.startswith("data:image"):
168
+ _, b64 = url.split(",", 1)
169
+ return base64.b64decode(b64)
170
+ return None
171
+
172
+ # ── Endpoints ──────────────────────────────────────────────────
173
 
174
  @web_app.post("/v1/chat/completions")
175
  async def chat_completions(request: Request):
 
179
  max_tokens = body.get("max_tokens", 512)
180
  temperature = body.get("temperature", 0.7)
181
  top_p = body.get("top_p", 0.9)
 
182
 
183
  if stream:
184
  async def event_stream():
 
207
  body = await request.json()
208
  prompt = body.get("prompt", "")
209
  max_tokens = body.get("max_tokens", 256)
 
210
 
211
  result = llm.create_completion(
212
  prompt=prompt,
213
  max_tokens=max_tokens,
214
+ temperature=body.get("temperature", 0.7),
215
  stream=False,
216
  )
217
  return JSONResponse(result)
 
225
  )
226
  return JSONResponse(result)
227
 
228
+ @web_app.post("/v1/vision")
229
+ async def vision(request: Request):
230
+ """
231
+ 多模态推理:接收图片(base64)和文本提示。
232
+ 如果 llm 未加载 mmproj,返回 400。
233
+ """
234
+ body = await request.json()
235
+ messages = body.get("messages", [])
236
+ max_tokens = body.get("max_tokens", 512)
237
+ temperature = body.get("temperature", 0.7)
238
+
239
+ if not os.path.isfile(vision_path):
240
+ return JSONResponse(
241
+ {"error": "Vision mmproj not loaded — deploy the model with mmproj file"},
242
+ status_code=400,
243
+ )
244
+
245
+ # llama-cpp-python 的 create_chat_completion 原生支持多模态
246
+ result = llm.create_chat_completion(
247
+ messages=messages,
248
+ max_tokens=max_tokens,
249
+ temperature=temperature,
250
+ stream=False,
251
+ )
252
+ return JSONResponse(result)
253
+
254
  @web_app.get("/health")
255
  async def health():
 
256
  return {
257
  "status": "ok",
258
  "model": "MiniCPM-o-4_5",
259
  "cuda": True,
260
+ "vision": os.path.isfile(vision_path),
261
  }
262
 
263
  @web_app.get("/v1/models")
 
269
  "object": "model",
270
  "created": 1,
271
  "owned_by": "prego-pal",
 
272
  }],
273
  }
274
 
275
  @web_app.get("/")
276
  async def root():
277
  return {
278
+ "service": "PregoPal MiniCPM-o-4_5 API",
279
+ "version": "2.0.0",
280
+ "model": MAIN_GGUF,
281
  "endpoints": {
282
+ "chat": "POST /v1/chat/completions",
283
+ "completions": "POST /v1/completions",
284
+ "embeddings": "POST /v1/embeddings",
285
+ "vision": "POST /v1/vision (多模态)",
286
+ "models": "GET /v1/models",
287
+ "health": "GET /health",
288
+ },
289
  }
290
 
291
  return web_app
292
 
293
+
294
+ # ═══════════════════════════════════════════���════════════════════════
295
+ # 4. MODEL UPLOAD 指引
296
+ # ════════════════════════════════════════════════════════════════════
297
 
298
  @app.function(
299
  image=_image,
 
301
  timeout=3600,
302
  )
303
  def upload_models():
304
+ """打印上传模型指引."""
305
  print("=" * 60)
306
+ print("📦 上传模型至 Modal Volume:")
307
  print()
308
  print(" # From your local models directory:")
309
  print(" modal volume put minicpm-o-4_5-models \\")
310
+ print(" ./models/MiniCPM-o-4_5-gguf /MiniCPM-o-4_5-gguf")
311
  print()
312
  print(" # Verify:")
313
  print(" modal volume ls minicpm-o-4_5-models /")
314
+ print(f" # Expected files:")
315
+ print(f" # {MAIN_GGUF}")
316
+ print(f" # {VISION_MMPROJ}")
317
  print("=" * 60)
318
 
319
+
320
+ # ════════════════════════════════════════════════════════════════════
321
+ # 5. TEST INFERENCE
322
+ # ════════════════════════════════════════════════════════════════════
323
 
324
  @app.function(
325
  image=_image,
 
328
  timeout=600,
329
  )
330
  def test_inference():
331
+ """ Modal 上测试推理."""
332
  import time
333
  import json
334
  from llama_cpp import Llama
335
 
336
  print("[PregoPal] ========== TEST INFERENCE ==========")
 
337
  print(f"[PregoPal] GPU: A100 (via Modal)")
 
338
 
339
  paths = get_model_paths(MODEL_SUBDIR)
340
  main_path = paths["main"]
341
  vision_path = paths["vision"]
342
 
343
  if not os.path.isfile(main_path):
344
+ print(f"[PregoPal] Model not found at {main_path}")
 
345
  return
346
 
 
347
  t0 = time.time()
348
  kwargs = dict(
349
  model_path=main_path,
350
  n_gpu_layers=-1,
351
  n_ctx=4096,
352
+ verbose=False,
353
  )
354
  if os.path.isfile(vision_path):
355
  kwargs["mmproj"] = vision_path
 
357
  print("[PregoPal] Loading model...")
358
  llm = Llama(**kwargs)
359
  load_time = time.time() - t0
360
+ print(f"[PregoPal] Model loaded in {load_time:.1f}s")
361
 
362
+ # Test 1
363
  print("\n[Test 1] Chinese greeting...")
364
  t0 = time.time()
365
  result = llm.create_chat_completion(
 
371
  content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
372
  print(f"Response ({elapsed:.1f}s): {content}")
373
 
374
+ # Test 2
375
  print("\n[Test 2] English instruction...")
376
  t0 = time.time()
377
  result = llm.create_chat_completion(
 
385
 
386
  print(f"\n{'='*50}")
387
  print(f"✅ Test complete! Loading: {load_time:.1f}s")
 
388
  print(f"{'='*50}")