mtp-autotune.sh
Rama main · réplica de lectura del repositorio original.
#!/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'