ShoaibSSM commited on
Commit
350d99c
·
verified ·
1 Parent(s): 42b02cd

Add lazy two-model router and detached ngrok setup

Browse files
Files changed (6) hide show
  1. .gitignore +1 -0
  2. README.md +37 -12
  3. scripts/bench.py +2 -1
  4. scripts/router.sh +55 -0
  5. scripts/serve.sh +1 -0
  6. 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` and enabled with `LLM_SPEC_MTP=1 scripts/start.sh qwen38`. It is off by default until its speed and output are checked on this GPU.
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 qwen38
32
  ```
33
 
34
- `serve.sh` generates a random API key in `secrets/api_keys.txt` (mode 0600) if needed. It binds to `127.0.0.1:8080` by default. Check readiness with `curl http://127.0.0.1:8080/health` and logs with `tail -f logs/server.log`. Select the other model by running `scripts/stop.sh` and then `scripts/start.sh qwen36`. To keep logs in your terminal, run `scripts/serve.sh qwen38` instead.
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/serve.sh qwen38
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` depending on which server is running. For access from another machine, use an SSH tunnel such as `ssh -L 8080:127.0.0.1:8080 user@server`. If you intentionally bind a public interface with `LLM_HOST=0.0.0.0`, put TLS in front of it so API keys are not sent over plain HTTP.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  ## Compare on this hardware
55
 
56
- Run each model alone, then run the same API timing prompts:
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=1200)
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/serve.sh" "$MODEL" > "$ROOT/logs/server.log" 2>&1 < /dev/null &
12
  echo "$!" > "$PID_FILE"
13
- echo "Started $MODEL (PID $(cat "$PID_FILE")). Log: $ROOT/logs/server.log"
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"