#!/usr/bin/env bash # Find the draft depth that is actually fastest on THIS machine, # then write it into .env. MTP is multi-token prediction, a small # drafter model guesses the next few tokens and the chat model # checks them all in one pass. draft depth is how many it guesses. # # Speculation only pays when checking K+1 tokens costs about what # checking 1 costs. Whether that holds depends on the card, so # the only honest answer is to measure. Measured on a 3060 with # the 12B: depth 1 gave +25%, depth 2 +16%, depth 3 broke even, # depth 4 came out Slower than no drafter at all. A bigger card # can afford a deeper draft. Yours might not. # # Needs the stack up and the models pulled. Safe to re-run any # time. After a GPU change you don't have to remember to, # start.sh sees the card this was measured on is gone and runs it # for you. # # ./mtp-autotune.sh # # No jq, no python. Installers run on machines that have neither. set -eu cd "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then R=$'\033[0m'; B=$'\033[1m' ACCENT=$'\033[38;2;124;158;255m' #7c9eff OK=$'\033[38;2;123;216;143m' #7bd88f WARN=$'\033[38;2;255;200;112m' #ffc870 DIM=$'\033[38;2;142;146;149m' #8e9295 else R=; B=; ACCENT=; OK=; WARN=; DIM= fi say() { printf " ${DIM}%s${R}\n" "$1"; } good() { printf " ${OK}v${R} %s\n" "$1"; } warn_(){ printf " ${WARN}!${R} %s\n" "$1"; } die() { printf " ${WARN}!${R} %s\n" "$1"; exit 1; } env_get() { [ -f .env ] || return 0 sed -n "s/^$1=//p" .env | tail -n1 } set_env() { tmp=".env.tmp.$$" if grep -qE "^$1=" .env 2>/dev/null; then grep -vE "^$1=" .env > "$tmp" printf '%s=%s\n' "$1" "$2" >> "$tmp" mv "$tmp" .env else printf '%s=%s\n' "$1" "$2" >> .env fi chmod 600 .env 2>/dev/null || true } # The card this tune was measured on, as one string: the vendor, # then every GPU's name and how much VRAM it has. Sorted biggest # card first, so moving cards between slots is not a change, only # a real swap is. # # Keep this byte for byte in step with the copy in start.sh. that # one compares what it prints against MTP_TUNED_GPU, so the day # the two print a different string for the same card, start.sh # reads every boot as a GPU change and re-runs the whole sweep, # forever. gpu_signature() { if command -v nvidia-smi >/dev/null 2>&1; then nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null \ | sed -e 's/\r//' -e 's/[[:space:]]*,[[:space:]]*/:/' \ | sort -t: -k2 -nr \ | awk 'NF { s = s (s ? "," : "") $0 } END { if (s) print "nvidia:" s }' elif command -v rocm-smi >/dev/null 2>&1; then rocm-smi --showmeminfo vram --csv 2>/dev/null \ | awk -F, 'NR>1 { gsub(/[^0-9]/,"",$2); if ($2 != "") print int($2/1048576) }' \ | sort -nr \ | awk 'NF { s = s (s ? "," : "") $0 } END { if (s) print "amd:" s }' fi } # Only a run that measured something gets to stamp, and only when # we could actually name the card. No stamp is better than one # that means "we could not tell", start.sh has nothing to compare # then and leaves you alone. stamp_gpu() { sig="$(gpu_signature)" [ -n "$sig" ] || return 0 set_env MTP_TUNED_GPU "$sig" } # The drafter that goes with a chat model is the same repo with # -MTP in the name, so Jun-LoRA-12B-GGUF drafts off # Jun-LoRA-12B-MTP-GGUF. The quant tag rides along untouched. A # repo that doesn't end in -GGUF just gets -MTP on the end. mtp_repo_for() { _ref="$1" case "${_ref##*/}" in *:*) _tag=":${_ref##*:}"; _repo="${_ref%:*}" ;; *) _tag=""; _repo="$_ref" ;; esac case "$_repo" in *-GGUF) printf '%s-MTP-GGUF%s\n' "${_repo%-GGUF}" "$_tag" ;; *) printf '%s-MTP%s\n' "$_repo" "$_tag" ;; esac } # One number out of a flat JSON body. Enough for eval_count and # friends, and it saves a dependency this script would otherwise # need on every distro. json_num() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\([0-9][0-9.]*\).*/\1/p" | head -n1 } command -v docker >/dev/null 2>&1 || die "docker not found" # Two in-character turns, no code. A coding prompt makes any # drafter look great, Gemma's assistants were tuned on code, and # then the number you tuned against is one Jun's traffic NEVER # sees. Prose is what she actually writes. P1='You have been quiet all evening. Talk to me, properly.' P2='Tell me what you remember about the day we met.' # 2 prompts x 80 tokens is 160 per row, five rows, so the whole # ollama sweep lands near half a minute on a normal card. Short # rows are noisier, that is what MARGIN below is for, a depth # that only ties inside the noise doesn't get to win anyway. TOKENS=80 # Her real system prompt goes in front of every one of those, # because it goes in front of every real message too. Measured # bare, depth 2 came out on top by 1%, measured with the prompt # in place depth 1 won by 6%. same box, same drafter, same # afternoon. Tuning without it picks the winner for a setup the # app never runs in. SYSTEM_JSON="" if [ -f webapp/system_prompt.txt ]; then SYSTEM_JSON="$(sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r//g' \ webapp/system_prompt.txt | awk '{ printf "%s\\n", $0 }')" fi messages_json() { if [ -n "$SYSTEM_JSON" ]; then printf '{"role":"system","content":"%s"},{"role":"user","content":"%s"}' "$SYSTEM_JSON" "$1" else printf '{"role":"user","content":"%s"}' "$1" fi } # Anything under this much is noise on a warm box, and a deeper # draft that only ties costs VRAM and gets worse the moment # layers spill to the CPU. So a deeper one has to actually earn # the spot, not tie for it. MARGIN=1.02 better() { awk -v a="$1" -v b="$2" -v m="$MARGIN" 'BEGIN { exit !(a > b * m) }' } # A baseline of zero means every request failed, not that she is # infinitely slow. Carrying on from there would read the silence # as "drafting never helps" and switch the feature off on the # strength of nothing. need_baseline() { better "$1" 0 || die "could not measure a baseline - $2" } # reads best, base and best_n from the tune_* function calling it announce_win() { gain="$(awk -v a="$best" -v b="$base" 'BEGIN { if (b > 0) printf "%.0f", (a / b - 1) * 100; else print 0 }')" good "draft $best_n wins: $best tok/s, ${gain}% over plain decoding" } ollama_exec() { docker exec -i omega-ollama sh -c "$1"; } # We just wrote OLLAMA_MTP into .env, but php got its copy from # compose the day the container was built and nothing re-reads # the file. Until php is recreated it still believes MTP is off, # so it keeps offering the raw drafter in the picker. pick that # one and ollama tries to load a drafter as a chat model, which # fails and reaches you as an empty reply. # # Only php. the GPU overlays don't touch that service, so # recreating it alone needs no -f flags and can't drop anyone # else's device config. sync_php_env() { docker ps --format '{{.Names}}' | grep -qx omega-php || return 0 _want="$(env_get OLLAMA_MTP)" _have="$(docker exec omega-php sh -c 'printf %s "${OLLAMA_MTP-}"' 2>/dev/null || true)" [ "$_want" = "$_have" ] && return 0 say "restarting php so it sees the new OLLAMA_MTP" docker compose up -d --force-recreate php >/dev/null 2>&1 \ || warn_ "could not restart php - run ./start.sh to pick the new setting up" } # tok/s for one ollama model, pooled over both prompts. Ollama # reports eval_count and eval_duration per request, so this # counts generation only and leaves prompt processing out of it. bench_ollama() { _model="$1"; _tok=0; _ns=0 for _p in "$P1" "$P2"; do _body=$(printf '{"model":"%s","stream":false,"messages":[%s],"options":{"temperature":0,"num_predict":%s}}' \ "$_model" "$(messages_json "$_p")" "$TOKENS") _out=$(printf '%s' "$_body" | ollama_exec 'cat > /tmp/mtp_req.json && curl -s --max-time 300 http://localhost:11434/api/chat -d @/tmp/mtp_req.json' 2>/dev/null || true) _c=$(printf '%s' "$_out" | json_num eval_count) _d=$(printf '%s' "$_out" | json_num eval_duration) [ -n "${_c:-}" ] && [ -n "${_d:-}" ] || continue _tok=$((_tok + _c)); _ns=$((_ns + _d)) done [ "$_ns" -gt 0 ] || { echo 0; return; } awk -v t="$_tok" -v n="$_ns" 'BEGIN { printf "%.2f", t / (n / 1000000000) }' } tune_ollama() { chat="$(env_get OLLAMA_MODELS_TO_PULL)" drafter="$(env_get OLLAMA_MTP)" mtp_model="$(env_get OLLAMA_MTP_MODEL)"; mtp_model="${mtp_model:-jun-mtp}" [ -n "$chat" ] || die "OLLAMA_MODELS_TO_PULL is empty." docker ps --format '{{.Names}}' | grep -qx omega-ollama || die "omega-ollama is not running - start the stack first." if [ -z "$drafter" ]; then drafter="$(mtp_repo_for "$chat")" say "no OLLAMA_MTP set, trying $drafter" ollama_exec "ollama pull '$drafter'" >/dev/null 2>&1 \ || die "$drafter is not there - set OLLAMA_MTP by hand if the drafter lives somewhere else." set_env OLLAMA_MTP "$drafter" fi # DRAFT wants a path to a gguf, a model name is rejected. For a # gguf-only pull the blob ollama landed it in IS the gguf, and # the modelfile is where it admits which blob that was. blob="$(ollama_exec "ollama show --modelfile '$drafter' 2>/dev/null | awk '/^FROM /{print \$2; exit}'" | tr -d '\r')" case "$blob" in /*) ;; *) die "could not find the drafter blob for $drafter - is it pulled?" ;; esac # same rule as docker/ollama-entrypoint.sh. ollama sizes the # gemma4 renderer off the name, jun-mtp has none and lands on # small, which is where her empty think-off replies come from renderer="" case "$(printf '%s' "$chat" | tr '[:upper:]' '[:lower:]')" in *e2b*|*e4b*) ;; *12b*|*26b*|*31b*) renderer="RENDERER gemma4-large" ;; esac printf '\n %smeasuring%s %s(a few seconds per row)%s\n' "$B" "$R" "$DIM" "$R" base="$(bench_ollama "$chat")" printf ' %sno drafter%s %s tok/s\n' "$DIM" "$R" "$base" need_baseline "$base" "is $chat pulled and the stack healthy?" best_n=0; best="$base" for n in 1 2 3 4; do printf 'FROM %s\nDRAFT %s\nPARAMETER draft_num_predict %s\n%s\n' "$chat" "$blob" "$n" "$renderer" \ | ollama_exec 'cat > /tmp/Modelfile.tune && ollama create jun-mtp-tune -f /tmp/Modelfile.tune' >/dev/null 2>&1 \ || { warn_ "depth $n failed to build, skipping"; continue; } got="$(bench_ollama jun-mtp-tune)" printf ' %sdraft %s%s %s tok/s\n' "$DIM" "$n" "$R" "$got" better "$got" "$best" && { best="$got"; best_n="$n"; } done ollama_exec 'ollama rm jun-mtp-tune' >/dev/null 2>&1 || true if [ "$best_n" = 0 ]; then warn_ "no draft depth beat plain decoding here - leaving MTP off." set_env OLLAMA_MTP "" stamp_gpu sync_php_env say "the drafter stays pulled, put OLLAMA_MTP back to try again." return fi printf 'FROM %s\nDRAFT %s\nPARAMETER draft_num_predict %s\n%s\n' "$chat" "$blob" "$best_n" "$renderer" \ | ollama_exec "cat > /tmp/Modelfile.mtp && ollama create '$mtp_model' -f /tmp/Modelfile.mtp" >/dev/null 2>&1 \ || die "could not rebuild $mtp_model at depth $best_n" set_env OLLAMA_MTP_N_MAX "$best_n" announce_win stamp_gpu sync_php_env } # the model llama-server serves her as, same pick as start.sh llamacpp_chat_model() { _file="$(env_get LLAMACPP_MODEL_FILE)" if [ -n "$_file" ]; then _alias="$(env_get LLAMACPP_MODEL_ALIAS)" echo "${_alias:-jun-local}" return fi _hf="$(env_get LLAMACPP_MODEL_HF)" _hf="${_hf:-efficiencyx/Jun-LoRA-E2B-GGUF:Q4_K_M}" echo "${_hf%%,*}" } # router mode loads models on demand, so healthy only means the # router is up. wait for HER, or the first bench row times the load. wait_llamacpp() { _model="$(llamacpp_chat_model)" i=0 while [ "$i" -lt 300 ]; do docker exec omega-llamacpp curl -fsS http://127.0.0.1:8080/v1/models 2>/dev/null \ | sed 's/{"id":/\n&/g' | grep -F "{\"id\":\"$_model\"" | grep -q '"value":"loaded"' && return 0 i=$((i + 1)) sleep 2 done return 1 } bench_llamacpp() { _tok=0; _ms=0 for _p in "$P1" "$P2"; do _body=$(printf '{"model":"%s","stream":false,"messages":[%s],"temperature":0,"max_tokens":%s}' \ "$(llamacpp_chat_model)" "$(messages_json "$_p")" "$TOKENS") _out=$(printf '%s' "$_body" | docker exec -i omega-llamacpp sh -c 'cat > /tmp/mtp_req.json && curl -s --max-time 300 http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d @/tmp/mtp_req.json' 2>/dev/null || true) _c=$(printf '%s' "$_out" | json_num completion_tokens) _t=$(printf '%s' "$_out" | json_num predicted_ms) [ -n "${_c:-}" ] && [ -n "${_t:-}" ] || continue _tok=$((_tok + _c)) _ms=$(awk -v a="$_ms" -v b="$_t" 'BEGIN { printf "%.0f", a + b }') done [ "${_ms:-0}" -gt 0 ] || { echo 0; return; } awk -v t="$_tok" -v m="$_ms" 'BEGIN { printf "%.2f", t / (m / 1000) }' } # start.sh re-runs this script when the GPU stamp is stale, and the # stamp stays stale until the sweep ends. so every restart in here # has to say "already tuning", otherwise the first one starts a # second sweep inside this one. restart_llamacpp() { MTP_AUTOTUNE_RUNNING=1 ./start.sh >/dev/null 2>&1 || true wait_llamacpp || { warn_ "llama-server did not come back healthy"; return 1; } } # llama-server takes the draft depth as a startup flag, so unlike # ollama there is no way to swap it on a live server. Every row # down here is a full restart, which is why this half is the slow # one. tune_llamacpp() { drafter="$(env_get LLAMACPP_MTP)" if [ -z "$drafter" ]; then chat="$(env_get LLAMACPP_MODEL_HF)" [ -n "$chat" ] || die "LLAMACPP_MTP is empty and LLAMACPP_MODEL_HF names nothing to derive it from." drafter="$(mtp_repo_for "$chat")" say "no LLAMACPP_MTP set, trying $drafter" set_env LLAMACPP_MTP "$drafter" fi keep_n="$(env_get LLAMACPP_MTP_N_MAX)" printf '\n %smeasuring%s %s(each row restarts llama-server, this takes a while)%s\n' "$B" "$R" "$DIM" "$R" set_env LLAMACPP_MTP "" restart_llamacpp || { set_env LLAMACPP_MTP "$drafter"; die "could not get a baseline"; } base="$(bench_llamacpp)" printf ' %sno drafter%s %s tok/s\n' "$DIM" "$R" "$base" set_env LLAMACPP_MTP "$drafter" need_baseline "$base" "is llama-server healthy?" best_n=0; best="$base" for n in 1 2 3 4; do set_env LLAMACPP_MTP_N_MAX "$n" if restart_llamacpp; then got="$(bench_llamacpp)" printf ' %sdraft %s%s %s tok/s\n' "$DIM" "$n" "$R" "$got" better "$got" "$best" && { best="$got"; best_n="$n"; } else warn_ "depth $n did not start, skipping" fi done if [ "$best_n" = 0 ]; then warn_ "no draft depth beat plain decoding here - leaving MTP off." set_env LLAMACPP_MTP "" set_env LLAMACPP_MTP_N_MAX "${keep_n:-1}" stamp_gpu restart_llamacpp || true return fi set_env LLAMACPP_MTP_N_MAX "$best_n" restart_llamacpp || true announce_win stamp_gpu } provider="$(env_get AI_PROVIDER)" printf '\n %s>%s %smulti-token prediction autotune%s\n' "$ACCENT" "$R" "$B" "$R" case "$provider" in ollama) tune_ollama ;; llamacpp) tune_llamacpp ;; *) die "AI_PROVIDER is '$provider' - MTP only applies to ollama and llamacpp." ;; esac # The sweep runs on an idle card. Once a browser is drawing # Live2D it takes about 1.5GB of the same VRAM, and if that # pushes layers onto the CPU every number above shifts down. # Deeper drafts got Worse under that pressure when it was # measured, not better, so the winner still holds. say "measured with nothing else on the GPU. Live2D in a browser wants ~1.5GB more." printf '\n'