yzhwang's picture
Escha runtime — SGLang wheel + ZML single-binary server, with verified per-GPU recipes
90cf55e
Raw
History Blame Contribute Delete
23.3 kB
#!/usr/bin/env bash
# Serve a Qwen3.6 / Qwen3.5 -35B-A3B 2-bit eschamoe export with escha-sglang, OpenAI-compatible,
# usable by any OpenAI-compatible client. eschamoe quant is auto-detected from config.json.
#
# Linux x86-64 + NVIDIA CUDA 12.x only (native or WSL2). See README.md.
#
# Required: set MODEL to your downloaded model dir (a flat folder of safetensors +
# config + tokenizer — the model repo has no nested subfolder). Everything else has
# safe defaults for a 24 GB card.
# MODEL=./Qwen3.6-35B-A3B-Escha-W2 SERVED_NAME=escha-qwen36-35b-a3b-w2 \
# bash serve.sh
set -euo pipefail
# ---- paths ----------------------------------------------------------------------------------
MODEL=${MODEL:?set MODEL=/path/to/your/eschamoe export dir}
SERVED_NAME=${SERVED_NAME:-escha-qwen36-35b-a3b-w2} # opencode/LM Studio must use this id verbatim
VENV=${VENV:-} # optional: path to your venv; if set, we activate it
ESCHA_SRC=${ESCHA_SRC:-} # optional: path to escha *source* tree's src/ (only if not pip-installed)
# ---- server ---------------------------------------------------------------------------------
HOST=${HOST:-127.0.0.1} # 0.0.0.0 to serve other machines — do this behind a VPN or tunnel and
# set API_KEY; never open the raw port to the internet.
PORT=${PORT:-30000}
API_KEY=${API_KEY:-} # REQUIRED if HOST=0.0.0.0 / exposed: clients must send this as the
# Bearer key. Empty = no auth (fine only for localhost).
MEM=${MEM:-0.78} # mem-fraction-static. 0.78 is the right default for a 24 GB card at the
# CTXLEN below: mem-fraction-static sizes the pool the KV cache is
# carved from, so too LOW is the common failure — 0.70 dies at startup
# with "Not enough memory. Please try to increase --mem-fraction-static"
# (measured, 4090 + CTXLEN 32768). Lower it only together with CTXLEN;
# ~0.60-0.64 is fine for short-generation throughput work.
CTXLEN=${CTXLEN:-32768} # KV is cheap on this hybrid model; raise once it works (see guide §2.5)
CHUNK=${CHUNK:-2048} # chunked-prefill-size
MAXREQ=${MAXREQ:-0} # max concurrent requests; 0 = auto. Lower (e.g. 4) frees Mamba pool for KV.
# NOTE: this is CLAMPED by the mamba req-slot pool —
# effective = min(MAXREQ, MAXMAMBA // ratio), ratio ~= 4 with the radix
# cache ON (RADIX=1) and ~= 1 with RADIX=0. So MAXREQ=8 MAXMAMBA=8 RADIX=1
# silently runs only ~2 concurrent. For bs 16: RADIX=0 MAXMAMBA=16, or keep
# radix and set MAXMAMBA=64. A clamp is logged at startup ("max_num_reqs ...").
MAXMAMBA=${MAXMAMBA:-0} # --max-mamba-cache-size; 0 = auto. Raise (e.g. 32) for deeper prefix cache /
# to lift the MAXREQ clamp above.
MAMBA_RATIO=${MAMBA_RATIO:-} # --mamba-full-memory-ratio; empty = default (0.9). Lower (e.g. 0.6) frees the
# pool for spec + CUDA-graph capture headroom. Set when SPEC=1 + GRAPHS=1.
# ---- behavior -------------------------------------------------------------------------------
GRAPHS=${GRAPHS:-1} # 1 = CUDA graphs ON (DEFAULT, 2026-07-23). This hybrid MoE launches many
# small kernels per token (30 GDN layers + 40 MoE layers + W8A16 GEMVs),
# so eager decode is launch-bound: graphs are worth ~4.4x (measured
# 16 GB sm_120: 27.7 -> 122 tok/s bs1) and captured cleanly on every
# card tested (sm_89 4090, sm_120 5060 Ti / 5090). Set GRAPHS=0 only
# to debug a capture failure — expect ~4x slower decode there.
CUDA_GRAPH_BS=${CUDA_GRAPH_BS:-"1 2 4 8 16"} # batch sizes to capture when GRAPHS=1. With SPEC=1 keep
# max(bs) * DRAFT_TOKENS <= 64 (escham_moe verify-op limit) — e.g. "1 2 4 8" at
# draft=4 for an 8-user pool.
THINK=${THINK:-1} # 1 = thinking-ON + --reasoning-parser qwen3 (simplest, always works).
# 0 = thinking-OFF (faster; clean `content`): patches the chat
# template default to non-thinking AND drops the reasoning parser.
# A per-request chat_template_kwargs:{"enable_thinking":false} is
# honoured on a THINK=1 server — `content` is populated and tool
# calls are parsed, so THINK=0 is not required for tool-calling
# clients that disable thinking. With thinking ON, the reasoning
# arrives in `reasoning_content` and the answer in `content`:
# read both.
RADIX=${RADIX:-1} # 1 = prefix caching ON (MambaRadixCache) — multi-turn agent reuse.
ATTN_BACKEND=${ATTN_BACKEND:-} # full-attention backend override. Empty = sglang default (fine on
# Ada/4090). REQUIRED on consumer Blackwell (RTX 50 / sm_120): the
# default resolves to flashinfer but the fork asserts triton/trtllm_mha
# /fa4 for hybrid-GDN on Blackwell -> set ATTN_BACKEND=triton.
TOOL_PARSER=${TOOL_PARSER:-qwen3_coder} # matches <function=..>/<parameter=..> XML. Verify w/ curl.
REASONING_PARSER=${REASONING_PARSER:-qwen3}
ENABLE_CLP=${ENABLE_CLP:-0} # 1 = --enable-custom-logit-processor (per-request thinking_budget cap etc.)
JSON_WS=${JSON_WS:-1} # 1 = --constrained-json-disable-any-whitespace (DEFAULT).
# With permissive whitespace, a json_schema `integer`/`number`
# property that follows a string property can take a spurious
# leading `-`: the grammar allows a "\n " branch after the
# colon, and from that off-distribution state the sign/digit
# choice is near-tied. Measured on a 5080: 2-3 of 3 identical
# greedy requests returned a NEGATIVE population for Tokyo --
# schema-valid, so no client validation catches it. Disabling
# any-whitespace removed it 0/6 and 0/3. Compact JSON is what
# structured-output consumers want anyway. `JSON_WS=0` opts out.
DETERMINISTIC=${DETERMINISTIC:-0} # 1 = --enable-deterministic-inference. Greedy is NOT
# bit-reproducible across requests by default (fp16 accumulation
# order): long reasoning chains diverge once a near-tie flips.
# Set 1 for eval reproducibility; expect a throughput cost.
# NOT AVAILABLE on consumer Blackwell (RTX 50 / sm_120): the
# deterministic attention kernel asks for 104 KB of shared memory
# per block, above the sm_120 limit, and the server exits at
# startup. Works on Ampere / Ada / Hopper.
# ---- speculative decoding: native MTP / NEXTN (self-draft) ----------------------------------
# Large single-stream + low-concurrency speedup (bs1 ~1.55-1.77x; still +1.1x at 8 concurrent; turns
# net-NEGATIVE only above ~12 concurrent -> the right default for a <=8-person team). The draft is the
# model's OWN pretrained MTP head dequantized to fp16 (NOT a 2-bit quant — quantizing the draft collapses
# acceptance); accept-len ~3.5 at draft=4. Evidence: docs/SOTA/serving_perf_notes.md (2026-06-29).
SPEC=${SPEC:-0} # 1 = enable MTP/NEXTN speculative decode. Needs DRAFT; pair with GRAPHS=1.
# NOTE: the published W2 model repo does NOT include an MTP draft export
# (the mtp.* head in the checkpoint is not servable as a draft), so
# SPEC=1 is unusable with that release as shipped — all published
# serving numbers are non-speculative.
DRAFT=${DRAFT:-} # MTP draft dir (fp16, the *-MTP-draft export). REQUIRED when SPEC=1.
STEPS=${STEPS:-3} # --speculative-num-steps (draft=4 / steps=3 is the measured sweet spot)
TOPK=${TOPK:-1} # --speculative-eagle-topk
DRAFT_TOKENS=${DRAFT_TOKENS:-4} # --speculative-num-draft-tokens (M = MAXREQ * this must stay <= 64)
MAMBA_SCHED=${MAMBA_SCHED:-} # --mamba-scheduler-strategy. AUTO -> extra_buffer when SPEC=1 + radix ON
# (this hybrid-SSM arch REQUIRES extra_buffer + Spec-V2 to run radix +
# spec together; otherwise the default no_buffer is used).
# ---- environment the escha stack needs ------------------------------------------------------
[[ -n "$VENV" && -f "$VENV/bin/activate" ]] && source "$VENV/bin/activate"
PYBIN=$(command -v python)
TORCH_LIB=$("$PYBIN" -c 'import torch,os;print(os.path.join(os.path.dirname(torch.__file__),"lib"))')
export LD_LIBRARY_PATH="$TORCH_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" # the reference codec_ext needs libc10
[[ -n "$ESCHA_SRC" ]] && export PYTHONPATH="$ESCHA_SRC${PYTHONPATH:+:$PYTHONPATH}"
export SGLANG_MAMBA_CONV_DTYPE=float16 # must match --dtype float16 or causal_conv1d crashes
export SGLANG_DISABLE_CUDNN_CHECK=1
# These qwen3_5_moe Escha checkpoints are TEXT-ONLY (the vision tower is in the quant `ignore`
# list — the checkpoint has zero visual.* tensors). Default to NOT instantiating the vision tower:
# it saves ~0.8 GB VRAM on every card, and prevents an image request from silently decoding through
# a random-init tower (garbage instead of an error). Override SGLANG_VLM_TEXT_ONLY=0 only if you
# ever serve a checkpoint that actually ships vision weights.
export SGLANG_VLM_TEXT_ONLY=${SGLANG_VLM_TEXT_ONLY:-1}
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # correct var name — cuts reserved-vs-allocated ~1.3GB
export PYTORCH_ALLOC_CONF=expandable_segments:True # (alias on newer torch; keep both)
export HF_HUB_OFFLINE=1
# ---- int8-as-stored profile -----------------------------------------------------------------
# The runtime can keep lm_head/embed/attn/GDN/shared-expert INT8 in VRAM ("int8-as-stored",
# served via the w8a16 kernel) instead of dequantizing to fp16 at load. The right choice depends
# on CONCURRENCY, not just VRAM. At bs1 decode is memory-bandwidth-bound and int8 wins, by a
# card-dependent margin (measured 2026-07-27): +21–24% tok/s on an RTX 5090, +35–42% on an
# RTX 3090 — and −2.2 GB VRAM either way. Past the crossover (between concurrency 4 and 8)
# decode turns compute-bound and it inverts: −26% aggregate at bs16 on the 5090, to −52% on
# other cards. Pick with one knob:
# INT8=auto (default) — VRAM-threshold rule: ON at ≤ SGLANG_INT8_AUTO_VRAM_GB (24), OFF above.
# INT8=on — force ON: single-user / latency-sensitive serving on ANY card size
# (e.g. a 32 GB RTX 5090 serving one user leaves ~20% single-stream on the
# table under auto).
# INT8=off — force OFF: batched / high-concurrency throughput serving on >24 GB cards.
INT8=${INT8:-auto}
case "$INT8" in
on) for _v in LM_HEAD EMBED ATTN GDN SHEXP; do export "SGLANG_INT8_${_v}=1"; done ;;
off) for _v in LM_HEAD EMBED ATTN GDN SHEXP; do export "SGLANG_INT8_${_v}=0"; done ;;
auto) : ;; # leave the five SGLANG_INT8_* vars as the caller set them (or unset -> AUTO)
*) echo "[serve] ERROR: INT8 must be auto|on|off (got '$INT8')" >&2; exit 1 ;;
esac
# The fused MoE decode kernel is the default, always-preferred path (auto-detected; falls back
# to the slower per-expert sorted dispatch only if the fused op/kernel build is unavailable).
# DEFAULT-ON fail-loud (2026-07-23): the silent fallback is ~40x slower — exactly what a
# benchmarking or production user must not hit unknowingly. Set ESCHA_MOE_REQUIRE_FUSED=0
# only while debugging a box where the fused path genuinely cannot run.
# Renamed 2026-07-25: DI_ESCHA_MOE_REQUIRE_FUSED -> ESCHA_MOE_REQUIRE_FUSED (every public
# knob is ESCHA_*). The old name is still honored, with a notice, so existing launch
# scripts keep working.
if [[ -n "${DI_ESCHA_MOE_REQUIRE_FUSED:-}" && -z "${ESCHA_MOE_REQUIRE_FUSED:-}" ]]; then
echo "[serve] DI_ESCHA_MOE_REQUIRE_FUSED is deprecated; use ESCHA_MOE_REQUIRE_FUSED"
ESCHA_MOE_REQUIRE_FUSED="$DI_ESCHA_MOE_REQUIRE_FUSED"
fi
export ESCHA_MOE_REQUIRE_FUSED=${ESCHA_MOE_REQUIRE_FUSED:-1}
# Mixed-bit (K2 gate_up / K3 down) fused epilogue. Worth +10% single-user decode
# on this model — measured 2026-07-25 on an RTX 4090 (sm_89) with the shipped
# wheel: 207.4-208.6 -> 228.5-228.8 tok/s at bs1, identical answers (arithmetic,
# recall, long-context needle). It was opt-in only so that older kernel builds
# without the capability probe would not trip; the probe
# (`escham_moe_prehad_k3_available`) is present in this build and auto-falls-back
# when absent, so ON is the right default. `ESCHA_ENABLE_K3_FUSED_EPI=0` opts out.
export ESCHA_ENABLE_K3_FUSED_EPI=${ESCHA_ENABLE_K3_FUSED_EPI:-1}
# Other passthrough knobs: ESCHA_FORCE_SORTED_MOE=1 forces the slower validated fallback
# (debug/parity only); ESCHA_ROUTE=lovelace|blackwell overrides the kernel's auto-selected
# launch geometry (bit-identical either way; blackwell can win even on pre-sm_100 cards).
# ptxas discovery (rewritten 2026-07-25 after two field reports).
#
# * Triton — which torch already pulls in — SHIPS a matching ptxas at
# site-packages/triton/backends/nvidia/bin/ptxas. Probe that FIRST: a CUDA
# toolkit is NOT a requirement of this runtime, and the old error message
# sent users off to install one they did not need.
# * Validate whatever the caller exported. A common shell profile sets
# TRITON_PTXAS_PATH to the CUDA *bin directory*; `[[ -x <dir> ]]` is TRUE for
# directories, so the old check accepted it and triton then died mid-startup
# with `PermissionError: [Errno 13] Permission denied: /usr/local/cuda-12.8/bin`.
# Require a regular file (-f) AND executable (-x); if a directory was given,
# try <dir>/ptxas before discarding it.
_ptxas_ok() { [[ -n "${1:-}" && -f "$1" && -x "$1" ]]; }
if ! _ptxas_ok "${TRITON_PTXAS_PATH:-}"; then
if [[ -n "${TRITON_PTXAS_PATH:-}" ]]; then
if _ptxas_ok "${TRITON_PTXAS_PATH%/}/ptxas"; then
TRITON_PTXAS_PATH="${TRITON_PTXAS_PATH%/}/ptxas"
echo "[serve] TRITON_PTXAS_PATH was a directory; using $TRITON_PTXAS_PATH"
else
echo "[serve] ignoring TRITON_PTXAS_PATH='$TRITON_PTXAS_PATH' (not an executable file)"
TRITON_PTXAS_PATH=""
fi
fi
if [[ -z "${TRITON_PTXAS_PATH:-}" ]]; then
for p in \
"$(python -c 'import os,triton;print(os.path.join(os.path.dirname(triton.__file__),"backends","nvidia","bin","ptxas"))' 2>/dev/null)" \
/usr/local/cuda*/bin/ptxas \
"$(command -v ptxas 2>/dev/null)"; do
_ptxas_ok "$p" && { TRITON_PTXAS_PATH="$p"; break; }
done
fi
export TRITON_PTXAS_PATH
fi
: "${TRITON_PTXAS_PATH:?no usable ptxas found. It normally ships inside the venv with triton (a torch dependency) — check that \`python -c \"import triton\"\` works. Otherwise point TRITON_PTXAS_PATH at a ptxas BINARY (not its directory).}"
# Only if you hit it: the first time triton compiles a kernel (during warmup AND during CUDA-graph
# capture), the multi-GB scheduler must fork() to spawn ptxas. Under the default overcommit heuristic
# that fork can fail -> the server dies with a triton JIT "Cannot allocate memory" crash. Setting
# vm.overcommit_memory=1 fixes it. Many boxes never hit this (a warm triton cache, or enough free RAM
# to satisfy the fork), so it is advisory, not mandatory. In a container the sysctl is often read-only
# ("sysctl: setting key ...: Read-only file system") — then it is the HOST's job, not something you
# can set from inside; the server usually starts fine there anyway.
# (Advisory phrased WITHOUT the literal errno string so log-watchers grepping for the real
# crash message don't false-positive on this note — friction 2026-07-23 F4.)
if [[ "$(cat /proc/sys/vm/overcommit_memory 2>/dev/null)" == "0" ]]; then
echo "[serve] note: if startup dies with a triton JIT memory-allocation failure at kernel-" >&2
echo "[serve] compile time, set 'sudo sysctl -w vm.overcommit_memory=1' (persist via" >&2
echo "[serve] /etc/sysctl.d/99-escha.conf). In a read-only container this must be set" >&2
echo "[serve] on the host; otherwise it's safe to ignore." >&2
fi
# Warn when the GPU is already substantially occupied (a foreign server / another model on a
# shared box is the #1 cause of an immediate, confusing pool-allocation OOM at startup).
_USED_MB=$(nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -dc '0-9')
if [[ -n "$_USED_MB" && "$_USED_MB" -gt 1024 ]]; then
echo "[serve] WARNING: GPU already has ${_USED_MB} MiB in use by other processes (nvidia-smi)." >&2
echo "[serve] The static pool sizing assumes a free GPU — expect an allocation failure" >&2
echo "[serve] if another server is resident. Check: nvidia-smi" >&2
fi
# ---- assemble flags -------------------------------------------------------------------------
GRAPH_ARGS=(--disable-piecewise-cuda-graph)
[[ "$GRAPHS" == "1" ]] && GRAPH_ARGS+=(--cuda-graph-bs $CUDA_GRAPH_BS) || GRAPH_ARGS+=(--disable-cuda-graph)
POOL_ARGS=()
[[ "$MAXREQ" != "0" ]] && POOL_ARGS+=(--max-running-requests "$MAXREQ")
[[ "$MAXMAMBA" != "0" ]] && POOL_ARGS+=(--max-mamba-cache-size "$MAXMAMBA")
[[ -n "$MAMBA_RATIO" ]] && POOL_ARGS+=(--mamba-full-memory-ratio "$MAMBA_RATIO")
RADIX_ARGS=(); [[ "$RADIX" == "0" ]] && RADIX_ARGS+=(--disable-radix-cache)
ATTN_ARGS=(); [[ -n "$ATTN_BACKEND" ]] && ATTN_ARGS+=(--attention-backend "$ATTN_BACKEND")
AUTH_ARGS=(); [[ -n "$API_KEY" ]] && AUTH_ARGS+=(--api-key "$API_KEY")
# speculative MTP/NEXTN draft (the model's own self-draft head)
SPEC_ARGS=()
if [[ "$SPEC" == "1" ]]; then
: "${DRAFT:?SPEC=1 requires DRAFT=/path/to/<model>-MTP-draft (the fp16 NEXTN head, NOT a 2-bit quant)}"
[[ -d "$DRAFT" ]] || { echo "[serve] ERROR: SPEC=1 but DRAFT dir not found: $DRAFT" >&2; exit 1; }
SPEC_ARGS+=(--speculative-algorithm NEXTN --speculative-draft-model-path "$DRAFT"
--speculative-num-steps "$STEPS" --speculative-eagle-topk "$TOPK"
--speculative-num-draft-tokens "$DRAFT_TOKENS")
if [[ "$GRAPHS" == "1" ]]; then # enforce the escham_moe verify-op limit: max running batch * draft <= 64
maxbs=0; for b in $CUDA_GRAPH_BS; do (( b > maxbs )) && maxbs=$b; done
if (( maxbs * DRAFT_TOKENS > 64 )); then
echo "[serve] WARNING: max cuda-graph bs ($maxbs) * draft_tokens ($DRAFT_TOKENS) > 64 — escham_moe only" >&2
echo "[serve] captures M<=64; trim CUDA_GRAPH_BS (e.g. \"1 2 4 8\" at draft=4)." >&2
fi
fi
# radix cache + spec on this hybrid (mamba) arch needs the extra-buffer scheduler + Spec-V2.
if [[ "$RADIX" != "0" ]]; then
export SGLANG_ENABLE_SPEC_V2=1
MAMBA_SCHED=${MAMBA_SCHED:-extra_buffer}
echo "[serve] radix+spec: exporting SGLANG_ENABLE_SPEC_V2=1, --mamba-scheduler-strategy $MAMBA_SCHED" >&2
fi
fi
SCHED_ARGS=(); [[ -n "$MAMBA_SCHED" ]] && SCHED_ARGS+=(--mamba-scheduler-strategy "$MAMBA_SCHED")
CLP_ARGS=(); [[ "$ENABLE_CLP" == "1" ]] && CLP_ARGS+=(--enable-custom-logit-processor)
if [[ "$HOST" != "127.0.0.1" && -z "$API_KEY" ]]; then
echo "[serve] WARNING: HOST=$HOST is exposed but API_KEY is empty — no auth. Set API_KEY." >&2
fi
REASON_ARGS=(); TEMPLATE_ARGS=()
if [[ "$THINK" == "1" ]]; then
REASON_ARGS+=(--reasoning-parser "$REASONING_PARSER")
else
# thinking-OFF: generate a patched chat template (default-closed <think>) from the model's own.
NOTHINK="${TMPDIR:-/tmp}/escha_nothink_$(basename "$MODEL").jinja"
"$PYBIN" - "$MODEL" "$NOTHINK" <<'PY'
import json, os, sys
mdir, out = sys.argv[1], sys.argv[2]
src = os.path.join(mdir, "chat_template.jinja")
tmpl = open(src).read() if os.path.exists(src) else json.load(open(os.path.join(mdir,"tokenizer_config.json")))["chat_template"]
# Flip the qwen "enable_thinking" default from ON to OFF. Two common phrasings handled:
cands = [
("{%- if enable_thinking is defined and enable_thinking is false %}\n {{- '<think>\\n\\n</think>\\n\\n' }}\n {%- else %}\n {{- '<think>\\n' }}\n {%- endif %}",
"{%- if enable_thinking is defined and enable_thinking is true %}\n {{- '<think>\\n' }}\n {%- else %}\n {{- '<think>\\n\\n</think>\\n\\n' }}\n {%- endif %}"),
# froggeric/Qwen-Fixed-Chat-Templates (v21.x) hoists the toggle into a single
# default-assignment near the top instead of an inline if/else at the generation
# prompt, so the block above is absent and THINK=0 has nothing to patch. Flipping the
# default is exactly equivalent here (ns_state.thinking is seeded from it).
("{%- set enable_thinking = enable_thinking if enable_thinking is defined else true %}",
"{%- set enable_thinking = enable_thinking if enable_thinking is defined else false %}"),
]
patched = tmpl
for old, new in cands:
if old in patched: patched = patched.replace(old, new); break
else:
sys.exit("THINK=0: could not find the enable_thinking block to patch in this model's chat "
"template. Serve with THINK=1, or hand-patch the template and pass it via --chat-template.")
open(out, "w").write(patched)
print(out)
PY
TEMPLATE_ARGS+=(--chat-template "$NOTHINK") # no reasoning parser when thinking is pre-closed
fi
echo "[serve] MODEL=$MODEL NAME=$SERVED_NAME $HOST:$PORT MEM=$MEM CTX=$CTXLEN GRAPHS=$GRAPHS THINK=$THINK RADIX=$RADIX SPEC=$SPEC"
echo "[serve] tool-call-parser=$TOOL_PARSER ptxas=$TRITON_PTXAS_PATH"
[[ "$SPEC" == "1" ]] && echo "[serve] MTP/NEXTN: draft=$DRAFT steps=$STEPS topk=$TOPK draft_tokens=$DRAFT_TOKENS cuda_graph_bs='$CUDA_GRAPH_BS'"
JSON_ARGS=()
[[ "$JSON_WS" == "1" ]] && JSON_ARGS+=(--constrained-json-disable-any-whitespace)
DET_ARGS=()
[[ "$DETERMINISTIC" == "1" ]] && DET_ARGS+=(--enable-deterministic-inference)
exec python -m sglang.launch_server \
--model-path "$MODEL" \
--served-model-name "$SERVED_NAME" \
--host "$HOST" --port "$PORT" \
--dtype float16 \
--mem-fraction-static "$MEM" \
--context-length "$CTXLEN" \
--chunked-prefill-size "$CHUNK" \
--sampling-backend pytorch \
--trust-remote-code \
--tool-call-parser "$TOOL_PARSER" \
"${REASON_ARGS[@]}" \
"${TEMPLATE_ARGS[@]}" \
"${POOL_ARGS[@]}" \
"${RADIX_ARGS[@]}" \
"${ATTN_ARGS[@]}" \
"${AUTH_ARGS[@]}" \
"${SPEC_ARGS[@]}" \
"${SCHED_ARGS[@]}" \
"${CLP_ARGS[@]}" \
"${GRAPH_ARGS[@]}" \
"${JSON_ARGS[@]}" \
"${DET_ARGS[@]}" \
--log-level info \
"$@" # forward any extra sglang flag verbatim