项目文件夹

文件
2026-07-13 13:33:03 +08:00

1331 行
60 KiB
Bash
可执行文件

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env bash
# test.sh - Focused static + local-host + Android-arm64 CI driver for MNN.
#
# Subcommands:
# ./test.sh static Run lightweight doc / cppcheck /
# PyMNN wrapper-resource checks.
# ./test.sh local Run host-side regression: build + the
# built-in unit-test suite (CPU) and the
# LLM smoke test.
# ./test.sh android <serial> Build for arm64-v8a, push artefacts and
# the LLM model to /data/local/tmp, then
# run the on-device matrix.
#
# Environment:
# ANDROID_NDK (required for android mode) NDK root. Falls back to
# $HOME/android-ndk-r21 when unset.
# LLM_MODEL_DIR Path to an existing MNN-format LLM model on disk. When
# set, that directory is used as-is and NO download is
# attempted. Defaults to models/<repo-basename>/.
# LLM_MODEL_REPO Model repo id used for the LLM smoke test.
# Defaults to taobao-mnn/Qwen2.5-0.5B-Instruct-MNN.
# LLM_MODEL_SOURCE Download source when LLM_MODEL_DIR is not provided:
# 'huggingface' (default) or 'modelscope' (CDN reachable
# from mainland China, where huggingface.co is blocked).
# LLM_MODEL_URL_BASE Override the resolve URL prefix outright. Wins over
# LLM_MODEL_SOURCE. Defaults to the source's resolve URL.
#
# Notes:
# * Replaces project/android/updateTest.sh natively (no shell-out).
# * Prefers `adbk` over `adb` and manages session lifecycle (--create-session
# on entry, --delete-session on EXIT, including failure paths).
# * Mirrors every OpenCL (backend=3) probe with a Vulkan (backend=7) probe;
# skipped (not failed) when the backend library is absent.
# * LLM model provisioning is lazy: the download (or LLM_MODEL_DIR check) is
# deferred until the llm stage actually runs, so unit / smoke / bench
# stages proceed even with no network. A provisioning failure skips the
# llm stage rather than aborting the run.
set -euo pipefail
IFS=$'\n\t'
umask 022
# ─────────────────────────────────────────────────────────────────────────────
# Globals
# ─────────────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODELS_DIR="${SCRIPT_DIR}/models"
# Declarative stage list — see test_stages.json for the schema and rationale
# behind each entry. Editing the JSON is the supported way to add, drop, or
# reconfigure unit/lowmem stages. Smoke and bench stages stay in shell since
# they iterate over external model files rather than a fixed parameter grid.
STAGES_JSON_FILE="${SCRIPT_DIR}/test_stages.json"
PUBLIC_MODELS_DIR="${SCRIPT_DIR}/resource/model"
DEVICE_DIR="/data/local/tmp/MNN"
DEVICE_MODEL_DIR="/data/local/tmp/MNN/models"
DEVICE_PUBLIC_MODELS_DIR="/data/local/tmp/MNN/public_models"
# Public smoke-test models. Populated by tools/script/get_model.sh from
# upstream MobileNet/SqueezeNet GitHub repos and the TensorFlow model zoo.
SMOKE_MODELS=(
MobileNet/v1/mobilenet_v1.caffe.mnn
MobileNet/v2/mobilenet_v2.caffe.mnn
SqueezeNet/v1.0/squeezenet_v1.0.caffe.mnn
SqueezeNet/v1.1/squeezenet_v1.1.caffe.mnn
)
# Capture whether the caller pinned LLM_MODEL_REPO before applying the default,
# so the ModelScope org remap below only rewrites the project's own default.
if [[ -n "${LLM_MODEL_REPO:-}" ]]; then LLM_MODEL_REPO_USER_SET=1; else LLM_MODEL_REPO_USER_SET=0; fi
LLM_MODEL_REPO_DEFAULT="$(python3 -c "import json; print(json.load(open('${STAGES_JSON_FILE}')).get('llm', {}).get('model_repo', 'taobao-mnn/Qwen2.5-0.5B-Instruct-MNN'))" 2>/dev/null || true)"
LLM_MODEL_REPO="${LLM_MODEL_REPO:-${LLM_MODEL_REPO_DEFAULT:-taobao-mnn/Qwen2.5-0.5B-Instruct-MNN}}"
# Resolve the download URL prefix. An explicit LLM_MODEL_URL_BASE always wins;
# otherwise it is derived from LLM_MODEL_SOURCE. HuggingFace serves under
# resolve/main, ModelScope under resolve/master. The MNN team mirrors its
# HuggingFace 'taobao-mnn/*' models under the 'MNN/*' org on ModelScope, so the
# built-in default's org is remapped for ModelScope (an explicitly-set
# LLM_MODEL_REPO is left verbatim). NB: this runs before the log_* helpers are
# defined, so errors print with a plain echo.
LLM_MODEL_SOURCE="${LLM_MODEL_SOURCE:-huggingface}"
if [[ -z "${LLM_MODEL_URL_BASE:-}" ]]; then
case "${LLM_MODEL_SOURCE}" in
huggingface|hf)
LLM_MODEL_URL_BASE="https://huggingface.co/${LLM_MODEL_REPO}/resolve/main" ;;
modelscope|ms)
LLM_MODEL_MS_REPO="${LLM_MODEL_REPO}"
if [[ ${LLM_MODEL_REPO_USER_SET} -eq 0 && "${LLM_MODEL_MS_REPO}" == taobao-mnn/* ]]; then
LLM_MODEL_MS_REPO="MNN/${LLM_MODEL_MS_REPO#taobao-mnn/}"
fi
LLM_MODEL_URL_BASE="https://modelscope.cn/models/${LLM_MODEL_MS_REPO}/resolve/master" ;;
*)
echo "ERROR: unknown LLM_MODEL_SOURCE='${LLM_MODEL_SOURCE}' (want: huggingface | modelscope)" >&2
exit 2 ;;
esac
fi
# LLM_MODEL_DIR may be pre-set by the caller to point at an existing on-disk
# MNN-format model, in which case no download is ever attempted. When it is
# left unset we fall back to the per-repo cache under models/ and may download.
if [[ -n "${LLM_MODEL_DIR:-}" ]]; then
LLM_MODEL_DIR_PROVIDED=1
LLM_MODEL_NAME="$(basename "${LLM_MODEL_DIR}")"
else
LLM_MODEL_DIR_PROVIDED=0
LLM_MODEL_NAME="$(basename "${LLM_MODEL_REPO}")"
LLM_MODEL_DIR="${MODELS_DIR}/${LLM_MODEL_NAME}"
fi
# Files that must be present in a complete MNN-format LLM checkout.
LLM_MODEL_FILES=(
config.json
llm_config.json
llm.mnn
llm.mnn.json
llm.mnn.weight
embeddings_bf16.bin
tokenizer.txt
)
MODE=""
FILTER="all"
DEVICE=""
ADB_BIN=""
USE_ADBK=0
SESSION_CREATED=0
STAGE_PASS=0
STAGE_FAIL=0
STAGE_SKIP=0
declare -a STAGE_LOG=()
# Per-run log directory; each stage's combined stdout/stderr lands here.
LOG_DIR="${SCRIPT_DIR}/logs/test-$(date -u +%Y%m%d-%H%M%S)"
mkdir -p "${LOG_DIR}"
# ─────────────────────────────────────────────────────────────────────────────
# Logging
# ─────────────────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
C_RST=$'\033[0m'; C_DIM=$'\033[2m'; C_RED=$'\033[31m'
C_GRN=$'\033[32m'; C_YLW=$'\033[33m'; C_CYN=$'\033[36m'; C_BLD=$'\033[1m'
else
C_RST=""; C_DIM=""; C_RED=""; C_GRN=""; C_YLW=""; C_CYN=""; C_BLD=""
fi
_ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
log_info() { printf "%s %sINFO%s %s\n" "$(_ts)" "${C_CYN}" "${C_RST}" "$*"; }
log_ok() { printf "%s %sOK%s %s\n" "$(_ts)" "${C_GRN}" "${C_RST}" "$*"; }
log_warn() { printf "%s %sWARN%s %s\n" "$(_ts)" "${C_YLW}" "${C_RST}" "$*" >&2; }
log_err() { printf "%s %sERROR%s %s\n" "$(_ts)" "${C_RED}" "${C_RST}" "$*" >&2; }
log_step() {
printf "\n%s%s═══ %s ═══%s\n" "${C_BLD}" "${C_CYN}" "$*" "${C_RST}"
}
# ─────────────────────────────────────────────────────────────────────────────
# Stage runner
# ─────────────────────────────────────────────────────────────────────────────
# Usage: run_stage <name> -- <cmd...>
# Prints a delimited block, captures status, never aborts the script.
run_stage() {
local name="$1"; shift
[[ "${1:-}" == "--" ]] && shift
log_step "stage: ${name}"
# Mirror combined stdout/stderr into a per-stage log so failures stay
# diagnosable after the run. Stage names contain '/'; flatten to '_'.
local logname="${name//\//_}"
local logfile="${LOG_DIR}/${logname}.log"
local rc=0
if "$@" 2>&1 | tee "${logfile}"; then
rc=0
else
# PIPESTATUS[0] is the command's rc; tee almost always returns 0.
rc=${PIPESTATUS[0]}
fi
if [[ $rc -eq 0 ]]; then
STAGE_PASS=$((STAGE_PASS + 1))
STAGE_LOG+=("PASS ${name}")
log_ok "stage '${name}' passed"
else
STAGE_FAIL=$((STAGE_FAIL + 1))
STAGE_LOG+=("FAIL ${name} (rc=${rc}, log=${logfile})")
log_err "stage '${name}' failed (rc=${rc}); log: ${logfile}"
if [[ $rc -eq 137 ]]; then
log_warn "rc=137 = SIGKILL — likely OOM-killed. Check 'dmesg | tail' on Linux"
elif [[ $rc -eq 139 ]]; then
log_warn "rc=139 = SIGSEGV — process crashed. See ${logfile} for trailing output"
fi
fi
return 0
}
skip_stage() {
local name="$1" reason="${2:-no reason given}"
STAGE_SKIP=$((STAGE_SKIP + 1))
STAGE_LOG+=("SKIP ${name} (${reason})")
log_warn "stage '${name}' skipped: ${reason}"
}
# ─────────────────────────────────────────────────────────────────────────────
# Usage / arg parsing
# ─────────────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $0 <subcommand> [args]
Subcommands:
static Run lightweight static checks.
local Run host-side regression suite.
android <device_serial> [filter]
Build arm64-v8a, push and test on device.
filter (optional, default 'all'):
all — every stage (cpu+opencl+vulkan+lowmem+llm+smoke).
cpu — CPU unit + lowmem + llm only.
opencl — OpenCL unit (IMAGE + BUFFER) + opencl smoke.
opencl-image — only OpenCL IMAGE-mem stage.
opencl-buffer — only OpenCL BUFFER-mem stage.
vulkan — Vulkan unit + vulkan smoke.
gpu — opencl + vulkan unit + smoke.
unit — all unit/op stages, no lowmem/smoke/llm.
lowmem — only lowmem matrix.
android-ci
— bench + smoke (cpu/opencl/vulkan) + llm only;
skips unit-test and lowmem stages.
Environment:
ANDROID_NDK NDK root (android mode). Defaults to
\$HOME/android-ndk-r21 when unset.
LLM_MODEL_DIR Use an existing on-disk MNN LLM model; no download.
Default: models/${LLM_MODEL_NAME}.
LLM_MODEL_REPO Model repo id for the LLM smoke test.
Default: ${LLM_MODEL_REPO}.
LLM_MODEL_SOURCE Download source: huggingface (default) | modelscope.
LLM_MODEL_URL_BASE Override resolve URL prefix (wins over _SOURCE).
The LLM model is fetched lazily — only when the llm stage runs — so the
unit / smoke / bench stages proceed even with no network.
Examples:
$0 static
$0 local
$0 android emulator-5554
$0 android R5CY71BJJ9D opencl-buffer
$0 android R5CY71BJJ9D cpu
$0 android R5CY71BJJ9D android-ci # bench + smoke + llm only
LLM_MODEL_DIR=/path/to/MNN-model $0 local # use a local model, no download
LLM_MODEL_SOURCE=modelscope $0 android R5CY71BJJ9D # fetch from ModelScope
EOF
}
parse_args() {
if [[ $# -lt 1 ]]; then usage; exit 2; fi
MODE="$1"; shift
case "${MODE}" in
static) ;;
local) ;;
android)
if [[ $# -lt 1 || -z "${1:-}" ]]; then
log_err "android mode requires a device serial"
usage; exit 2
fi
DEVICE="$1"; shift
if [[ $# -ge 1 && -n "${1:-}" ]]; then
FILTER="$1"; shift
fi
case "${FILTER}" in
all|cpu|opencl|opencl-image|opencl-buffer|vulkan|gpu|unit|lowmem|android-ci) ;;
*) log_err "unknown filter: ${FILTER}"; usage; exit 2 ;;
esac
;;
-h|--help|help) usage; exit 0 ;;
*) log_err "unknown subcommand: ${MODE}"; usage; exit 2 ;;
esac
}
# ─────────────────────────────────────────────────────────────────────────────
# adb / adbk wrapper
# ─────────────────────────────────────────────────────────────────────────────
detect_adb() {
local adbk_path adb_path
adbk_path="$(command -v adbk || true)"
adb_path="$(command -v adb || true)"
if [[ -n "${adbk_path}" ]]; then
ADB_BIN="${adbk_path}"
USE_ADBK=1
log_info "using adbk at ${adbk_path}"
elif [[ -n "${adb_path}" ]]; then
ADB_BIN="${adb_path}"
USE_ADBK=0
log_info "using adb at ${adb_path} (adbk not found)"
else
log_err "neither adbk nor adb found on PATH"
exit 1
fi
}
ad() { "${ADB_BIN}" -s "${DEVICE}" "$@"; }
ensure_adbk_session() {
[[ ${USE_ADBK} -eq 1 ]] || return 0
local user current
user="${USER:-$(id -un)}"
if current="$("${ADB_BIN}" --status 2>/dev/null)"; then
if printf '%s\n' "${current}" | grep -q "${DEVICE}.*Session.*${user}"; then
log_info "reusing existing adbk session for ${DEVICE}"
return 0
fi
fi
log_info "creating adbk session for ${DEVICE}"
if ! "${ADB_BIN}" -s "${DEVICE}" --create-session >/dev/null; then
log_err "adbk --create-session failed"
exit 1
fi
SESSION_CREATED=1
log_ok "adbk session created"
}
teardown_adbk_session() {
[[ ${USE_ADBK} -eq 1 && ${SESSION_CREATED} -eq 1 ]] || return 0
log_info "deleting adbk session for ${DEVICE}"
"${ADB_BIN}" -s "${DEVICE}" --delete-session >/dev/null 2>&1 || true
}
verify_device_online() {
if ! ad shell echo ok >/dev/null 2>&1; then
log_err "device ${DEVICE} is not reachable via ${ADB_BIN}"
exit 1
fi
log_ok "device ${DEVICE} online"
}
# ─────────────────────────────────────────────────────────────────────────────
# Exit handling
# ─────────────────────────────────────────────────────────────────────────────
print_summary() {
local total=$((STAGE_PASS + STAGE_FAIL + STAGE_SKIP))
printf "\n%s════════════════ test.sh summary ════════════════%s\n" "${C_BLD}" "${C_RST}"
printf " mode : %s\n" "${MODE}"
[[ "${MODE}" == "android" ]] && printf " device : %s\n" "${DEVICE}"
printf " total : %d\n" "${total}"
printf " %spassed %s : %d\n" "${C_GRN}" "${C_RST}" "${STAGE_PASS}"
printf " %sfailed %s : %d\n" "${C_RED}" "${C_RST}" "${STAGE_FAIL}"
printf " %sskipped%s : %d\n" "${C_YLW}" "${C_RST}" "${STAGE_SKIP}"
if [[ ${#STAGE_LOG[@]} -gt 0 ]]; then
printf " %sstages%s :\n" "${C_DIM}" "${C_RST}"
local line
for line in "${STAGE_LOG[@]}"; do
printf " %s\n" "${line}"
done
fi
printf "%s════════════════════════════════════════════════════%s\n" "${C_BLD}" "${C_RST}"
}
_on_exit() {
local rc=$?
teardown_adbk_session
# Suppress the summary on usage / arg-parse errors (no stages attempted).
local total=$((STAGE_PASS + STAGE_FAIL + STAGE_SKIP))
if [[ ${total} -gt 0 ]]; then
print_summary
fi
if [[ ${STAGE_FAIL} -gt 0 ]]; then exit 1; fi
exit "${rc}"
}
trap _on_exit EXIT
# ─────────────────────────────────────────────────────────────────────────────
# Static checks
# ─────────────────────────────────────────────────────────────────────────────
_changed_files() {
git show --name-only --format= 2>/dev/null || true
}
_emit_static_success() {
echo 'TEST_CASE={"name":"静态检查", "failed":0, "passed":3}'
}
_fail_static() {
printf "TEST_NAME_EXCEPTION: Exception\nTEST_CASE_AMOUNT_EXCEPTION: {\"blocked\":0,\"failed\":1,\"passed\":0,\"skipped\":0}\n"
exit 1
}
doc_check() {
local IFS=$' \n\t'
log_step "doc_check"
local cmake_files
cmake_files="$(find tools source demo test benchmark \
\( -path source/internal -o -path schema/private \) -prune -o \
-name CMakeLists.txt -type f -print)"
cmake_files="${cmake_files}"$'\n'"CMakeLists.txt"
local macros executables cmake_file macro executable
macros=""
executables=""
while IFS= read -r cmake_file; do
[[ -n "${cmake_file}" && -f "${cmake_file}" ]] || continue
executables="${executables} $(grep -oE "add_executable\((.+) " "${cmake_file}" | awk '{print $1}' | awk -F "(" '{print $2}' || true)"
macros="${macros} $(grep -oE "option\((.+) " "${cmake_file}" | awk '{print $1}' | awk -F "(" '{print $2}' || true)"
done <<<"${cmake_files}"
for macro in ${macros}; do
if [[ $(grep -c "${macro}" ./docs/compile/cmake.md || true) -le 0 ]]; then
echo "DOC CHECK FAILED: ${macro} not in ./docs/compile/cmake.md"
_fail_static
fi
done
for executable in ${executables}; do
if [[ $(grep -c "${executable}" ./docs/compile/other.md || true) -le 0 ]]; then
echo "DOC CHECK FAILED: ${executable} not in ./docs/compile/other.md"
_fail_static
fi
done
local cv_apis expr_apis cv_api expr_api
cv_apis="$(grep -oE " .+, \".+\"" pymnn/src/cv.h | awk '{ print $1 }' | awk -F ',' '{ print $1 }' || true)"
cv_apis="${cv_apis} $(grep -oE "def .+\(" pymnn/pip_package/MNN/cv/__init__.py | awk '{ print $2 }' | awk -F '(' '{print $1}' | grep -v "__" || true)"
for cv_api in ${cv_apis}; do
if [[ $(grep -c "${cv_api}" ./docs/pymnn/cv.md || true) -le 0 ]]; then
echo "DOC CHECK FAILED: ${cv_api} not in ./docs/pymnn/cv.md"
_fail_static
fi
done
expr_apis="$(grep -oE " [a-z_]+, \"" pymnn/src/expr.h | awk '{ print $1 }' | awk -F ',' '{ print $1 }' || true)"
for expr_api in ${expr_apis}; do
if [[ $(grep -c "${expr_api}" ./docs/pymnn/expr.md || true) -le 0 ]]; then
echo "DOC CHECK WARNING: ${expr_api} not in ./docs/pymnn/expr.md"
fi
done
}
py_check() {
log_step "py_check"
local py_change
py_change="$(_changed_files | grep -E "^pymnn/pip_package/MNN/.*\.(py)$" || true)"
[[ -n "${py_change}" ]] || return 0
pushd pymnn >/dev/null
if ./update_mnn_wrapper_assets.sh -c; then
printf "TEST_NAME_PYC_CHECK: pyc资源文件校验\nTEST_CASE_AMOUNT_PYC_CHECK: {\"blocked\":0,\"failed\":0,\"passed\":1,\"skipped\":0}\n"
else
printf "TEST_NAME_PYC_CHECK: pyc资源文件校验\nTEST_CASE_AMOUNT_PYC_CHECK: {\"blocked\":0,\"failed\":1,\"passed\":0,\"skipped\":0}\n"
popd >/dev/null
echo '### pyc资源文件校验失败,测试终止!'
_fail_static
fi
popd >/dev/null
}
static_check() {
log_step "static_check"
local source_change
source_change="$(_changed_files \
| grep -E "^source/(backend|core|common|cv|geometry|math|plugin|shape|utils)/.*\.(cpp|cc|c|hpp)$" \
| grep -Ev "aliyun-log-c-sdk|hiai|tensorrt|Backend|FunctionDispatcher|ThreadPool" || true)"
[[ -n "${source_change}" ]] || return 0
if ! command -v cppcheck >/dev/null 2>&1; then
log_err "cppcheck is required for static_check"
_fail_static
fi
if cppcheck --error-exitcode=1 --language=c++ --std=c++14 ${source_change} >/dev/null; then
printf "TEST_NAME_STATIC_CHECK: cppcheck静态分析\nTEST_CASE_AMOUNT_STATIC_CHECK: {\"blocked\":0,\"failed\":0,\"passed\":1,\"skipped\":0}\n"
else
printf "TEST_NAME_STATIC_CHECK: cppcheck静态分析\nTEST_CASE_AMOUNT_STATIC_CHECK: {\"blocked\":0,\"failed\":1,\"passed\":0,\"skipped\":0}\n"
echo '### cppcheck静态分析失败,测试终止!'
_fail_static
fi
}
drive_static() {
log_info "mode=static script_dir=${SCRIPT_DIR}"
doc_check
static_check
py_check
_emit_static_success
}
# ─────────────────────────────────────────────────────────────────────────────
# LLM model provisioning (HuggingFace / ModelScope / local dir)
# ─────────────────────────────────────────────────────────────────────────────
# Cache layout: ${MODELS_DIR}/<repo-basename>/{config.json,llm.mnn,...}
# Plus a generated prompt.txt so llm_demo has something to consume.
#
# Provisioning is lazy — invoked only once a run reaches the llm stage — and
# returns non-zero on failure (instead of aborting the whole script) so the
# caller can skip just the llm stage. When LLM_MODEL_DIR is caller-supplied the
# directory is used as-is and nothing is downloaded.
llm_model_complete() {
local f
for f in "${LLM_MODEL_FILES[@]}"; do
[[ -s "${LLM_MODEL_DIR}/${f}" ]] || return 1
done
return 0
}
provision_llm_model() {
log_step "provisioning LLM model: ${LLM_MODEL_NAME}"
if llm_model_complete; then
log_ok "LLM model present at ${LLM_MODEL_DIR}"
elif [[ ${LLM_MODEL_DIR_PROVIDED} -eq 1 ]]; then
log_err "LLM_MODEL_DIR=${LLM_MODEL_DIR} is missing required files; not downloading (caller-supplied path)"
return 1
else
log_info "downloading from ${LLM_MODEL_SOURCE} (${LLM_MODEL_URL_BASE})"
mkdir -p "${LLM_MODEL_DIR}"
local f url tmp dst
for f in "${LLM_MODEL_FILES[@]}"; do
dst="${LLM_MODEL_DIR}/${f}"
if [[ -s "${dst}" ]]; then
continue
fi
url="${LLM_MODEL_URL_BASE}/${f}"
tmp="${dst}.part"
log_info "fetching ${f}"
if ! curl -fL --retry 3 --retry-delay 2 -o "${tmp}" "${url}"; then
log_err "failed to download ${url}"
rm -f "${tmp}"
return 1
fi
mv "${tmp}" "${dst}"
done
log_ok "LLM model staged at ${LLM_MODEL_DIR}"
fi
# Ensure a small prompt exists for the smoke test. Best-effort: a read-only
# caller-supplied model dir is fine as long as it already ships a prompt.
local prompt_path="${LLM_MODEL_DIR}/prompt.txt"
if [[ ! -s "${prompt_path}" ]]; then
if ! printf 'Hello, who are you?\n' > "${prompt_path}" 2>/dev/null; then
log_err "no prompt.txt in ${LLM_MODEL_DIR} and the directory is not writable"
return 1
fi
fi
return 0
}
# ─────────────────────────────────────────────────────────────────────────────
# Public smoke models (resource/model/) — Caffe MobileNet/SqueezeNet pairs
# fetched from upstream GitHub repos and converted with our local MNNConvert.
# We deliberately do NOT use tools/script/get_model.sh: it pulls extra TFLite
# tarballs from URLs that frequently 404 / 503, producing noisy gzip + parser
# errors (and an MNNConvert SIGSEGV on a corrupt .tflite payload) even when
# the four .mnn files we actually need have already been converted.
# ─────────────────────────────────────────────────────────────────────────────
public_models_complete() {
local m
for m in "${SMOKE_MODELS[@]}"; do
[[ -s "${PUBLIC_MODELS_DIR}/${m}" ]] || return 1
done
return 0
}
# Convert a caffemodel/prototxt pair to ${PUBLIC_MODELS_DIR}/<relpath>.
# Args: 1=caffemodel basename, 2=prototxt basename, 3=output relpath
_local_convert_caffe_pair() {
local caffemodel="$1" prototxt="$2" relpath="$3"
local src_caffe="${SMOKE_SOURCES_DIR}/${caffemodel}"
local src_proto="${SMOKE_SOURCES_DIR}/${prototxt}"
local dst="${PUBLIC_MODELS_DIR}/${relpath}"
if [[ -s "${dst}" ]]; then
return 0
fi
if [[ ! -s "${src_caffe}" || ! -s "${src_proto}" ]]; then
log_err "missing source: ${src_caffe} or ${src_proto}"
return 1
fi
mkdir -p "$(dirname "${dst}")"
log_info "converting ${relpath}"
"${SCRIPT_DIR}/build/MNNConvert" \
-f CAFFE \
--modelFile "${src_caffe}" \
--prototxt "${src_proto}" \
--MNNModel "${dst}" \
--bizCode 0000 \
--keepInputFormat=0 >/dev/null
}
# Returns 0 if the public smoke set is ready, 1 otherwise. Caller decides
# whether to skip downstream stages.
provision_public_models() {
log_step "provisioning public smoke models"
if public_models_complete; then
log_ok "public model cache hit at ${PUBLIC_MODELS_DIR}"
return 0
fi
if [[ ! -x "${SCRIPT_DIR}/build/MNNConvert" ]]; then
log_warn "build/MNNConvert missing — smoke stages will skip"
return 1
fi
if ! provision_smoke_sources; then
log_warn "smoke source download failed — smoke stages will skip"
return 1
fi
local rc=0
_local_convert_caffe_pair mobilenet_v1.caffemodel mobilenet_v1.prototxt \
MobileNet/v1/mobilenet_v1.caffe.mnn || rc=1
_local_convert_caffe_pair mobilenet_v2.caffemodel mobilenet_v2.prototxt \
MobileNet/v2/mobilenet_v2.caffe.mnn || rc=1
_local_convert_caffe_pair squeezenet_v1.0.caffemodel squeezenet_v1.0.prototxt \
SqueezeNet/v1.0/squeezenet_v1.0.caffe.mnn || rc=1
_local_convert_caffe_pair squeezenet_v1.1.caffemodel squeezenet_v1.1.prototxt \
SqueezeNet/v1.1/squeezenet_v1.1.caffe.mnn || rc=1
if [[ ${rc} -ne 0 ]]; then
log_warn "one or more conversions failed — smoke stages will skip"
return 1
fi
if ! public_models_complete; then
log_warn "expected .mnn files missing after conversion — smoke stages will skip"
return 1
fi
log_ok "public smoke models ready"
return 0
}
# ─────────────────────────────────────────────────────────────────────────────
# NDK helpers
# ─────────────────────────────────────────────────────────────────────────────
ensure_android_ndk() {
if [[ -n "${ANDROID_NDK:-}" && -d "${ANDROID_NDK}" ]]; then
log_info "using ANDROID_NDK=${ANDROID_NDK}"
return 0
fi
local fallback="${HOME}/android-ndk-r21"
if [[ -d "${fallback}" ]]; then
export ANDROID_NDK="${fallback}"
log_warn "ANDROID_NDK unset; falling back to ${fallback}"
return 0
fi
log_err "ANDROID_NDK not set and ${fallback} does not exist"
exit 1
}
# ─────────────────────────────────────────────────────────────────────────────
# Local-mode build
# ─────────────────────────────────────────────────────────────────────────────
_host_jobs() {
if command -v nproc >/dev/null 2>&1; then
nproc
else
sysctl -n hw.ncpu 2>/dev/null || echo 4
fi
}
local_build() {
log_step "configuring + building host (build/) [CPU-only]"
local build_dir="${SCRIPT_DIR}/build"
mkdir -p "${build_dir}"
pushd "${build_dir}" >/dev/null
# On macOS, CMake fails to auto-pick the SDK when CMAKE_CXX_COMPILER
# resolves to /usr/bin/c++ (the Apple shim), leaving CMAKE_OSX_SYSROOT
# empty and the OBJECT-library targets (e.g. MNNARM64) unable to find
# <vector>/<map>. Pass the active SDK path explicitly.
#
# Additionally, partially-upgraded Command Line Tools installs leave a
# stale /Library/Developer/CommandLineTools/usr/include/c++/v1 with only
# a few legacy files. clang's internal-isystem hits that dir first and
# stops searching, so the SDK's complete libc++ headers are never seen.
# Prepend the SDK's libc++ via CPLUS_INCLUDE_PATH to force a hit.
local -a platform_args=()
if [[ "$(uname -s)" == "Darwin" ]]; then
local sdkpath
sdkpath="$(xcrun --show-sdk-path 2>/dev/null || true)"
if [[ -n "${sdkpath}" ]]; then
platform_args+=("-DCMAKE_OSX_SYSROOT=${sdkpath}")
local sdk_libcxx="${sdkpath}/usr/include/c++/v1"
if [[ -d "${sdk_libcxx}" && ! -f "/Library/Developer/CommandLineTools/usr/include/c++/v1/vector" ]]; then
export CPLUS_INCLUDE_PATH="${sdk_libcxx}${CPLUS_INCLUDE_PATH:+:${CPLUS_INCLUDE_PATH}}"
log_warn "host CommandLineTools libc++ is incomplete; prepending ${sdk_libcxx} to CPLUS_INCLUDE_PATH"
fi
fi
fi
# Local mode is CPU-only: host GPU drivers are usually unavailable or
# unreliable on dev machines, so we omit OpenCL/Vulkan to keep the
# build fast and the test surface meaningful.
cmake .. \
-DCMAKE_BUILD_TYPE=Release \
-DMNN_BUILD_TEST=ON \
-DMNN_LOW_MEMORY=ON \
-DMNN_SUPPORT_TRANSFORMER_FUSE=ON \
-DMNN_BUILD_LLM=ON \
-DMNN_BUILD_CONVERTER=ON \
"${platform_args[@]}"
make -j"$(_host_jobs)"
popd >/dev/null
log_ok "host build complete"
}
# Source archives for the smoke corpus. Hosts the upstream URLs that
# tools/script/get_model.sh hits. Format per row:
# <url> <local_basename> <output_mnn_relpath> <framework>
# framework is "CAFFE" (paired prototxt download follows immediately) or
# "TFLITE" (single .tflite payload).
# Total payload ~40 MB.
SMOKE_SOURCES=(
"https://raw.githubusercontent.com/shicai/MobileNet-Caffe/master/mobilenet.caffemodel|mobilenet_v1.caffemodel|MobileNet/v1/mobilenet_v1.caffe.mnn|CAFFE"
"https://raw.githubusercontent.com/shicai/MobileNet-Caffe/master/mobilenet_deploy.prototxt|mobilenet_v1.prototxt|MobileNet/v1/mobilenet_v1.caffe.mnn|CAFFE_PROTOTXT"
"https://raw.githubusercontent.com/shicai/MobileNet-Caffe/master/mobilenet_v2.caffemodel|mobilenet_v2.caffemodel|MobileNet/v2/mobilenet_v2.caffe.mnn|CAFFE"
"https://raw.githubusercontent.com/shicai/MobileNet-Caffe/master/mobilenet_v2_deploy.prototxt|mobilenet_v2.prototxt|MobileNet/v2/mobilenet_v2.caffe.mnn|CAFFE_PROTOTXT"
"https://raw.githubusercontent.com/DeepScale/SqueezeNet/master/SqueezeNet_v1.0/squeezenet_v1.0.caffemodel|squeezenet_v1.0.caffemodel|SqueezeNet/v1.0/squeezenet_v1.0.caffe.mnn|CAFFE"
"https://raw.githubusercontent.com/DeepScale/SqueezeNet/master/SqueezeNet_v1.0/deploy.prototxt|squeezenet_v1.0.prototxt|SqueezeNet/v1.0/squeezenet_v1.0.caffe.mnn|CAFFE_PROTOTXT"
"https://raw.githubusercontent.com/DeepScale/SqueezeNet/master/SqueezeNet_v1.1/squeezenet_v1.1.caffemodel|squeezenet_v1.1.caffemodel|SqueezeNet/v1.1/squeezenet_v1.1.caffe.mnn|CAFFE"
"https://raw.githubusercontent.com/DeepScale/SqueezeNet/b6b5ae2ce884a3866c21efd31e103defde8631ae/SqueezeNet_v1.1/deploy.prototxt|squeezenet_v1.1.prototxt|SqueezeNet/v1.1/squeezenet_v1.1.caffe.mnn|CAFFE_PROTOTXT"
)
SMOKE_SOURCES_DIR="${SCRIPT_DIR}/smoke_sources"
# Map MNN-output relpath -> (caffemodel, prototxt) basenames. Mirrors the
# pairing in SMOKE_SOURCES above and is consumed by android conversion.
_smoke_pair_for() {
case "$1" in
MobileNet/v1/mobilenet_v1.caffe.mnn)
printf '%s\n%s\n' mobilenet_v1.caffemodel mobilenet_v1.prototxt ;;
MobileNet/v2/mobilenet_v2.caffe.mnn)
printf '%s\n%s\n' mobilenet_v2.caffemodel mobilenet_v2.prototxt ;;
SqueezeNet/v1.0/squeezenet_v1.0.caffe.mnn)
printf '%s\n%s\n' squeezenet_v1.0.caffemodel squeezenet_v1.0.prototxt ;;
SqueezeNet/v1.1/squeezenet_v1.1.caffe.mnn)
printf '%s\n%s\n' squeezenet_v1.1.caffemodel squeezenet_v1.1.prototxt ;;
*)
return 1 ;;
esac
}
# Download upstream caffe sources to the host cache. Skips files already
# present. Returns non-zero if any download fails.
provision_smoke_sources() {
log_step "fetching smoke-model sources"
mkdir -p "${SMOKE_SOURCES_DIR}"
local entry url fname rest
for entry in "${SMOKE_SOURCES[@]}"; do
url="${entry%%|*}"
rest="${entry#*|}"
fname="${rest%%|*}"
local dst="${SMOKE_SOURCES_DIR}/${fname}"
if [[ -s "${dst}" ]]; then
continue
fi
log_info "fetching ${fname}"
if ! curl -fL --retry 3 --retry-delay 2 -o "${dst}.part" "${url}"; then
log_err "failed to download ${url}"
rm -f "${dst}.part"
return 1
fi
mv "${dst}.part" "${dst}"
done
log_ok "smoke sources cached at ${SMOKE_SOURCES_DIR}"
return 0
}
# ─────────────────────────────────────────────────────────────────────────────
# Local-mode runners — invoked by the JSON dispatch table; one wrapper per
# binary, named symmetrically with the remote/_remote_* counterparts.
# ─────────────────────────────────────────────────────────────────────────────
_local_run_test() {
(cd "${SCRIPT_DIR}/build" && ./run_test.out "$@");
}
_local_v2basic() {
(cd "${SCRIPT_DIR}/build" && ./MNNV2Basic.out "$@")
}
_local_backendtest() {
(cd "${SCRIPT_DIR}/build" && ./backendTest.out "$@")
}
local_run_stages() {
# Local mode is CPU-only by design — see local_build() comment.
# Unit and smoke stages flow through the JSON config (section: "local").
_run_json_stages local _local_run_test
# Stage A on CPU: forward-smoke per public model. Stage B (CPU-vs-
# backend) is meaningless without a GPU build, so the local section of
# the JSON only declares smoke_a_stages.
if [[ ! -x "${SCRIPT_DIR}/build/MNNV2Basic.out" ]]; then
skip_stage "smokeA" "build/MNNV2Basic.out not built (check MNN_BUILD_TOOLS)"
elif ! public_models_complete; then
skip_stage "smokeA" "public smoke models missing under ${PUBLIC_MODELS_DIR} (get_model.sh failed?)"
else
_run_json_model_stages smokeA local "${PUBLIC_MODELS_DIR}"
fi
# LLM smoke test. Model provisioning is lazy: it happens here, after the
# unit + smoke stages have already run, so those proceed even with no
# network. A failed provision skips just this stage.
if [[ ! -x "${SCRIPT_DIR}/build/llm_demo" ]]; then
skip_stage "llm/${LLM_MODEL_NAME}" "llm_demo not built"
elif ! provision_llm_model; then
skip_stage "llm/${LLM_MODEL_NAME}" "model unavailable (see provisioning log above)"
else
run_stage "llm/${LLM_MODEL_NAME}" -- bash -c \
"cd '${SCRIPT_DIR}/build' && ./llm_demo '${LLM_MODEL_DIR}/config.json' '${LLM_MODEL_DIR}/prompt.txt'"
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# Android-mode build
# ─────────────────────────────────────────────────────────────────────────────
ANDROID_BUILD_DIR="${SCRIPT_DIR}/project/android/build_64"
android_build() {
log_step "building MNN for arm64-v8a"
ensure_android_ndk
mkdir -p "${ANDROID_BUILD_DIR}"
pushd "${ANDROID_BUILD_DIR}" >/dev/null
# ANDROID_EXTRA_CMAKE lets the caller append/override cmake flags, e.g.
# to bisect a suspected backend regression:
# ANDROID_EXTRA_CMAKE="-DMNN_SME2=OFF -DMNN_KLEIDIAI=OFF" \
# ./test.sh android <serial>
local -a extra=()
if [[ -n "${ANDROID_EXTRA_CMAKE:-}" ]]; then
# shellcheck disable=SC2206
extra=(${ANDROID_EXTRA_CMAKE})
fi
bash ../build_64.sh \
-DMNN_BUILD_TRAIN=OFF \
-DMNN_ARM82=true \
-DMNN_OPENCL=true \
-DMNN_VULKAN=true \
-DMNN_LOW_MEMORY=true \
-DMNN_SUPPORT_TRANSFORMER_FUSE=ON \
-DMNN_BUILD_LLM=ON \
-DMNN_BUILD_CONVERTER=ON \
"${extra[@]}"
# build_64.sh hard-codes `make -j4`; respin with all cores so subsequent
# incremental work uses full parallelism.
make -j"$(_host_jobs)"
popd >/dev/null
log_ok "android build complete"
}
# ─────────────────────────────────────────────────────────────────────────────
# Native push (replaces project/android/updateTest.sh; NPU dropped)
# ─────────────────────────────────────────────────────────────────────────────
ANDROID_BIN_LIST=(
libllm.so
llm_demo
libMNN.so
libMNN_CL.so
libMNN_Vulkan.so
libMNN_Express.so
MNNV2Basic.out
ModuleBasic.out
testModel.out
testModelWithDescribe.out
backendTest.out
timeProfile.out
benchmark.out
benchmarkExprModels.out
run_test.out
MNNConvert
# MNNConvert dynamically links against libMNNConvertDeps.so which is
# built under tools/converter/. Without this push the on-device caffe->
# mnn conversion fails ("library libMNNConvertDeps.so not found"),
# causing smokeA / smokeB / bench to be skipped.
tools/converter/libMNNConvertDeps.so
)
DEVICE_SMOKE_SRC_DIR="/data/local/tmp/MNN/smoke_sources"
push_artifacts() {
log_step "pushing artefacts to ${DEVICE_DIR}"
ad shell "mkdir -p ${DEVICE_DIR} && rm -rf ${DEVICE_DIR}/output && mkdir -p ${DEVICE_DIR}/output" >/dev/null
local pushed=0 missing=0
local rel src dst
for rel in "${ANDROID_BIN_LIST[@]}"; do
src="${ANDROID_BUILD_DIR}/${rel}"
dst="${DEVICE_DIR}/$(basename "${rel}")"
if [[ -e "${src}" ]]; then
ad push "${src}" "${dst}" >/dev/null
pushed=$((pushed + 1))
else
log_warn "missing artefact: ${rel} (skipped)"
missing=$((missing + 1))
fi
done
log_ok "pushed ${pushed} artefact(s); ${missing} missing"
}
# Push the cached caffe sources (.caffemodel + .prototxt) to the device,
# then drive the on-device MNNConvert binary to produce .mnn files. Avoids
# requiring a host MNNConvert.
convert_smoke_on_device() {
log_step "converting smoke models on device (arm64 MNNConvert)"
if ! ad shell "[ -x ${DEVICE_DIR}/MNNConvert ]" 2>/dev/null; then
log_warn "device MNNConvert missing; cannot convert smoke models"
return 1
fi
ad shell "mkdir -p ${DEVICE_SMOKE_SRC_DIR} ${DEVICE_PUBLIC_MODELS_DIR}" >/dev/null
# Push every source file we have cached. Idempotent: skip when the size
# matches what's already on device.
local f local_path remote_path
for f in "${SMOKE_SOURCES_DIR}"/*; do
[[ -f "${f}" ]] || continue
local_path="${f}"
remote_path="${DEVICE_SMOKE_SRC_DIR}/$(basename "${f}")"
local local_size remote_size
local_size="$(stat -f%z "${local_path}" 2>/dev/null || stat -c%s "${local_path}" 2>/dev/null || echo 0)"
remote_size="$(ad shell "stat -c%s ${remote_path} 2>/dev/null || echo 0" 2>/dev/null \
| tr -d '[:space:]\r' || echo 0)"
if [[ "${local_size}" != "${remote_size}" ]]; then
ad push "${local_path}" "${remote_path}" >/dev/null
fi
done
# Convert each model on device. Skip if the .mnn already exists with
# non-zero size (idempotent on re-runs).
local m short ok=0 fail=0
for m in "${SMOKE_MODELS[@]}"; do
short="${m##*/}"
# Flat layout on device: benchmark.out's findModelFiles() does a
# non-recursive readdir() of its dir argument and trips over any
# subdirectories, so all .mnn files must sit at the same level.
# _emit_json_smoke_stages is section-aware and uses basename for
# android paths to match.
local mnn_remote="${DEVICE_PUBLIC_MODELS_DIR}/${short}"
if ad shell "[ -s ${mnn_remote} ]" 2>/dev/null; then
log_info "skip convert ${short} (already on device)"
ok=$((ok + 1))
continue
fi
local pair_caffe pair_proto
pair_caffe="$(_smoke_pair_for "${m}" | sed -n '1p')"
pair_proto="$(_smoke_pair_for "${m}" | sed -n '2p')"
if [[ -z "${pair_caffe}" || -z "${pair_proto}" ]]; then
log_warn "no source pairing known for ${m}; skipping"
fail=$((fail + 1))
continue
fi
local cmd="cd ${DEVICE_DIR} && export LD_LIBRARY_PATH=. && ./MNNConvert -f CAFFE \
--modelFile ${DEVICE_SMOKE_SRC_DIR}/${pair_caffe} \
--prototxt ${DEVICE_SMOKE_SRC_DIR}/${pair_proto} \
--MNNModel ${mnn_remote} \
--bizCode 0000 --keepInputFormat=0"
log_info "converting ${short}"
if ad shell "${cmd}" >/dev/null 2>&1; then
ok=$((ok + 1))
else
log_warn "conversion failed for ${short}"
fail=$((fail + 1))
fi
done
log_ok "on-device conversion complete (${ok} ok, ${fail} failed)"
[[ ${fail} -eq 0 ]]
}
push_llm_model() {
log_step "pushing LLM model ${LLM_MODEL_NAME} to device"
local remote="${DEVICE_MODEL_DIR}/${LLM_MODEL_NAME}"
ad shell "mkdir -p ${DEVICE_MODEL_DIR}" >/dev/null
local local_count remote_count
local_count="$(find "${LLM_MODEL_DIR}" -type f | wc -l | tr -d '[:space:]')"
remote_count="$(ad shell "find ${remote} -type f 2>/dev/null | wc -l" 2>/dev/null \
| tr -d '[:space:]\r' || echo 0)"
if [[ "${remote_count}" == "${local_count}" && "${local_count}" -gt 0 ]]; then
log_info "skip push (already on device, ${local_count} files)"
return 0
fi
ad shell "rm -rf ${remote}" >/dev/null
ad push "${LLM_MODEL_DIR}" "${remote}" >/dev/null
log_ok "LLM model pushed (${local_count} files)"
}
# ─────────────────────────────────────────────────────────────────────────────
# Android on-device stages
# ─────────────────────────────────────────────────────────────────────────────
# NOTE: the script-wide IFS is set to $'\n\t', so unquoted "$*" would join
# args with newlines. Embedded in an `adb shell` command string those
# newlines split into separate remote commands, breaking the run with rc=127
# on the trailing tokens. Force a local space-only IFS for the join.
_remote_run_test() {
local IFS=' '
# MNN_TEST_SKIP is honored by MNNTestSuite::run() to omit named tests
# from the in-process loop. Used by the OpenCL BUFFER stage to drop
# ops that hit Mali driver bugs in BUFFER-mode loop kernels.
local skip_env=""
if [[ -n "${MNN_TEST_SKIP:-}" ]]; then
skip_env="export MNN_TEST_SKIP='${MNN_TEST_SKIP}' && "
fi
ad shell "cd ${DEVICE_DIR} && export LD_LIBRARY_PATH=. && ${skip_env}./run_test.out $*"
}
_remote_v2basic() {
local IFS=' '
ad shell "cd ${DEVICE_DIR} && export LD_LIBRARY_PATH=. && ./MNNV2Basic.out $*"
}
_remote_backendtest() {
local IFS=' '
ad shell "cd ${DEVICE_DIR} && export LD_LIBRARY_PATH=. && ./backendTest.out $*"
}
_remote_benchmark() {
local IFS=' '
ad shell "cd ${DEVICE_DIR} && export LD_LIBRARY_PATH=. && ./benchmark.out $*"
}
# ─────────────────────────────────────────────────────────────────────────────
# JSON dispatch — every stage in test_stages.json is materialised to a row
# (name|filter|binary|argv) and dispatched through one of two binary maps.
# ─────────────────────────────────────────────────────────────────────────────
# Map JSON 'binary' string -> shell function that invokes the on-device tool.
_remote_for_binary() {
case "$1" in
run_test) echo _remote_run_test ;;
v2basic) echo _remote_v2basic ;;
backendtest) echo _remote_backendtest ;;
benchmark) echo _remote_benchmark ;;
*) return 1 ;;
esac
}
# Map JSON 'binary' string -> shell function that invokes the host tool.
# Local mode has no benchmark.out (not built); benchmark stages live in
# JSON's android section only, so this map intentionally omits it.
_local_for_binary() {
case "$1" in
run_test) echo _local_run_test ;;
v2basic) echo _local_v2basic ;;
backendtest) echo _local_backendtest ;;
*) return 1 ;;
esac
}
# Materialize per-model smoke stage rows. One TAB-separated row per
# (smoke_a or smoke_b stage) × (model). Columns:
# 1: stage name (with model short-name suffix)
# 2: filter tag
# 3: shell function name (_remote_v2basic / _remote_backendtest)
# 4: shell-quoted argv (with {model} / {models_dir} substituted)
_emit_json_smoke_stages() {
local section_root="$1" # "android" or "local"
local stages_key="$2" # "smoke_a_stages" or "smoke_b_stages"
local models_dir="$3" # device or host public_models dir
SMOKE_SECTION_ROOT="${section_root}" SMOKE_STAGES_KEY="${stages_key}" \
SMOKE_MODELS_DIR="${models_dir}" \
python3 - "${STAGES_JSON_FILE}" <<'PY'
import json, os, shlex, sys
root = os.environ["SMOKE_SECTION_ROOT"]
key = os.environ["SMOKE_STAGES_KEY"]
models_dir = os.environ["SMOKE_MODELS_DIR"]
with open(sys.argv[1]) as f:
data = json.load(f)
node = data.get(root, {})
models = node.get("smoke_models", []) or data.get("android", {}).get("smoke_models", [])
stages = node.get(key, [])
for m in models:
short = m.split("/")[-1]
# Local mode keeps host's nested layout (resource/model/MobileNet/v1/...).
# Android mode keeps a flat layout under DEVICE_PUBLIC_MODELS_DIR because
# benchmark.out's findModelFiles() is non-recursive (see benchmark.cpp:54).
device_path = f"{models_dir}/{m}" if root == "local" else f"{models_dir}/{short}"
for st in stages:
argv = [
a.replace("{model}", device_path).replace("{models_dir}", models_dir)
for a in st["args"]
]
name = f"{st['name']}/{short}"
quoted = " ".join(shlex.quote(a) for a in argv)
print(f"{name}|{st['filter']}|{st['binary']}|{quoted}")
PY
}
# Materialize bench stage rows (one per backend, models_dir substituted).
_emit_json_bench_stages() {
local section_root="$1" # "android"
local models_dir="$2"
BENCH_SECTION_ROOT="${section_root}" BENCH_MODELS_DIR="${models_dir}" \
python3 - "${STAGES_JSON_FILE}" <<'PY'
import json, os, shlex, sys
root = os.environ["BENCH_SECTION_ROOT"]
models_dir = os.environ["BENCH_MODELS_DIR"]
with open(sys.argv[1]) as f:
data = json.load(f)
for st in data.get(root, {}).get("bench_stages", []):
argv = [a.replace("{models_dir}", models_dir) for a in st["args"]]
print(f"{st['name']}|{st['filter']}|{st['binary']}|{' '.join(shlex.quote(a) for a in argv)}")
PY
}
# Driver for smoke A / smoke B / bench JSON-defined stages. Buffers rows
# into an array first so adb's stdin can't close the read loop early
# (the classic `while read … | tee | adb` gotcha).
_run_json_model_stages() {
local kind="$1" # smokeA | smokeB | bench
local section_root="${2:-android}"
local models_dir="${3:-${DEVICE_PUBLIC_MODELS_DIR}}"
local -a rows=()
local row
case "${kind}" in
smokeA)
while IFS= read -r row; do rows+=("${row}"); done \
< <(_emit_json_smoke_stages "${section_root}" smoke_a_stages "${models_dir}") ;;
smokeB)
while IFS= read -r row; do rows+=("${row}"); done \
< <(_emit_json_smoke_stages "${section_root}" smoke_b_stages "${models_dir}") ;;
bench)
while IFS= read -r row; do rows+=("${row}"); done \
< <(_emit_json_bench_stages "${section_root}" "${models_dir}") ;;
*) return 1 ;;
esac
local name filt binary argv runner
for row in "${rows[@]}"; do
IFS='|' read -r name filt binary argv <<<"${row}"
[[ -z "${name}" ]] && continue
if ! _filter_runs "${filt}"; then
continue
fi
if [[ "${section_root}" == "local" ]]; then
runner="$(_local_for_binary "${binary}")" || continue
else
runner="$(_remote_for_binary "${binary}")" || continue
fi
# ${argv} is shell-quoted by Python's shlex.quote, so eval-driven
# word-splitting is required to honour the original token
# boundaries. \$name defers expansion to eval's parse pass.
eval "run_stage \"\$name\" -- ${runner} ${argv}"
done
}
android_benchmarks() {
if ! ad shell "[ -d ${DEVICE_PUBLIC_MODELS_DIR} ]" 2>/dev/null; then
skip_stage "bench" "public_models dir absent on device"
return 0
fi
_run_json_model_stages bench android "${DEVICE_PUBLIC_MODELS_DIR}"
}
# Backwards-compatible wrappers — drive_android still calls these.
android_smoke_a_stages() {
_run_json_model_stages smokeA android "${DEVICE_PUBLIC_MODELS_DIR}"
}
android_smoke_b_stages() {
_run_json_model_stages smokeB android "${DEVICE_PUBLIC_MODELS_DIR}"
}
# ─────────────────────────────────────────────────────────────────────────────
# Filter logic — translates the user-supplied --filter (FILTER global) into a
# per-stage admission decision based on the row's tag.
#
# Unit-test matrix for the Android arm64 on-device driver.
#
# IMPORTANT: argv[4] of run_test.out has DIFFERENT semantics per backend.
# - CPU (type 0) -> thread count (we use 1 or 4).
# - OpenCL (type 3) -> gpuMode bitmask (MNN_GPU_TUNING_* | MNN_GPU_MEMORY_*).
# 132 = TUNING_WIDE (4) | MEMORY_IMAGE (128) — the
# recommended OpenCL default. Plain 4 leaves memory
# mode unset, which on some drivers segfaults.
# - Vulkan (type 7) -> gpuMode bitmask, but only TUNING_* bits are valid.
# 4 = TUNING_WIDE.
# ─────────────────────────────────────────────────────────────────────────────
_filter_runs() {
# arg1 = backend tag (cpu|opencl-image|opencl-buffer|vulkan|lowmem|smoke|llm)
# returns 0 (truthy in bash if-statements) if the stage should run.
local tag="$1"
case "${FILTER}" in
all) return 0 ;;
cpu) [[ "${tag}" == "cpu" || "${tag}" == "lowmem" || "${tag}" == "llm" ]] ;;
opencl) [[ "${tag}" == "opencl-image" || "${tag}" == "opencl-buffer" || "${tag}" == "smoke-opencl" ]] ;;
opencl-image) [[ "${tag}" == "opencl-image" ]] ;;
opencl-buffer) [[ "${tag}" == "opencl-buffer" ]] ;;
vulkan) [[ "${tag}" == "vulkan" || "${tag}" == "smoke-vulkan" ]] ;;
gpu) [[ "${tag}" == "opencl-image" || "${tag}" == "opencl-buffer" || "${tag}" == "vulkan" || "${tag}" == "smoke-opencl" || "${tag}" == "smoke-vulkan" ]] ;;
unit) [[ "${tag}" == "cpu" || "${tag}" == "opencl-image" || "${tag}" == "opencl-buffer" || "${tag}" == "vulkan" ]] ;;
lowmem) [[ "${tag}" == "lowmem" ]] ;;
# android-ci: bench (filter=cpu inside bench_stages) + smokeA/B
# (cpu, smoke-opencl, smoke-vulkan) + llm. Unit/lowmem are skipped at
# the drive_android level by gating android_tests, so accepting "cpu"
# here only lets bench and smokeA/cpu rows through.
android-ci) [[ "${tag}" == "cpu" || "${tag}" == "smoke-opencl" || "${tag}" == "smoke-vulkan" || "${tag}" == "llm" ]] ;;
esac
}
# Materialize each stage from a $section.stages array in test_stages.json
# into one shell-quoted line per stage. Columns are pipe-delimited because
# `read` collapses runs of whitespace IFS chars (tab/space/newline) into a
# single separator — using '|' (non-whitespace) keeps empty fields like
# 'skip' addressable instead of losing them.
# 1: name
# 2: filter tag
# 3: skip-list (comma-joined; empty if none)
# 4: positional argv for the chosen binary, already shell-quoted
_emit_json_stages() {
local section="${1:-android}"
JSON_SECTION="${section}" python3 - "${STAGES_JSON_FILE}" <<'PY'
import json, os, shlex, sys
section = os.environ["JSON_SECTION"]
with open(sys.argv[1]) as f:
data = json.load(f)
node = data.get(section, {})
for st in node.get("stages", []):
name = st["name"]
filt = st["filter"]
skip = ",".join(st.get("skip") or [])
argv = [
str(st["prefix"]),
str(st["type"]),
str(st["precision"]),
str(st["threadOrGpuMode"]),
str(st["tag"]),
]
if st.get("memory") is not None:
argv.append(str(st["memory"]))
if st.get("dynamicOption") is not None:
argv.append(str(st["dynamicOption"]))
if st.get("kleidiAi") is not None:
argv.append(str(st["kleidiAi"]))
quoted = " ".join(shlex.quote(a) for a in argv)
print(f"{name}|{filt}|{skip}|{quoted}")
PY
}
# Iterate JSON-defined stages from the named section and dispatch each one.
# Honours --runs filter via `_filter_runs` and the per-stage skip list via
# MNN_TEST_SKIP. The stage list is captured up-front into an array so the
# inner adb-shell calls in run_stage can't eat the read loop's stdin
# (classic gotcha with `while read … done < <(generator)` + ssh/adb).
_run_json_stages() {
local section="${1:-android}"
local runner="${2:-_remote_run_test}"
local -a rows=()
local row
while IFS= read -r row; do
rows+=("${row}")
done < <(_emit_json_stages "${section}")
local name filt skip argv
for row in "${rows[@]}"; do
IFS='|' read -r name filt skip argv <<<"${row}"
[[ -z "${name}" ]] && continue
if ! _filter_runs "${filt}"; then
continue
fi
# ${argv} is shell-quoted by Python's shlex.quote, so eval-driven
# word-splitting is required to honour the original token
# boundaries. \$name defers expansion to eval's parse pass.
if [[ -n "${skip}" ]]; then
MNN_TEST_SKIP="${skip}" eval "run_stage \"\$name\" -- ${runner} ${argv}"
else
eval "run_stage \"\$name\" -- ${runner} ${argv}"
fi
done
}
android_tests() {
_run_json_stages android _remote_run_test
}
android_llm_test() {
local remote="${DEVICE_MODEL_DIR}/${LLM_MODEL_NAME}"
if ad shell "[ -f ${remote}/config.json ]" 2>/dev/null; then
run_stage "llm/${LLM_MODEL_NAME}" -- ad shell \
"cd ${DEVICE_DIR} && export LD_LIBRARY_PATH=. && ./llm_demo ${remote}/config.json ${remote}/prompt.txt"
else
skip_stage "llm/${LLM_MODEL_NAME}" "model dir absent on device"
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# Drivers
# ─────────────────────────────────────────────────────────────────────────────
drive_local() {
log_info "mode=local script_dir=${SCRIPT_DIR}"
if [[ ! -x "${SCRIPT_DIR}/build/run_test.out" ]]; then
local_build
else
log_info "reusing existing build/ (delete it to force a rebuild)"
fi
# Try to populate the public smoke set after the build (MNNConvert is
# produced by the build). Stages downstream gate on availability.
provision_public_models || true
local_run_stages
}
drive_android() {
log_info "mode=android device=${DEVICE} script_dir=${SCRIPT_DIR}"
detect_adb
ensure_adbk_session
verify_device_online
# Cache caffe sources on host (small download). The conversion itself
# runs on device with the arm64 MNNConvert we build below.
local smoke_ok=1
provision_smoke_sources || smoke_ok=0
android_build
push_artifacts
if [[ ${smoke_ok} -eq 1 ]]; then
convert_smoke_on_device || smoke_ok=0
fi
# Unit/op + lowmem matrix. android-ci skips these so the on-device run
# stays focused on bench / smoke / llm.
if [[ "${FILTER}" != "android-ci" ]]; then
android_tests
fi
if [[ ${smoke_ok} -eq 1 ]]; then
# Each driver iterates the JSON-defined backend list and applies
# _filter_runs internally, so a single call covers cpu/opencl/vulkan.
android_smoke_a_stages
android_smoke_b_stages
android_benchmarks
else
skip_stage "smokeA" "smoke source download or on-device conversion failed"
skip_stage "smokeB" "smoke source download or on-device conversion failed"
skip_stage "bench" "smoke source download or on-device conversion failed"
fi
# LLM stage. Provisioning + device push are lazy: deferred to here so the
# unit / smoke / bench stages run even with no network, and so a missing
# model skips only this stage instead of aborting the run.
if _filter_runs llm; then
if provision_llm_model; then
push_llm_model
android_llm_test
else
skip_stage "llm/${LLM_MODEL_NAME}" "model unavailable (see provisioning log above)"
fi
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────────────────────────────────────────
main() {
parse_args "$@"
case "${MODE}" in
static) drive_static ;;
local) drive_local ;;
android) drive_android ;;
esac
}
main "$@"