#!/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 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//. # 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 -- # 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 < [args] Subcommands: static Run lightweight static checks. local Run host-side regression suite. android [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}//{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}/. # 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 # /. 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: # # 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 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 "$@"