Unified Gemma 4 family image for Gemma-4-31B and GEV-26B-Decide
All checks were successful
Docker Build and Push / docker (push) Successful in 1m11s
All checks were successful
Docker Build and Push / docker (push) Successful in 1m11s
- detect_model.py picks TP/port/served name/eager/util from config.json - validate TP against the model config (dense global KV heads, MoE k % 32) - one apply_patch.py covers both variants, idempotent - Dockerfile stays generic: no ENV/WORKDIR/EXPOSE/ENTRYPOINT - run.sh/verify.sh follow the llama4-bi150 layout
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitea
|
||||
README.md
|
||||
run.sh
|
||||
verify.sh
|
||||
docker-compose.yml
|
||||
*.log
|
||||
132
.gitea/workflows/docker-build-push.yml
Normal file
132
.gitea/workflows/docker-build-push.yml
Normal file
@@ -0,0 +1,132 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: amd64-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
run: |
|
||||
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||
git checkout "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Set image metadata
|
||||
run: |
|
||||
IMAGE_NAME="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
|
||||
IMAGE="${DOCKER_REGISTRY}/${DOCKER_USERNAME}/${IMAGE_NAME}:${{ gitea.ref_name }}"
|
||||
|
||||
echo "IMAGE_NAME=${IMAGE_NAME}" >> "$GITEA_ENV"
|
||||
echo "IMAGE=${IMAGE}" >> "$GITEA_ENV"
|
||||
|
||||
- name: Load and Validate Task Info
|
||||
run: |
|
||||
set -a
|
||||
. .gitea/workflows/task_info.env
|
||||
set +a
|
||||
|
||||
for name in FRAMEWORK GPU_TYPE TASK_TYPE; do
|
||||
eval "value=\${${name}:-}"
|
||||
if [ "$name" = "FRAMEWORK" ] && [ -z "$value" ]; then
|
||||
echo "${name} is empty in .gitea/workflows/task_info.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${name}=${value}" >> "$GITEA_ENV"
|
||||
done
|
||||
|
||||
- name: Validate Image Verify Metadata
|
||||
run: |
|
||||
if [ -z "${FIXED_TOKEN:-}" ]; then
|
||||
echo "FIXED_TOKEN is not configured on runner"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! response="$(curl --silent --show-error --location --get 'https://modelhub.org.cn/adminApi/image-verify/validate' \
|
||||
--header "Xc-Token: ${FIXED_TOKEN}" \
|
||||
--data-urlencode "gpuType=${GPU_TYPE:-}" \
|
||||
--data-urlencode "taskType=${TASK_TYPE:-}")"; then
|
||||
echo "failed to call image verify validate API"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VALIDATE_RESPONSE="$response" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
raw = os.environ.get("VALIDATE_RESPONSE", "")
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
print("image verify validate API returned invalid JSON")
|
||||
print(raw)
|
||||
sys.exit(1)
|
||||
|
||||
if body.get("code") == 0 and body.get("data") is True:
|
||||
print("image verify metadata validation passed")
|
||||
sys.exit(0)
|
||||
|
||||
message = body.get("message") or "unknown error"
|
||||
print(f"image verify metadata validation failed: {message}")
|
||||
print(raw)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
- name: Login to Docker Registry
|
||||
run: |
|
||||
echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" \
|
||||
-u "$DOCKER_USERNAME" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker build -t "$IMAGE" .
|
||||
|
||||
- name: Push Docker Image
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
echo "Starting docker push attempt ${attempt}/3 for ${IMAGE}"
|
||||
docker push "$IMAGE" &
|
||||
PUSH_PID=$!
|
||||
|
||||
while kill -0 "$PUSH_PID" 2>/dev/null; do
|
||||
echo "docker push is still running at $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
sleep 60
|
||||
done
|
||||
|
||||
if wait "$PUSH_PID"; then
|
||||
echo "docker push completed successfully"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "docker push failed on attempt ${attempt}/3"
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "docker push failed after 3 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Notify Image Verify
|
||||
run: |
|
||||
if [ -z "${FIXED_TOKEN:-}" ]; then
|
||||
echo "FIXED_TOKEN is not configured on runner"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --silent --show-error --fail-with-body --location --request POST 'https://modelhub.org.cn//adminApi/image-verify' \
|
||||
--header "Xc-Token: ${FIXED_TOKEN}" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{
|
||||
\"framework\": \"${FRAMEWORK}\",
|
||||
\"gpuType\": \"${GPU_TYPE}\",
|
||||
\"imageUrl\": \"${IMAGE}\",
|
||||
\"taskType\": \"${TASK_TYPE}\",
|
||||
\"createBy\": \"${{ gitea.actor }}\",
|
||||
\"repoUrl\": \"${{ gitea.server_url }}/${{ gitea.repository }}\",
|
||||
\"tag\": \"${{ github.ref_name }}\"
|
||||
}"
|
||||
3
.gitea/workflows/task_info.env
Normal file
3
.gitea/workflows/task_info.env
Normal file
@@ -0,0 +1,3 @@
|
||||
FRAMEWORK=vllm_gemma_4
|
||||
GPU_TYPE=Iluvatar_bi-150
|
||||
TASK_TYPE=text-generation
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
# Gemma 4 family on Iluvatar BI-V150
|
||||
#
|
||||
# 一个镜像同时支持:
|
||||
# - Gemma-4-31B-it (dense,TP4,4 卡,8095,eager 关)
|
||||
# - GEV-26B-Decide (MoE,TP2,2 卡,8094,eager 开)
|
||||
#
|
||||
# 两个变体共用同一个基础镜像和同一组补丁,差别只在 TP / 端口 / served name /
|
||||
# eager / 显存 / 并发数,全部由入口脚本读 config.json 后决定。
|
||||
#
|
||||
# 镜像只包含运行时依赖和补丁,不设置 ENV / WORKDIR / EXPOSE / ENTRYPOINT / CMD;
|
||||
# 模型路径、端口、TP、GPU、挂载等全部由 run.sh / docker-compose.yml 在启动时传入。
|
||||
#
|
||||
# 构建:
|
||||
# docker build -t gemma4-bi150:2026-10-27 .
|
||||
#
|
||||
# 运行(由 run.sh 传入 entrypoint 和全部参数):
|
||||
# ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
# ./run.sh /mnt/disk0/models/autotrust/GEV-26B-Decide
|
||||
#
|
||||
# 补丁是针对下面这个精确基础镜像生成的;换基础镜像需要重新生成 diff。
|
||||
# registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v7
|
||||
# (vLLM 0.17.0+corex.20260612070235)
|
||||
|
||||
FROM registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v7
|
||||
|
||||
ARG IMAGE_VERSION=2026-10-27
|
||||
|
||||
LABEL org.opencontainers.image.title="gemma4-bi150" \
|
||||
org.opencontainers.image.description="Gemma 4 31B-it / GEV-26B-Decide on Iluvatar BI-V150 with vLLM 0.17.0+corex" \
|
||||
org.opencontainers.image.version="${IMAGE_VERSION}"
|
||||
|
||||
COPY apply_patch.py /opt/gemma4/apply_patch.py
|
||||
COPY detect_model.py /opt/gemma4/detect_model.py
|
||||
COPY entrypoint.sh /usr/local/bin/gemma4-entrypoint.sh
|
||||
|
||||
RUN chmod +x /usr/local/bin/gemma4-entrypoint.sh \
|
||||
&& chmod +x /opt/gemma4/detect_model.py \
|
||||
&& python3 /opt/gemma4/apply_patch.py
|
||||
|
||||
# 故意不设置 ENV / WORKDIR / EXPOSE / ENTRYPOINT / CMD:
|
||||
# 镜像保持通用,启动方式由 run.sh 或 docker-compose.yml 决定。
|
||||
391
README.md
Normal file
391
README.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# gemma4-bi150 — Gemma 4 家族统一部署(Iluvatar BI-V150)
|
||||
|
||||
一个镜像同时支持两个 Gemma 4 家族模型,启动时读模型 `config.json` 自动选择运行参数:
|
||||
|
||||
| 模型 | 类型 | 权重 | TP | 卡数 | 端口 | served name | eager | 显存 | seqs |
|
||||
|---|---|---:|---:|---:|---:|---|---:|---:|---:|
|
||||
| `gemma-4-31B-it` | dense,60 层 | 58.25 GiB | **4** | 4 | **8095** | `gemma-4-31B-it` | 0 | 0.90 | 4 |
|
||||
| `GEV-26B-Decide` | MoE,30 层 / 128 experts | 48.07 GiB | **2** | 2 | **8094** | `GEV-26B-Decide` | 1 | 0.95 | 2 |
|
||||
|
||||
> 本仓库由 `memory/gemma4-31b-deploy/` 和 `memory/gev-26b-decide-deploy/` 合并而来,
|
||||
> 原目录保留未删。合并依据见文末「为什么可以合并」。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目录结构
|
||||
|
||||
```text
|
||||
gemma4-bi150-deploy/
|
||||
├── Dockerfile # 只放基础镜像 + 补丁 + 脚本,不写 ENV/EXPOSE/ENTRYPOINT
|
||||
├── entrypoint.sh # 容器入口:打补丁 → detect → exec vllm
|
||||
├── detect_model.py # 从 config.json 判断 dense / MoE,并校验 TP 可行性
|
||||
├── apply_patch.py # 幂等应用两个 CoreX vLLM 补丁
|
||||
├── run.sh # 构建 + 启动 + 健康检查
|
||||
├── verify.sh # health / models / chat / 可选推理 / 可选图片
|
||||
├── docker-compose.yml
|
||||
├── .dockerignore
|
||||
├── .gitignore
|
||||
├── .gitea/workflows/
|
||||
│ ├── docker-build-push.yml
|
||||
│ └── task_info.env
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 快速开始
|
||||
|
||||
```bash
|
||||
cd gemma4-bi150-deploy
|
||||
|
||||
# Gemma-4-31B(4 卡,默认用 15,14,13,12)
|
||||
./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
|
||||
# GEV-26B-Decide(2 卡,默认用 14,15)
|
||||
./run.sh /mnt/disk0/models/autotrust/GEV-26B-Decide
|
||||
|
||||
# 验证
|
||||
./verify.sh --model-dir /mnt/disk0/models/google/gemma-4-31B-it
|
||||
./verify.sh --model-dir /mnt/disk0/models/autotrust/GEV-26B-Decide --with-image
|
||||
```
|
||||
|
||||
模型类型不需要手写参数,`run.sh` 会调用 `detect_model.py` 读 `config.json` 判断。
|
||||
|
||||
### 2.1 不传模型目录
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
按顺序尝试:
|
||||
|
||||
```text
|
||||
/mnt/disk0/models/google/gemma-4-31B-it
|
||||
/mnt/disk0/models/autotrust/GEV-26B-Decide
|
||||
```
|
||||
|
||||
### 2.2 Compose
|
||||
|
||||
```bash
|
||||
MODEL_HOST_DIR=/mnt/disk0/models/google/gemma-4-31B-it docker compose up -d --build
|
||||
```
|
||||
|
||||
Compose 里 TP / PORT / SERVED_NAME / MAX_NUM_SEQS / GPU_MEM_UTIL / ENFORCE_EAGER
|
||||
都默认留空,由容器内 `entrypoint.sh` 检测后填充。
|
||||
|
||||
---
|
||||
|
||||
## 3. 自动识别逻辑
|
||||
|
||||
### 3.1 判断变体
|
||||
|
||||
```python
|
||||
arch = config["architectures"][0] # 必须以 Gemma4 开头
|
||||
text = config.get("text_config", config)
|
||||
experts = num_experts / num_local_experts / n_routed_experts
|
||||
```
|
||||
|
||||
| 条件 | 变体 | 运行参数 |
|
||||
|---|---|---|
|
||||
| `experts > 1` | GEV-26B-Decide(MoE) | TP2 / 8094 / eager1 / 0.95 / 2 seqs |
|
||||
| `experts` 缺失或 `== 1` | Gemma-4-31B(dense) | TP4 / 8095 / eager0 / 0.90 / 4 seqs |
|
||||
| 无 experts 且目录名含 `gev` | GEV | 同上 |
|
||||
| 无 experts 且目录名含 `gemma` | Gemma-4-31B | 同上 |
|
||||
| 其它 / 架构不是 Gemma4 | 直接报错 | 不启动 |
|
||||
|
||||
`num_experts=1` 按 dense 处理,因为部分 dense 配置用 1 表示"没有专家"。
|
||||
|
||||
### 3.2 顺便校验 TP 可行性(这是合并后的额外收益)
|
||||
|
||||
两个变体都有硬性 TP 上限,`detect_model.py` 会拿 `config.json` 反查:
|
||||
|
||||
```text
|
||||
dense: num_global_key_value_heads % TP == 0
|
||||
Gemma-4-31B 的 4 个 global KV head,TP8 会切成 0.5 → 拒绝启动
|
||||
MoE: (moe_intermediate_size / TP) % 32 == 0
|
||||
CoreX ixfops.moe_w16a16_group_gemm 要求 k % 32 == 0
|
||||
GEV 704/2=352 ✅,704/4=176 ❌
|
||||
```
|
||||
|
||||
所以下面这些都会被提前拦住,而不是等 vLLM 跑到一半才崩:
|
||||
|
||||
```bash
|
||||
TP=8 ./run.sh .../gemma-4-31B-it # num_global_key_value_heads=4 % 8 != 0
|
||||
TP=4 ./run.sh .../GEV-26B-Decide # 704/4=176,k % 32 != 0
|
||||
```
|
||||
|
||||
确实需要强跑时用 `GEMMA4_SKIP_TP_CHECK=1` 降级为警告。
|
||||
|
||||
### 3.3 检测输出
|
||||
|
||||
```text
|
||||
$ python3 detect_model.py /mnt/disk0/models/autotrust/GEV-26B-Decide
|
||||
DETECTED_MODEL_KIND=gev-26b-decide
|
||||
DETECTED_TP=2
|
||||
DETECTED_PORT=8094
|
||||
DETECTED_SERVED_NAME=GEV-26B-Decide
|
||||
DETECTED_GPU_COUNT=2
|
||||
DETECTED_MAX_NUM_SEQS=2
|
||||
DETECTED_GPU_MEM_UTIL=0.95
|
||||
DETECTED_ENFORCE_EAGER=1
|
||||
DETECTED_DESCRIPTION='AutoTrust GEV-26B-Decide MoE (BF16, TP2, 2 GPUs)'
|
||||
```
|
||||
|
||||
```bash
|
||||
python3 detect_model.py <dir> --json # verify.sh 用
|
||||
python3 detect_model.py <dir> --tp 4 # 按 TP4 校验可行性
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 覆盖启动参数
|
||||
|
||||
显式环境变量优先于检测结果。
|
||||
|
||||
```bash
|
||||
# 换卡
|
||||
GPUS=12,13,14,15 ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
|
||||
# 换端口和模型名
|
||||
PORT=9001 SERVED_NAME=my-gemma4 ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
|
||||
# 加 vLLM 参数
|
||||
EXTRA_ARGS="--max-model-len 16384" ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
|
||||
# 加 docker 参数
|
||||
EXTRA_DOCKER_ARGS="-v /mnt/disk1:/data:ro" ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
|
||||
# 用已有镜像(CI 产物)不重新 build
|
||||
BUILD=0 IMAGE=harbor.4pd.io/modelhubxc/xiejiahao/gemma4-bi150:v0.1.0 \
|
||||
./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
|
||||
# 只打印命令
|
||||
DRY_RUN=1 ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
```
|
||||
|
||||
常用环境变量:
|
||||
|
||||
```text
|
||||
IMAGE NAME MODEL_HOST_DIR MODEL_MOUNT PORT SERVED_NAME TP GPUS
|
||||
MAX_MODEL_LEN MAX_NUM_SEQS GPU_MEM_UTIL MAX_IMAGES ENFORCE_EAGER
|
||||
AUTO_DETECT HEALTH_TIMEOUT EXTRA_ARGS EXTRA_DOCKER_ARGS BUILD DRY_RUN
|
||||
GEMMA4_SKIP_TP_CHECK
|
||||
```
|
||||
|
||||
`AUTO_DETECT=0` 可关闭容器内检测,全部手动给值。
|
||||
|
||||
---
|
||||
|
||||
## 5. 为什么 Dockerfile 里没有 ENV / EXPOSE / ENTRYPOINT
|
||||
|
||||
镜像必须能同时服务 dense 和 MoE 两个变体,而这些参数在两个变体之间不同:
|
||||
|
||||
| 参数 | Gemma-4-31B | GEV-26B-Decide |
|
||||
|---|---:|---:|
|
||||
| TP | 4 | 2 |
|
||||
| 端口 | 8095 | 8094 |
|
||||
| eager | 0 | 1 |
|
||||
| 显存 | 0.90 | 0.95 |
|
||||
| seqs | 4 | 2 |
|
||||
|
||||
写进镜像就只能二选一,或者做两个镜像,违背"一个家族一个镜像"的目标。
|
||||
所以启动方式统一由 `run.sh` / `docker-compose.yml` 传入:
|
||||
|
||||
```bash
|
||||
docker run ... \
|
||||
--entrypoint /usr/local/bin/gemma4-entrypoint.sh \
|
||||
--workdir /opt/gemma4 \
|
||||
--expose 8095 \
|
||||
-e MODEL_PATH=/models/gemma4 \
|
||||
-e AUTO_DETECT=1 \
|
||||
<image>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 补丁
|
||||
|
||||
`apply_patch.py` 应用两个补丁,构建镜像时和容器启动时都会幂等执行一次。
|
||||
|
||||
| 补丁 | 目标文件 | 不打的后果 |
|
||||
|---|---|---|
|
||||
| `MultiModalDataDict` 导入路径 | `vllm/model_executor/models/gemma4_mm.py` | `ImportError`,模型检查阶段就失败(**两个变体都需要**) |
|
||||
| decode fast path 的 attention metadata 判断 | `vllm/model_executor/layers/fused_moe/fused_moe.py` | GEV 的 CUDA graph capture 报 `AttributeError: no attribute 'num_decodes'` |
|
||||
|
||||
第二个补丁对 dense 的 Gemma-4-31B 通常用不到,但保持幂等一起打,避免同一镜像换模型后失效。
|
||||
|
||||
补丁是针对这个精确基础镜像生成的,换镜像需要重新生成:
|
||||
|
||||
```text
|
||||
registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v7
|
||||
vLLM 0.17.0+corex.20260612070235
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 验证
|
||||
|
||||
```bash
|
||||
# 自动找运行中的容器
|
||||
./verify.sh
|
||||
|
||||
# 指定模型目录(推荐,能顺带校验变体)
|
||||
./verify.sh --model-dir /mnt/disk0/models/google/gemma-4-31B-it
|
||||
|
||||
# 指定容器
|
||||
./verify.sh --container gemma4-31b
|
||||
|
||||
# 额外图片识别
|
||||
./verify.sh --model-dir /mnt/disk0/models/autotrust/GEV-26B-Decide --with-image
|
||||
|
||||
# 额外推理题(期望 27)
|
||||
./verify.sh --model-dir /mnt/disk0/models/google/gemma-4-31B-it --with-reasoning
|
||||
|
||||
# 直接指定服务地址
|
||||
./verify.sh --base-url http://127.0.0.1:8095 --served-name gemma-4-31B-it
|
||||
```
|
||||
|
||||
检查项:`/health`、`/v1/models`、`/v1/chat/completions`,可选推理题和图片识别。
|
||||
只依赖 `bash`、`curl`、`python3`。全部通过返回 0。
|
||||
|
||||
> `/health` 只看 `curl` 退出码,不看 body——vLLM 会返回 200 但 body 为空。
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键参数的理由
|
||||
|
||||
### 8.1 Gemma-4-31B 为什么是 TP4
|
||||
|
||||
```text
|
||||
Gemma4ForConditionalGeneration
|
||||
num_attention_heads = 32
|
||||
num_key_value_heads = 16 # sliding attention
|
||||
num_global_key_value_heads = 4 # full attention
|
||||
head_dim / global_head_dim = 256 / 512
|
||||
num_hidden_layers = 60
|
||||
```
|
||||
|
||||
`num_global_key_value_heads=4` 是最紧的约束:TP8 会切成 0.5,当前 vLLM 不支持。
|
||||
TP4 下 4/4=1,权重 58.25 GiB / 4 ≈ 14.6 GiB/卡,实测加载后 15.89 GiB/卡。
|
||||
|
||||
### 8.2 GEV-26B-Decide 为什么是 TP2
|
||||
|
||||
```text
|
||||
Gemma4ForConditionalGeneration
|
||||
hidden_size = 2816
|
||||
num_hidden_layers = 30
|
||||
num_attention_heads = 16
|
||||
num_key_value_heads = 8
|
||||
moe_intermediate_size = 704
|
||||
num_experts = 128
|
||||
top_k_experts = 8
|
||||
```
|
||||
|
||||
CoreX `ixfops.moe_w16a16_group_gemm` 要求分组 GEMM 的 K 维能被 32 整除,
|
||||
`704 / TP` 的结果:TP8→88 ❌、TP4→176 ❌、TP2→352 ✅。
|
||||
TP1 显存不够,所以只有 TP2。
|
||||
|
||||
### 8.3 GEV 为什么开 `--enforce-eager`
|
||||
|
||||
MoE + CoreX 的 CUDA graph 路径不稳定,实测开 eager 才能稳定启动;
|
||||
dense 的 Gemma-4-31B 不需要,关掉可以拿到更好的吞吐。
|
||||
|
||||
### 8.4 GEV 为什么用 0.95 显存
|
||||
|
||||
权重 48.07 GiB / 2 ≈ 24.99 GiB/卡,0.95 下 KV 约 4.9 GiB,适合低并发。
|
||||
`max-num-seqs` 同时降到 2,避免 KV 被并发打爆。
|
||||
|
||||
---
|
||||
|
||||
## 9. GPU 选择
|
||||
|
||||
`run.sh` 默认优先序号大的卡,避免和 16 卡大模型抢 0-11:
|
||||
|
||||
```text
|
||||
Gemma-4-31B → 15,14,13,12
|
||||
GEV-26B-Decide → 14,15
|
||||
```
|
||||
|
||||
卡数不足会直接报错,卡数多余只警告(vLLM 只用前 TP 张):
|
||||
|
||||
```bash
|
||||
GPUS=12,13 ./run.sh .../gemma-4-31B-it # 报错:需要 4 张
|
||||
GPUS=0,1,2,3,4,5 ./run.sh .../GEV-26B-Decide # 警告:只用其中 2 张
|
||||
```
|
||||
|
||||
查看空闲卡:
|
||||
|
||||
```bash
|
||||
/usr/local/corex/bin/ixsmi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| `ImportError: cannot import name 'MultiModalDataDict'` | 补丁没打上 | 确认 `apply_patch.py` 执行过 |
|
||||
| `AttributeError: 'TritonAttentionMetadata' object has no attribute 'num_decodes'` | MoE 补丁没打上 | 同上 |
|
||||
| `Expected k % 32 == 0 to be true` | GEV 用了 TP4/TP8 | 回到 TP2;`detect_model.py` 会提前拦住 |
|
||||
| KV head 无法切分 / head 数不整除 | dense 用了 TP8 | 回到 TP4 |
|
||||
| `cudaGetDeviceCount() ... Error 1001` | 少了 `--privileged -v /dev:/dev` | 用 `run.sh`,不要手写简化版 `docker run` |
|
||||
| 启动 10 分钟还没 ready | 冷读 49–59 GB 权重 | 默认给 900s;`docker logs -f` 看 `Loading weights` |
|
||||
| `/health` 超时但容器在跑 | 还在加载权重 | 以 `Loading weights took` 和 `/v1/models` 为准 |
|
||||
|
||||
---
|
||||
|
||||
## 11. CI 与发布
|
||||
|
||||
```bash
|
||||
# 首次
|
||||
git tag v0.1.0 && git push origin v0.1.0
|
||||
```
|
||||
|
||||
- 只有推送 `v*` 标签才触发;
|
||||
- runner label:`amd64-ubuntu-24.04`,需要 `DOCKER_*` / `FIXED_TOKEN`;
|
||||
- `task_info.env`:`FRAMEWORK=vllm_gemma_4`(必须独特,不要用泛化的 `vllm`);
|
||||
- CI 被取消后**不要复用标签**,直接 bump patch;
|
||||
- CI 构建 vLLM 基础镜像较久,本次预计 1.5 小时量级,别着急。
|
||||
|
||||
CI 产物通用,不会自动启动,用 `run.sh` 指定 entrypoint:
|
||||
|
||||
```bash
|
||||
BUILD=0 IMAGE=<ci-image> ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 为什么可以合并
|
||||
|
||||
合并前是 `gemma4-31b-deploy/` 和 `gev-26b-decide-deploy/` 两套目录。逐项对比:
|
||||
|
||||
| 项 | Gemma-4-31B | GEV-26B-Decide | 能否 detect |
|
||||
|---|---|---|---|
|
||||
| 基础镜像 | 同一个 | 同一个 | — |
|
||||
| 架构 | `Gemma4ForConditionalGeneration` | 同一个 | 不需要 |
|
||||
| vLLM 补丁 | 两个 | 同样两个 | 不需要 |
|
||||
| docker run 参数 | 相同 | 相同 | — |
|
||||
| TP | 4 | 2 | ✅ config 可判 |
|
||||
| 端口 | 8095 | 8094 | ✅ config 可判 |
|
||||
| served name | `gemma-4-31B-it` | `GEV-26B-Decide` | ✅ config 可判 |
|
||||
| eager | 0 | 1 | ✅ config 可判 |
|
||||
| 显存 / seqs | 0.90 / 4 | 0.95 / 2 | ✅ config 可判 |
|
||||
|
||||
差异项全部可由 `config.json` 推断,和 Llama 4 用 `num_local_experts` 分 Scout/Maverick 是同一形态,
|
||||
因此按"一个模型家族一个仓库"的原则合并。
|
||||
|
||||
顺带解决的问题:
|
||||
|
||||
1. 两份 `apply_patch.py` 原本只差注释,现在只有一份;
|
||||
2. 两个 Dockerfile 原本都写死了 `ENV/EXPOSE/ENTRYPOINT`,现在通用;
|
||||
3. 一次 CI 覆盖两个模型,而不是两次构建(每次约 1.5 小时);
|
||||
4. 新增 TP 可行性校验,把"GEV 只能 TP2、Gemma4 只能 TP4"从文档结论变成启动前断言。
|
||||
|
||||
---
|
||||
|
||||
## 13. 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|---|---|
|
||||
| 2026-10-27 | 由 `gemma4-31b-deploy` + `gev-26b-decide-deploy` 合并为本目录;新增 `detect_model.py`、`verify.sh`、`.gitea`;Dockerfile 去掉 ENV/EXPOSE/ENTRYPOINT;新增 TP 可行性校验和 `GEMMA4_SKIP_TP_CHECK` |
|
||||
109
apply_patch.py
Executable file
109
apply_patch.py
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply the CoreX vLLM compatibility patches required by the Gemma 4 family.
|
||||
|
||||
Both members of the family (Gemma-4-31B-it dense, GEV-26B-Decide MoE) need the
|
||||
same two fixes, generated against the exact base image
|
||||
|
||||
registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v7
|
||||
(vLLM 0.17.0+corex.20260612070235)
|
||||
|
||||
1. `vllm/model_executor/models/gemma4_mm.py` imports `MultiModalDataDict`
|
||||
from `vllm.inputs`, but this build exposes it from
|
||||
`vllm.multimodal.inputs`. Without the fix, model inspection fails before
|
||||
the server starts:
|
||||
ImportError: cannot import name 'MultiModalDataDict' from 'vllm.inputs'
|
||||
|
||||
2. `vllm/model_executor/layers/fused_moe/fused_moe.py` checks
|
||||
`TritonAttentionMetadata.num_decodes` / `num_prefills` for the decode-only
|
||||
fast path. The CoreX Triton attention metadata only has `max_query_len`.
|
||||
Gemma-4-31B is dense so this path is normally unused, but GEV (MoE) fails
|
||||
CUDA graph capture without the fix:
|
||||
AttributeError: 'TritonAttentionMetadata' object has no attribute 'num_decodes'
|
||||
|
||||
The script is idempotent, tolerates an already-reformatted target file, and
|
||||
validates every patched file with `ast.parse`. It runs both at image build
|
||||
time and again on container start, so a stale image still gets fixed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import vllm
|
||||
except ImportError as exc: # pragma: no cover - only in broken images
|
||||
print(f"ERROR: cannot import vllm: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
PKG = Path(
|
||||
os.environ.get(
|
||||
"VLLM_PKG_ROOT",
|
||||
str(Path(vllm.__file__).resolve().parent.parent),
|
||||
)
|
||||
)
|
||||
|
||||
MM_FILE = PKG / "vllm/model_executor/models/gemma4_mm.py"
|
||||
MOE_FILE = PKG / "vllm/model_executor/layers/fused_moe/fused_moe.py"
|
||||
|
||||
MM_OLD = "from vllm.inputs import MultiModalDataDict"
|
||||
MM_NEW = "from vllm.multimodal.inputs import MultiModalDataDict"
|
||||
MM_MARK = "from vllm.multimodal.inputs import MultiModalDataDict"
|
||||
|
||||
MOE_OLD = (
|
||||
" only_decode = (use_ep == False and all("
|
||||
"t.num_decodes > 0 and t.num_prefills ==0 for t in "
|
||||
"list(attn_metadata.values())))"
|
||||
)
|
||||
MOE_NEW = """ only_decode = (
|
||||
use_ep == False
|
||||
and all(
|
||||
getattr(t, "max_query_len", 2) == 1
|
||||
for t in list(attn_metadata.values())
|
||||
)
|
||||
)"""
|
||||
# 单双引号两种写法都算已打过,避免不同格式化工具造成重复替换。
|
||||
MOE_MARK = (
|
||||
'getattr(t, "max_query_len", 2) == 1',
|
||||
"getattr(t, 'max_query_len', 2) == 1",
|
||||
)
|
||||
|
||||
|
||||
def _patch(
|
||||
path: Path,
|
||||
old: str,
|
||||
new: str,
|
||||
marker: str | tuple[str, ...],
|
||||
label: str,
|
||||
) -> None:
|
||||
if not path.exists():
|
||||
raise SystemExit(f"target file not found: {path}")
|
||||
|
||||
src = path.read_text(encoding="utf-8")
|
||||
markers = (marker,) if isinstance(marker, str) else marker
|
||||
if any(m in src for m in markers):
|
||||
print(f"[skip] {label}: already patched ({path})")
|
||||
return
|
||||
if old not in src:
|
||||
raise SystemExit(
|
||||
f"ERROR: {label}: expected code block not found in {path}.\n"
|
||||
"The base image or vLLM version may differ; inspect the file manually."
|
||||
)
|
||||
|
||||
path.write_text(src.replace(old, new, 1), encoding="utf-8")
|
||||
print(f"[ok] {label}: {path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_patch(MM_FILE, MM_OLD, MM_NEW, MM_MARK, "Gemma4 MultiModalDataDict import")
|
||||
_patch(MOE_FILE, MOE_OLD, MOE_NEW, MOE_MARK, "TritonAttentionMetadata decode check")
|
||||
|
||||
for path in (MM_FILE, MOE_FILE):
|
||||
ast.parse(path.read_text(encoding="utf-8"))
|
||||
print("all Gemma 4 family patches applied; syntax OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
236
detect_model.py
Executable file
236
detect_model.py
Executable file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect a Gemma 4 variant on BI-150 from its config.json.
|
||||
|
||||
The unified image serves both members of this family:
|
||||
|
||||
- Gemma-4-31B-it (dense, 60 layers, 4 global KV heads)
|
||||
-> TP4, 8095, eager off, util 0.90, 4 seqs
|
||||
- GEV-26B-Decide (MoE, 30 layers, 128 experts)
|
||||
-> TP2, 8094, eager on, util 0.95, 2 seqs
|
||||
|
||||
Both are `Gemma4ForConditionalGeneration` and share the same two vLLM patches,
|
||||
so only the runtime profile differs. Dense vs MoE is decided by the presence
|
||||
of an expert count in the config (top-level or `text_config`).
|
||||
|
||||
On top of the static profile the script *validates* the profile against the
|
||||
model config, because both variants have a hard TP ceiling:
|
||||
|
||||
* dense: `num_global_key_value_heads` must be divisible by TP
|
||||
(Gemma-4-31B has 4 -> TP8 would split a KV head into 0.5)
|
||||
* MoE: `moe_intermediate_size / TP` must be divisible by 32
|
||||
(CoreX `ixfops.moe_w16a16_group_gemm` requires k % 32 == 0;
|
||||
GEV 704/2=352 ok, 704/4=176 -> RuntimeError)
|
||||
|
||||
Set GEMMA4_SKIP_TP_CHECK=1 to downgrade that validation to a warning.
|
||||
|
||||
Detected values are printed as shell assignments so run.sh / entrypoint.sh can
|
||||
`eval` them; `--json` is used by verify.sh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
PROFILES = {
|
||||
"gemma4-31b": {
|
||||
"tp": 4,
|
||||
"port": 8095,
|
||||
"served_name": "gemma-4-31B-it",
|
||||
"gpu_count": 4,
|
||||
"max_num_seqs": 4,
|
||||
"gpu_mem_util": "0.90",
|
||||
"enforce_eager": 0,
|
||||
"description": "Gemma 4 31B-it dense (BF16, TP4, 4 GPUs)",
|
||||
},
|
||||
"gev-26b-decide": {
|
||||
"tp": 2,
|
||||
"port": 8094,
|
||||
"served_name": "GEV-26B-Decide",
|
||||
"gpu_count": 2,
|
||||
"max_num_seqs": 2,
|
||||
"gpu_mem_util": "0.95",
|
||||
"enforce_eager": 1,
|
||||
"description": "AutoTrust GEV-26B-Decide MoE (BF16, TP2, 2 GPUs)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_config(model_dir: str) -> dict:
|
||||
path = pathlib.Path(model_dir) / "config.json"
|
||||
if not path.is_file():
|
||||
raise ValueError(f"config.json not found: {path}")
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"invalid config.json: {exc}") from exc
|
||||
|
||||
|
||||
def text_section(cfg: dict) -> dict:
|
||||
"""Return the text sub-config if present, else the top-level config."""
|
||||
text = cfg.get("text_config")
|
||||
if isinstance(text, dict):
|
||||
return text
|
||||
return cfg
|
||||
|
||||
|
||||
def expert_count(text: dict, cfg: dict) -> int | None:
|
||||
for source in (text, cfg):
|
||||
for key in ("num_experts", "num_local_experts", "n_routed_experts"):
|
||||
value = source.get(key)
|
||||
if value is not None:
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def divisibility_errors(kind: str, tp: int, text: dict, cfg: dict) -> list[str]:
|
||||
"""Return human-readable reasons why `tp` cannot be used for this model."""
|
||||
errors: list[str] = []
|
||||
|
||||
for key in ("num_attention_heads", "num_key_value_heads", "num_global_key_value_heads"):
|
||||
value = text.get(key, cfg.get(key))
|
||||
if value is None:
|
||||
continue
|
||||
value = int(value)
|
||||
if value % tp != 0:
|
||||
errors.append(
|
||||
f"{key}={value} is not divisible by TP={tp} "
|
||||
f"({value} / {tp} = {value / tp:g})"
|
||||
)
|
||||
|
||||
if kind == "gev-26b-decide":
|
||||
moe = text.get("moe_intermediate_size", cfg.get("moe_intermediate_size"))
|
||||
if moe is not None:
|
||||
moe = int(moe)
|
||||
if moe % tp != 0:
|
||||
errors.append(f"moe_intermediate_size={moe} is not divisible by TP={tp}")
|
||||
elif (moe // tp) % 32 != 0:
|
||||
errors.append(
|
||||
f"moe_intermediate_size/TP = {moe}/{tp} = {moe // tp} is not a "
|
||||
f"multiple of 32; CoreX ixfops.moe_w16a16_group_gemm requires "
|
||||
f"k % 32 == 0"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def detect(model_dir: str, tp_override: int | None = None) -> dict:
|
||||
cfg = load_config(model_dir)
|
||||
archs = [str(a) for a in (cfg.get("architectures") or [])]
|
||||
arch = archs[0] if archs else ""
|
||||
if not arch.startswith("Gemma4"):
|
||||
raise ValueError(
|
||||
f"unsupported architecture={arch!r}; expected something like "
|
||||
f"'Gemma4ForConditionalGeneration'"
|
||||
)
|
||||
|
||||
text = text_section(cfg)
|
||||
experts = expert_count(text, cfg)
|
||||
|
||||
if experts is not None and experts > 1:
|
||||
kind = "gev-26b-decide"
|
||||
detection = f"num_experts={experts}"
|
||||
elif experts is not None:
|
||||
# num_experts=1 is how some dense configs spell "no experts".
|
||||
kind = "gemma4-31b"
|
||||
detection = f"num_experts={experts} (dense)"
|
||||
else:
|
||||
name = pathlib.Path(model_dir).name.lower()
|
||||
if "gev" in name:
|
||||
kind = "gev-26b-decide"
|
||||
elif "gemma" in name:
|
||||
kind = "gemma4-31b"
|
||||
else:
|
||||
raise ValueError(
|
||||
"cannot determine Gemma 4 variant: no num_experts in the config "
|
||||
"and the directory name contains neither 'gemma' nor 'gev'"
|
||||
)
|
||||
detection = "directory name"
|
||||
|
||||
profile = dict(PROFILES[kind])
|
||||
tp = int(profile["tp"]) if tp_override is None else int(tp_override)
|
||||
|
||||
errors = divisibility_errors(kind, tp, text, cfg)
|
||||
if errors and os.environ.get("GEMMA4_SKIP_TP_CHECK") != "1":
|
||||
raise ValueError(
|
||||
f"{kind} cannot run with TP={tp} on this model config:\n "
|
||||
+ "\n ".join(errors)
|
||||
+ "\nThe base image or the model variant may have changed; inspect "
|
||||
"config.json. If you have already verified another TP manually, "
|
||||
"set GEMMA4_SKIP_TP_CHECK=1 to bypass this check."
|
||||
)
|
||||
if errors:
|
||||
print(
|
||||
"WARN: TP check skipped by GEMMA4_SKIP_TP_CHECK=1:\n "
|
||||
+ "\n ".join(errors),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if tp_override is not None:
|
||||
profile["tp"] = tp
|
||||
profile["gpu_count"] = tp
|
||||
|
||||
profile.update(
|
||||
{
|
||||
"kind": kind,
|
||||
"architecture": arch,
|
||||
"num_experts": experts,
|
||||
"num_hidden_layers": text.get("num_hidden_layers", cfg.get("num_hidden_layers")),
|
||||
"hidden_size": text.get("hidden_size", cfg.get("hidden_size")),
|
||||
"detection": detection,
|
||||
"model_dir": str(pathlib.Path(model_dir).resolve()),
|
||||
}
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detect a Gemma 4 variant (Gemma-4-31B / GEV-26B-Decide) from config.json"
|
||||
)
|
||||
parser.add_argument("model_dir", help="directory containing config.json")
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="print JSON instead of shell assignments",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tp",
|
||||
type=int,
|
||||
default=None,
|
||||
help="validate against this TP instead of the profile default",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
profile = detect(args.model_dir, tp_override=args.tp)
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(profile, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
values = {
|
||||
"DETECTED_MODEL_KIND": profile["kind"],
|
||||
"DETECTED_TP": profile["tp"],
|
||||
"DETECTED_PORT": profile["port"],
|
||||
"DETECTED_SERVED_NAME": profile["served_name"],
|
||||
"DETECTED_GPU_COUNT": profile["gpu_count"],
|
||||
"DETECTED_MAX_NUM_SEQS": profile["max_num_seqs"],
|
||||
"DETECTED_GPU_MEM_UTIL": profile["gpu_mem_util"],
|
||||
"DETECTED_ENFORCE_EAGER": profile["enforce_eager"],
|
||||
"DETECTED_DESCRIPTION": profile["description"],
|
||||
}
|
||||
for key, value in values.items():
|
||||
print(f"{key}={shlex.quote(str(value))}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
services:
|
||||
gemma4:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: gemma4-bi150:${TAG:-2026-10-27}
|
||||
container_name: gemma4
|
||||
|
||||
# 镜像本身不设置启动命令,启动方式全部由这里决定。
|
||||
entrypoint: ["/usr/local/bin/gemma4-entrypoint.sh"]
|
||||
working_dir: /opt/gemma4
|
||||
expose:
|
||||
- "8094"
|
||||
- "8095"
|
||||
|
||||
# vLLM 多卡 NCCL / 端口直通
|
||||
network_mode: host
|
||||
ipc: host
|
||||
|
||||
# Iluvatar 运行时需要 privileged + /dev 绑定。
|
||||
# 只给 --device=/dev/iluvatar* 会在 worker init 时报
|
||||
# cudaGetDeviceCount() ... Error 1001
|
||||
privileged: true
|
||||
pid: host
|
||||
|
||||
volumes:
|
||||
- /dev:/dev
|
||||
- ${MODEL_HOST_DIR:-/mnt/disk0/models/google/gemma-4-31B-it}:/models/gemma4:ro
|
||||
|
||||
environment:
|
||||
MODEL_PATH: /models/gemma4
|
||||
AUTO_DETECT: "${AUTO_DETECT:-1}"
|
||||
HOST: "${HOST:-0.0.0.0}"
|
||||
|
||||
# 默认给 4 张(Gemma-4-31B 的 TP4);GEV 只需要 2 张,
|
||||
# entrypoint 按检测出的 TP 使用前 TP 张。
|
||||
CUDA_VISIBLE_DEVICES: "${GPUS:-15,14,13,12}"
|
||||
|
||||
# 留空时由 entrypoint 根据 config.json 自动判断。
|
||||
# 需要手动覆盖时直接设置同名环境变量即可。
|
||||
TP: "${TP:-}"
|
||||
PORT: "${PORT:-}"
|
||||
SERVED_NAME: "${SERVED_NAME:-}"
|
||||
MAX_NUM_SEQS: "${MAX_NUM_SEQS:-}"
|
||||
GPU_MEM_UTIL: "${GPU_MEM_UTIL:-}"
|
||||
ENFORCE_EAGER: "${ENFORCE_EAGER:-}"
|
||||
|
||||
MAX_MODEL_LEN: "${MAX_MODEL_LEN:-8192}"
|
||||
MAX_IMAGES: "${MAX_IMAGES:-4}"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
EXTRA_ARGS: "${EXTRA_ARGS:-}"
|
||||
|
||||
ulimits:
|
||||
memlock: -1
|
||||
stack: 67108864
|
||||
|
||||
restart: unless-stopped
|
||||
97
entrypoint.sh
Executable file
97
entrypoint.sh
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Gemma 4 统一容器入口:
|
||||
# 1. 幂等应用 CoreX vLLM 的 Gemma4 / MoE 补丁
|
||||
# 2. 读取模型 config.json,自动判断 Gemma-4-31B(dense)/ GEV-26B-Decide(MoE)
|
||||
# 3. 选择对应的 TP / 端口 / served name / eager / 显存,再启动 vLLM
|
||||
#
|
||||
# AUTO_DETECT=1(默认)按 config.json 自动选择;显式设置的
|
||||
# TP / PORT / SERVED_NAME / MAX_NUM_SEQS / GPU_MEM_UTIL / ENFORCE_EAGER
|
||||
# 会覆盖检测结果。AUTO_DETECT=0 完全手动。
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PATCH_SCRIPT="${GEMMA4_PATCH_SCRIPT:-/opt/gemma4/apply_patch.py}"
|
||||
DETECT_SCRIPT="${GEMMA4_DETECT_SCRIPT:-/opt/gemma4/detect_model.py}"
|
||||
VLLM_BIN="${VLLM_BIN:-vllm}"
|
||||
|
||||
python3 "${PATCH_SCRIPT}"
|
||||
|
||||
MODEL_PATH="${MODEL_PATH:-/models/gemma4}"
|
||||
AUTO_DETECT="${AUTO_DETECT:-1}"
|
||||
MODEL_DESCRIPTION=""
|
||||
|
||||
if [[ ! -f "${MODEL_PATH}/config.json" ]]; then
|
||||
echo "ERROR: 模型目录不完整:${MODEL_PATH}/config.json 不存在" >&2
|
||||
echo "请确认已挂载模型,例如:" >&2
|
||||
echo " -v /mnt/disk0/models/google/gemma-4-31B-it:/models/gemma4:ro" >&2
|
||||
echo " -v /mnt/disk0/models/autotrust/GEV-26B-Decide:/models/gemma4:ro" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${AUTO_DETECT}" == "1" ]]; then
|
||||
DETECT_ARGS=()
|
||||
if [[ -n "${TP:-}" ]]; then
|
||||
DETECT_ARGS=(--tp "${TP}")
|
||||
fi
|
||||
DETECT_OUTPUT="$(python3 "${DETECT_SCRIPT}" "${MODEL_PATH}" "${DETECT_ARGS[@]}")" || exit 1
|
||||
eval "${DETECT_OUTPUT}"
|
||||
MODEL_KIND="${MODEL_KIND:-${DETECTED_MODEL_KIND}}"
|
||||
TP="${TP:-${DETECTED_TP}}"
|
||||
PORT="${PORT:-${DETECTED_PORT}}"
|
||||
SERVED_NAME="${SERVED_NAME:-${DETECTED_SERVED_NAME}}"
|
||||
MAX_NUM_SEQS="${MAX_NUM_SEQS:-${DETECTED_MAX_NUM_SEQS}}"
|
||||
GPU_MEM_UTIL="${GPU_MEM_UTIL:-${DETECTED_GPU_MEM_UTIL}}"
|
||||
ENFORCE_EAGER="${ENFORCE_EAGER:-${DETECTED_ENFORCE_EAGER}}"
|
||||
MODEL_DESCRIPTION="${DETECTED_DESCRIPTION:-}"
|
||||
else
|
||||
MODEL_KIND="${MODEL_KIND:-unknown}"
|
||||
fi
|
||||
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
TP="${TP:-4}"
|
||||
PORT="${PORT:-8095}"
|
||||
SERVED_NAME="${SERVED_NAME:-gemma4}"
|
||||
MAX_MODEL_LEN="${MAX_MODEL_LEN:-8192}"
|
||||
MAX_NUM_SEQS="${MAX_NUM_SEQS:-4}"
|
||||
GPU_MEM_UTIL="${GPU_MEM_UTIL:-0.90}"
|
||||
MAX_IMAGES="${MAX_IMAGES:-4}"
|
||||
ENFORCE_EAGER="${ENFORCE_EAGER:-0}"
|
||||
EXTRA_ARGS="${EXTRA_ARGS:-}"
|
||||
|
||||
EAGER_ARGS=()
|
||||
if [[ "${ENFORCE_EAGER}" == "1" ]]; then
|
||||
EAGER_ARGS=(--enforce-eager)
|
||||
fi
|
||||
|
||||
echo "============================================================"
|
||||
echo "Gemma 4 family (BI-V150 unified image)"
|
||||
echo " model kind : ${MODEL_KIND}"
|
||||
echo " model : ${MODEL_PATH}"
|
||||
echo " served name: ${SERVED_NAME}"
|
||||
echo " listen : ${HOST}:${PORT}"
|
||||
echo " TP : ${TP} (${TP} GPUs)"
|
||||
echo " max len : ${MAX_MODEL_LEN}"
|
||||
echo " max seqs : ${MAX_NUM_SEQS}"
|
||||
echo " gpu util : ${GPU_MEM_UTIL}"
|
||||
echo " max images : ${MAX_IMAGES}"
|
||||
echo " eager : ${ENFORCE_EAGER}"
|
||||
echo " visible : ${CUDA_VISIBLE_DEVICES:-all}"
|
||||
echo " extra : ${EXTRA_ARGS}"
|
||||
if [[ -n "${MODEL_DESCRIPTION}" ]]; then
|
||||
echo " detected : ${MODEL_DESCRIPTION}"
|
||||
fi
|
||||
echo "============================================================"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
exec "${VLLM_BIN}" serve "${MODEL_PATH}" \
|
||||
--host "${HOST}" \
|
||||
--port "${PORT}" \
|
||||
--served-model-name "${SERVED_NAME}" \
|
||||
--tensor-parallel-size "${TP}" \
|
||||
--max-model-len "${MAX_MODEL_LEN}" \
|
||||
--max-num-seqs "${MAX_NUM_SEQS}" \
|
||||
--gpu-memory-utilization "${GPU_MEM_UTIL}" \
|
||||
--limit-mm-per-prompt "{\"image\": ${MAX_IMAGES}}" \
|
||||
"${EAGER_ARGS[@]}" \
|
||||
${EXTRA_ARGS}
|
||||
200
run.sh
Executable file
200
run.sh
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Gemma 4 家族统一构建 + 启动(Iluvatar BI-V150)
|
||||
#
|
||||
# 自动读取模型目录下的 config.json,判断是 dense 还是 MoE,然后选择:
|
||||
# Gemma-4-31B-it : TP4 / 4 卡 / 8095 / gemma-4-31B-it / eager0 / 0.90 / 4 seqs
|
||||
# GEV-26B-Decide : TP2 / 2 卡 / 8094 / GEV-26B-Decide / eager1 / 0.95 / 2 seqs
|
||||
#
|
||||
# 用法:
|
||||
# ./run.sh /mnt/disk0/models/google/gemma-4-31B-it
|
||||
# ./run.sh /mnt/disk0/models/autotrust/GEV-26B-Decide
|
||||
# MODEL_HOST_DIR=/path/to/model ./run.sh
|
||||
# ./run.sh # 依次尝试两个默认模型目录
|
||||
#
|
||||
# 常用覆盖项:
|
||||
# GPUS=12,13,14,15 ./run.sh /path/to/gemma4
|
||||
# EXTRA_DOCKER_ARGS="-v /mnt/disk1:/data:ro" ./run.sh /path/to/model
|
||||
# EXTRA_ARGS="--max-model-len 16384" ./run.sh /path/to/model
|
||||
# TP=4 PORT=8099 SERVED_NAME=my-gemma4 ./run.sh /path/to/model
|
||||
# BUILD=0 IMAGE=<ci-image> ./run.sh /path/to/model # 使用已有镜像
|
||||
# DRY_RUN=1 ./run.sh /path/to/model # 只打印 docker run 命令
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
用法: ./run.sh [MODEL_DIR]
|
||||
|
||||
不传 MODEL_DIR 时,按顺序尝试:
|
||||
/mnt/disk0/models/google/gemma-4-31B-it
|
||||
/mnt/disk0/models/autotrust/GEV-26B-Decide
|
||||
|
||||
模型类型由 MODEL_DIR/config.json 自动判断,不需要 gemma4-31b / gev-26b-decide 参数。
|
||||
|
||||
环境变量覆盖:
|
||||
IMAGE NAME MODEL_HOST_DIR MODEL_MOUNT PORT SERVED_NAME TP GPUS
|
||||
MAX_MODEL_LEN MAX_NUM_SEQS GPU_MEM_UTIL MAX_IMAGES ENFORCE_EAGER
|
||||
AUTO_DETECT EXTRA_ARGS EXTRA_DOCKER_ARGS BUILD=0/1 DRY_RUN=0/1
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MODEL_HOST_DIR="${1:-${MODEL_HOST_DIR:-}}"
|
||||
if [[ -z "${MODEL_HOST_DIR}" ]]; then
|
||||
for candidate in \
|
||||
"/mnt/disk0/models/google/gemma-4-31B-it" \
|
||||
"/mnt/disk0/models/autotrust/GEV-26B-Decide"; do
|
||||
if [[ -f "${candidate}/config.json" ]]; then
|
||||
MODEL_HOST_DIR="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "${MODEL_HOST_DIR}" ]]; then
|
||||
echo "ERROR: 未指定模型目录,也没有找到默认模型目录。" >&2
|
||||
usage
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "${MODEL_HOST_DIR}/config.json" ]]; then
|
||||
echo "ERROR: 模型目录不完整: ${MODEL_HOST_DIR}/config.json" >&2
|
||||
echo "请先下载模型,或把 MODEL_DIR 指向正确目录。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DETECT_ARGS=()
|
||||
if [[ -n "${TP:-}" ]]; then
|
||||
DETECT_ARGS=(--tp "${TP}")
|
||||
fi
|
||||
DETECT_OUTPUT="$(python3 detect_model.py "${MODEL_HOST_DIR}" "${DETECT_ARGS[@]}")" || exit 1
|
||||
eval "${DETECT_OUTPUT}"
|
||||
|
||||
MODEL_KIND="${MODEL_KIND:-${DETECTED_MODEL_KIND}}"
|
||||
IMAGE="${IMAGE:-gemma4-bi150:2026-10-27}"
|
||||
NAME="${NAME:-${MODEL_KIND}}"
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
PORT="${PORT:-${DETECTED_PORT}}"
|
||||
MODEL_MOUNT="${MODEL_MOUNT:-/models/gemma4}"
|
||||
SERVED_NAME="${SERVED_NAME:-${DETECTED_SERVED_NAME}}"
|
||||
TP="${TP:-${DETECTED_TP}}"
|
||||
MAX_MODEL_LEN="${MAX_MODEL_LEN:-8192}"
|
||||
MAX_NUM_SEQS="${MAX_NUM_SEQS:-${DETECTED_MAX_NUM_SEQS}}"
|
||||
GPU_MEM_UTIL="${GPU_MEM_UTIL:-${DETECTED_GPU_MEM_UTIL}}"
|
||||
MAX_IMAGES="${MAX_IMAGES:-4}"
|
||||
ENFORCE_EAGER="${ENFORCE_EAGER:-${DETECTED_ENFORCE_EAGER}}"
|
||||
AUTO_DETECT="${AUTO_DETECT:-1}"
|
||||
EXTRA_ARGS="${EXTRA_ARGS:-}"
|
||||
EXTRA_DOCKER_ARGS="${EXTRA_DOCKER_ARGS:-}"
|
||||
BUILD="${BUILD:-1}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
|
||||
GPU_COUNT="${DETECTED_GPU_COUNT}"
|
||||
|
||||
# 默认优先使用序号最大的卡(12-15),避免和 16 卡大模型抢 0-11。
|
||||
if [[ -n "${GPUS:-}" ]]; then
|
||||
GPUS="${GPUS}"
|
||||
elif [[ -n "${CUDA_VISIBLE_DEVICES:-}" ]]; then
|
||||
GPUS="${CUDA_VISIBLE_DEVICES}"
|
||||
elif (( GPU_COUNT == 4 )); then
|
||||
GPUS="15,14,13,12"
|
||||
else
|
||||
GPUS="$(seq -s, $((16 - GPU_COUNT)) 15)"
|
||||
fi
|
||||
GPUS="${GPUS// /}"
|
||||
|
||||
IFS=',' read -r -a GPU_LIST <<< "${GPUS}"
|
||||
if (( ${#GPU_LIST[@]} < GPU_COUNT )); then
|
||||
echo "ERROR: ${MODEL_KIND} 需要 ${GPU_COUNT} 张卡,但 GPUS 只给了 ${#GPU_LIST[@]} 张: ${GPUS}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if (( ${#GPU_LIST[@]} > GPU_COUNT )); then
|
||||
echo "WARN: ${MODEL_KIND} 只需要 ${GPU_COUNT} 张卡,但 GPUS 给了 ${#GPU_LIST[@]} 张;vLLM 只会使用其中 ${GPU_COUNT} 张。" >&2
|
||||
fi
|
||||
|
||||
DOCKER_EXTRA=()
|
||||
if [[ -n "${EXTRA_DOCKER_ARGS}" ]]; then
|
||||
# 按空白拆分;路径中不要带空格。
|
||||
read -r -a DOCKER_EXTRA <<< "${EXTRA_DOCKER_ARGS}"
|
||||
fi
|
||||
|
||||
DOCKER_RUN=(
|
||||
docker run -d --name "${NAME}"
|
||||
--entrypoint /usr/local/bin/gemma4-entrypoint.sh
|
||||
--workdir /opt/gemma4
|
||||
--expose "${PORT}"
|
||||
--privileged
|
||||
--pid host
|
||||
--network host
|
||||
--ipc host
|
||||
-v /dev:/dev
|
||||
-v "${MODEL_HOST_DIR}:${MODEL_MOUNT}:ro"
|
||||
--ulimit memlock=-1
|
||||
--ulimit stack=67108864
|
||||
-e CUDA_VISIBLE_DEVICES="${GPUS}"
|
||||
-e MODEL_PATH="${MODEL_MOUNT}"
|
||||
-e AUTO_DETECT="${AUTO_DETECT}"
|
||||
-e MODEL_KIND="${MODEL_KIND}"
|
||||
-e HOST="${HOST}"
|
||||
-e SERVED_NAME="${SERVED_NAME}"
|
||||
-e PORT="${PORT}"
|
||||
-e TP="${TP}"
|
||||
-e MAX_MODEL_LEN="${MAX_MODEL_LEN}"
|
||||
-e MAX_NUM_SEQS="${MAX_NUM_SEQS}"
|
||||
-e GPU_MEM_UTIL="${GPU_MEM_UTIL}"
|
||||
-e MAX_IMAGES="${MAX_IMAGES}"
|
||||
-e ENFORCE_EAGER="${ENFORCE_EAGER}"
|
||||
-e PYTHONUNBUFFERED=1
|
||||
-e EXTRA_ARGS="${EXTRA_ARGS}"
|
||||
"${DOCKER_EXTRA[@]}"
|
||||
--restart unless-stopped
|
||||
"${IMAGE}"
|
||||
)
|
||||
|
||||
echo "==> detected ${MODEL_KIND}: ${DETECTED_DESCRIPTION}"
|
||||
echo " model : ${MODEL_HOST_DIR}"
|
||||
echo " TP : ${TP} (${GPU_COUNT} GPUs), port=${PORT}, name=${SERVED_NAME}"
|
||||
echo " GPUs : ${GPUS}"
|
||||
echo " eager : ${ENFORCE_EAGER}, util=${GPU_MEM_UTIL}, seqs=${MAX_NUM_SEQS}"
|
||||
|
||||
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||
printf '%q ' "${DOCKER_RUN[@]}"
|
||||
printf '\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${BUILD}" == "1" ]]; then
|
||||
echo "==> build ${IMAGE}"
|
||||
docker build -t "${IMAGE}" .
|
||||
else
|
||||
echo "==> skip build (BUILD=0), image=${IMAGE}"
|
||||
fi
|
||||
|
||||
echo "==> recreate ${NAME}"
|
||||
docker rm -f "${NAME}" 2>/dev/null || true
|
||||
|
||||
echo "==> run ${MODEL_KIND}"
|
||||
"${DOCKER_RUN[@]}"
|
||||
|
||||
# 冷启动:Gemma-4-31B 约 59 GB、GEV 约 49 GB,首次读盘实测 5-8 分钟。
|
||||
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-900}"
|
||||
echo "==> wait for http://127.0.0.1:${PORT}/health (up to ${HEALTH_TIMEOUT}s)"
|
||||
DEADLINE=$((SECONDS + HEALTH_TIMEOUT))
|
||||
while (( SECONDS < DEADLINE )); do
|
||||
if curl -sf "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
|
||||
echo "ready: http://127.0.0.1:${PORT}/v1/models"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "ERROR: 服务 ${HEALTH_TIMEOUT}s 未就绪,查看日志:" >&2
|
||||
echo " docker logs -f ${NAME}" >&2
|
||||
exit 1
|
||||
376
verify.sh
Executable file
376
verify.sh
Executable file
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Gemma 4 家族服务验证脚本(Gemma-4-31B / GEV-26B-Decide)
|
||||
#
|
||||
# 默认行为:
|
||||
# 1. 如果 docker 可用,自动找正在运行的 gemma4-bi150 容器;
|
||||
# 2. 从容器挂载/环境变量或 MODEL_HOST_DIR 推断模型目录、端口、served name;
|
||||
# 3. 如果模型目录可读,调用 detect_model.py 自动判断变体;
|
||||
# 4. 依次检查 /health、/v1/models、/v1/chat/completions;
|
||||
# 5. 可选 --with-reasoning 和 --with-image。
|
||||
#
|
||||
# 常用:
|
||||
# ./verify.sh
|
||||
# ./verify.sh --model-dir /mnt/disk0/models/autotrust/GEV-26B-Decide --with-image
|
||||
# ./verify.sh --container gemma4-gemma4-31b --with-reasoning
|
||||
# ./verify.sh --base-url http://127.0.0.1:8095 --served-name gemma-4-31B-it
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
MODEL_DIR="${MODEL_HOST_DIR:-}"
|
||||
CONTAINER="${CONTAINER:-}"
|
||||
PORT="${PORT:-}"
|
||||
SERVED_NAME="${SERVED_NAME:-}"
|
||||
BASE_URL="${BASE_URL:-}"
|
||||
TIMEOUT="${TIMEOUT:-300}"
|
||||
WITH_IMAGE=0
|
||||
WITH_REASONING=0
|
||||
SKIP_CHAT=0
|
||||
KIND="unknown"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
用法: ./verify.sh [options]
|
||||
|
||||
--model-dir PATH 模型目录(含 config.json),用于自动判断变体
|
||||
--container NAME 运行中的容器名;默认自动查找 gemma4 容器
|
||||
--port PORT 服务端口;默认从模型/容器推断
|
||||
--served-name NAME OpenAI API 模型名;默认从模型/容器推断
|
||||
--base-url URL 直接指定服务地址,例如 http://127.0.0.1:8095
|
||||
--timeout SEC 等待和请求超时,默认 300
|
||||
--with-reasoning 额外跑一个简单推理题(期望 27)
|
||||
--with-image 额外跑图片识别(纯红图片,期望回答红色)
|
||||
--skip-chat 只检查 /health 和 /v1/models
|
||||
-h, --help 显示帮助
|
||||
|
||||
环境变量也可以设置:MODEL_HOST_DIR CONTAINER PORT SERVED_NAME BASE_URL TIMEOUT
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--model-dir) MODEL_DIR="${2:?--model-dir 需要路径}"; shift 2 ;;
|
||||
--container) CONTAINER="${2:?--container 需要名称}"; shift 2 ;;
|
||||
--port) PORT="${2:?--port 需要端口}"; shift 2 ;;
|
||||
--served-name) SERVED_NAME="${2:?--served-name 需要名称}"; shift 2 ;;
|
||||
--base-url) BASE_URL="${2:?--base-url 需要 URL}"; shift 2 ;;
|
||||
--timeout) TIMEOUT="${2:?--timeout 需要秒数}"; shift 2 ;;
|
||||
--with-image) WITH_IMAGE=1; shift ;;
|
||||
--with-reasoning) WITH_REASONING=1; shift ;;
|
||||
--skip-chat) SKIP_CHAT=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: 未知参数: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
pass() { echo "PASS: $*"; }
|
||||
|
||||
command -v curl >/dev/null 2>&1 || fail "缺少 curl"
|
||||
command -v python3 >/dev/null 2>&1 || fail "缺少 python3"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMP}"' EXIT
|
||||
|
||||
# ---------------------------------------------------------------- container
|
||||
if [[ -z "${CONTAINER}" ]] && command -v docker >/dev/null 2>&1; then
|
||||
CONTAINER="$(docker ps --format '{{.Names}} {{.Image}}' 2>/dev/null \
|
||||
| awk '/gemma4|gev-26b/ {print $1; exit}' || true)"
|
||||
fi
|
||||
|
||||
container_env() {
|
||||
local name="$1" key="$2"
|
||||
docker inspect "${name}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
|
||||
| awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' || true
|
||||
}
|
||||
|
||||
if [[ -n "${CONTAINER}" ]] && command -v docker >/dev/null 2>&1; then
|
||||
if [[ -z "${MODEL_DIR}" ]]; then
|
||||
MODEL_DIR="$(docker inspect "${CONTAINER}" --format '{{json .Mounts}}' 2>/dev/null \
|
||||
| python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
mounts = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for m in mounts:
|
||||
if m.get("Destination") == "/models/gemma4":
|
||||
print(m.get("Source", ""))
|
||||
break
|
||||
' || true)"
|
||||
fi
|
||||
[[ -z "${PORT}" ]] && PORT="$(container_env "${CONTAINER}" PORT)"
|
||||
[[ -z "${SERVED_NAME}" ]] && SERVED_NAME="$(container_env "${CONTAINER}" SERVED_NAME)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- model dir
|
||||
GEMMA4_DEFAULT="/mnt/disk0/models/google/gemma-4-31B-it"
|
||||
GEV_DEFAULT="/mnt/disk0/models/autotrust/GEV-26B-Decide"
|
||||
|
||||
# 没有 docker 时(例如在 ugly_hopper 内运行),按端口探测正在运行的服务。
|
||||
if [[ -z "${CONTAINER}" && -z "${PORT}" && -z "${MODEL_DIR}" && -z "${BASE_URL}" ]]; then
|
||||
for candidate_port in 8095 8094; do
|
||||
if curl -fsS --max-time 2 "http://127.0.0.1:${candidate_port}/health" >/dev/null 2>&1; then
|
||||
PORT="${candidate_port}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 从 served name 反推模型目录(方便 --base-url + --served-name 的用法)。
|
||||
if [[ -z "${MODEL_DIR}" && -n "${SERVED_NAME}" ]]; then
|
||||
case "${SERVED_NAME}" in
|
||||
*GEV*|*gev*)
|
||||
[[ -f "${GEV_DEFAULT}/config.json" ]] && MODEL_DIR="${GEV_DEFAULT}"
|
||||
;;
|
||||
*gemma*|*Gemma*)
|
||||
[[ -f "${GEMMA4_DEFAULT}/config.json" ]] && MODEL_DIR="${GEMMA4_DEFAULT}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ -z "${MODEL_DIR}" && -z "${BASE_URL}" ]]; then
|
||||
if [[ "${PORT}" == "8094" && -f "${GEV_DEFAULT}/config.json" ]]; then
|
||||
MODEL_DIR="${GEV_DEFAULT}"
|
||||
elif [[ -f "${GEMMA4_DEFAULT}/config.json" ]]; then
|
||||
MODEL_DIR="${GEMMA4_DEFAULT}"
|
||||
elif [[ -f "${GEV_DEFAULT}/config.json" ]]; then
|
||||
MODEL_DIR="${GEV_DEFAULT}"
|
||||
fi
|
||||
fi
|
||||
|
||||
DETECT_JSON=""
|
||||
if [[ -n "${MODEL_DIR}" ]]; then
|
||||
if [[ -f "${MODEL_DIR}/config.json" ]]; then
|
||||
DETECT_JSON="$(python3 detect_model.py "${MODEL_DIR}" --json)" || {
|
||||
echo "ERROR: 无法识别模型: ${MODEL_DIR}" >&2
|
||||
exit 1
|
||||
}
|
||||
elif [[ -n "${CONTAINER}" ]] && command -v docker >/dev/null 2>&1; then
|
||||
# 宿主机路径不可读时,退回到容器内检测
|
||||
DETECT_JSON="$(docker exec "${CONTAINER}" python3 /opt/gemma4/detect_model.py /models/gemma4 --json 2>/dev/null || true)"
|
||||
else
|
||||
fail "模型目录缺少 config.json: ${MODEL_DIR}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${DETECT_JSON}" ]]; then
|
||||
KIND="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["kind"])' <<< "${DETECT_JSON}")"
|
||||
DETECTED_PORT="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["port"])' <<< "${DETECT_JSON}")"
|
||||
DETECTED_NAME="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["served_name"])' <<< "${DETECT_JSON}")"
|
||||
[[ -z "${PORT}" ]] && PORT="${DETECTED_PORT}"
|
||||
[[ -z "${SERVED_NAME}" ]] && SERVED_NAME="${DETECTED_NAME}"
|
||||
fi
|
||||
|
||||
PORT="${PORT:-8095}"
|
||||
SERVED_NAME="${SERVED_NAME:-gemma4}"
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:${PORT}}"
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
|
||||
echo "=== Gemma 4 family verification ==="
|
||||
echo "container : ${CONTAINER:-<none>}"
|
||||
echo "model dir : ${MODEL_DIR:-<not detected>}"
|
||||
echo "kind : ${KIND}"
|
||||
echo "base URL : ${BASE_URL}"
|
||||
echo "model name: ${SERVED_NAME}"
|
||||
echo
|
||||
|
||||
# ---------------------------------------------------------------- health
|
||||
# vLLM 的 /health 可能返回 200 但 body 为空,所以只看 curl 退出码。
|
||||
echo "==> waiting for /health (up to ${TIMEOUT}s)"
|
||||
HEALTH_OK=0
|
||||
DEADLINE=$((SECONDS + TIMEOUT))
|
||||
while (( SECONDS < DEADLINE )); do
|
||||
if curl -fsS --max-time 5 "${BASE_URL}/health" > "${TMP}/health.txt" 2>/dev/null; then
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ "${HEALTH_OK}" != "1" ]]; then
|
||||
fail "health check 超时: ${BASE_URL}/health"
|
||||
fi
|
||||
HEALTH="$(cat "${TMP}/health.txt" 2>/dev/null || true)"
|
||||
pass "health: ${HEALTH:-OK}"
|
||||
|
||||
# ---------------------------------------------------------------- models
|
||||
echo "==> /v1/models"
|
||||
curl -fsS --max-time "${TIMEOUT}" "${BASE_URL}/v1/models" > "${TMP}/models.json" \
|
||||
|| fail "GET /v1/models 失败"
|
||||
python3 - "${TMP}/models.json" "${SERVED_NAME}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(open(sys.argv[1]))
|
||||
expected = sys.argv[2]
|
||||
ids = [m.get("id") for m in data.get("data", [])]
|
||||
if expected not in ids:
|
||||
print(f"FAIL: /v1/models 中没有 {expected!r}; 实际: {ids}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for m in data.get("data", []):
|
||||
if m.get("id") == expected:
|
||||
print(f"PASS: model={expected}, max_model_len={m.get('max_model_len')}")
|
||||
break
|
||||
PY
|
||||
|
||||
if [[ "${SKIP_CHAT}" == "1" ]]; then
|
||||
echo
|
||||
echo "All checks passed (chat skipped)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- chat
|
||||
echo "==> /v1/chat/completions"
|
||||
python3 - "${SERVED_NAME}" > "${TMP}/chat.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
print(json.dumps({
|
||||
"model": sys.argv[1],
|
||||
"messages": [{"role": "user", "content": "你好,请用一句中文介绍你自己。"}],
|
||||
"max_tokens": 64,
|
||||
"temperature": 0,
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
curl -fsS --max-time "${TIMEOUT}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"${TMP}/chat.json" \
|
||||
"${BASE_URL}/v1/chat/completions" > "${TMP}/chat_resp.json" \
|
||||
|| fail "chat completion 失败"
|
||||
python3 - "${TMP}/chat_resp.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(open(sys.argv[1]))
|
||||
try:
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError):
|
||||
print(f"FAIL: unexpected chat response: {data}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
part.get("text", "") for part in content if isinstance(part, dict)
|
||||
)
|
||||
content = (content or "").strip()
|
||||
if not content:
|
||||
print("FAIL: chat response content is empty", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"PASS: chat: {content[:200]}")
|
||||
PY
|
||||
|
||||
# ---------------------------------------------------------------- reasoning
|
||||
if [[ "${WITH_REASONING}" == "1" ]]; then
|
||||
echo "==> reasoning"
|
||||
python3 - "${SERVED_NAME}" > "${TMP}/reason.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
print(json.dumps({
|
||||
"model": sys.argv[1],
|
||||
"messages": [{"role": "user", "content": "3 个人 3 天喝 3 桶水,9 个人 9 天喝几桶水?只给最终数字。"}],
|
||||
"max_tokens": 16,
|
||||
"temperature": 0,
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
curl -fsS --max-time "${TIMEOUT}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"${TMP}/reason.json" \
|
||||
"${BASE_URL}/v1/chat/completions" > "${TMP}/reason_resp.json" \
|
||||
|| fail "reasoning 请求失败"
|
||||
python3 - "${TMP}/reason_resp.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(open(sys.argv[1]))
|
||||
content = data["choices"][0]["message"].get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
part.get("text", "") for part in content if isinstance(part, dict)
|
||||
)
|
||||
content = (content or "").strip()
|
||||
if "27" not in content:
|
||||
print(f"FAIL: expected 27, got: {content!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"PASS: reasoning: {content[:100]}")
|
||||
PY
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- image
|
||||
if [[ "${WITH_IMAGE}" == "1" ]]; then
|
||||
echo "==> image (336x336 red PNG)"
|
||||
python3 - "${TMP}/red.b64" <<'PY'
|
||||
import base64
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
w = h = 336
|
||||
row = bytes((220, 20, 20)) * w
|
||||
raw = b"".join(b"\x00" + row for _ in range(h))
|
||||
|
||||
def chunk(kind, data):
|
||||
return (
|
||||
struct.pack(">I", len(data))
|
||||
+ kind
|
||||
+ data
|
||||
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
png = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
open(sys.argv[1], "w").write(base64.b64encode(png).decode())
|
||||
PY
|
||||
python3 - "${SERVED_NAME}" "${TMP}/red.b64" > "${TMP}/image.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
b64 = open(sys.argv[2]).read().strip()
|
||||
payload = {
|
||||
"model": sys.argv[1],
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "这张图片主要是什么颜色?只回答颜色。"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}},
|
||||
],
|
||||
}],
|
||||
"max_tokens": 32,
|
||||
"temperature": 0,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
PY
|
||||
curl -fsS --max-time "${TIMEOUT}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"${TMP}/image.json" \
|
||||
"${BASE_URL}/v1/chat/completions" > "${TMP}/image_resp.json" \
|
||||
|| fail "image 请求失败"
|
||||
python3 - "${TMP}/image_resp.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(open(sys.argv[1]))
|
||||
content = data["choices"][0]["message"].get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
part.get("text", "") for part in content if isinstance(part, dict)
|
||||
)
|
||||
content = (content or "").strip()
|
||||
if not content:
|
||||
print("FAIL: image response content is empty", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
lower = content.lower()
|
||||
if "红" not in lower and "red" not in lower:
|
||||
print(f"FAIL: expected red, got: {content[:200]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"PASS: image: {content[:200]}")
|
||||
PY
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "All checks passed for ${KIND} at ${BASE_URL}."
|
||||
Reference in New Issue
Block a user