Compare commits
4 Commits
v0.1.0
...
v0.3.0-all
| Author | SHA1 | Date | |
|---|---|---|---|
| 742d93a7c5 | |||
| d74a99dc33 | |||
| fb5a163d07 | |||
| f8937cc505 |
@@ -3,21 +3,33 @@ name: Build and verify Qwen2.5 compatibility engines
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*-nvidia-a100"
|
||||||
|
- "v*-cambricon-mlu370"
|
||||||
|
- "v*-metax-c500"
|
||||||
|
- "v*-vastai-va16"
|
||||||
|
- "v*-all"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
runs-on: amd64-ubuntu-24.04
|
runs-on: amd64-ubuntu-24.04
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||||
|
REPOSITORY: ${{ gitea.repository }}
|
||||||
|
SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
ACTOR: ${{ gitea.actor }}
|
||||||
steps:
|
steps:
|
||||||
- name: Clone tagged source
|
- name: Clone tagged source
|
||||||
run: |
|
run: |
|
||||||
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
git clone "$SERVER_URL/$REPOSITORY.git" .
|
||||||
git checkout "${{ gitea.ref_name }}"
|
git checkout "$RELEASE_TAG"
|
||||||
|
|
||||||
- name: Run compatibility tests
|
- name: Run compatibility tests
|
||||||
run: python3 -m unittest discover -s tests -q
|
run: |
|
||||||
|
python3 -m unittest discover -s tests -q
|
||||||
|
python3 -m py_compile engine_compat.py release_plan.py
|
||||||
|
bash -n vllm_wrapper.sh
|
||||||
|
|
||||||
- name: Load image metadata
|
- name: Select release targets and load image metadata
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
set -a
|
set -a
|
||||||
@@ -25,10 +37,9 @@ jobs:
|
|||||||
set +a
|
set +a
|
||||||
test -n "$FRAMEWORK"
|
test -n "$FRAMEWORK"
|
||||||
test -n "$TASK_TYPE"
|
test -n "$TASK_TYPE"
|
||||||
test -n "$GPU_TYPES"
|
python3 release_plan.py "$RELEASE_TAG" --gitea-env "$GITEA_ENV"
|
||||||
echo "FRAMEWORK=$FRAMEWORK" >> "$GITEA_ENV"
|
echo "FRAMEWORK=$FRAMEWORK" >> "$GITEA_ENV"
|
||||||
echo "TASK_TYPE=$TASK_TYPE" >> "$GITEA_ENV"
|
echo "TASK_TYPE=$TASK_TYPE" >> "$GITEA_ENV"
|
||||||
echo "GPU_TYPES=$GPU_TYPES" >> "$GITEA_ENV"
|
|
||||||
|
|
||||||
- name: Validate GPU and task metadata
|
- name: Validate GPU and task metadata
|
||||||
run: |
|
run: |
|
||||||
@@ -56,19 +67,37 @@ jobs:
|
|||||||
- name: Build and push target images
|
- name: Build and push target images
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
image_name="$(printf '%s' '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
|
image_name="$(printf '%s' "$REPOSITORY" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
|
||||||
image_base="$DOCKER_REGISTRY/$DOCKER_USERNAME/$image_name"
|
image_base="$DOCKER_REGISTRY/$DOCKER_USERNAME/$image_name"
|
||||||
a100_image="$image_base:${{ gitea.ref_name }}-nvidia-a100"
|
echo "IMAGE_BASE=$image_base" >> "$GITEA_ENV"
|
||||||
cambricon_image="$image_base:${{ gitea.ref_name }}-cambricon-mlu370"
|
for target in $IMAGE_TARGETS; do
|
||||||
|
if [ "$RELEASE_TARGET" = all ]; then
|
||||||
|
image_tag="$RELEASE_TAG-$target"
|
||||||
|
else
|
||||||
|
image_tag="$RELEASE_TAG"
|
||||||
|
fi
|
||||||
|
image="$image_base:$image_tag"
|
||||||
|
case "$target" in
|
||||||
|
nvidia-a100)
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg BASE_IMAGE=harbor.4pd.io/dooke/vllm/vllm/vllm-openai:v0.11.0 \
|
--build-arg BASE_IMAGE=harbor.4pd.io/dooke/vllm/vllm/vllm-openai:v0.11.0 \
|
||||||
-t "$a100_image" .
|
-t "$image" . ;;
|
||||||
|
cambricon-mlu370)
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg BASE_IMAGE=harbor.4pd.io/hardcore-tech/cambricon-mlu370-pytorch:v25.01-20260228 \
|
--build-arg BASE_IMAGE=harbor.4pd.io/hardcore-tech/cambricon-mlu370-pytorch:v25.01-20260228 \
|
||||||
-t "$cambricon_image" .
|
-t "$image" . ;;
|
||||||
|
metax-c500)
|
||||||
for image in "$a100_image" "$cambricon_image"; do
|
docker build \
|
||||||
|
--build-arg BASE_IMAGE=harbor.4pd.io/modelhubxc/enginex-metax/vllm:0.9.1 \
|
||||||
|
--build-arg PYTHON_BIN=/opt/conda/bin/python3 \
|
||||||
|
--build-arg VLLM_BIN=/opt/conda/bin/vllm \
|
||||||
|
-t "$image" . ;;
|
||||||
|
vastai-va16)
|
||||||
|
docker build \
|
||||||
|
--build-arg BASE_IMAGE=harbor.4pd.io/modelhubxc/sunruoxi/enginex-vastai-vllm-vacc-fix-tokenizer:VVI-26.02 \
|
||||||
|
-t "$image" . ;;
|
||||||
|
*) echo "unknown image target: $target"; exit 1 ;;
|
||||||
|
esac
|
||||||
success=0
|
success=0
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
if docker push "$image"; then success=1; break; fi
|
if docker push "$image"; then success=1; break; fi
|
||||||
@@ -77,22 +106,29 @@ jobs:
|
|||||||
done
|
done
|
||||||
test "$success" -eq 1
|
test "$success" -eq 1
|
||||||
done
|
done
|
||||||
echo "A100_IMAGE=$a100_image" >> "$GITEA_ENV"
|
|
||||||
echo "CAMBRICON_IMAGE=$cambricon_image" >> "$GITEA_ENV"
|
|
||||||
|
|
||||||
- name: Notify ModelHub image verification
|
- name: Notify ModelHub image verification
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
for gpu in $GPU_TYPES; do
|
for mapping in $VERIFY_PAIRS; do
|
||||||
case "$gpu" in
|
gpu="${mapping%%:*}"
|
||||||
Nvidia-A100) image="$A100_IMAGE" ;;
|
target="${mapping#*:}"
|
||||||
Cambricon_mlu-370-x4|Cambricon_mlu-370-x8) image="$CAMBRICON_IMAGE" ;;
|
case " $IMAGE_TARGETS " in
|
||||||
*) echo "unknown GPU target: $gpu"; exit 1 ;;
|
*" $target "*) ;;
|
||||||
|
*) echo "GPU target was not built: $gpu"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
payload="$(GPU_TYPE="$gpu" IMAGE="$image" REPO_URL='${{ gitea.server_url }}/${{ gitea.repository }}' TAG='${{ gitea.ref_name }}' ACTOR='${{ gitea.actor }}' python3 -c 'import json, os; print(json.dumps({"framework": os.environ["FRAMEWORK"], "gpuType": os.environ["GPU_TYPE"], "imageUrl": os.environ["IMAGE"], "taskType": os.environ["TASK_TYPE"], "createBy": os.environ["ACTOR"], "repoUrl": os.environ["REPO_URL"], "tag": os.environ["TAG"]}))')"
|
if [ "$RELEASE_TARGET" = all ]; then
|
||||||
curl --silent --show-error --fail-with-body --location \
|
image_tag="$RELEASE_TAG-$target"
|
||||||
|
else
|
||||||
|
image_tag="$RELEASE_TAG"
|
||||||
|
fi
|
||||||
|
image="$IMAGE_BASE:$image_tag"
|
||||||
|
payload="$(GPU_TYPE="$gpu" IMAGE="$image" REPO_URL="$SERVER_URL/$REPOSITORY" TAG="$RELEASE_TAG" python3 -c 'import json, os; print(json.dumps({"framework": os.environ["FRAMEWORK"], "gpuType": os.environ["GPU_TYPE"], "imageUrl": os.environ["IMAGE"], "taskType": os.environ["TASK_TYPE"], "createBy": os.environ["ACTOR"], "repoUrl": os.environ["REPO_URL"], "tag": os.environ["TAG"]}))')"
|
||||||
|
response="$(curl --silent --show-error --fail-with-body --location \
|
||||||
--request POST 'https://modelhub.org.cn/adminApi/image-verify' \
|
--request POST 'https://modelhub.org.cn/adminApi/image-verify' \
|
||||||
--header "Xc-Token: $FIXED_TOKEN" \
|
--header "Xc-Token: $FIXED_TOKEN" \
|
||||||
--header 'Content-Type: application/json' \
|
--header 'Content-Type: application/json' \
|
||||||
--data-raw "$payload"
|
--data-raw "$payload")"
|
||||||
|
VERIFY_RESPONSE="$response" python3 -c 'import json,os,sys; body=json.loads(os.environ["VERIFY_RESPONSE"]); sys.exit(0 if body.get("code")==0 else 1)'
|
||||||
|
echo "submitted image verification for $gpu"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
FRAMEWORK=vllm_qwen25_compat
|
FRAMEWORK=vllm_qwen25_compat
|
||||||
TASK_TYPE=text-generation
|
TASK_TYPE=text-generation
|
||||||
GPU_TYPES="Nvidia-A100 Cambricon_mlu-370-x4 Cambricon_mlu-370-x8"
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
ARG BASE_IMAGE=harbor.4pd.io/dooke/vllm/vllm/vllm-openai:v0.11.0
|
ARG BASE_IMAGE=harbor.4pd.io/dooke/vllm/vllm/vllm-openai:v0.11.0
|
||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
|
ARG PYTHON_BIN=python3
|
||||||
|
ARG VLLM_BIN=vllm
|
||||||
|
ENV MODELHUB_COMPAT_PYTHON=${PYTHON_BIN}
|
||||||
|
|
||||||
COPY engine_compat.py /opt/modelhub/engine_compat.py
|
COPY engine_compat.py /opt/modelhub/engine_compat.py
|
||||||
COPY vllm_wrapper.sh /opt/modelhub/vllm_wrapper.sh
|
COPY vllm_wrapper.sh /opt/modelhub/vllm_wrapper.sh
|
||||||
|
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
python3 -m py_compile /opt/modelhub/engine_compat.py; \
|
"$PYTHON_BIN" -m py_compile /opt/modelhub/engine_compat.py; \
|
||||||
vllm_bin="$(command -v vllm)"; \
|
if [ "$VLLM_BIN" = vllm ]; then vllm_bin="$(command -v vllm)"; else vllm_bin="$VLLM_BIN"; fi; \
|
||||||
|
test -x "$vllm_bin"; \
|
||||||
mv "$vllm_bin" "$(dirname "$vllm_bin")/vllm_real"; \
|
mv "$vllm_bin" "$(dirname "$vllm_bin")/vllm_real"; \
|
||||||
cp /opt/modelhub/vllm_wrapper.sh "$vllm_bin"; \
|
cp /opt/modelhub/vllm_wrapper.sh "$vllm_bin"; \
|
||||||
chmod 0755 "$vllm_bin" /opt/modelhub/engine_compat.py
|
chmod 0755 "$vllm_bin" /opt/modelhub/engine_compat.py
|
||||||
|
|||||||
48
README.md
48
README.md
@@ -4,10 +4,11 @@
|
|||||||
的方式:标签触发 `.gitea/workflows/docker-build-push.yml`,runner 检查元数据、构建并推送镜像,
|
的方式:标签触发 `.gitea/workflows/docker-build-push.yml`,runner 检查元数据、构建并推送镜像,
|
||||||
最后调用 ModelHub 的 `image-verify` 接口注册验证。
|
最后调用 ModelHub 的 `image-verify` 接口注册验证。
|
||||||
|
|
||||||
当前为 A100 参考端和寒武纪 MLU370 x4/x8 构建两个镜像,三个 GPU 共用
|
支持 A100 参考端、寒武纪 MLU370 x4/x8、沐曦 C500 和 Vastai VA16;
|
||||||
`vllm_qwen25_compat` 框架名。A100 基础镜像来自示例仓库;寒武纪基础镜像来自
|
一次显式全量发布构建四个镜像,五个 GPU 共用 `vllm_qwen25_compat` 框架名。
|
||||||
平台现有 MLU370 验证任务使用的镜像。是否允许一个仓库为多个 GPU 注册同一框架,
|
寒武纪 x4/x8 共用一个镜像,但仍分别进行平台验证。基础镜像均来自
|
||||||
需要以 runner 的 `validate` 和镜像验证结果为准。
|
平台已有验证任务使用的版本;A100 沿用示例仓库的基础镜像。每个 GPU 的注册
|
||||||
|
与可用性仍以平台镜像验证结果和 `frameworks` 接口回读为准。
|
||||||
|
|
||||||
## 修复范围
|
## 修复范围
|
||||||
|
|
||||||
@@ -16,11 +17,14 @@
|
|||||||
- 对明确写成 `TokenizersBackend` 且带 `tokenizer.json` 的仓库,改用
|
- 对明确写成 `TokenizersBackend` 且带 `tokenizer.json` 的仓库,改用
|
||||||
`PreTrainedTokenizerFast`。
|
`PreTrainedTokenizerFast`。
|
||||||
- 对 `model_type=qwen2`、`eos_token=<|im_end|>` 且缺少聊天模板的模型,补充
|
- 对 `model_type=qwen2`、`eos_token=<|im_end|>` 且缺少聊天模板的模型,补充
|
||||||
简单的 Qwen ChatML 模板,避免 `/v1/chat/completions` 返回 400。
|
简单的 Qwen ChatML 模板,避免 `/v1/chat/completions` 返回 400;已有独立
|
||||||
|
`chat_template.jinja` 或 `chat_template.json` 的模型不补写。
|
||||||
|
|
||||||
启动时在 `/tmp` 建立新模型视图,只重写 `tokenizer_config.json`。权重、模型代码和
|
启动时在 `/tmp` 建立新视图,只重写 `tokenizer_config.json`。若基础镜像使用
|
||||||
其他文件以符号链接指回只读 `/model`;源模型不变。没有命中上述问题时直接调用原始
|
`--tokenizer` 指向单独的临时目录(Vastai 的现有启动脚本如此),则修复实际加载的
|
||||||
`vllm`。修复记录写在新视图的 `.modelhub-compat-report.json`,同时输出到容器日志。
|
tokenizer 目录并保留模型仓库的独立聊天模板,权重路径仍指向原模型;否则修复模型
|
||||||
|
视图。源模型不变。没有命中问题时直接调用原始 `vllm`。修复记录写在新视图的
|
||||||
|
`.modelhub-compat-report.json`,同时输出到容器日志。
|
||||||
|
|
||||||
这不能修复缺失权重、尺寸不匹配、Ascend 上持续出现的 0 分输出或平台评测超时。
|
这不能修复缺失权重、尺寸不匹配、Ascend 上持续出现的 0 分输出或平台评测超时。
|
||||||
尤其是分数为 0 的参考端与目标端必须分别排查;只换一个端的镜像不能保证过审。
|
尤其是分数为 0 的参考端与目标端必须分别排查;只换一个端的镜像不能保证过审。
|
||||||
@@ -39,17 +43,31 @@ docker build --build-arg BASE_IMAGE=harbor.4pd.io/dooke/vllm/vllm/vllm-openai:v0
|
|||||||
-t qwen25-compat:a100 .
|
-t qwen25-compat:a100 .
|
||||||
docker build --build-arg BASE_IMAGE=harbor.4pd.io/hardcore-tech/cambricon-mlu370-pytorch:v25.01-20260228 \
|
docker build --build-arg BASE_IMAGE=harbor.4pd.io/hardcore-tech/cambricon-mlu370-pytorch:v25.01-20260228 \
|
||||||
-t qwen25-compat:mlu370 .
|
-t qwen25-compat:mlu370 .
|
||||||
|
docker build --build-arg BASE_IMAGE=harbor.4pd.io/modelhubxc/enginex-metax/vllm:0.9.1 \
|
||||||
|
--build-arg PYTHON_BIN=/opt/conda/bin/python3 \
|
||||||
|
--build-arg VLLM_BIN=/opt/conda/bin/vllm \
|
||||||
|
-t qwen25-compat:metax .
|
||||||
|
docker build --build-arg BASE_IMAGE=harbor.4pd.io/modelhubxc/sunruoxi/enginex-vastai-vllm-vacc-fix-tokenizer:VVI-26.02 \
|
||||||
|
-t qwen25-compat:vastai .
|
||||||
```
|
```
|
||||||
|
|
||||||
## 用 runner 发布
|
## 用 runner 发布
|
||||||
|
|
||||||
1. 源码已推送到公开仓库 `a4913994/enginex-qwen25-compat` 的 `main`;目前尚未推送触发构建的 `v*` 标签。
|
1. 源码在公开仓库 `a4913994/enginex-qwen25-compat` 的 `main`。推送源码不会构建镜像;
|
||||||
2. 为该仓库的 runner 配置 `DOCKER_REGISTRY`、`DOCKER_USERNAME`、
|
只有带目标后缀的版本标签才触发 runner。历史 `v0.1.0` 和 `v0.2.1` 的镜像验证
|
||||||
`DOCKER_PASSWORD`、`FIXED_TOKEN`。不要把凭证写进仓库。
|
结果仍须单独回读。
|
||||||
3. 推送 `v*` 标签。工作流依次跑单元测试、验证 GPU/任务元数据、构建与推送镜像、
|
2. runner 使用 `DOCKER_REGISTRY`、`DOCKER_USERNAME`、`DOCKER_PASSWORD`、
|
||||||
为 A100 和 MLU370 x4/x8 各发起一次镜像验证。
|
`FIXED_TOKEN`;不要把凭证写进仓库。
|
||||||
4. 在“我的驱动列表”确认三个镜像验证通过,并检查新框架出现在对应 GPU 的
|
3. 选择本次确实需要更新的镜像目标,推送 `vX.Y.Z-nvidia-a100`、
|
||||||
`frameworks` 接口中,再用少量已知缺陷模型做对照验证。
|
`vX.Y.Z-cambricon-mlu370`、`vX.Y.Z-metax-c500` 或 `vX.Y.Z-vastai-va16`。
|
||||||
|
例如 `v0.3.0-metax-c500` 只构建一份沐曦镜像并验证沐曦 C500;寒武纪标签
|
||||||
|
构建一份镜像并分别验证 x4/x8。只有明确使用 `vX.Y.Z-all` 才构建四份镜像、
|
||||||
|
发起五项验证。普通 `vX.Y.Z` 标签不触发工作流,也不会隐式全量发布。
|
||||||
|
4. 工作流先跑单元测试和目标 GPU 元数据校验,再构建、推送选定镜像并检查平台
|
||||||
|
`image-verify` 接口业务码。通过本目录的 `python release_plan.py v0.3.0-metax-c500`
|
||||||
|
可以在打标签前预览将构建与验证的目标。
|
||||||
|
5. 回读平台镜像验证记录,并检查新框架出现在目标 GPU 的 `frameworks` 接口中,
|
||||||
|
再用少量已知缺陷模型做对照验证。`pending` 不代表镜像已经可用。
|
||||||
|
|
||||||
只有平台确认新框架可选且 A100 参考端实际使用新镜像后,才应让 `xc-agent` 把
|
只有平台确认新框架可选且 A100 参考端实际使用新镜像后,才应让 `xc-agent` 把
|
||||||
列表型 `extra_special_tokens` 的模型路由到此框架。当前智能体继续预检并跳过它们。
|
列表型 `extra_special_tokens` 的模型路由到此框架。当前智能体继续预检并跳过它们。
|
||||||
|
|||||||
@@ -43,10 +43,11 @@ def _key(value: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def repair_tokenizer_config(
|
def repair_tokenizer_config(
|
||||||
model_dir: Path, tokenizer: dict[str, Any]
|
model_dir: Path, tokenizer: dict[str, Any], *, tokenizer_dir: Path | None = None
|
||||||
) -> tuple[dict[str, Any], list[str]]:
|
) -> tuple[dict[str, Any], list[str]]:
|
||||||
patched = dict(tokenizer)
|
patched = dict(tokenizer)
|
||||||
repairs: list[str] = []
|
repairs: list[str] = []
|
||||||
|
effective_tokenizer_dir = tokenizer_dir or model_dir
|
||||||
|
|
||||||
extra = patched.get("extra_special_tokens")
|
extra = patched.get("extra_special_tokens")
|
||||||
if isinstance(extra, list):
|
if isinstance(extra, list):
|
||||||
@@ -76,12 +77,20 @@ def repair_tokenizer_config(
|
|||||||
# Only replace the known class mismatch from existing ModelHub failures.
|
# Only replace the known class mismatch from existing ModelHub failures.
|
||||||
# Arbitrary custom tokenizer classes may require their own Python code.
|
# Arbitrary custom tokenizer classes may require their own Python code.
|
||||||
if patched.get("tokenizer_class") == "TokenizersBackend":
|
if patched.get("tokenizer_class") == "TokenizersBackend":
|
||||||
if not (model_dir / "tokenizer.json").is_file():
|
if not (effective_tokenizer_dir / "tokenizer.json").is_file():
|
||||||
raise CompatibilityError("TokenizersBackend requires tokenizer.json")
|
raise CompatibilityError("TokenizersBackend requires tokenizer.json")
|
||||||
patched["tokenizer_class"] = "PreTrainedTokenizerFast"
|
patched["tokenizer_class"] = "PreTrainedTokenizerFast"
|
||||||
repairs.append("TokenizersBackend -> PreTrainedTokenizerFast")
|
repairs.append("TokenizersBackend -> PreTrainedTokenizerFast")
|
||||||
|
|
||||||
if not patched.get("chat_template"):
|
# Transformers can load a standalone template with higher precedence than
|
||||||
|
# tokenizer_config.json. Do not synthesize one when the repository already
|
||||||
|
# supplies a template in that format.
|
||||||
|
has_standalone_template = any(
|
||||||
|
(directory / name).is_file()
|
||||||
|
for directory in (effective_tokenizer_dir, model_dir)
|
||||||
|
for name in ("chat_template.jinja", "chat_template.json")
|
||||||
|
)
|
||||||
|
if not patched.get("chat_template") and not has_standalone_template:
|
||||||
config_path = model_dir / "config.json"
|
config_path = model_dir / "config.json"
|
||||||
if config_path.is_file():
|
if config_path.is_file():
|
||||||
model_config = _json_object(config_path)
|
model_config = _json_object(config_path)
|
||||||
@@ -95,15 +104,37 @@ def repair_tokenizer_config(
|
|||||||
return patched, repairs
|
return patched, repairs
|
||||||
|
|
||||||
|
|
||||||
def prepare_model(model_dir: str | Path, *, work_root: str | Path | None = None) -> str | None:
|
def prepare_model(
|
||||||
source = Path(model_dir).resolve()
|
model_dir: str | Path,
|
||||||
|
*,
|
||||||
|
tokenizer_dir: str | Path | None = None,
|
||||||
|
work_root: str | Path | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
model_source = Path(model_dir).resolve()
|
||||||
|
if not model_source.is_dir():
|
||||||
|
raise CompatibilityError(f"model directory is missing: {model_source}")
|
||||||
|
source = Path(tokenizer_dir).resolve() if tokenizer_dir is not None else model_source
|
||||||
if not source.is_dir():
|
if not source.is_dir():
|
||||||
raise CompatibilityError(f"model directory is missing: {source}")
|
raise CompatibilityError(f"tokenizer directory is missing: {source}")
|
||||||
config_path = source / "tokenizer_config.json"
|
config_path = source / "tokenizer_config.json"
|
||||||
if not config_path.is_file():
|
if not config_path.is_file():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
patched, repairs = repair_tokenizer_config(source, _json_object(config_path))
|
patched, repairs = repair_tokenizer_config(
|
||||||
|
model_source, _json_object(config_path), tokenizer_dir=source
|
||||||
|
)
|
||||||
|
# Some vendor entrypoints copy tokenizer files to /tmp and pass that path
|
||||||
|
# explicitly with --tokenizer, omitting a standalone template in the copy.
|
||||||
|
# Keep the original template visible to the effective tokenizer directory.
|
||||||
|
extra_templates = [
|
||||||
|
name
|
||||||
|
for name in ("chat_template.jinja", "chat_template.json")
|
||||||
|
if source != model_source
|
||||||
|
and not (source / name).is_file()
|
||||||
|
and (model_source / name).is_file()
|
||||||
|
]
|
||||||
|
if extra_templates:
|
||||||
|
repairs.append("preserved standalone chat template")
|
||||||
if not repairs:
|
if not repairs:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -130,8 +161,17 @@ def prepare_model(model_dir: str | Path, *, work_root: str | Path | None = None)
|
|||||||
raise CompatibilityError(
|
raise CompatibilityError(
|
||||||
f"cannot link model item: {item.name}"
|
f"cannot link model item: {item.name}"
|
||||||
) from link_exc
|
) from link_exc
|
||||||
|
for name in extra_templates:
|
||||||
|
(view / name).symlink_to(model_source / name)
|
||||||
(view / ".modelhub-compat-report.json").write_text(
|
(view / ".modelhub-compat-report.json").write_text(
|
||||||
json.dumps({"source": str(source), "repairs": repairs}, ensure_ascii=False)
|
json.dumps(
|
||||||
|
{
|
||||||
|
"model_source": str(model_source),
|
||||||
|
"tokenizer_source": str(source),
|
||||||
|
"repairs": repairs,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
+ "\n",
|
+ "\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -146,11 +186,13 @@ def prepare_model(model_dir: str | Path, *, work_root: str | Path | None = None)
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) not in (2, 3):
|
||||||
print("usage: engine_compat.py MODEL_DIR", file=sys.stderr)
|
print("usage: engine_compat.py MODEL_DIR [TOKENIZER_DIR]", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
try:
|
try:
|
||||||
view = prepare_model(sys.argv[1])
|
view = prepare_model(
|
||||||
|
sys.argv[1], tokenizer_dir=sys.argv[2] if len(sys.argv) == 3 else None
|
||||||
|
)
|
||||||
except CompatibilityError as exc:
|
except CompatibilityError as exc:
|
||||||
print(f"model compatibility error: {exc}", file=sys.stderr)
|
print(f"model compatibility error: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
74
release_plan.py
Normal file
74
release_plan.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Select the image builds and GPU verifications for a scoped release tag."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
GPU_GROUPS = {
|
||||||
|
"nvidia-a100": ("Nvidia-A100",),
|
||||||
|
"cambricon-mlu370": ("Cambricon_mlu-370-x4", "Cambricon_mlu-370-x8"),
|
||||||
|
"metax-c500": ("MetaX_c-500",),
|
||||||
|
"vastai-va16": ("Vastai_va16",),
|
||||||
|
}
|
||||||
|
RELEASE_TAG = re.compile(
|
||||||
|
r"v[0-9]+\.[0-9]+\.[0-9]+-(nvidia-a100|cambricon-mlu370|metax-c500|vastai-va16|all)\Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReleasePlan:
|
||||||
|
target: str
|
||||||
|
image_targets: tuple[str, ...]
|
||||||
|
gpu_types: tuple[str, ...]
|
||||||
|
verification_pairs: tuple[tuple[str, str], ...]
|
||||||
|
|
||||||
|
|
||||||
|
def plan_for_tag(tag: str) -> ReleasePlan:
|
||||||
|
match = RELEASE_TAG.fullmatch(tag)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(
|
||||||
|
"release tag must be vX.Y.Z-<target>, where target is "
|
||||||
|
"nvidia-a100, cambricon-mlu370, metax-c500, vastai-va16, or all"
|
||||||
|
)
|
||||||
|
target = match.group(1)
|
||||||
|
image_targets = tuple(GPU_GROUPS) if target == "all" else (target,)
|
||||||
|
verification_pairs = tuple(
|
||||||
|
(gpu, group) for group in image_targets for gpu in GPU_GROUPS[group]
|
||||||
|
)
|
||||||
|
gpu_types = tuple(gpu for gpu, _ in verification_pairs)
|
||||||
|
return ReleasePlan(target, image_targets, gpu_types, verification_pairs)
|
||||||
|
|
||||||
|
|
||||||
|
def write_gitea_env(plan: ReleasePlan, path: Path) -> None:
|
||||||
|
with path.open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"RELEASE_TARGET={plan.target}\n")
|
||||||
|
env_file.write(f"IMAGE_TARGETS={' '.join(plan.image_targets)}\n")
|
||||||
|
env_file.write(f"GPU_TYPES={' '.join(plan.gpu_types)}\n")
|
||||||
|
env_file.write(
|
||||||
|
"VERIFY_PAIRS="
|
||||||
|
+ " ".join(f"{gpu}:{group}" for gpu, group in plan.verification_pairs)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("tag", help="scoped release tag, such as v0.3.0-metax-c500")
|
||||||
|
parser.add_argument("--gitea-env", type=Path, help="append the plan to GITEA_ENV")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
plan = plan_for_tag(args.tag)
|
||||||
|
except ValueError as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
if args.gitea_env:
|
||||||
|
write_gitea_env(plan, args.gitea_env)
|
||||||
|
print(json.dumps(plan.__dict__, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -53,6 +53,14 @@ class EngineCompatTests(unittest.TestCase):
|
|||||||
self.assertIsNone(prepare_model(source, work_root=root))
|
self.assertIsNone(prepare_model(source, work_root=root))
|
||||||
self.assertEqual([p.name for p in root.iterdir()], ["model"])
|
self.assertEqual([p.name for p in root.iterdir()], ["model"])
|
||||||
|
|
||||||
|
def test_standalone_chat_template_is_preserved(self):
|
||||||
|
for filename in ("chat_template.jinja", "chat_template.json"):
|
||||||
|
with self.subTest(filename=filename), tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = self._model(root, {"eos_token": "<|im_end|>"})
|
||||||
|
(source / filename).write_text("{{ messages }}", encoding="utf-8")
|
||||||
|
self.assertIsNone(prepare_model(source, work_root=root))
|
||||||
|
|
||||||
def test_known_tokenizer_class_is_replaced_only_with_tokenizer_json(self):
|
def test_known_tokenizer_class_is_replaced_only_with_tokenizer_json(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
@@ -76,6 +84,47 @@ class EngineCompatTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIsNone(prepare_model(source, work_root=root))
|
self.assertIsNone(prepare_model(source, work_root=root))
|
||||||
|
|
||||||
|
def test_explicit_tokenizer_directory_gets_qwen_template(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
model = self._model(root, {"eos_token": "<|im_end|>"})
|
||||||
|
tokenizer = root / "vendor-tokenizer"
|
||||||
|
tokenizer.mkdir()
|
||||||
|
(tokenizer / "tokenizer_config.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"eos_token": "<|im_end|>",
|
||||||
|
"extra_special_tokens": ["<|im_start|>"],
|
||||||
|
"tokenizer_class": "TokenizersBackend",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(tokenizer / "tokenizer.json").write_text("{}", encoding="utf-8")
|
||||||
|
view = Path(prepare_model(model, tokenizer_dir=tokenizer, work_root=root))
|
||||||
|
fixed = json.loads((view / "tokenizer_config.json").read_text("utf-8"))
|
||||||
|
self.assertEqual(fixed["chat_template"], QWEN_CHAT_TEMPLATE)
|
||||||
|
self.assertEqual(fixed["additional_special_tokens"], ["<|im_start|>"])
|
||||||
|
self.assertEqual(fixed["tokenizer_class"], "PreTrainedTokenizerFast")
|
||||||
|
self.assertEqual((model / "model.safetensors").read_bytes(), b"sample-weights")
|
||||||
|
self.assertFalse((view / "model.safetensors").exists())
|
||||||
|
self.assertEqual((view / "tokenizer.json").read_text("utf-8"), "{}")
|
||||||
|
|
||||||
|
def test_explicit_tokenizer_directory_preserves_model_standalone_template(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
model = self._model(root, {"eos_token": "<|im_end|>"})
|
||||||
|
(model / "chat_template.jinja").write_text("{{ messages }}", encoding="utf-8")
|
||||||
|
tokenizer = root / "vendor-tokenizer"
|
||||||
|
tokenizer.mkdir()
|
||||||
|
(tokenizer / "tokenizer_config.json").write_text(
|
||||||
|
json.dumps({"eos_token": "<|im_end|>"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
view = Path(prepare_model(model, tokenizer_dir=tokenizer, work_root=root))
|
||||||
|
fixed = json.loads((view / "tokenizer_config.json").read_text("utf-8"))
|
||||||
|
self.assertNotIn("chat_template", fixed)
|
||||||
|
self.assertEqual((view / "chat_template.jinja").read_text("utf-8"), "{{ messages }}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
66
tests/test_release_plan.py
Normal file
66
tests/test_release_plan.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from release_plan import GPU_GROUPS, plan_for_tag, write_gitea_env
|
||||||
|
|
||||||
|
|
||||||
|
class ReleasePlanTests(unittest.TestCase):
|
||||||
|
def test_one_vendor_tag_builds_only_that_image_and_gpu(self):
|
||||||
|
plan = plan_for_tag("v0.3.0-metax-c500")
|
||||||
|
self.assertEqual(plan.target, "metax-c500")
|
||||||
|
self.assertEqual(plan.image_targets, ("metax-c500",))
|
||||||
|
self.assertEqual(plan.gpu_types, ("MetaX_c-500",))
|
||||||
|
self.assertEqual(plan.verification_pairs, (("MetaX_c-500", "metax-c500"),))
|
||||||
|
|
||||||
|
def test_cambricon_cards_reuse_one_image(self):
|
||||||
|
plan = plan_for_tag("v0.3.0-cambricon-mlu370")
|
||||||
|
self.assertEqual(plan.image_targets, ("cambricon-mlu370",))
|
||||||
|
self.assertEqual(
|
||||||
|
plan.gpu_types,
|
||||||
|
("Cambricon_mlu-370-x4", "Cambricon_mlu-370-x8"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
plan.verification_pairs,
|
||||||
|
(
|
||||||
|
("Cambricon_mlu-370-x4", "cambricon-mlu370"),
|
||||||
|
("Cambricon_mlu-370-x8", "cambricon-mlu370"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_requires_explicit_suffix(self):
|
||||||
|
plan = plan_for_tag("v0.3.0-all")
|
||||||
|
self.assertEqual(plan.image_targets, tuple(GPU_GROUPS))
|
||||||
|
self.assertEqual(len(plan.gpu_types), 5)
|
||||||
|
self.assertEqual(len(set(plan.gpu_types)), 5)
|
||||||
|
|
||||||
|
def test_unscoped_or_unknown_tag_cannot_default_to_all(self):
|
||||||
|
for tag in (
|
||||||
|
"v0.3.0",
|
||||||
|
"v0.3.0-unknown",
|
||||||
|
"v0.3.0-metax-c500-extra",
|
||||||
|
"v0.3.0-all",
|
||||||
|
"latest",
|
||||||
|
):
|
||||||
|
with self.subTest(tag=tag), self.assertRaises(ValueError):
|
||||||
|
plan_for_tag(tag)
|
||||||
|
|
||||||
|
def test_gitea_env_contains_only_selected_targets(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "gitea_env"
|
||||||
|
path.write_text("FRAMEWORK=vllm_qwen25_compat\n", encoding="utf-8")
|
||||||
|
write_gitea_env(plan_for_tag("v0.3.0-vastai-va16"), path)
|
||||||
|
self.assertEqual(
|
||||||
|
path.read_text(encoding="utf-8").splitlines(),
|
||||||
|
[
|
||||||
|
"FRAMEWORK=vllm_qwen25_compat",
|
||||||
|
"RELEASE_TARGET=vastai-va16",
|
||||||
|
"IMAGE_TARGETS=vastai-va16",
|
||||||
|
"GPU_TYPES=Vastai_va16",
|
||||||
|
"VERIFY_PAIRS=Vastai_va16:vastai-va16",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
vllm_real="$(dirname "$(readlink -f "$0")")/vllm_real"
|
vllm_real="$(dirname "$(readlink -f "$0")")/vllm_real"
|
||||||
|
python_bin="${MODELHUB_COMPAT_PYTHON:-python3}"
|
||||||
|
|
||||||
if [[ "${1:-}" != "serve" || -z "${2:-}" ]]; then
|
if [[ "${1:-}" != "serve" || -z "${2:-}" ]]; then
|
||||||
exec "$vllm_real" "$@"
|
exec "$vllm_real" "$@"
|
||||||
@@ -9,17 +10,39 @@ fi
|
|||||||
|
|
||||||
model_dir="$2"
|
model_dir="$2"
|
||||||
shift 2
|
shift 2
|
||||||
prepared="$(python3 /opt/modelhub/engine_compat.py "$model_dir")"
|
args=("$@")
|
||||||
|
tokenizer_dir=""
|
||||||
|
for ((i=0; i<${#args[@]}; i++)); do
|
||||||
|
if [[ "${args[i]}" == "--tokenizer" && $((i+1)) -lt ${#args[@]} ]]; then
|
||||||
|
tokenizer_dir="${args[i+1]}"
|
||||||
|
break
|
||||||
|
elif [[ "${args[i]}" == --tokenizer=* ]]; then
|
||||||
|
tokenizer_dir="${args[i]#--tokenizer=}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$tokenizer_dir" && -d "$tokenizer_dir" ]]; then
|
||||||
|
prepared="$("$python_bin" /opt/modelhub/engine_compat.py "$model_dir" "$tokenizer_dir")"
|
||||||
|
else
|
||||||
|
prepared="$("$python_bin" /opt/modelhub/engine_compat.py "$model_dir")"
|
||||||
|
tokenizer_dir=""
|
||||||
|
fi
|
||||||
if [[ -z "$prepared" ]]; then
|
if [[ -z "$prepared" ]]; then
|
||||||
exec "$vllm_real" serve "$model_dir" "$@"
|
exec "$vllm_real" serve "$model_dir" "${args[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
args=("$@")
|
effective_model="$prepared"
|
||||||
|
if [[ -n "$tokenizer_dir" && "$(readlink -f "$tokenizer_dir")" != "$(readlink -f "$model_dir")" ]]; then
|
||||||
|
# A vendor entrypoint's --tokenizer points at a copied tokenizer tree.
|
||||||
|
# Keep model weights at the original path; redirect only that tokenizer.
|
||||||
|
effective_model="$model_dir"
|
||||||
|
fi
|
||||||
for ((i=0; i<${#args[@]}; i++)); do
|
for ((i=0; i<${#args[@]}; i++)); do
|
||||||
if [[ "${args[i]}" == "--tokenizer" && $((i+1)) -lt ${#args[@]} && "${args[i+1]}" == "$model_dir" ]]; then
|
if [[ "${args[i]}" == "--tokenizer" && $((i+1)) -lt ${#args[@]} && "${args[i+1]}" == "$tokenizer_dir" ]]; then
|
||||||
args[i+1]="$prepared"
|
args[i+1]="$prepared"
|
||||||
elif [[ "${args[i]}" == "--tokenizer=$model_dir" ]]; then
|
elif [[ "${args[i]}" == "--tokenizer=$tokenizer_dir" && -n "$tokenizer_dir" ]]; then
|
||||||
args[i]="--tokenizer=$prepared"
|
args[i]="--tokenizer=$prepared"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
exec "$vllm_real" serve "$prepared" "${args[@]}"
|
exec "$vllm_real" serve "$effective_model" "${args[@]}"
|
||||||
|
|||||||
Reference in New Issue
Block a user