- config.yml: default/commit -> frank-medium (dankserver); task/tiny/smol/vision -> frank-small (dankclaw-3090); vision xhigh - models.yml: add llamacpp (dankclaw-3090) provider; frank-large preserve_thinking + context notes - niri: focus-ring/border/shadow layout, hot-corners off, re-add noctalia.kdl include; blankpad eDP-1 scale 1.0 - memories: flush rollouts; retire frank_large + marreta_authentik skills; add auth-chain/kv-pool/quadlet-log skills - managed-skills: 40+ new fleet runbook skills - agent/skills: import built-in skill set
6.8 KiB
name, description
| name | description |
|---|---|
| llamacpp-mtp-vision-endpoint-bringup | Bring up a new llama.cpp (mainline) OpenAI endpoint on a single CUDA GPU with MTP speculative decoding and vision — GGUF header preflight by HTTP range, hybrid-architecture KV arithmetic, the -mtp / --spec-type draft-mtp contract, --parallel vs ctx math, and the verification battery that proves MTP, tools and vision actually work. |
llama.cpp MTP + vision endpoint bring-up (single CUDA GPU)
Use when standing up or swapping a mainline llama.cpp endpoint for a GGUF that
may ship an MTP (NextN) head and a vision projector. Verified end to end on
Dankclaw 2026-09-16 (RTX 3090 24 GiB, Qwen3.8-27B-GSQ-RCO IQ3_S -mtp +
BF16 mmproj, 131072 ctx / 1 slot, port 7771).
1. Preflight the GGUF WITHOUT downloading it
GGUF metadata lives in the header, and HuggingFace honours ranged GETs, so a few MB answers every sizing question before a 13 GB transfer:
import urllib.request
req = urllib.request.Request(url, headers={"Range": "bytes=0-8000000", "User-Agent": "curl/8"})
buf = urllib.request.urlopen(req, timeout=180).read()
Parse: magic GGUF, u32 version, u64 n_tensors, u64 n_kv, then key/value pairs
(string = u64 len + bytes; ARRAY = u32 elem type + u64 count; BOOL is 1 byte —
the type hand-written parsers forget). Walk arrays by byte size even when you
only want a summary: a naive parser desynchronises on tokenizer.ggml.tokens
and lands gigabytes off.
Read these keys: general.architecture, <arch>.block_count,
<arch>.context_length, <arch>.nextn_predict_layers, <arch>.head_count_kv,
<arch>.key_length / value_length, <arch>.full_attention_interval,
<arch>.ssm.state_size / group_count / time_step_rank, and for the
projector file clip.projector_type (e.g. qwen3vl_merger) plus
clip.has_vision_encoder.
2. Decide the file from nextn_predict_layers
MTP is a property of the FILE, not of the flags. If the release ships an
-mtp build, that build carries the head as blk.<n>.nextn.* (a few hundred
MB larger); the plain build has no MTP tensors at all.
With the -mtp file, llama.cpp needs only --spec-type draft-mtp: it sets
load_mtp and the draft context runs on the MAIN model's weights
(shares_model, LLAMA_CONTEXT_TYPE_MTP). Do NOT add --model-draft — that
loads the whole file a second time (12 GiB twice on a 24 GiB card). A separate
-md is only correct for standalone MTP-only sidecars.
Confirm the flag exists in the deployed build before writing the script:
grep -rn "common_speculative_all_types_str" common/speculative.cpp and
llama-server --help | grep -A4 spec-type.
3. KV arithmetic (do this before choosing ctx)
Only full-attention layers take KV. For a hybrid arch (Qwen3-Next/3.8 style
16 x (3 x Gated DeltaNet + 1 x Gated Attention)):
attn_layers = block_count / full_attention_interval
bytes/token = attn_layers x n_head_kv x head_dim x 2 (K,V) x bytes_per_elem
f16 = 2 B | q8_0 = 1.0625 B | q4_0 = 0.5625 B
The linear-attention layers hold constant-size per-sequence state instead
(~0.16 GiB per sequence at 48 layers / 128 d_state / 48 v-heads in f32), and
the MTP draft context costs its own n_ctx x (1 layer x n_head_kv x head_dim x 2 x 2 B) ≈ 4 KiB/token for a 1-layer head.
VRAM budget: weights + mmproj + MTP draft KV + DeltaNet state + ~1.5 GiB of CUDA context and compute buffers. On a 24 GiB card that leaves ~9.5 GiB for KV, which at 64 KiB/token is ~155k tokens total.
--parallel N DIVIDES --ctx-size between slots — it does not multiply.
-np 1 -c 131072 is one 131k conversation; -np 4 -c 131072 is four 32k ones.
4. Flags that matter
-ngl 999,-fa on,-ctk/-ctv f16for the usual shape.- Quantized KV (
q8_0,q4_0) requires a build with-DGGML_CUDA_FA_ALL_QUANTS=ON; without it CUDA prefill collapses ~12x. f16 needs nothing, so prefer it unless the context demand is real. --jinja— pin it explicitly even when the build defaults it on: the GGUF's own template suppliesreasoning_effort,preserve_thinkingand the tool-call grammar, and a future default flip must not silently remove tools.--metricsif anything scrapes/metrics,--aliasto set the served id,--temp 0.0for a greedy-by-default fleet contract,--reasoning-format deepseek --reasoning-budget -1to project thinking intoreasoning_content.
5. Serve-script and unit invariants
Fail loudly before loading VRAM: check the binary, the model file and the
mmproj exist, and exit 1 with a named message otherwise — a missing weight
must not become a Restart=on-failure crash loop that reads like a runtime
fault. For a native CUDA binary the unit is a ROOT systemd unit
([Install] WantedBy=multi-user.target, Restart=on-failure,
RestartSec=10, LimitMEMLOCK=infinity); a container would be a Quadlet.
6. Verification battery (prove each claim, do not infer)
/health200, and/v1/modelsreports the intended alias.- A chat completion returns
contentANDreasoning_content. - A request carrying
toolsreturnstool_calls— a RUNTIME property: the ik_llama.cpp runtime answered500 tools param requires --jinja flag. GET /metricsafter a generation:llamacpp:spec_decode_num_draft_tokens_totaland..._accepted_tokens_totalboth > 0, acceptance 0.6-0.95 ⇒ MTP engaged, not merely configured. The slot timing lines in the journal say the same.- One image request (
data:URL or bare base64) ⇒ an answer that reflects the image; confirm the journal printed a multimodal projector load. nvidia-smimemory used is close to the modelled total, andn_slots/n_ctx_slotin the load log match the intended shape.
Traps that cost real time
- A template can reject the client's thinking level. Qwen3.8 accepts only
xhigh|medium|lowforreasoning_effortand raises otherwise — HTTP 500 naming Jinja, not the model. Map every harness level onto the accepted set (e.g.compat.reasoningEffortMap: {minimal: low, low: low, medium: medium, high: xhigh, max: xhigh}) and probe each level with curl before routing roles at the endpoint. - Thinking counts against
max_tokens. A budget that runs out mid-thought returnsfinish_reason: "length"with emptycontent— not a broken endpoint, not a vision failure. Raise the budget. - Never point
--model-draftat the-mtpfile (step 2). - Any
-ot/-ncmoe-style override can silently disable mmap for the whole load, turning a mapped model into anonymous memory. - On a host that publishes ports through pasta, the replacement must not
start until the predecessor released the port: poll
ss -tan "sport = :<port>"(listener, accepted AND TIME_WAIT) or the container dies withAddress already in use. - On a shared host, read the serving engine's own boot ledger for device
budget vs need: a pool that silently clamped still answers a green
/health.