Add lazy two-model router and detached ngrok setup
Browse files- .gitignore +1 -0
- README.md +37 -12
- scripts/bench.py +2 -1
- scripts/router.sh +55 -0
- scripts/serve.sh +1 -0
- scripts/start.sh +2 -3
.gitignore
CHANGED
|
@@ -7,3 +7,4 @@ results/
|
|
| 7 |
__pycache__/
|
| 8 |
.venv/
|
| 9 |
.venv_lib/
|
|
|
|
|
|
| 7 |
__pycache__/
|
| 8 |
.venv/
|
| 9 |
.venv_lib/
|
| 10 |
+
/ngrok
|
README.md
CHANGED
|
@@ -13,12 +13,12 @@ Reproducible OpenAI-compatible local API for a single NVIDIA A100 40 GB GPU. It
|
|
| 13 |
|
| 14 |
Both files take about 49.02 GB of disk space. The CUDA archives take another 0.76 GB until removed. Only one model can be resident on a 40 GB A100 at a time. Model weights, binaries, logs, benchmark outputs, and API keys are excluded from this repo. The installer uses Hugging Face Hub/Xet for chunked model downloads at pinned revisions and verifies the published SHA-256 digests.
|
| 15 |
|
| 16 |
-
An optional 3.16 GB Qwen3.8 MTP draft can be installed with `WITH_MTP=1 bash scripts/install.sh`
|
| 17 |
|
| 18 |
## Requirements
|
| 19 |
|
| 20 |
- Linux x86-64 with an NVIDIA A100 40 GB and a CUDA 12.8-compatible driver.
|
| 21 |
-
- `bash`, `curl`, `tar`, `sha256sum`, and Python 3.
|
| 22 |
- The pinned prebuilt binary needs `libgomp.so.1`. This machine provides it in `/opt/conda/lib`; adjust `LD_LIBRARY_PATH` in `serve.sh` on other machines if needed.
|
| 23 |
- Approximately 55 GB of free storage and internet access for the initial download. Python `venv` and `pip` are needed for the pinned Hub/Xet downloader.
|
| 24 |
|
|
@@ -28,15 +28,15 @@ An optional 3.16 GB Qwen3.8 MTP draft can be installed with `WITH_MTP=1 bash scr
|
|
| 28 |
git clone https://huggingface.co/ShoaibSSM/a100-qwen-api-runtime
|
| 29 |
cd a100-qwen-api-runtime
|
| 30 |
bash scripts/install.sh
|
| 31 |
-
scripts/start.sh
|
| 32 |
```
|
| 33 |
|
| 34 |
-
`
|
| 35 |
|
| 36 |
-
The starting configuration uses full GPU offload, flash attention, Q8 KV cache, 65,536 total context tokens, one request slot, and 2048/512 batch sizes. GPU offload is forced so a model that does not fit fails visibly instead of becoming slow through CPU offloading. For larger contexts or concurrent requests, adjust `LLM_CONTEXT` and `LLM_PARALLEL` and measure memory use and latency. The claimed model context lengths are *not* measured throughput guarantees on a 40 GB GPU.
|
| 37 |
|
| 38 |
```bash
|
| 39 |
-
LLM_CONTEXT=32768 LLM_PARALLEL=2 scripts/
|
| 40 |
```
|
| 41 |
|
| 42 |
## Use the API
|
|
@@ -49,18 +49,43 @@ curl -sS http://127.0.0.1:8080/v1/chat/completions \
|
|
| 49 |
-d '{"model":"qwen38","messages":[{"role":"user","content":"Explain this algorithm."}],"max_tokens":512}'
|
| 50 |
```
|
| 51 |
|
| 52 |
-
With the OpenAI Python client, set `base_url="http://127.0.0.1:8080/v1"`, `api_key` to the generated key, and `model` to `qwen38` or `qwen36`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
## Compare on this hardware
|
| 55 |
|
| 56 |
-
Run
|
| 57 |
|
| 58 |
```bash
|
| 59 |
-
scripts/start.sh qwen38
|
| 60 |
python scripts/bench.py qwen38
|
| 61 |
-
# Stop the server, then:
|
| 62 |
-
scripts/stop.sh
|
| 63 |
-
scripts/start.sh qwen36
|
| 64 |
python scripts/bench.py qwen36
|
| 65 |
```
|
| 66 |
|
|
|
|
| 13 |
|
| 14 |
Both files take about 49.02 GB of disk space. The CUDA archives take another 0.76 GB until removed. Only one model can be resident on a 40 GB A100 at a time. Model weights, binaries, logs, benchmark outputs, and API keys are excluded from this repo. The installer uses Hugging Face Hub/Xet for chunked model downloads at pinned revisions and verifies the published SHA-256 digests.
|
| 15 |
|
| 16 |
+
An optional 3.16 GB Qwen3.8 MTP draft can be installed with `WITH_MTP=1 bash scripts/install.sh`. The two-model router leaves speculative decoding off by default.
|
| 17 |
|
| 18 |
## Requirements
|
| 19 |
|
| 20 |
- Linux x86-64 with an NVIDIA A100 40 GB and a CUDA 12.8-compatible driver.
|
| 21 |
+
- `bash`, `curl`, `tar`, `sha256sum`, `setsid`, `nohup`, `taskset`, and Python 3.
|
| 22 |
- The pinned prebuilt binary needs `libgomp.so.1`. This machine provides it in `/opt/conda/lib`; adjust `LD_LIBRARY_PATH` in `serve.sh` on other machines if needed.
|
| 23 |
- Approximately 55 GB of free storage and internet access for the initial download. Python `venv` and `pip` are needed for the pinned Hub/Xet downloader.
|
| 24 |
|
|
|
|
| 28 |
git clone https://huggingface.co/ShoaibSSM/a100-qwen-api-runtime
|
| 29 |
cd a100-qwen-api-runtime
|
| 30 |
bash scripts/install.sh
|
| 31 |
+
scripts/start.sh
|
| 32 |
```
|
| 33 |
|
| 34 |
+
`start.sh` uses `nohup` and `setsid`, so closing Codex or the shell does not stop the API process while the container is running. It generates a random API key in `secrets/api_keys.txt` (mode 0600) if needed and binds to `127.0.0.1:8080`. Both models start unloaded. A request with `"model":"qwen38"` or `"model":"qwen36"` loads it on demand; at most one model runs at once. After 15 minutes without inference work, the model sleeps and releases its memory. The next request wakes it. Switching or waking models incurs a cold-load delay. Check readiness with `curl http://127.0.0.1:8080/health`, logs with `tail -f logs/server.log`, and stop with `scripts/stop.sh`. Change the idle timer with `LLM_IDLE_SECONDS=600 scripts/start.sh`.
|
| 35 |
|
| 36 |
+
The starting configuration uses full GPU offload, flash attention, Q8 KV cache, 65,536 total context tokens, one request slot, medium reasoning effort, and 2048/512 batch sizes. GPU offload is forced so a model that does not fit fails visibly instead of becoming slow through CPU offloading. For larger contexts or concurrent requests, adjust `LLM_CONTEXT` and `LLM_PARALLEL` and measure memory use and latency. Set `LLM_REASONING_EFFORT=xhigh` for harder tasks and allow enough `max_tokens` for both reasoning and the final answer. The claimed model context lengths are *not* measured throughput guarantees on a 40 GB GPU.
|
| 37 |
|
| 38 |
```bash
|
| 39 |
+
LLM_CONTEXT=32768 LLM_PARALLEL=2 scripts/start.sh
|
| 40 |
```
|
| 41 |
|
| 42 |
## Use the API
|
|
|
|
| 49 |
-d '{"model":"qwen38","messages":[{"role":"user","content":"Explain this algorithm."}],"max_tokens":512}'
|
| 50 |
```
|
| 51 |
|
| 52 |
+
With the OpenAI Python client, set `base_url="http://127.0.0.1:8080/v1"`, `api_key` to the generated key, and `model` to `qwen38` or `qwen36` per request.
|
| 53 |
+
|
| 54 |
+
## Reach it through ngrok
|
| 55 |
+
|
| 56 |
+
From another shell in this directory, install the standalone [ngrok Linux agent](https://ngrok.com/download/linux) and add your own ngrok account authtoken. This token is distinct from the LLM API key.
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
curl -fsSL https://bin.ngrok.com/c/bNyj1mQVY4c/ngrok-v3-stable-linux-amd64.tgz | tar -xz
|
| 60 |
+
read -rsp 'ngrok authtoken: ' NGROK_AUTHTOKEN; echo
|
| 61 |
+
./ngrok config add-authtoken "$NGROK_AUTHTOKEN"
|
| 62 |
+
unset NGROK_AUTHTOKEN
|
| 63 |
+
GOMAXPROCS=2 nohup setsid ./ngrok http 8080 --log=stdout > logs/ngrok.log 2>&1 < /dev/null &
|
| 64 |
+
echo $! > runtime/ngrok.pid
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
Read the HTTPS URL with `curl -s http://127.0.0.1:4040/api/tunnels | python -c 'import json,sys; print(next(t["public_url"] for t in json.load(sys.stdin)["tunnels"] if t["public_url"].startswith("https://")))'`. Use that URL plus `/v1/chat/completions` from the other machine with the `Authorization: Bearer <LLM API key>` header. The API and tunnel persist after the shell closes, but both stop if the container stops. The tunnel URL may change on restart. Stop the tunnel with `kill "$(cat runtime/ngrok.pid)"`.
|
| 68 |
+
|
| 69 |
+
On your laptop, set `LLM_URL` to that HTTPS URL and `LLM_API_KEY` to the contents of `secrets/api_keys.txt` from this server:
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
export LLM_URL='https://YOUR-TUNNEL.ngrok-free.app'
|
| 73 |
+
read -rsp 'LLM API key: ' LLM_API_KEY; echo
|
| 74 |
+
export LLM_API_KEY
|
| 75 |
+
curl -sS "$LLM_URL/v1/chat/completions" \
|
| 76 |
+
-H "Authorization: Bearer $LLM_API_KEY" \
|
| 77 |
+
-H 'Content-Type: application/json' \
|
| 78 |
+
-d '{"model":"qwen38","messages":[{"role":"user","content":"Hello"}],"max_tokens":512}'
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
For an OpenAI-compatible client: `base_url="$LLM_URL/v1"`, `api_key="$LLM_API_KEY"`, and `model="qwen38"` or `model="qwen36"`. The first call after sleep, or a model switch, can take longer while weights load; give the client at least a 180-second timeout.
|
| 82 |
|
| 83 |
## Compare on this hardware
|
| 84 |
|
| 85 |
+
Run the same API timing prompts through the router if you want a local comparison:
|
| 86 |
|
| 87 |
```bash
|
|
|
|
| 88 |
python scripts/bench.py qwen38
|
|
|
|
|
|
|
|
|
|
| 89 |
python scripts/bench.py qwen36
|
| 90 |
```
|
| 91 |
|
scripts/bench.py
CHANGED
|
@@ -19,7 +19,7 @@ def main():
|
|
| 19 |
p = argparse.ArgumentParser()
|
| 20 |
p.add_argument("model", choices=["qwen38", "qwen36"])
|
| 21 |
p.add_argument("--base-url", default="http://127.0.0.1:8080")
|
| 22 |
-
p.add_argument("--max-tokens", type=int, default=
|
| 23 |
args = p.parse_args()
|
| 24 |
key = os.environ.get("LLM_API_KEY") or (ROOT / "secrets/api_keys.txt").read_text().strip()
|
| 25 |
records = []
|
|
@@ -34,6 +34,7 @@ def main():
|
|
| 34 |
"temperature": 1.0,
|
| 35 |
"top_p": 0.95,
|
| 36 |
"max_tokens": args.max_tokens,
|
|
|
|
| 37 |
"stream": False,
|
| 38 |
},
|
| 39 |
timeout=900,
|
|
|
|
| 19 |
p = argparse.ArgumentParser()
|
| 20 |
p.add_argument("model", choices=["qwen38", "qwen36"])
|
| 21 |
p.add_argument("--base-url", default="http://127.0.0.1:8080")
|
| 22 |
+
p.add_argument("--max-tokens", type=int, default=4096)
|
| 23 |
args = p.parse_args()
|
| 24 |
key = os.environ.get("LLM_API_KEY") or (ROOT / "secrets/api_keys.txt").read_text().strip()
|
| 25 |
records = []
|
|
|
|
| 34 |
"temperature": 1.0,
|
| 35 |
"top_p": 0.95,
|
| 36 |
"max_tokens": args.max_tokens,
|
| 37 |
+
"reasoning_effort": "medium",
|
| 38 |
"stream": False,
|
| 39 |
},
|
| 40 |
timeout=900,
|
scripts/router.sh
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
| 5 |
+
QWEN38="$ROOT/models/Qwen3.8-27B-Q6_K.gguf"
|
| 6 |
+
QWEN36="$ROOT/models/Qwen3.6-35B-A3B-UD-Q5_K_M.gguf"
|
| 7 |
+
[[ -f "$QWEN38" && "$(stat -c %s "$QWEN38")" == 22563871392 ]] || { echo "Missing or incomplete: $QWEN38" >&2; exit 1; }
|
| 8 |
+
[[ -f "$QWEN36" && "$(stat -c %s "$QWEN36")" == 26456194016 ]] || { echo "Missing or incomplete: $QWEN36" >&2; exit 1; }
|
| 9 |
+
|
| 10 |
+
BIN="$ROOT/runtime/b11177/llama-server"
|
| 11 |
+
[[ -x "$BIN" ]] || BIN="$ROOT/runtime/b11177/build/bin/llama-server"
|
| 12 |
+
[[ -x "$BIN" ]] || { echo 'llama-server missing. Run bash scripts/install.sh.' >&2; exit 1; }
|
| 13 |
+
|
| 14 |
+
mkdir -p "$ROOT/secrets" "$ROOT/runtime"
|
| 15 |
+
KEY_FILE="$ROOT/secrets/api_keys.txt"
|
| 16 |
+
if [[ ! -s "$KEY_FILE" ]]; then
|
| 17 |
+
umask 077
|
| 18 |
+
python -c 'import secrets; print(secrets.token_urlsafe(32))' > "$KEY_FILE"
|
| 19 |
+
fi
|
| 20 |
+
chmod 600 "$KEY_FILE"
|
| 21 |
+
|
| 22 |
+
PRESET="$ROOT/runtime/models.ini"
|
| 23 |
+
cat > "$PRESET" <<EOF
|
| 24 |
+
version = 1
|
| 25 |
+
|
| 26 |
+
[qwen38]
|
| 27 |
+
model = $QWEN38
|
| 28 |
+
|
| 29 |
+
[qwen36]
|
| 30 |
+
model = $QWEN36
|
| 31 |
+
EOF
|
| 32 |
+
|
| 33 |
+
export LD_LIBRARY_PATH="$ROOT/runtime/b11177:/opt/conda/lib:${LD_LIBRARY_PATH:-}"
|
| 34 |
+
CPU_SET="$(python -c 'import os; print(",".join(map(str, sorted(os.sched_getaffinity(0))[:int(os.getenv("LLM_CPU_CORES", "32"))])))')"
|
| 35 |
+
exec taskset -c "$CPU_SET" "$BIN" \
|
| 36 |
+
--models-preset "$PRESET" \
|
| 37 |
+
--models-max 1 \
|
| 38 |
+
--models-autoload \
|
| 39 |
+
--sleep-idle-seconds "${LLM_IDLE_SECONDS:-900}" \
|
| 40 |
+
--host "${LLM_HOST:-127.0.0.1}" \
|
| 41 |
+
--port "${LLM_PORT:-8080}" \
|
| 42 |
+
--api-key-file "$KEY_FILE" \
|
| 43 |
+
--n-gpu-layers all \
|
| 44 |
+
--fit off \
|
| 45 |
+
--flash-attn on \
|
| 46 |
+
--cache-type-k q8_0 \
|
| 47 |
+
--cache-type-v q8_0 \
|
| 48 |
+
--ctx-size "${LLM_CONTEXT:-65536}" \
|
| 49 |
+
--parallel "${LLM_PARALLEL:-1}" \
|
| 50 |
+
--threads "${LLM_THREADS:-8}" \
|
| 51 |
+
--threads-batch "${LLM_THREADS_BATCH:-16}" \
|
| 52 |
+
--reasoning-effort "${LLM_REASONING_EFFORT:-medium}" \
|
| 53 |
+
--batch-size 2048 \
|
| 54 |
+
--ubatch-size 512 \
|
| 55 |
+
--jinja
|
scripts/serve.sh
CHANGED
|
@@ -48,6 +48,7 @@ exec "$BIN" \
|
|
| 48 |
--cache-type-v q8_0 \
|
| 49 |
--ctx-size "${LLM_CONTEXT:-65536}" \
|
| 50 |
--parallel "${LLM_PARALLEL:-1}" \
|
|
|
|
| 51 |
--batch-size 2048 \
|
| 52 |
--ubatch-size 512 \
|
| 53 |
--jinja \
|
|
|
|
| 48 |
--cache-type-v q8_0 \
|
| 49 |
--ctx-size "${LLM_CONTEXT:-65536}" \
|
| 50 |
--parallel "${LLM_PARALLEL:-1}" \
|
| 51 |
+
--reasoning-effort "${LLM_REASONING_EFFORT:-medium}" \
|
| 52 |
--batch-size 2048 \
|
| 53 |
--ubatch-size 512 \
|
| 54 |
--jinja \
|
scripts/start.sh
CHANGED
|
@@ -1,14 +1,13 @@
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
set -euo pipefail
|
| 3 |
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
| 4 |
-
MODEL="${1:-qwen38}"
|
| 5 |
PID_FILE="$ROOT/runtime/server.pid"
|
| 6 |
mkdir -p "$ROOT/logs" "$ROOT/runtime"
|
| 7 |
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
| 8 |
echo "Server already running (PID $(cat "$PID_FILE"))." >&2
|
| 9 |
exit 1
|
| 10 |
fi
|
| 11 |
-
nohup "$ROOT/scripts/
|
| 12 |
echo "$!" > "$PID_FILE"
|
| 13 |
-
echo "Started
|
| 14 |
echo "Health: curl http://127.0.0.1:${LLM_PORT:-8080}/health"
|
|
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
set -euo pipefail
|
| 3 |
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
| 4 |
PID_FILE="$ROOT/runtime/server.pid"
|
| 5 |
mkdir -p "$ROOT/logs" "$ROOT/runtime"
|
| 6 |
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
| 7 |
echo "Server already running (PID $(cat "$PID_FILE"))." >&2
|
| 8 |
exit 1
|
| 9 |
fi
|
| 10 |
+
nohup setsid "$ROOT/scripts/router.sh" > "$ROOT/logs/server.log" 2>&1 < /dev/null &
|
| 11 |
echo "$!" > "$PID_FILE"
|
| 12 |
+
echo "Started router (PID $(cat "$PID_FILE")). Log: $ROOT/logs/server.log"
|
| 13 |
echo "Health: curl http://127.0.0.1:${LLM_PORT:-8080}/health"
|