From 05f1c3932fdca7f98bd5bc4d93198a4b92de35ae Mon Sep 17 00:00:00 2001 From: xiejiahao Date: Tue, 27 Oct 2026 14:19:51 +0800 Subject: [PATCH] Unified Gemma 4 family image for Gemma-4-31B and GEV-26B-Decide - 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 --- .dockerignore | 7 + .gitea/workflows/docker-build-push.yml | 132 +++++++++ .gitea/workflows/task_info.env | 3 + .gitignore | 3 + Dockerfile | 41 +++ README.md | 391 +++++++++++++++++++++++++ apply_patch.py | 109 +++++++ detect_model.py | 236 +++++++++++++++ docker-compose.yml | 57 ++++ entrypoint.sh | 97 ++++++ run.sh | 200 +++++++++++++ verify.sh | 376 ++++++++++++++++++++++++ 12 files changed, 1652 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/docker-build-push.yml create mode 100644 .gitea/workflows/task_info.env create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 apply_patch.py create mode 100755 detect_model.py create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh create mode 100755 run.sh create mode 100755 verify.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..988de73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitea +README.md +run.sh +verify.sh +docker-compose.yml +*.log diff --git a/.gitea/workflows/docker-build-push.yml b/.gitea/workflows/docker-build-push.yml new file mode 100644 index 0000000..9c5c285 --- /dev/null +++ b/.gitea/workflows/docker-build-push.yml @@ -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 }}\" + }" diff --git a/.gitea/workflows/task_info.env b/.gitea/workflows/task_info.env new file mode 100644 index 0000000..7fba5ce --- /dev/null +++ b/.gitea/workflows/task_info.env @@ -0,0 +1,3 @@ +FRAMEWORK=vllm_gemma_4 +GPU_TYPE=Iluvatar_bi-150 +TASK_TYPE=text-generation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..873c238 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.log +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ebd2189 --- /dev/null +++ b/Dockerfile @@ -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 决定。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7958b80 --- /dev/null +++ b/README.md @@ -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 --json # verify.sh 用 +python3 detect_model.py --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 \ + +``` + +--- + +## 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= ./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` | diff --git a/apply_patch.py b/apply_patch.py new file mode 100755 index 0000000..6df0a0e --- /dev/null +++ b/apply_patch.py @@ -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() diff --git a/detect_model.py b/detect_model.py new file mode 100755 index 0000000..4f063c6 --- /dev/null +++ b/detect_model.py @@ -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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..87bd405 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..2b0a482 --- /dev/null +++ b/entrypoint.sh @@ -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} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..a5b1b56 --- /dev/null +++ b/run.sh @@ -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= ./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 diff --git a/verify.sh b/verify.sh new file mode 100755 index 0000000..99cf7e0 --- /dev/null +++ b/verify.sh @@ -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:-}" +echo "model dir : ${MODEL_DIR:-}" +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}."