qwen3.5-122B-A10B on DGX Spark: vLLM + DFlash + dense-bandwidth stack, one-shot installer
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Logs / local results
|
||||||
|
*.log
|
||||||
|
/local/
|
||||||
|
/results/
|
||||||
|
|
||||||
|
# Never commit weights or checkpoints
|
||||||
|
*.safetensors
|
||||||
|
*.gguf
|
||||||
|
*.bin
|
||||||
|
/hybrid-ckpt/
|
||||||
|
*-hybrid-int4-fp8/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 qwen3.5-122B-A10B-on-spark contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# qwen3.5-122B-A10B-on-spark
|
||||||
|
|
||||||
|
[`Qwen3.5-122B-A10B`](https://huggingface.co/Intel/Qwen3.5-122B-A10B-int4-AutoRound)
|
||||||
|
(hybrid GDN + mamba + 128-expert MoE, ~10B active) running on a single
|
||||||
|
**NVIDIA DGX Spark** (GB10 / SM121, 128 GiB unified) under **vLLM**, with
|
||||||
|
**[DFlash](https://modal.com/blog/spec-is-all-u-need) block-diffusion speculative
|
||||||
|
decode** and an optional **dense-bandwidth patch stack** — measured end-to-end,
|
||||||
|
with a per-token bandwidth model that explains every number.
|
||||||
|
|
||||||
|
**Status:** Working end-to-end, one-shot install. On real Hermes-agent
|
||||||
|
tool-call turns, **DFlash decode reaches a median ~81 tok/s on GB10** —
|
||||||
|
**~2× the native MTP-2 head (~40 tok/s)** on the same workload, and above the
|
||||||
|
best previously published number for this model on Spark (albond's fully-patched
|
||||||
|
MTP stack, 51.58 tok/s end-to-end). DFlash's acceptance is task-dependent (it
|
||||||
|
block-drafts 12 tokens in one parallel forward), so the win is largest on
|
||||||
|
structured/tool-call/code traffic and collapses to parity on open-ended prose.
|
||||||
|
|
||||||
|
A separate **dense-bandwidth stack** (hybrid INT4+FP8 shared experts + int8
|
||||||
|
lm-head) adds **+28 % to no-spec / base decode** (28.2 → 36.0 tok/s) but, by the
|
||||||
|
amortization law below, washes out to ~null on high-acceptance agent traffic —
|
||||||
|
so it's a lever for *base / low-acceptance* serving, not for the agent path.
|
||||||
|
|
||||||
|
- **Engine:** [`vLLM`](https://github.com/vllm-project/vllm) 0.23, sm121 build with the DFlash PRs, via the prebuilt image `ghcr.io/aeon-7/aeon-vllm-ultimate:2026-06-18-v0.23.0-dflashfix`. No host build — the four runtime patches in [`runtime/`](runtime/) are applied at serve time.
|
||||||
|
- **Target:** [`Intel/Qwen3.5-122B-A10B-int4-AutoRound`](https://huggingface.co/Intel/Qwen3.5-122B-A10B-int4-AutoRound) — INT4 (AutoRound/GPTQ) routed experts + attention, BF16 shared experts/embeddings/head, ~62 GiB. (Safetensors, *not* GGUF — vLLM serves HF checkpoints directly.)
|
||||||
|
- **Drafter:** [`z-lab/Qwen3.5-122B-A10B-DFlash`](https://huggingface.co/z-lab/Qwen3.5-122B-A10B-DFlash) — 0.8B / 6-layer non-causal block-diffusion drafter (block 16), shares the target's `embed_tokens` + `lm_head`, ~1.6 GiB.
|
||||||
|
- **Hardware:** NVIDIA DGX Spark, GB10, SM121, 128 GiB LPDDR5X unified, ~273 GB/s.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
On a DGX Spark with Docker + the NVIDIA container runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://raw.githubusercontent.com/Entrpi/qwen3.5-122B-A10B-on-spark/main/install.sh | bash -s -- --start
|
||||||
|
```
|
||||||
|
|
||||||
|
That one command:
|
||||||
|
|
||||||
|
1. Verifies the host (aarch64, GB10/SM121, Docker GPU access, free disk).
|
||||||
|
2. Pulls the sm121 vLLM image (~40 GiB, one-time).
|
||||||
|
3. Downloads the INT4 target (~62 GiB) + DFlash drafter (~1.6 GiB) into the HF cache.
|
||||||
|
4. Starts the `dflash` profile on `:8000`, waits until READY, and runs the
|
||||||
|
"capital of France" smoke test (asserts "Paris").
|
||||||
|
|
||||||
|
**Already have the model?** Skip the 62 GiB download:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# point at a checkpoint dir you already have (mounted read-only at /model):
|
||||||
|
./install.sh --start --model-dir /path/to/Qwen3.5-122B-A10B-int4-AutoRound
|
||||||
|
# or reuse an existing HF cache (download becomes a no-op if already present):
|
||||||
|
./install.sh --start --hf-home /mnt/big/hf
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview without running: `... | bash -s -- --help`.
|
||||||
|
|
||||||
|
## Hardware requirements
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Validated on | NVIDIA DGX Spark (GB10, SM121, 128 GiB unified) |
|
||||||
|
| Likely to work | other Blackwell with `--force` (untested) |
|
||||||
|
| Runtime | Docker + NVIDIA container runtime (`docker run --gpus all`) |
|
||||||
|
| Disk | ≥ 75 GiB free (image + weights); ≥ 150 GiB if `--build-hybrid` |
|
||||||
|
| OS | aarch64 Linux (Grace) |
|
||||||
|
| Memory | 128 GiB unified is enough for the model + DFlash drafter + KV @ 16k |
|
||||||
|
|
||||||
|
GB10 is detected via `nvidia-smi --query-gpu=compute_cap` returning `12.1`;
|
||||||
|
anything else needs `--force`.
|
||||||
|
|
||||||
|
## What you get — profiles
|
||||||
|
|
||||||
|
Pick with `--profile`:
|
||||||
|
|
||||||
|
| Profile | Stack | Best for | Measured |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **`dflash`** *(default)* | INT4 + DFlash n=12 | agents / tool-calls / code | **~81 tok/s** Hermes · 53.7 albond-bench |
|
||||||
|
| `dense` | hybrid INT4+FP8 + int8 lm-head + DFlash n=12 | base / low-accept serving | 36.0 base (+28%) · 59.0 albond-bench |
|
||||||
|
| `base` | plain INT4, no spec | airtight baseline | 28.2 tok/s c=1 |
|
||||||
|
| `mtp` | INT4 + native MTP-2 head | comparison | ~40 tok/s Hermes |
|
||||||
|
|
||||||
|
The server is OpenAI-compatible (`/v1/chat/completions` with tool calls + SSE,
|
||||||
|
`/v1/completions`, `/v1/models`) and serves under the model name `qwen`.
|
||||||
|
|
||||||
|
## Benchmarks
|
||||||
|
|
||||||
|
All single-stream (c=1), temperature 0, GB10. "Hermes" = regenerating the next
|
||||||
|
assistant turn over 10 real conversations from a live agent's `state.db` (73 %
|
||||||
|
tool-calls); "albond-bench" = albond's own end-to-end harness (completion_tokens
|
||||||
|
/ total wallclock incl. prefill, 5 prompts, run-1 discarded — directly
|
||||||
|
comparable to his published 51.58).
|
||||||
|
|
||||||
|
### DFlash vs MTP, same harness, unpatched
|
||||||
|
|
||||||
|
| Workload (accept len) | base no-spec | MTP-2 | **DFlash n=12** |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Prose (~2.3) | 28.2 | 33.7 | 33.2 *(use n=4)* |
|
||||||
|
| Code (~5.4) | 28.2 | 40.5 | **54.5** |
|
||||||
|
| Counting (~11) | 28.2 | 43.7 *(MTP caps at acc 3)* | **124.5** |
|
||||||
|
| **Hermes, real turns (8.3)** | — | **39.9** | **~81** |
|
||||||
|
| albond-bench e2e (6.5) | — | — | **53.7** |
|
||||||
|
|
||||||
|
MTP-2 drafts 2 tokens *sequentially* (acceptance caps at ~3); DFlash block-drafts
|
||||||
|
12 in **one parallel forward**, so on predictable/agent traffic it accepts 5–11
|
||||||
|
and pulls ~2× ahead. They tie only on low-acceptance prose. **53.7 unpatched
|
||||||
|
already clears albond's fully-patched MTP (51.58)** under his own method.
|
||||||
|
|
||||||
|
### The dense-bandwidth stack (`dense` profile)
|
||||||
|
|
||||||
|
Two independent always-on levers, ported to vLLM 0.23 as runtime patches:
|
||||||
|
hybrid INT4+FP8 (BF16 shared experts → calibrated FP8) and int8 lm-head (the
|
||||||
|
248 320-row vocab projection → int8 w8a16 GEMV, ~2× the bf16 read).
|
||||||
|
|
||||||
|
| Config | base (acc 1) | DFlash spec, albond-bench (acc 6.4) | Hermes (acc 8.3) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| INT4 baseline | 28.2 | 53.7 | ~81 |
|
||||||
|
| + hybrid-FP8 | 30.4 (+7.8%) | 57.0 (+6.1%) | ~80 |
|
||||||
|
| + int8 lm-head | 32.7 (+16%) | — | — |
|
||||||
|
| **+ both** | **36.0 (+28%)** | **59.0 (+10%)** | ~80–87 *(noise)* |
|
||||||
|
|
||||||
|
## The amortization law
|
||||||
|
|
||||||
|
The dense levers cut **always-on** weight reads (shared experts + lm-head, read
|
||||||
|
every token). Under speculative decode the verify forward reads those weights
|
||||||
|
**once and amortizes them across the accepted block**, so the gain shrinks as
|
||||||
|
acceptance rises — monotonically, across the whole curve:
|
||||||
|
|
||||||
|
```
|
||||||
|
dense stack uplift: +28% (base, accept 1)
|
||||||
|
→ +10% (albond-bench, accept ~6.4)
|
||||||
|
→ ~0% (Hermes, accept ~8.3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Consequence:** for the **agent path (`dflash`)**, DFlash's own high acceptance
|
||||||
|
already saturates the dense levers — its remaining bottleneck is *routed-expert*
|
||||||
|
verify-batch reads, which no dense-weight quant touches. For **base / low-accept
|
||||||
|
serving (`dense`)**, the stack is a real +28 %. See [`docs/FINDINGS.md`](docs/FINDINGS.md).
|
||||||
|
|
||||||
|
## Under the hood: the four runtime patches
|
||||||
|
|
||||||
|
vLLM is unmodified on disk; [`runtime/serve.sh`](runtime/serve.sh) edits the
|
||||||
|
installed package in-place before `vllm serve` (idempotent, sentinel-guarded):
|
||||||
|
|
||||||
|
| Patch | What it does | Needed by |
|
||||||
|
|---|---|---|
|
||||||
|
| [`patch_unify2.py`](runtime/patch_unify2.py) | scale-block KV-cache **unify** so the hybrid GDN+mamba target absorbs the drafter's attention spec (the original assert can't); + `--no-enable-prefix-caching` routes to the no-hash-assert coordinator | **DFlash** (any spec profile) |
|
||||||
|
| [`patch_inc_hybrid.py`](runtime/patch_inc_hybrid.py) | adds an `INCConfig.maybe_update_config` override that detects FP8 dense layers in the hybrid checkpoint and dispatches `Fp8LinearMethod` for `shared_expert` | `dense` |
|
||||||
|
| [`patch_int8_lmhead_v3.py`](runtime/patch_int8_lmhead_v3.py) | replaces the lm-head matmul in `_get_logits` with a batched int8 w8a16 Triton GEMV (keeps bf16 weight for the shared drafter) | `dense` |
|
||||||
|
| [`patch_fla_shmem.py`](runtime/patch_fla_shmem.py) | lets the FLA GDN chunk kernels use big tiles on sm121's 99 KiB shmem (prefill/TTFT only; harmless) | always (free) |
|
||||||
|
|
||||||
|
Why DFlash needs the unify patch at all, why the drafter must run `FLASH_ATTN`
|
||||||
|
(non-causal), and the full vLLM-vs-SGLANG dead-end history are in
|
||||||
|
[`docs/FINDINGS.md`](docs/FINDINGS.md).
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
install.sh One-shot installer (curl | bash | --help)
|
||||||
|
runtime/ Mounted read-only at /host inside the container:
|
||||||
|
serve.sh vLLM serve wrapper (applies the patches, then serves)
|
||||||
|
patch_unify2.py DFlash KV-unify fix
|
||||||
|
patch_inc_hybrid.py hybrid INT4+FP8 dispatch
|
||||||
|
patch_int8_lmhead_v3.py int8 lm-head GEMV
|
||||||
|
patch_fla_shmem.py FLA sm121 big-tile (prefill)
|
||||||
|
mtp_serve.sh MTP-2 comparison serve
|
||||||
|
scripts/ Host-side helpers:
|
||||||
|
monitor.sh Container-startup monitor with OOM auto-kill guard
|
||||||
|
bench_decode.py Decode-only tok/s (excludes TTFT)
|
||||||
|
bench_albond.py albond's e2e method (comparable to his 51.58)
|
||||||
|
hermes_bench.py Real agent turns from ~/.hermes/state.db
|
||||||
|
run_bank.sh prose/code/counting/hermes bank on any server
|
||||||
|
tools/
|
||||||
|
build-hybrid-checkpoint.py Build the hybrid INT4+FP8 ckpt (for --build-hybrid)
|
||||||
|
inspect_ckpt.py Which layers are INT4 vs BF16 vs FP8
|
||||||
|
validate_*.py Standalone correctness checks for the patches
|
||||||
|
docs/
|
||||||
|
FINDINGS.md The full investigation, methodology, and the
|
||||||
|
amortization-law derivation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reproducing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# default agent path (DFlash) + smoke test:
|
||||||
|
./install.sh --start
|
||||||
|
|
||||||
|
# the dense stack (build the hybrid ckpt once, ~20 min, then serve):
|
||||||
|
./install.sh --build-hybrid
|
||||||
|
./install.sh --start --profile dense
|
||||||
|
|
||||||
|
# benches (run on the host against the server; need: pip install requests):
|
||||||
|
python3 scripts/bench_decode.py --base-url http://127.0.0.1:8000 --model qwen \
|
||||||
|
--prompt "Write a detailed essay about the history of tea."
|
||||||
|
python3 scripts/bench_albond.py http://127.0.0.1:8000 "dflash" # e2e, vs 51.58
|
||||||
|
python3 scripts/hermes_bench.py --base-url http://127.0.0.1:8000 # real agent turns
|
||||||
|
|
||||||
|
# MTP comparison:
|
||||||
|
./install.sh --start --profile mtp
|
||||||
|
```
|
||||||
|
|
||||||
|
## How this fits with related work
|
||||||
|
|
||||||
|
| Piece | Role |
|
||||||
|
|---|---|
|
||||||
|
| [`vLLM`](https://github.com/vllm-project/vllm) | the inference engine; this repo serves Qwen3.5 + DFlash on it, unmodified-on-disk |
|
||||||
|
| [`Intel/...int4-AutoRound`](https://huggingface.co/Intel/Qwen3.5-122B-A10B-int4-AutoRound) · [`z-lab/...DFlash`](https://huggingface.co/z-lab/Qwen3.5-122B-A10B-DFlash) | the target + drafter weights |
|
||||||
|
| [`albond/DGX_Spark_Qwen3.5-122B-A10B-AR-INT4`](https://github.com/albond/DGX_Spark_Qwen3.5-122B-A10B-AR-INT4) | the MTP + hybrid-FP8 + int8-lmhead recipe we benchmarked against and ported the dense levers from |
|
||||||
|
| [`Entrpi/ds4-on-spark`](https://github.com/Entrpi/ds4-on-spark) | sibling repo, same hardware, different model (DeepSeek-V4-Flash via ds4) |
|
||||||
|
| [Modal: *Speculative decoding is all you need*](https://modal.com/blog/spec-is-all-u-need) | the DFlash block-diffusion drafter and the task-dependent-acceptance framing |
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
- [`z-lab`](https://huggingface.co/z-lab) / [Modal](https://modal.com/blog/spec-is-all-u-need) — the DFlash drafter and block-diffusion speculative decode.
|
||||||
|
- [`Intel/AutoRound`](https://huggingface.co/Intel) — the INT4 target quantization.
|
||||||
|
- [`vLLM`](https://github.com/vllm-project/vllm) and the AEON sm121 image maintainers — the engine and the DFlash-enabled GB10 build.
|
||||||
|
- [`albond`](https://github.com/albond/DGX_Spark_Qwen3.5-122B-A10B-AR-INT4) — the MTP/hybrid-FP8/int8-lmhead recipe and the end-to-end benchmark methodology.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE). The patches are original; vendored third-party
|
||||||
|
files (`tools/build-hybrid-checkpoint.py`) retain their upstream attribution.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# FINDINGS — DFlash + dense levers for Qwen3.5-122B-A10B on DGX Spark
|
||||||
|
|
||||||
|
Single-stream (c=1) decode of `Qwen3.5-122B-A10B` (hybrid GDN + mamba + 128-expert
|
||||||
|
MoE, ~10B active) on GB10 / SM121, 128 GiB unified, ~273 GB/s. The agent this
|
||||||
|
backs (Hermes) is ~73 % tool-calls. All numbers temperature 0.
|
||||||
|
|
||||||
|
## 1. Getting DFlash to run on the hybrid 122B in vLLM
|
||||||
|
|
||||||
|
The DFlash drafter is **non-causal** (it block-drafts 16 tokens in one parallel
|
||||||
|
forward) — only the `FLASH_ATTN` (FA2) backend supports non-causal attention.
|
||||||
|
But the hybrid GDN+mamba+MoE target's KV-cache page geometry won't *unify* with
|
||||||
|
the drafter's attention spec:
|
||||||
|
|
||||||
|
- vLLM auto-aligns the hybrid (attention block 2240, mamba page padded +0.54 % to
|
||||||
|
match), so `max_page_size` is a *padded* value. `unify_kv_cache_spec_page_size`
|
||||||
|
scales the drafter's attention block by `ratio` and then asserts
|
||||||
|
`page == max` — which fails, because `page_size_bytes` ignores `block_size`
|
||||||
|
once `page_size_padded` is set.
|
||||||
|
- **Fix** ([`patch_unify2.py`](../runtime/patch_unify2.py)): keep the *scaled*
|
||||||
|
`block_size` **and** pad the <1 % remainder (mirrors vLLM's own
|
||||||
|
`HiddenStateCacheSpec` handling). The earlier "pad but keep block_size=16"
|
||||||
|
patch mis-strided the drafter KV → acceptance collapsed to 1.47 (a real bug,
|
||||||
|
not a quant mismatch).
|
||||||
|
- `--mamba-block-size 256` (from other Spark recipes) **breaks** the 122B: it
|
||||||
|
makes the mamba group block ≠ cache block, tripping the coordinator hash
|
||||||
|
assert. Omit it.
|
||||||
|
- `--no-enable-prefix-caching` routes to `KVCacheCoordinatorNoPrefixCache`, which
|
||||||
|
has no line-504 hash assert. Prefix caching is irrelevant at c=1 anyway.
|
||||||
|
|
||||||
|
Working stack: `patch_unify2` + prefix-off + INT4 (bf16 KV) target + drafter
|
||||||
|
pinned to `FLASH_ATTN`. **No FA4 shim needed** — vLLM gates FA4 to cap families
|
||||||
|
90/100/110 (excludes 120), so the drafter runs FA2. (The whole fa4-sm120 saga is
|
||||||
|
SGLang-only; SGLang's DFlash works too but its sm121 base decode is ~2× slower
|
||||||
|
than vLLM's, so it loses on absolute throughput.)
|
||||||
|
|
||||||
|
## 2. DFlash vs MTP — acceptance is task-dependent
|
||||||
|
|
||||||
|
MTP-2 (the native head) drafts 2 tokens **sequentially**, so acceptance caps at
|
||||||
|
~3. DFlash block-drafts 12 in **one parallel forward**, so its acceptance fills
|
||||||
|
the block on predictable traffic. Same harness, unpatched, flash_attn:
|
||||||
|
|
||||||
|
| Workload | accept (MTP-2 / DFlash) | tok/s (MTP-2 / DFlash) |
|
||||||
|
|---|---|---|
|
||||||
|
| Prose | 2.24 / 2.3 | 33.7 / 33.2 *(tie; use DFlash n=4)* |
|
||||||
|
| Code | 2.77 / 5.4 | 40.5 / 54.5 |
|
||||||
|
| Counting | **3.00 (maxed)** / 11 | 43.7 / 124.5 |
|
||||||
|
| Hermes (real) | 2.88 / **8.66** | 39.9 / **~81** |
|
||||||
|
|
||||||
|
The "DFlash caps at 2.3 / 33 tok/s" story was a **prose-benchmark artifact**.
|
||||||
|
On agent/code traffic DFlash pulls ~2× ahead because MTP is acceptance-saturated.
|
||||||
|
`n` (num_speculative_tokens) is task-dependent: prose → 4, agent/code → 12+.
|
||||||
|
|
||||||
|
## 3. Methodology — two non-comparable harnesses
|
||||||
|
|
||||||
|
- `bench_decode.py` = **decode-only** tok/s (excludes TTFT). Good for c=1 kernel
|
||||||
|
comparisons. This is the ~81 Hermes number.
|
||||||
|
- `bench_albond.py` = **end-to-end** (completion_tokens / total wallclock incl.
|
||||||
|
prefill, non-streaming, 5 prompts, run-1 discarded). This reproduces albond's
|
||||||
|
own method and is directly comparable to his published **51.58**.
|
||||||
|
|
||||||
|
Apples-to-apples (albond's method): **DFlash n=12 unpatched = 53.7 tok/s
|
||||||
|
cross-prompt mean — already above his fully-patched MTP stack (51.58).**
|
||||||
|
|
||||||
|
## 4. The dense-bandwidth levers and the amortization law
|
||||||
|
|
||||||
|
albond's non-MTP wins are *always-on* bandwidth cuts. We ported the two that
|
||||||
|
transfer to vLLM 0.23 + DFlash:
|
||||||
|
|
||||||
|
- **hybrid INT4+FP8** ([`patch_inc_hybrid.py`](../runtime/patch_inc_hybrid.py)):
|
||||||
|
the Intel base already stores **attention as INT4** (0.5 B/param, *better* than
|
||||||
|
albond's FP8 attention), so the only thing to gain is the BF16 **shared
|
||||||
|
experts** → calibrated FP8 (144 layers, 0.48 GB saved). The dispatch patch adds
|
||||||
|
an `INCConfig.maybe_update_config` override (AEON 0.23's hook signature takes
|
||||||
|
`hf_config=`, unlike albond's 0.19) that detects FP8 dense layers and
|
||||||
|
dispatches `Fp8LinearMethod` for them.
|
||||||
|
- **int8 lm-head** ([`patch_int8_lmhead_v3.py`](../runtime/patch_int8_lmhead_v3.py)):
|
||||||
|
the 248 320-row vocab projection is the single largest dense read (1.5 GB BF16,
|
||||||
|
*every token*). A batched int8 w8a16 Triton GEMV reads it at ~227 GB/s (vs bf16
|
||||||
|
~6.5–8.8 ms) — **~2× faster, argmax-exact**. Prior ports failed not on the
|
||||||
|
kernel but on **integration**: zeroing the lm-head weight corrupted the
|
||||||
|
*drafter-shared* head (garbage), and a per-row loop for B>4 was slower under
|
||||||
|
spec. v3 uses one batched kernel and **keeps** the bf16 weight.
|
||||||
|
|
||||||
|
Why the denominator matters: the 0.48 GB shared-expert saving is **0.7 % of the
|
||||||
|
71 GB on disk** but **~8 % of the ~6 GB *active per-token* footprint** (the disk
|
||||||
|
is mostly sparse routed experts). Shared experts and the lm-head are **dense —
|
||||||
|
read every token** — so at base decode the savings land in full:
|
||||||
|
|
||||||
|
| Config | base (acc 1) | albond-bench (acc 6.4) | Hermes (acc 8.3) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| INT4 baseline | 28.2 | 53.7 | ~81 |
|
||||||
|
| + hybrid-FP8 | 30.4 (+7.8%) | 57.0 (+6.1%) | ~80 |
|
||||||
|
| + int8 lm-head | 32.7 (+16%) | — | — |
|
||||||
|
| **+ both** | **36.0 (+28%)** | **59.0 (+10%)** | ~80–87 (noise) |
|
||||||
|
|
||||||
|
The levers compose additively (step savings 2.6 + 4.9 ≈ 7.7 ms). But the uplift
|
||||||
|
**decays monotonically with acceptance**:
|
||||||
|
|
||||||
|
> Under speculative decode the verify forward processes ~`accept` positions and
|
||||||
|
> reads each dense weight **once**, amortized across them. So a dense-weight cut
|
||||||
|
> that is +X % at base is ~+X/accept % under spec.
|
||||||
|
|
||||||
|
```
|
||||||
|
+28% base (accept 1) → +10% albond-bench (accept 6.4) → ~0% Hermes (accept 8.3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Consequences**
|
||||||
|
|
||||||
|
- For the **agent path** (`dflash`), DFlash's own high acceptance already
|
||||||
|
amortizes the dense levers to ~null. Its remaining bottleneck is **routed-expert
|
||||||
|
verify-batch reads** (each of ~13 verify positions routes to different experts)
|
||||||
|
— untouched by any dense-weight quant. To push Hermes further you must attack
|
||||||
|
*that*: a smaller/faster drafter, lower `n` at equal acceptance, or sub-INT4
|
||||||
|
routed experts.
|
||||||
|
- For **base / low-acceptance** serving (`dense`), the stack is a real **+28 %**
|
||||||
|
(36 tok/s) and is the recommended config there.
|
||||||
|
|
||||||
|
## 5. Things that did NOT help c=1 decode
|
||||||
|
|
||||||
|
- **FLASHINFER target backend** — null both short-context and Hermes (attention
|
||||||
|
isn't the bottleneck on this GDN/mamba-heavy MoE; most layers are linear
|
||||||
|
attention). albond's "+16 %" was on his dense-attention MTP path.
|
||||||
|
- **b12x / native FP4 MoE** — null at c=1 (a throughput/concurrency lever, not a
|
||||||
|
latency one; at batch 1 the active-expert GEMM is tiny).
|
||||||
|
- **FLA sm121 big-tile shmem fix** — real bug, but prefill/TTFT only; c=1 decode
|
||||||
|
uses the GDN *recurrent* path, a different kernel. Kept (free TTFT win).
|
||||||
|
- **PR#38325 swapAB FP8 GEMM** — marginal (+0.76 %), only with the FP8 checkpoint.
|
||||||
|
|
||||||
|
## Production recommendation
|
||||||
|
|
||||||
|
Ship **`dflash`** for the agent (DFlash unpatched, ~81 tok/s, ~2× MTP, > albond's
|
||||||
|
patched 51.58). Reserve **`dense`** for base / low-acceptance serving (+28 %).
|
||||||
|
The dense patches are upside there, not a requirement for the agent to win.
|
||||||
Executable
+319
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install.sh — Qwen3.5-122B-A10B + DFlash speculative decode on NVIDIA DGX Spark
|
||||||
|
# (GB10 / SM121, 128 GiB unified), via vLLM in Docker.
|
||||||
|
#
|
||||||
|
# curl -sSL https://raw.githubusercontent.com/Entrpi/qwen3.5-122B-A10B-on-spark/main/install.sh | bash
|
||||||
|
# curl -sSL https://raw.githubusercontent.com/Entrpi/qwen3.5-122B-A10B-on-spark/main/install.sh | bash -s -- --help
|
||||||
|
#
|
||||||
|
# What this does (every step idempotent — safe to re-run):
|
||||||
|
#
|
||||||
|
# 1. Verifies the host is a DGX Spark / GB10 (SM121) with Docker + the NVIDIA
|
||||||
|
# container runtime, and enough free disk for the chosen profile.
|
||||||
|
# 2. Pulls the prebuilt sm121 vLLM image (DFlash-enabled, vLLM 0.23).
|
||||||
|
# 3. Downloads the INT4 target + DFlash drafter from Hugging Face into the HF
|
||||||
|
# cache — OR reuses a checkpoint you already have (--model-dir / --hf-home).
|
||||||
|
# 4. (optional) Builds the hybrid INT4+FP8 checkpoint for the "dense" profile.
|
||||||
|
# 5. Starts the vLLM server for the chosen --profile, waits until READY, and
|
||||||
|
# runs the "capital of France" smoke test (expects "Paris").
|
||||||
|
#
|
||||||
|
# The script makes NO changes outside:
|
||||||
|
# - the Docker image cache (the pulled image)
|
||||||
|
# - $HF_HOME (default ~/.cache/huggingface)
|
||||||
|
# - $HYBRID_DIR (only with --build-hybrid)
|
||||||
|
# - the running container named $NAME (only with --start / smoke)
|
||||||
|
#
|
||||||
|
# This repo provides the install + serve + patch + benchmark layer ON TOP of:
|
||||||
|
# - Intel/Qwen3.5-122B-A10B-int4-AutoRound (target weights)
|
||||||
|
# - z-lab/Qwen3.5-122B-A10B-DFlash (block-diffusion drafter)
|
||||||
|
# - ghcr.io/aeon-7/aeon-vllm-ultimate (sm121 DFlash-enabled vLLM)
|
||||||
|
#
|
||||||
|
# License: MIT. Source: https://github.com/Entrpi/qwen3.5-122B-A10B-on-spark
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 0. defaults + flag parsing
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Prebuilt sm121 vLLM image with the DFlash PRs + the .pth that auto-applies our
|
||||||
|
# KV-unify patch is NOT baked in — we apply patches at serve time from runtime/.
|
||||||
|
IMAGE="${QWEN_IMAGE:-ghcr.io/aeon-7/aeon-vllm-ultimate:2026-06-18-v0.23.0-dflashfix}"
|
||||||
|
|
||||||
|
TARGET_REPO="${TARGET_REPO:-Intel/Qwen3.5-122B-A10B-int4-AutoRound}" # INT4 target (~62 GiB)
|
||||||
|
DRAFT_REPO="${DRAFT_REPO:-z-lab/Qwen3.5-122B-A10B-DFlash}" # 0.8B drafter (~1.6 GiB)
|
||||||
|
FP8_REPO="${FP8_REPO:-Qwen/Qwen3.5-122B-A10B-FP8}" # FP8 donor for --build-hybrid
|
||||||
|
|
||||||
|
HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}"
|
||||||
|
HYBRID_DIR="${HYBRID_DIR:-$HOME/qwen3.5-122b-hybrid-int4-fp8}"
|
||||||
|
MODEL_DIR="" # --model-dir: a pre-downloaded INT4 checkpoint dir
|
||||||
|
|
||||||
|
# This repo's own dir (works for `curl | bash` too: falls back to a clone).
|
||||||
|
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "$HOME/code/qwen3.5-122B-A10B-on-spark")}"
|
||||||
|
REPO_URL="${REPO_URL:-https://github.com/Entrpi/qwen3.5-122B-A10B-on-spark.git}"
|
||||||
|
|
||||||
|
NAME="${NAME:-qwen-spark}"
|
||||||
|
PROFILE="dflash" # dflash | dense | base | mtp
|
||||||
|
NSPEC="" # override num_speculative_tokens (default per profile)
|
||||||
|
PORT="${PORT:-8000}"
|
||||||
|
CTX="${CTX:-16384}"
|
||||||
|
GPU_MEM="${GPU_MEM:-0.8}"
|
||||||
|
BACKEND="${BACKEND:-flash_attn}"
|
||||||
|
|
||||||
|
FORCE_HW=0
|
||||||
|
SKIP_PULL=0
|
||||||
|
SKIP_DOWNLOAD=0
|
||||||
|
BUILD_HYBRID=0
|
||||||
|
START_SERVER=0
|
||||||
|
SKIP_SMOKE=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [flags]
|
||||||
|
|
||||||
|
Profiles (--profile):
|
||||||
|
dflash INT4 target + DFlash drafter, n=12 (DEFAULT — best for agents/Hermes;
|
||||||
|
~81 tok/s on real tool-call turns)
|
||||||
|
dense hybrid INT4+FP8 + int8 lm-head + DFlash (the dense-bandwidth stack;
|
||||||
|
+28% at base, +10% low-accept spec.
|
||||||
|
Needs --build-hybrid first.)
|
||||||
|
base plain INT4, no speculative decode (~28 tok/s c=1 baseline)
|
||||||
|
mtp INT4 + native MTP-2 head (the albond comparison path)
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--help Show this help.
|
||||||
|
--profile NAME One of dflash|dense|base|mtp (default: dflash).
|
||||||
|
--start Start the vLLM server + smoke test after setup.
|
||||||
|
--build-hybrid Build the hybrid INT4+FP8 checkpoint (~20 min, needs FP8 donor).
|
||||||
|
--no-pull Skip docker pull (use the local image).
|
||||||
|
--no-download Skip HF download (assume target+drafter already cached).
|
||||||
|
--model-dir DIR Use a pre-downloaded INT4 target checkpoint dir (skip its
|
||||||
|
download; mounted read-only at /model).
|
||||||
|
--hf-home DIR Use/populate this HF cache dir (default: $HF_HOME).
|
||||||
|
--nspec N num_speculative_tokens (default 12 dflash/dense, 2 mtp, 0 base).
|
||||||
|
--port N Server port (default: $PORT).
|
||||||
|
--ctx N max-model-len (default: $CTX).
|
||||||
|
--gpu-mem F gpu-memory-utilization, keep <=0.84 on 128GiB (default: $GPU_MEM).
|
||||||
|
--force Skip the GB10/SM121 host check.
|
||||||
|
--no-smoke Start the server but skip the Paris smoke test.
|
||||||
|
|
||||||
|
Environment equivalents:
|
||||||
|
QWEN_IMAGE TARGET_REPO DRAFT_REPO FP8_REPO HF_HOME HYBRID_DIR
|
||||||
|
NAME PORT CTX GPU_MEM BACKEND REPO_DIR REPO_URL
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--help|-h) usage; exit 0 ;;
|
||||||
|
--profile) PROFILE="$2"; shift 2 ;;
|
||||||
|
--start) START_SERVER=1; shift ;;
|
||||||
|
--build-hybrid) BUILD_HYBRID=1; shift ;;
|
||||||
|
--no-pull) SKIP_PULL=1; shift ;;
|
||||||
|
--no-download) SKIP_DOWNLOAD=1; shift ;;
|
||||||
|
--model-dir) MODEL_DIR="$2"; shift 2 ;;
|
||||||
|
--hf-home) HF_HOME="$2"; shift 2 ;;
|
||||||
|
--nspec) NSPEC="$2"; shift 2 ;;
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
--ctx) CTX="$2"; shift 2 ;;
|
||||||
|
--gpu-mem) GPU_MEM="$2"; shift 2 ;;
|
||||||
|
--backend) BACKEND="$2"; shift 2 ;;
|
||||||
|
--force) FORCE_HW=1; shift ;;
|
||||||
|
--no-smoke) SKIP_SMOKE=1; shift ;;
|
||||||
|
*) echo "Unknown flag: $1" >&2; usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$PROFILE" in dflash|dense|base|mtp) ;; *) echo "Bad --profile: $PROFILE" >&2; exit 2 ;; esac
|
||||||
|
|
||||||
|
c_red() { printf '\033[31m%s\033[0m' "$*"; }
|
||||||
|
c_green() { printf '\033[32m%s\033[0m' "$*"; }
|
||||||
|
c_yellow(){ printf '\033[33m%s\033[0m' "$*"; }
|
||||||
|
log() { printf '%s %s\n' "[$(date +%H:%M:%S)]" "$*"; }
|
||||||
|
die() { printf '\n%s %s\n' "$(c_red FATAL:)" "$*" >&2; exit 1; }
|
||||||
|
warn(){ printf '%s %s\n' "$(c_yellow WARN:)" "$*" >&2; }
|
||||||
|
ok() { printf '%s %s\n' "$(c_green OK:)" "$*"; }
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 1. host verification
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
verify_host() {
|
||||||
|
log "Verifying host..."
|
||||||
|
local m; m=$(uname -m)
|
||||||
|
if [[ "$m" != "aarch64" ]] && [[ "$FORCE_HW" -eq 0 ]]; then
|
||||||
|
die "Expected aarch64 (Grace+Blackwell); got $m. Pass --force to skip."
|
||||||
|
fi
|
||||||
|
command -v docker >/dev/null 2>&1 || die "docker not found. Install Docker + the NVIDIA container runtime."
|
||||||
|
command -v nvidia-smi >/dev/null 2>&1 || die "nvidia-smi not found. Need the NVIDIA driver."
|
||||||
|
local gpu; gpu=$(nvidia-smi --query-gpu=name,compute_cap --format=csv,noheader 2>/dev/null || true)
|
||||||
|
[[ -n "$gpu" ]] || die "nvidia-smi failed to enumerate GPUs."
|
||||||
|
log "GPU: $gpu"
|
||||||
|
if ! echo "$gpu" | grep -qE '12\.1|GB10|Spark'; then
|
||||||
|
[[ "$FORCE_HW" -eq 1 ]] || die "Not detecting GB10/SM12.1. Pass --force (and maybe --backend) to proceed."
|
||||||
|
warn "Host is not GB10/SM121; proceeding under --force (untested)."
|
||||||
|
fi
|
||||||
|
# Docker can see the GPU?
|
||||||
|
if ! docker info 2>/dev/null | grep -qiE 'nvidia|Default Runtime: nvidia' \
|
||||||
|
&& ! docker run --rm --gpus all "$IMAGE" true 2>/dev/null; then
|
||||||
|
warn "Could not confirm Docker GPU access (nvidia-container-toolkit). 'docker run --gpus all' must work."
|
||||||
|
fi
|
||||||
|
# Disk
|
||||||
|
local need=75; [[ "$BUILD_HYBRID" -eq 1 ]] && need=150
|
||||||
|
local free; free=$(df -BG "$HOME" | awk 'NR==2{gsub("G","",$4);print $4}')
|
||||||
|
if (( free < need )) && [[ "$SKIP_DOWNLOAD" -eq 0 ]] && [[ -z "$MODEL_DIR" ]]; then
|
||||||
|
die "Need >= ${need} GiB free under $HOME for profile '$PROFILE'; have ${free} GiB. Use --model-dir / --no-download, or free space."
|
||||||
|
fi
|
||||||
|
ok "Host checks passed (free ${free} GiB)."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 2. pull image
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
pull_image() {
|
||||||
|
if [[ "$SKIP_PULL" -eq 1 ]]; then log "Skipping docker pull (--no-pull)."; return; fi
|
||||||
|
if docker image inspect "$IMAGE" >/dev/null 2>&1; then ok "Image present: $IMAGE"; return; fi
|
||||||
|
log "Pulling $IMAGE (~40 GiB, one-time) ..."
|
||||||
|
docker pull "$IMAGE"
|
||||||
|
ok "Image pulled."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 3. download models (idempotent — snapshot_download no-ops if cached)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
hf_get() { # repo -> populate HF cache via the image's huggingface_hub
|
||||||
|
local repo="$1"
|
||||||
|
docker run --rm --net=host -e HF_HOME=/hf ${HF_TOKEN:+-e HF_TOKEN="$HF_TOKEN"} \
|
||||||
|
-v "$HF_HOME:/hf" --entrypoint python3 "$IMAGE" \
|
||||||
|
-c "from huggingface_hub import snapshot_download as s; s('$repo')"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_models() {
|
||||||
|
if [[ "$SKIP_DOWNLOAD" -eq 1 ]]; then log "Skipping HF download (--no-download)."; return; fi
|
||||||
|
mkdir -p "$HF_HOME"
|
||||||
|
if [[ -n "$MODEL_DIR" ]]; then
|
||||||
|
[[ -f "$MODEL_DIR/config.json" ]] || die "--model-dir $MODEL_DIR has no config.json"
|
||||||
|
log "Using pre-downloaded target at $MODEL_DIR (skipping target download)."
|
||||||
|
else
|
||||||
|
log "Fetching target $TARGET_REPO into $HF_HOME ..."
|
||||||
|
hf_get "$TARGET_REPO"
|
||||||
|
fi
|
||||||
|
log "Fetching drafter $DRAFT_REPO ..."
|
||||||
|
hf_get "$DRAFT_REPO"
|
||||||
|
ok "Models ready."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 4. optional: build the hybrid INT4+FP8 checkpoint
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
build_hybrid() {
|
||||||
|
[[ "$BUILD_HYBRID" -eq 1 ]] || return
|
||||||
|
if [[ -f "$HYBRID_DIR/model.safetensors.index.json" ]]; then ok "Hybrid ckpt present: $HYBRID_DIR"; return; fi
|
||||||
|
local gptq="$MODEL_DIR"
|
||||||
|
if [[ -z "$gptq" ]]; then
|
||||||
|
gptq=$(docker run --rm -v "$HF_HOME:/hf" -e HF_HOME=/hf --entrypoint python3 "$IMAGE" \
|
||||||
|
-c "from huggingface_hub import snapshot_download as s; print(s('$TARGET_REPO'))" | tail -1)
|
||||||
|
gptq="/hf-snap" # mount the cache; resolve inside the container below
|
||||||
|
fi
|
||||||
|
mkdir -p "$HYBRID_DIR"
|
||||||
|
log "Building hybrid INT4+FP8 checkpoint -> $HYBRID_DIR (~20 min) ..."
|
||||||
|
docker run --rm --net=host -e HF_HOME=/hf ${HF_TOKEN:+-e HF_TOKEN="$HF_TOKEN"} \
|
||||||
|
-v "$HF_HOME:/hf" -v "$HYBRID_DIR:/out" -v "$REPO_DIR/tools:/tools:ro" \
|
||||||
|
${MODEL_DIR:+-v "$MODEL_DIR:/gptq:ro"} \
|
||||||
|
--entrypoint bash "$IMAGE" -c '
|
||||||
|
set -e
|
||||||
|
GPTQ="'"${MODEL_DIR:+/gptq}"'"
|
||||||
|
if [ -z "$GPTQ" ]; then
|
||||||
|
GPTQ=$(python3 -c "from huggingface_hub import snapshot_download as s; print(s(\"'"$TARGET_REPO"'\"))")
|
||||||
|
fi
|
||||||
|
python3 /tools/build-hybrid-checkpoint.py --gptq-dir "$GPTQ" \
|
||||||
|
--fp8-repo "'"$FP8_REPO"'" --output /out --force
|
||||||
|
rm -rf /out/.fp8_cache'
|
||||||
|
ok "Hybrid checkpoint built: $HYBRID_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 5. start server (+ smoke test)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
ensure_runtime() { # make sure runtime/ (serve wrapper + patches) is on disk
|
||||||
|
if [[ -f "$REPO_DIR/runtime/serve.sh" ]]; then return; fi
|
||||||
|
log "runtime/ not found next to install.sh — cloning repo to $HOME/code/qwen3.5-122B-A10B-on-spark"
|
||||||
|
REPO_DIR="$HOME/code/qwen3.5-122B-A10B-on-spark"
|
||||||
|
[[ -d "$REPO_DIR/.git" ]] || git clone --depth 1 "$REPO_URL" "$REPO_DIR"
|
||||||
|
[[ -f "$REPO_DIR/runtime/serve.sh" ]] || die "runtime/serve.sh still missing after clone."
|
||||||
|
}
|
||||||
|
|
||||||
|
start_server() {
|
||||||
|
[[ "$START_SERVER" -eq 1 ]] || { log "Setup complete. Re-run with --start to launch the server."; return; }
|
||||||
|
ensure_runtime
|
||||||
|
|
||||||
|
# profile -> serve args + env + mounts
|
||||||
|
local nspec model_env=() mounts=() serve_args
|
||||||
|
case "$PROFILE" in
|
||||||
|
dflash) nspec="${NSPEC:-12}"; serve_args="$nspec $BACKEND" ;;
|
||||||
|
dense) nspec="${NSPEC:-12}"; serve_args="$nspec $BACKEND"
|
||||||
|
[[ -f "$HYBRID_DIR/model.safetensors.index.json" ]] || die "dense profile needs the hybrid ckpt — run with --build-hybrid first."
|
||||||
|
model_env=(-e MODEL=/model -e INC_HYBRID=1 -e INT8_LMHEAD_V3=1)
|
||||||
|
mounts=(-v "$HYBRID_DIR:/model:ro") ;;
|
||||||
|
base) nspec="${NSPEC:-0}"; serve_args="$nspec $BACKEND" ;;
|
||||||
|
mtp) nspec="${NSPEC:-2}"; serve_args="$nspec $BACKEND" ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "$MODEL_DIR" && "$PROFILE" != "dense" ]]; then
|
||||||
|
model_env=(-e MODEL=/model); mounts=(-v "$MODEL_DIR:/model:ro")
|
||||||
|
fi
|
||||||
|
local wrapper="/host/serve.sh"; [[ "$PROFILE" == "mtp" ]] && wrapper="/host/mtp_serve.sh"
|
||||||
|
|
||||||
|
log "Starting profile=$PROFILE (nspec=$nspec, ctx=$CTX, gpu-mem=$GPU_MEM) as container '$NAME' ..."
|
||||||
|
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker run -d --name "$NAME" --gpus all --net=host --ipc=host --ulimit memlock=-1:-1 \
|
||||||
|
-e HF_HOME=/hf -e MAX_MODEL_LEN="$CTX" -e GPU_MEM="$GPU_MEM" ${HF_TOKEN:+-e HF_TOKEN="$HF_TOKEN"} \
|
||||||
|
"${model_env[@]}" \
|
||||||
|
-v "$HF_HOME:/hf" -v "$REPO_DIR/runtime:/host:ro" "${mounts[@]}" \
|
||||||
|
--entrypoint bash "$IMAGE" "$wrapper" $serve_args >/dev/null
|
||||||
|
log "Container started. Model load + compile is ~8-12 min. Tail: docker logs -f $NAME"
|
||||||
|
|
||||||
|
log "Waiting for http://127.0.0.1:$PORT/health ..."
|
||||||
|
local i
|
||||||
|
for i in $(seq 1 180); do
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q "^$NAME$"; then
|
||||||
|
docker logs "$NAME" 2>&1 | tail -30; die "Container exited during load. See log above."
|
||||||
|
fi
|
||||||
|
if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||||
|
ok "Server READY on http://127.0.0.1:$PORT"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 || die "Server not ready within ~15 min. docker logs $NAME"
|
||||||
|
|
||||||
|
[[ "$SKIP_SMOKE" -eq 1 ]] && { log "Skipping smoke test (--no-smoke)."; return; }
|
||||||
|
log "Smoke test: 'capital of France' ..."
|
||||||
|
local out
|
||||||
|
out=$(curl -s "http://127.0.0.1:$PORT/v1/completions" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"model\":\"qwen\",\"prompt\":\"What is the capital of France? Answer in one word.\",\"max_tokens\":8,\"temperature\":0}" \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['choices'][0]['text'])" 2>/dev/null || true)
|
||||||
|
echo " -> $out"
|
||||||
|
echo "$out" | grep -qi paris && ok "Smoke test PASSED — 'Paris'." || die "Smoke test FAILED — 'Paris' not in output."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# main
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
verify_host
|
||||||
|
pull_image
|
||||||
|
download_models
|
||||||
|
build_hybrid
|
||||||
|
start_server
|
||||||
|
|
||||||
|
echo
|
||||||
|
ok "Done (profile=$PROFILE)."
|
||||||
|
echo " Server: http://127.0.0.1:$PORT/v1 (model name: qwen)"
|
||||||
|
echo " Logs: docker logs -f $NAME"
|
||||||
|
echo " Stop: docker rm -f $NAME"
|
||||||
|
echo " Bench: python3 scripts/bench_decode.py --base-url http://127.0.0.1:$PORT --model qwen --prompt 'Write an essay about tea.'"
|
||||||
|
echo " Agent: python3 scripts/hermes_bench.py --base-url http://127.0.0.1:$PORT # real tool-call turns"
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# mtp_serve.sh — native qwen3_5 MTP-N head for the comparison (`--profile mtp`).
|
||||||
|
# The MTP head (1 layer, reuses target KV/embed/lm_head) is in the Intel
|
||||||
|
# checkpoint (mtp.layers.0) — no separate drafter, no unify patch needed.
|
||||||
|
# $1 = num_speculative_tokens (default 2 = the "MTP-2" recipe); $2 = backend.
|
||||||
|
set -euo pipefail
|
||||||
|
NSPEC="${1:-2}"
|
||||||
|
BACKEND="${2:-flash_attn}"
|
||||||
|
MODEL="${MODEL:-Intel/Qwen3.5-122B-A10B-int4-AutoRound}"
|
||||||
|
MAX_MODEL_LEN="${MAX_MODEL_LEN:-16384}"
|
||||||
|
GPU_MEM="${GPU_MEM:-0.8}"
|
||||||
|
PORT="${PORT:-8000}"
|
||||||
|
echo "[mtp] qwen3_5_mtp — backend=$BACKEND, num_speculative_tokens=$NSPEC, model=$MODEL"
|
||||||
|
exec vllm serve "$MODEL" \
|
||||||
|
--served-model-name qwen \
|
||||||
|
--host 0.0.0.0 --port "$PORT" \
|
||||||
|
--max-model-len "$MAX_MODEL_LEN" \
|
||||||
|
--max-num-seqs 16 \
|
||||||
|
--max-num-batched-tokens "$MAX_MODEL_LEN" \
|
||||||
|
--gpu-memory-utilization "$GPU_MEM" \
|
||||||
|
--no-enable-prefix-caching \
|
||||||
|
--enable-chunked-prefill \
|
||||||
|
--trust-remote-code \
|
||||||
|
--attention-backend "$BACKEND" \
|
||||||
|
--speculative-config "{\"method\":\"qwen3_5_mtp\",\"num_speculative_tokens\":$NSPEC,\"model\":\"$MODEL\"}"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""spark-fla-shmem: let the FLA (flash-linear-attention / GDN) Triton kernels use their
|
||||||
|
BIG tiles on sm121 (GB10 / DGX Spark).
|
||||||
|
|
||||||
|
The FLA Backend gate (vllm/model_executor/layers/fla/ops/utils.py, identical in SGLang)
|
||||||
|
picks big tiles only if device_max_shared_mem >= Backend.DEFAULT (102400 = 100 KiB):
|
||||||
|
cumsum.py : BS_LIST = [32,64] if check_shared_mem() else [16,32]
|
||||||
|
chunk_o.py: BKV_LIST = [64,128] if check_shared_mem() else [32,64]
|
||||||
|
sm121 reports max_shared_mem = 101376 (99 KiB), JUST below 102400 -> check returns False
|
||||||
|
-> small tiles -> slower GDN/linear-attention (the per-token hot path). But 101376 is
|
||||||
|
EXACTLY Backend.ADA, and RTX 4090 (ADA, same 99 KiB) runs the big tiles fine, so they
|
||||||
|
provably fit in 99 KiB. Lower DEFAULT to 101376 so sm121 (and ADA) pass the gate.
|
||||||
|
"""
|
||||||
|
import pathlib, sys
|
||||||
|
|
||||||
|
P = pathlib.Path(
|
||||||
|
"/usr/local/lib/python3.12/site-packages/vllm/model_executor/layers/fla/ops/utils.py"
|
||||||
|
)
|
||||||
|
src = P.read_text()
|
||||||
|
if "spark-fla-shmem" in src:
|
||||||
|
print("[patch_fla_shmem] already patched", flush=True)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
OLD = " DEFAULT = 102400 # Default"
|
||||||
|
NEW = " DEFAULT = 101376 # spark-fla-shmem: 102400->101376 so sm121 GB10 (99 KiB) uses big GDN tiles (already fit on ADA's identical 99 KiB)"
|
||||||
|
|
||||||
|
if OLD not in src:
|
||||||
|
print("[patch_fla_shmem] ERROR: target line not found — FLA source differs", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
P.write_text(src.replace(OLD, NEW))
|
||||||
|
print("[patch_fla_shmem] patched OK (DEFAULT 102400 -> 101376; big GDN tiles on sm121)", flush=True)
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""spark-dflash-hybrid-fp8: port of albond's hybrid INT4+FP8 dispatch
|
||||||
|
(patches/01-hybrid-int4-fp8/inc.py.patch) onto AEON 0.23's vllm INCConfig.
|
||||||
|
|
||||||
|
Adds, to vllm/model_executor/layers/quantization/inc.py:
|
||||||
|
* INCConfig.fp8_config / fp8_layers fields
|
||||||
|
* maybe_update_config OVERRIDE — AEON 0.23 signature (model_name, hf_config=None,
|
||||||
|
revision=None); the base hook is already CALLED from config/vllm.py:634, so we
|
||||||
|
only supply the override. Scans the checkpoint's safetensors metadata for
|
||||||
|
float8_e4m3fn weights that have a .weight_scale_inv, builds an Fp8Config, and
|
||||||
|
records those layer prefixes.
|
||||||
|
* _is_layer_fp8 — exact + fused + substring match against fp8_layers
|
||||||
|
* FP8 dispatch at BOTH dense short-circuits: get_quant_method's extra_config
|
||||||
|
override AND the apply_*_quant_layer not-quantized blocks.
|
||||||
|
|
||||||
|
Idempotent; sentinel 'spark-dflash-hybrid-fp8'. Mirrors patch_unify2.py's style.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import vllm.model_executor.layers.quantization.inc as inc_mod
|
||||||
|
|
||||||
|
path = inc_mod.__file__
|
||||||
|
src = open(path).read()
|
||||||
|
SENT = "spark-dflash-hybrid-fp8"
|
||||||
|
if SENT in src:
|
||||||
|
print(f"[patch_inc_hybrid] already applied: {path}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 1. __init__ fields (anchor unique: only INCConfig has pack_factor = Fraction(32,..))
|
||||||
|
a1 = " self.pack_factor = Fraction(32, weight_bits)\n"
|
||||||
|
b1 = a1 + (
|
||||||
|
" # spark-dflash-hybrid-fp8: populated by maybe_update_config\n"
|
||||||
|
" self.fp8_config = None\n"
|
||||||
|
" self.fp8_layers = set()\n"
|
||||||
|
)
|
||||||
|
assert src.count(a1) == 1, f"anchor1 count={src.count(a1)}"
|
||||||
|
src = src.replace(a1, b1)
|
||||||
|
|
||||||
|
# 2. apply_vllm_mapper: remap fp8_layers (HF->vLLM names) after extra_config remap
|
||||||
|
a2 = (
|
||||||
|
" if self.extra_config is not None:\n"
|
||||||
|
" self.extra_config = hf_to_vllm_mapper.apply_dict(self.extra_config)\n"
|
||||||
|
)
|
||||||
|
b2 = a2 + (
|
||||||
|
" if self.fp8_layers: # spark-dflash-hybrid-fp8\n"
|
||||||
|
" self.fp8_layers = set(\n"
|
||||||
|
" hf_to_vllm_mapper.apply_list(list(self.fp8_layers))\n"
|
||||||
|
" )\n"
|
||||||
|
)
|
||||||
|
assert src.count(a2) == 1, f"anchor2 count={src.count(a2)}"
|
||||||
|
src = src.replace(a2, b2)
|
||||||
|
|
||||||
|
# 3. insert maybe_update_config + _is_layer_fp8 before apply_awq_quant_layer
|
||||||
|
a3 = ' def apply_awq_quant_layer(self, layer, prefix: str, backend: str = "auto"):\n'
|
||||||
|
methods = ''' def maybe_update_config( # spark-dflash-hybrid-fp8
|
||||||
|
self,
|
||||||
|
model_name: str,
|
||||||
|
hf_config=None,
|
||||||
|
revision: str | None = None,
|
||||||
|
):
|
||||||
|
"""Detect FP8 dense layers in a hybrid INT4+FP8 checkpoint."""
|
||||||
|
import torch as _torch
|
||||||
|
from safetensors.torch import _TYPES as _SF
|
||||||
|
from vllm.transformers_utils.config import get_safetensors_params_metadata
|
||||||
|
from vllm.model_executor.layers.quantization.fp8 import Fp8Config
|
||||||
|
metadata = get_safetensors_params_metadata(model_name, revision=revision)
|
||||||
|
fp8_weights = {}
|
||||||
|
for pn, info in metadata.items():
|
||||||
|
ds = info.get("dtype", None)
|
||||||
|
if ds is None:
|
||||||
|
continue
|
||||||
|
if _SF.get(ds) == _torch.float8_e4m3fn and pn.endswith(".weight"):
|
||||||
|
sn = pn.replace(".weight", ".weight_scale_inv")
|
||||||
|
if sn in metadata:
|
||||||
|
fp8_weights[pn] = info
|
||||||
|
if not fp8_weights:
|
||||||
|
logger.info("spark-dflash-hybrid-fp8: no FP8 dense layers detected")
|
||||||
|
return
|
||||||
|
block_size = None
|
||||||
|
for pn, info in fp8_weights.items():
|
||||||
|
sn = pn.replace(".weight", ".weight_scale_inv")
|
||||||
|
ws = info.get("shape", [])
|
||||||
|
ss = metadata[sn].get("shape", [])
|
||||||
|
if len(ws) == 2 and len(ss) == 2:
|
||||||
|
block_size = [ws[0] // ss[0], ws[1] // ss[1]]
|
||||||
|
break
|
||||||
|
if block_size is None:
|
||||||
|
return
|
||||||
|
self.fp8_config = Fp8Config(
|
||||||
|
is_checkpoint_fp8_serialized=True,
|
||||||
|
activation_scheme="dynamic",
|
||||||
|
weight_block_size=block_size,
|
||||||
|
)
|
||||||
|
self.fp8_layers = {n.rsplit(".weight", 1)[0] for n in fp8_weights}
|
||||||
|
_sample = sorted(self.fp8_layers)[:3]
|
||||||
|
logger.info(
|
||||||
|
"spark-dflash-hybrid-fp8: detected %d FP8 dense layers "
|
||||||
|
"(block_size=%s) e.g. %s",
|
||||||
|
len(self.fp8_layers), block_size, _sample,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_layer_fp8(self, prefix: str) -> bool: # spark-dflash-hybrid-fp8
|
||||||
|
if not self.fp8_layers:
|
||||||
|
return False
|
||||||
|
if prefix in self.fp8_layers:
|
||||||
|
return True
|
||||||
|
fused = getattr(self, "packed_modules_mapping", {})
|
||||||
|
proj = prefix.split(".")[-1]
|
||||||
|
if proj in fused:
|
||||||
|
shards = [prefix.replace(proj, s) for s in fused[proj]]
|
||||||
|
return all(
|
||||||
|
any(fl in sp for fl in self.fp8_layers) for sp in shards
|
||||||
|
)
|
||||||
|
return any(fl in prefix for fl in self.fp8_layers)
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert src.count(a3) == 1, f"anchor3 count={src.count(a3)}"
|
||||||
|
src = src.replace(a3, methods + a3)
|
||||||
|
|
||||||
|
# 4. FP8 dispatch in the not-quantized blocks (awq/gptq/xpu/cpu are byte-identical;
|
||||||
|
# guard is inert unless fp8_config set, so patching all is safe)
|
||||||
|
a4 = (
|
||||||
|
" if not self.check_quantized(weight_bits):\n"
|
||||||
|
" if isinstance(layer, (LinearBase, ParallelLMHead)):\n"
|
||||||
|
" return UnquantizedLinearMethod()\n"
|
||||||
|
" else:\n"
|
||||||
|
" return None\n"
|
||||||
|
)
|
||||||
|
b4 = (
|
||||||
|
" if not self.check_quantized(weight_bits):\n"
|
||||||
|
" if self.fp8_config and self._is_layer_fp8(prefix): # spark-dflash-hybrid-fp8\n"
|
||||||
|
" from vllm.model_executor.layers.quantization.fp8 import (\n"
|
||||||
|
" Fp8LinearMethod,\n"
|
||||||
|
" )\n"
|
||||||
|
" return Fp8LinearMethod(self.fp8_config)\n"
|
||||||
|
" if isinstance(layer, (LinearBase, ParallelLMHead)):\n"
|
||||||
|
" return UnquantizedLinearMethod()\n"
|
||||||
|
" else:\n"
|
||||||
|
" return None\n"
|
||||||
|
)
|
||||||
|
n4 = src.count(a4)
|
||||||
|
assert n4 >= 2, f"anchor4 count={n4}"
|
||||||
|
src = src.replace(a4, b4)
|
||||||
|
|
||||||
|
# 5. FP8 dispatch in get_quant_method's extra_config (bits>=16) override
|
||||||
|
a5 = (
|
||||||
|
' ) and self.extra_config[layer_name].get("bits", 16) >= 16:\n'
|
||||||
|
" return UnquantizedLinearMethod()\n"
|
||||||
|
)
|
||||||
|
b5 = (
|
||||||
|
' ) and self.extra_config[layer_name].get("bits", 16) >= 16:\n'
|
||||||
|
" if self.fp8_config and self._is_layer_fp8(prefix): # spark-dflash-hybrid-fp8\n"
|
||||||
|
" from vllm.model_executor.layers.quantization.fp8 import (\n"
|
||||||
|
" Fp8LinearMethod,\n"
|
||||||
|
" )\n"
|
||||||
|
" return Fp8LinearMethod(self.fp8_config)\n"
|
||||||
|
" return UnquantizedLinearMethod()\n"
|
||||||
|
)
|
||||||
|
assert src.count(a5) == 1, f"anchor5 count={src.count(a5)}"
|
||||||
|
src = src.replace(a5, b5)
|
||||||
|
|
||||||
|
open(path, "w").write(src)
|
||||||
|
print(f"[patch_inc_hybrid] applied {SENT} to {path} (not-quant blocks x{n4})")
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""INT8 W8A16 lm-head v3 for AEON vLLM 0.23 (sm121). Replaces ONLY the
|
||||||
|
`lm_head.quant_method.apply(...)` call inside LogitsProcessor._get_logits with a
|
||||||
|
batched int8 GEMV (one kernel launch for any batch), leaving the existing TP
|
||||||
|
gather + org_vocab_size trim untouched.
|
||||||
|
|
||||||
|
Fixes vs the broken v2 port:
|
||||||
|
* KEEPS the bf16 lm_head weight (DFlash drafter shares it) — does NOT zero it.
|
||||||
|
Trades the memory saving for correctness; the speed win is the int8 read in
|
||||||
|
_get_logits, independent of keeping bf16 around.
|
||||||
|
* Single BATCHED kernel (dot-based, pad B->16) for ALL B — no per-row Python
|
||||||
|
loop (the v2 B>4 loop was what made spec decode SLOWER).
|
||||||
|
* Fixed proven config (N128/K128/w4/s3, ~227 GB/s, argmax-exact vs bf16 on the
|
||||||
|
real [248320,3072] shape) — no autotune (avoids sm121 bad-config miscompiles).
|
||||||
|
|
||||||
|
Sentinel DGX_SPARK_INT8_LMHEAD_V3. Verified standalone: int8 3.35ms vs bf16 8.8ms
|
||||||
|
(B=1) / 6.5ms (B=13); maxerr 3e-4 < quant floor, argmax 100%.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
TARGET = "/usr/local/lib/python3.12/site-packages/vllm/model_executor/layers/logits_processor.py"
|
||||||
|
|
||||||
|
ANCHOR = (
|
||||||
|
" # Get the logits for the next tokens.\n"
|
||||||
|
" logits = lm_head.quant_method.apply(lm_head, hidden_states, bias=embedding_bias)\n"
|
||||||
|
)
|
||||||
|
REPLACE = (
|
||||||
|
" # DGX_SPARK_INT8_LMHEAD_V3: int8 w8a16 GEMV for the huge vocab projection\n"
|
||||||
|
" logits = _spark_int8_lmhead_apply(self, lm_head, hidden_states, embedding_bias)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
MODULE_CODE = '''
|
||||||
|
|
||||||
|
# ===================== DGX_SPARK_INT8_LMHEAD_V3 =====================
|
||||||
|
import triton as _spark_triton
|
||||||
|
import triton.language as _spark_tl
|
||||||
|
|
||||||
|
|
||||||
|
@_spark_triton.jit
|
||||||
|
def _spark_k_int8(x_ptr, w_ptr, s_ptr, o_ptr, B, N, K,
|
||||||
|
sxb, sxk, swn, swk, sob, son,
|
||||||
|
BLOCK_B: _spark_tl.constexpr, BLOCK_N: _spark_tl.constexpr,
|
||||||
|
BLOCK_K: _spark_tl.constexpr):
|
||||||
|
pid_n = _spark_tl.program_id(0)
|
||||||
|
offs_b = _spark_tl.arange(0, BLOCK_B)
|
||||||
|
offs_n = pid_n * BLOCK_N + _spark_tl.arange(0, BLOCK_N)
|
||||||
|
offs_k = _spark_tl.arange(0, BLOCK_K)
|
||||||
|
x_ptrs = x_ptr + offs_b[:, None] * sxb + offs_k[None, :] * sxk
|
||||||
|
w_ptrs = w_ptr + offs_n[:, None] * swn + offs_k[None, :] * swk
|
||||||
|
acc = _spark_tl.zeros((BLOCK_B, BLOCK_N), dtype=_spark_tl.float32)
|
||||||
|
for k in range(0, K, BLOCK_K):
|
||||||
|
km = (offs_k[None, :] + k) < K
|
||||||
|
x = _spark_tl.load(x_ptrs, mask=(offs_b[:, None] < B) & km, other=0.0).to(_spark_tl.float16)
|
||||||
|
w = _spark_tl.load(w_ptrs, mask=(offs_n[:, None] < N) & km, other=0).to(_spark_tl.float16)
|
||||||
|
acc += _spark_tl.dot(x, w.T)
|
||||||
|
x_ptrs += BLOCK_K * sxk
|
||||||
|
w_ptrs += BLOCK_K * swk
|
||||||
|
s = _spark_tl.load(s_ptr + offs_n, mask=offs_n < N, other=0.0).to(_spark_tl.float32)
|
||||||
|
acc = acc * s[None, :]
|
||||||
|
o_ptrs = o_ptr + offs_b[:, None] * sob + offs_n[None, :] * son
|
||||||
|
_spark_tl.store(o_ptrs, acc, mask=(offs_b[:, None] < B) & (offs_n[None, :] < N))
|
||||||
|
|
||||||
|
|
||||||
|
def _spark_int8_gemm(hidden, w_int8, w_scale):
|
||||||
|
import torch
|
||||||
|
N, K = w_int8.shape
|
||||||
|
x = hidden.reshape(-1, K)
|
||||||
|
B = x.shape[0]
|
||||||
|
BLOCK_B = max(16, _spark_triton.next_power_of_2(B))
|
||||||
|
out = torch.empty(B, N, dtype=torch.float32, device=x.device)
|
||||||
|
xf = x.to(torch.float16)
|
||||||
|
grid = ((N + 127) // 128,)
|
||||||
|
_spark_k_int8[grid](xf, w_int8, w_scale, out, B, N, K,
|
||||||
|
xf.stride(0), xf.stride(1), w_int8.stride(0), w_int8.stride(1),
|
||||||
|
out.stride(0), out.stride(1),
|
||||||
|
BLOCK_B=BLOCK_B, BLOCK_N=128, BLOCK_K=128,
|
||||||
|
num_warps=4, num_stages=3)
|
||||||
|
return out.reshape(hidden.shape[:-1] + (N,))
|
||||||
|
|
||||||
|
|
||||||
|
def _spark_int8_lmhead_apply(self, lm_head, hidden_states, embedding_bias):
|
||||||
|
import sys
|
||||||
|
import torch
|
||||||
|
if not getattr(lm_head, "_spark_int8_ready", None) is True and \\
|
||||||
|
not getattr(lm_head, "_spark_int8_disabled", False):
|
||||||
|
w = getattr(lm_head, "weight", None)
|
||||||
|
if (w is not None and w.dtype in (torch.bfloat16, torch.float16)
|
||||||
|
and w.dim() == 2 and w.shape[0] > 100000):
|
||||||
|
with torch.no_grad():
|
||||||
|
scales = (w.float().abs().amax(dim=1) / 127.0).clamp(min=1e-12)
|
||||||
|
w_int8 = (w.float() / scales.unsqueeze(1)).round().clamp(-127, 127).to(torch.int8)
|
||||||
|
lm_head._spark_w_int8 = w_int8.contiguous()
|
||||||
|
lm_head._spark_w_scale = scales.to(torch.float16)
|
||||||
|
lm_head._spark_int8_ready = True
|
||||||
|
print("DGX_SPARK_INT8_LMHEAD_V3: lm_head -> int8 (%s), bf16 kept for shared drafter"
|
||||||
|
% (list(w_int8.shape),), file=sys.stderr, flush=True)
|
||||||
|
else:
|
||||||
|
lm_head._spark_int8_disabled = True
|
||||||
|
if getattr(lm_head, "_spark_int8_ready", False) and embedding_bias is None:
|
||||||
|
return _spark_int8_gemm(hidden_states, lm_head._spark_w_int8, lm_head._spark_w_scale)
|
||||||
|
return lm_head.quant_method.apply(lm_head, hidden_states, bias=embedding_bias)
|
||||||
|
# =================== end DGX_SPARK_INT8_LMHEAD_V3 ===================
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(TARGET):
|
||||||
|
print(f"FAIL: {TARGET} not found"); sys.exit(1)
|
||||||
|
src = open(TARGET).read()
|
||||||
|
if "DGX_SPARK_INT8_LMHEAD_V3" in src:
|
||||||
|
print("SKIP: int8 lm-head v3 already applied"); return
|
||||||
|
if ANCHOR not in src:
|
||||||
|
print("FAIL: _get_logits apply-anchor not found"); sys.exit(1)
|
||||||
|
if src.count(ANCHOR) != 1:
|
||||||
|
print(f"FAIL: anchor count={src.count(ANCHOR)} (expected 1)"); sys.exit(1)
|
||||||
|
src = src.replace(ANCHOR, REPLACE)
|
||||||
|
src = src + MODULE_CODE
|
||||||
|
open(TARGET, "w").write(src)
|
||||||
|
print("OK: int8 lm-head v3 applied")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""spark-dflash-unify (CORRECTED): let the DFlash drafter's attention KV spec unify
|
||||||
|
with the hybrid GDN+mamba target's *padded* page size WITHOUT corrupting the drafter.
|
||||||
|
|
||||||
|
Root cause (AEON 0.23.0+aeon.sm121a.dflash): with --mamba-block-size set, vLLM's own
|
||||||
|
hybrid alignment makes target mamba page == target attention page by PADDING the mamba
|
||||||
|
page (e.g. "+0.54%"). That padded value becomes max_page_size. The drafter's attention
|
||||||
|
page is smaller; unify_kv_cache_spec_page_size scales its block_size by
|
||||||
|
ratio = max_page_size // layer_page_size, but because max_page_size is a *padded* (non
|
||||||
|
block-size-linear) number, the scaled page lands just under it and
|
||||||
|
`assert new_spec.page_size_bytes == max_page_size` fires.
|
||||||
|
|
||||||
|
The OLD spark-pad-unify patch "fixed" this by `replace(layer_spec,
|
||||||
|
page_size_padded=max_page_size)` -- but it DROPPED the block_size scaling, leaving the
|
||||||
|
drafter at block_size=16 behind a max-sized physical page. That mis-strided the drafter
|
||||||
|
KV and pinned mean accept length at ~1.47 (garbage drafts), so DFlash net-lost.
|
||||||
|
|
||||||
|
This corrected patch mirrors what get_kv_cache_groups already does for
|
||||||
|
HiddenStateCacheSpec layers: keep the SCALED block_size AND pad the <1% remainder.
|
||||||
|
Strides stay correct (block_size is properly scaled); only the page tail is padded.
|
||||||
|
"""
|
||||||
|
import pathlib, sys
|
||||||
|
|
||||||
|
P = pathlib.Path("/usr/local/lib/python3.12/site-packages/vllm/v1/core/kv_cache_utils.py")
|
||||||
|
src = P.read_text()
|
||||||
|
|
||||||
|
if "spark-dflash-unify" in src:
|
||||||
|
print("[patch_unify2] already patched", flush=True)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
OLD = ''' new_spec = replace(layer_spec, block_size=new_block_size)
|
||||||
|
assert new_spec.page_size_bytes == max_page_size
|
||||||
|
new_kv_cache_spec[layer_name] = new_spec'''
|
||||||
|
|
||||||
|
NEW = ''' new_spec = replace(layer_spec, block_size=new_block_size)
|
||||||
|
if new_spec.page_size_bytes != max_page_size:
|
||||||
|
# spark-dflash-unify: max_page_size is a *padded* hybrid page; the
|
||||||
|
# scaled attention page lands just under it. Pad the remainder while
|
||||||
|
# KEEPING the scaled block_size, exactly as get_kv_cache_groups does
|
||||||
|
# for HiddenStateCacheSpec. (The old patch dropped the scaling ->
|
||||||
|
# mis-strided the DFlash drafter -> accept len stuck ~1.47.)
|
||||||
|
new_spec = replace(
|
||||||
|
layer_spec,
|
||||||
|
block_size=new_block_size,
|
||||||
|
page_size_padded=max_page_size,
|
||||||
|
)
|
||||||
|
assert new_spec.page_size_bytes == max_page_size
|
||||||
|
new_kv_cache_spec[layer_name] = new_spec'''
|
||||||
|
|
||||||
|
if OLD not in src:
|
||||||
|
print("[patch_unify2] ERROR: target block not found — vLLM source differs", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
P.write_text(src.replace(OLD, NEW))
|
||||||
|
print("[patch_unify2] patched OK (scaled-block + pad-remainder)", flush=True)
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# serve.sh — runs INSIDE the sm121 vLLM container (mounted at /host). Applies the
|
||||||
|
# runtime monkeypatches, then `vllm serve`s the Qwen3.5-122B-A10B INT4 target with
|
||||||
|
# the DFlash drafter. Driven by install.sh; can also be run by hand.
|
||||||
|
#
|
||||||
|
# args: $1 = num_speculative_tokens (0 = no-spec baseline)
|
||||||
|
# $2 = target attention backend (flash_attn | FLASHINFER)
|
||||||
|
# env: MODEL target path/repo (default Intel INT4; /model for hybrid)
|
||||||
|
# INC_HYBRID=1 apply the hybrid INT4+FP8 dense-expert dispatch patch
|
||||||
|
# INT8_LMHEAD_V3=1 apply the int8 lm-head GEMV patch
|
||||||
|
# MAX_MODEL_LEN GPU_MEM PORT
|
||||||
|
#
|
||||||
|
# Stack rationale: the DFlash drafter is non-causal -> needs FLASH_ATTN (FA2). The
|
||||||
|
# hybrid GDN+mamba+MoE target's KV page geometry won't absorb the drafter's
|
||||||
|
# attention spec without patch_unify2 (scale-block unify) + prefix-caching OFF
|
||||||
|
# (NoPrefixCache coordinator, dodges the hash assert). See docs/FINDINGS.md.
|
||||||
|
set -euo pipefail
|
||||||
|
NSPEC="${1:-12}"
|
||||||
|
BACKEND="${2:-flash_attn}"
|
||||||
|
MODEL="${MODEL:-Intel/Qwen3.5-122B-A10B-int4-AutoRound}"
|
||||||
|
DRAFT="${DRAFT:-z-lab/Qwen3.5-122B-A10B-DFlash}"
|
||||||
|
MAX_MODEL_LEN="${MAX_MODEL_LEN:-16384}"
|
||||||
|
GPU_MEM="${GPU_MEM:-0.8}"
|
||||||
|
PORT="${PORT:-8000}"
|
||||||
|
|
||||||
|
# FLA sm121 big-tile shmem fix (prefill/TTFT only on sm121; harmless, free).
|
||||||
|
echo "[serve] FLA sm121 big-tile shmem patch"
|
||||||
|
python3 /host/patch_fla_shmem.py || true
|
||||||
|
|
||||||
|
if [ "${INC_HYBRID:-0}" = "1" ]; then
|
||||||
|
echo "[serve] hybrid INT4+FP8 dispatch patch (inc.py)"
|
||||||
|
python3 /host/patch_inc_hybrid.py
|
||||||
|
fi
|
||||||
|
if [ "${INT8_LMHEAD_V3:-0}" = "1" ]; then
|
||||||
|
echo "[serve] int8 lm-head v3 patch (batched w8a16 GEMV)"
|
||||||
|
python3 /host/patch_int8_lmhead_v3.py
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NSPEC" = "0" ]; then
|
||||||
|
SPEC_ARG=()
|
||||||
|
echo "[serve] NO-SPEC baseline (identical flags, prefix-off)"
|
||||||
|
else
|
||||||
|
SPEC_ARG=(--speculative-config "{\"method\":\"dflash\",\"model\":\"$DRAFT\",\"num_speculative_tokens\":$NSPEC,\"attention_backend\":\"FLASH_ATTN\"}")
|
||||||
|
echo "[serve] DFlash n=$NSPEC, target-backend=$BACKEND, drafter=FLASH_ATTN, model=$MODEL"
|
||||||
|
fi
|
||||||
|
python3 /host/patch_unify2.py || { [ "$NSPEC" = "0" ] && true; }
|
||||||
|
|
||||||
|
exec vllm serve "$MODEL" \
|
||||||
|
--served-model-name qwen \
|
||||||
|
--host 0.0.0.0 --port "$PORT" \
|
||||||
|
--max-model-len "$MAX_MODEL_LEN" \
|
||||||
|
--max-num-seqs 16 \
|
||||||
|
--max-num-batched-tokens "$MAX_MODEL_LEN" \
|
||||||
|
--gpu-memory-utilization "$GPU_MEM" \
|
||||||
|
--no-enable-prefix-caching \
|
||||||
|
--enable-chunked-prefill \
|
||||||
|
--trust-remote-code \
|
||||||
|
--attention-backend "$BACKEND" \
|
||||||
|
"${SPEC_ARG[@]}"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Faithful reproduction of albond's bench_qwen35.sh methodology so our DFlash/MTP
|
||||||
|
numbers are directly comparable to his reported 51.58 tok/s.
|
||||||
|
|
||||||
|
His method (verbatim): non-streaming /v1/chat/completions, time the WHOLE request,
|
||||||
|
tok/s = completion_tokens / wall_time (INCLUDES prefill + overhead = END-TO-END).
|
||||||
|
5 prompts (Q&A 256 / Code 512 / JSON 1024 / Math 64 / LongCode 2048), temp 0, run 1
|
||||||
|
discarded as JIT warmup. We add: decode-only tok/s isn't measured here on purpose
|
||||||
|
(his isn't either) + an aggregate spec-accept-len from /metrics deltas for context.
|
||||||
|
"""
|
||||||
|
import json, sys, time, urllib.request, statistics
|
||||||
|
|
||||||
|
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000"
|
||||||
|
MODEL = "qwen"
|
||||||
|
PROMPTS = [
|
||||||
|
("Q&A", "What are the main differences between TCP and UDP? Be concise.", 256),
|
||||||
|
("Code", "Write a Python function that implements binary search on a sorted list. Include type hints and docstring.", 512),
|
||||||
|
("JSON", "Generate a JSON array of 10 fictional employees with fields: name, age, department, salary, email, skills (array of 3). Output ONLY valid JSON, no explanation.", 1024),
|
||||||
|
("Math", "What is 7823 * 4519? Show only the answer.", 64),
|
||||||
|
("LongCode", "Write a complete Python implementation of a red-black tree with insert, delete, search, and in-order traversal. Include all rotation methods.", 2048),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def scrape():
|
||||||
|
acc = dr = 0.0
|
||||||
|
try:
|
||||||
|
txt = urllib.request.urlopen(BASE + "/metrics", timeout=10).read().decode()
|
||||||
|
for ln in txt.splitlines():
|
||||||
|
if ln.startswith("#") or not ln.split():
|
||||||
|
continue
|
||||||
|
v = float(ln.split()[-1])
|
||||||
|
if "spec_decode_num_accepted_tokens_total" in ln:
|
||||||
|
acc += v
|
||||||
|
elif "spec_decode_num_drafts_total" in ln:
|
||||||
|
dr += v
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return acc, dr
|
||||||
|
|
||||||
|
|
||||||
|
def chat_e2e(prompt, max_tokens):
|
||||||
|
body = json.dumps({"model": MODEL, "messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": max_tokens, "temperature": 0.0}).encode()
|
||||||
|
req = urllib.request.Request(BASE + "/v1/chat/completions", data=body,
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": "Bearer x"})
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
r = json.loads(urllib.request.urlopen(req, timeout=600).read())
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
ct = r["usage"]["completion_tokens"]
|
||||||
|
return ct, elapsed, ct / elapsed if elapsed > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
label = sys.argv[2] if len(sys.argv) > 2 else "server"
|
||||||
|
print(f"=== albond-method e2e bench :: {label} ===")
|
||||||
|
a0, d0 = scrape()
|
||||||
|
run2 = {}
|
||||||
|
for run in (1, 2):
|
||||||
|
tag = "WARMUP(discard)" if run == 1 else "RUN2"
|
||||||
|
for name, prompt, mt in PROMPTS:
|
||||||
|
try:
|
||||||
|
ct, el, tps = chat_e2e(prompt, mt)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{name}] FAILED: {type(e).__name__}: {e}"); continue
|
||||||
|
if run == 2:
|
||||||
|
run2[name] = tps
|
||||||
|
print(f" {tag:16s} [{name:8s}] {ct:4d} tok in {el:6.2f}s = {tps:6.1f} tok/s (e2e)")
|
||||||
|
a1, d1 = scrape()
|
||||||
|
print(f"\n{label} RUN2 e2e tok/s: " + " ".join(f"{k}={v:.1f}" for k, v in run2.items()))
|
||||||
|
if run2:
|
||||||
|
print(f" cross-prompt mean (RUN2) = {statistics.mean(run2.values()):.1f} tok/s (albond reports 51.58)")
|
||||||
|
if d1 - d0 > 0:
|
||||||
|
print(f" aggregate spec accept length over bench = {1 + (a1-a0)/(d1-d0):.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Single-stream (c=1) decode-rate benchmark for an OpenAI-compatible vLLM server.
|
||||||
|
|
||||||
|
Measures *decode* tok/s (excludes TTFT/prefill) over N sequential requests, and
|
||||||
|
optionally scrapes vLLM /metrics for speculative-decode acceptance length.
|
||||||
|
|
||||||
|
stdlib only — runs anywhere with python3, no pip installs.
|
||||||
|
|
||||||
|
python3 bench_decode.py --base-url http://127.0.0.1:8000 \
|
||||||
|
--model Intel/Qwen3.5-122B-A10B-int4-AutoRound \
|
||||||
|
--max-tokens 256 --runs 5 --label "int4 baseline"
|
||||||
|
"""
|
||||||
|
import argparse, json, statistics, sys, time, urllib.request, urllib.error
|
||||||
|
|
||||||
|
PROMPT = ("You are a careful writer. Write a detailed, continuous explanation of how "
|
||||||
|
"a modern mixture-of-experts transformer performs autoregressive decoding, "
|
||||||
|
"covering routing, KV cache, and memory bandwidth. Begin now:\n\n")
|
||||||
|
|
||||||
|
|
||||||
|
def post_stream(base_url, model, prompt, max_tokens, timeout):
|
||||||
|
"""Stream /v1/completions; return (completion_tokens, t_first, t_last)."""
|
||||||
|
body = json.dumps({
|
||||||
|
"model": model, "prompt": prompt, "max_tokens": max_tokens,
|
||||||
|
"temperature": 0.0, "stream": True,
|
||||||
|
"stream_options": {"include_usage": True},
|
||||||
|
# force the full token budget so we measure steady-state decode
|
||||||
|
"ignore_eos": True, "min_tokens": max_tokens,
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(base_url.rstrip("/") + "/v1/completions",
|
||||||
|
data=body, headers={"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer x"})
|
||||||
|
t_first = t_last = None
|
||||||
|
completion_tokens = 0
|
||||||
|
chunks = 0
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
for raw in r:
|
||||||
|
line = raw.decode("utf-8", "replace").strip()
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
data = line[5:].strip()
|
||||||
|
if data == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
obj = json.loads(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
usage = obj.get("usage")
|
||||||
|
if usage and usage.get("completion_tokens"):
|
||||||
|
completion_tokens = usage["completion_tokens"]
|
||||||
|
choices = obj.get("choices") or []
|
||||||
|
if choices and choices[0].get("text"):
|
||||||
|
now = time.perf_counter()
|
||||||
|
if t_first is None:
|
||||||
|
t_first = now
|
||||||
|
t_last = now
|
||||||
|
chunks += 1
|
||||||
|
if completion_tokens == 0:
|
||||||
|
completion_tokens = chunks # fallback: 1 chunk ~= 1 token
|
||||||
|
return completion_tokens, t_first, t_last
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_metrics(base_url, timeout=10):
|
||||||
|
"""Return dict of spec-decode counters from vLLM /metrics, if present."""
|
||||||
|
keys = ("num_accepted_tokens", "num_draft_tokens", "num_drafts",
|
||||||
|
"accepted_tokens", "draft_tokens")
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(base_url.rstrip("/") + "/metrics", timeout=timeout) as r:
|
||||||
|
for line in r.read().decode("utf-8", "replace").splitlines():
|
||||||
|
if line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "spec_decode" in line or "speculat" in line:
|
||||||
|
name, _, val = line.partition(" ")
|
||||||
|
try:
|
||||||
|
out[name] = out.get(name, 0.0) + float(val)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except (urllib.error.URLError, OSError):
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||||||
|
ap.add_argument("--model", required=True)
|
||||||
|
ap.add_argument("--max-tokens", type=int, default=256)
|
||||||
|
ap.add_argument("--runs", type=int, default=5)
|
||||||
|
ap.add_argument("--warmup", type=int, default=1)
|
||||||
|
ap.add_argument("--timeout", type=float, default=600)
|
||||||
|
ap.add_argument("--label", default="")
|
||||||
|
ap.add_argument("--prompt", default=PROMPT)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
m_before = scrape_metrics(args.base_url)
|
||||||
|
for _ in range(args.warmup):
|
||||||
|
try:
|
||||||
|
post_stream(args.base_url, args.model, args.prompt, 32, args.timeout)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"warmup failed: {e}", file=sys.stderr); sys.exit(2)
|
||||||
|
|
||||||
|
decode_tps, ttfts, e2e_tps = [], [], []
|
||||||
|
for i in range(args.runs):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
toks, tf, tl = post_stream(args.base_url, args.model, args.prompt, args.max_tokens, args.timeout)
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
if not toks or tf is None or tl is None or tl <= tf:
|
||||||
|
print(f" run {i}: degenerate (toks={toks})", file=sys.stderr); continue
|
||||||
|
dec = (toks - 1) / (tl - tf)
|
||||||
|
decode_tps.append(dec); ttfts.append((tf - t0) * 1000); e2e_tps.append(toks / (t1 - t0))
|
||||||
|
print(f" run {i}: {toks} tok decode={dec:6.1f} tok/s ttft={ (tf-t0)*1000:6.0f} ms")
|
||||||
|
m_after = scrape_metrics(args.base_url)
|
||||||
|
|
||||||
|
if not decode_tps:
|
||||||
|
print("no successful runs", file=sys.stderr); sys.exit(1)
|
||||||
|
print(f"\n=== {args.label or args.model} ===")
|
||||||
|
print(f"decode tok/s : median {statistics.median(decode_tps):.1f} "
|
||||||
|
f"mean {statistics.mean(decode_tps):.1f} min {min(decode_tps):.1f} max {max(decode_tps):.1f}")
|
||||||
|
print(f"ttft ms : median {statistics.median(ttfts):.0f}")
|
||||||
|
print(f"e2e tok/s : median {statistics.median(e2e_tps):.1f}")
|
||||||
|
|
||||||
|
# spec-decode acceptance length, if counters moved
|
||||||
|
def delta(k):
|
||||||
|
return m_after.get(k, 0.0) - m_before.get(k, 0.0)
|
||||||
|
acc = next((delta(k) for k in m_after if "accepted" in k), 0.0)
|
||||||
|
drafts = next((delta(k) for k in m_after if "num_drafts" in k or ("draft" in k and "tokens" not in k)), 0.0)
|
||||||
|
dtoks = next((delta(k) for k in m_after if "draft_tokens" in k or "num_draft_tokens" in k), 0.0)
|
||||||
|
if acc or dtoks:
|
||||||
|
al = (acc / drafts) if drafts else float("nan")
|
||||||
|
rate = (acc / dtoks) if dtoks else float("nan")
|
||||||
|
print(f"spec accept : +{acc:.0f} accepted, +{dtoks:.0f} drafted, "
|
||||||
|
f"mean accept len ~{al:.2f}, accept rate ~{rate:.1%}")
|
||||||
|
else:
|
||||||
|
print("spec accept : (no spec-decode counters — baseline/no drafter)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Real-world DFlash/MTP acceptance bench on the actual Hermes agent workload.
|
||||||
|
|
||||||
|
Reconstructs real conversation contexts from ~/.hermes/state.db and has the loaded
|
||||||
|
server regenerate the *next assistant turn* (so the model that produced the history is
|
||||||
|
irrelevant — only the realistic context matters). Measures mean acceptance length
|
||||||
|
(authoritative vLLM /metrics deltas) + decode tok/s, aggregated over N real turns.
|
||||||
|
Privacy: runs entirely on the box; prints only metrics, not conversation content.
|
||||||
|
"""
|
||||||
|
import argparse, json, sqlite3, sys, time, urllib.request, urllib.error
|
||||||
|
|
||||||
|
DB = "/home/ent/.hermes/state.db"
|
||||||
|
|
||||||
|
|
||||||
|
def scrape(base):
|
||||||
|
out = {"acc": 0.0, "drafts": 0.0, "dtoks": 0.0}
|
||||||
|
try:
|
||||||
|
txt = urllib.request.urlopen(base.rstrip("/") + "/metrics", timeout=10).read().decode("utf-8", "replace")
|
||||||
|
except Exception:
|
||||||
|
return out
|
||||||
|
for ln in txt.splitlines():
|
||||||
|
if ln.startswith("#"):
|
||||||
|
continue
|
||||||
|
v = ln.split()[-1] if ln.split() else "0"
|
||||||
|
try:
|
||||||
|
val = float(v)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if "spec_decode_num_accepted_tokens_total" in ln:
|
||||||
|
out["acc"] += val
|
||||||
|
elif "spec_decode_num_drafts_total" in ln:
|
||||||
|
out["drafts"] += val
|
||||||
|
elif "spec_decode_num_draft_tokens_total" in ln:
|
||||||
|
out["dtoks"] += val
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_messages(cur, session_id, system_prompt, char_budget=24000):
|
||||||
|
rows = list(cur.execute(
|
||||||
|
"SELECT role, content, tool_calls, tool_call_id, tool_name FROM messages "
|
||||||
|
"WHERE session_id=? ORDER BY id", (session_id,)))
|
||||||
|
# find the LAST assistant turn -> generate it; prompt = everything before it
|
||||||
|
last_asst = None
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
if r[0] == "assistant":
|
||||||
|
last_asst = i
|
||||||
|
if last_asst is None or last_asst == 0:
|
||||||
|
return None
|
||||||
|
pre = rows[:last_asst]
|
||||||
|
msgs = []
|
||||||
|
for role, content, tool_calls, tool_call_id, tool_name in pre:
|
||||||
|
content = content or ""
|
||||||
|
if role == "user":
|
||||||
|
msgs.append({"role": "user", "content": content})
|
||||||
|
elif role == "assistant":
|
||||||
|
m = {"role": "assistant", "content": content}
|
||||||
|
if tool_calls:
|
||||||
|
try:
|
||||||
|
tc = json.loads(tool_calls)
|
||||||
|
if isinstance(tc, list) and tc:
|
||||||
|
m["tool_calls"] = tc
|
||||||
|
if not content:
|
||||||
|
m["content"] = ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
msgs.append(m)
|
||||||
|
elif role == "tool":
|
||||||
|
msgs.append({"role": "tool", "content": content,
|
||||||
|
"tool_call_id": tool_call_id or "call_0"})
|
||||||
|
# skip session_meta
|
||||||
|
if not msgs:
|
||||||
|
return None
|
||||||
|
# truncate oldest non-system messages to fit budget
|
||||||
|
sys_msg = [{"role": "system", "content": system_prompt}] if system_prompt else []
|
||||||
|
while sum(len(json.dumps(m)) for m in msgs) > char_budget and len(msgs) > 1:
|
||||||
|
msgs.pop(0)
|
||||||
|
return sys_msg + msgs
|
||||||
|
|
||||||
|
|
||||||
|
def stream_chat(base, model, messages, max_tokens, timeout):
|
||||||
|
body = json.dumps({"model": model, "messages": messages, "max_tokens": max_tokens,
|
||||||
|
"temperature": 0.0, "stream": True,
|
||||||
|
"stream_options": {"include_usage": True}}).encode()
|
||||||
|
req = urllib.request.Request(base.rstrip("/") + "/v1/chat/completions", data=body,
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": "Bearer x"})
|
||||||
|
t_first = t_last = None
|
||||||
|
toks = 0
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
for raw in r:
|
||||||
|
ln = raw.decode("utf-8", "replace").strip()
|
||||||
|
if not ln.startswith("data:"):
|
||||||
|
continue
|
||||||
|
d = ln[5:].strip()
|
||||||
|
if d == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
o = json.loads(d)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
u = o.get("usage")
|
||||||
|
if u and u.get("completion_tokens"):
|
||||||
|
toks = u["completion_tokens"]
|
||||||
|
ch = o.get("choices") or []
|
||||||
|
if ch and (ch[0].get("delta") or {}).get("content"):
|
||||||
|
now = time.perf_counter()
|
||||||
|
if t_first is None:
|
||||||
|
t_first = now
|
||||||
|
t_last = now
|
||||||
|
return toks, t_first, t_last
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||||||
|
ap.add_argument("--model", default="qwen")
|
||||||
|
ap.add_argument("--max-tokens", type=int, default=200)
|
||||||
|
ap.add_argument("--n-samples", type=int, default=10)
|
||||||
|
ap.add_argument("--min-msgs", type=int, default=6)
|
||||||
|
ap.add_argument("--timeout", type=float, default=300)
|
||||||
|
ap.add_argument("--label", default="HERMES real")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
db = sqlite3.connect(DB)
|
||||||
|
cur = db.cursor()
|
||||||
|
sess = list(cur.execute(
|
||||||
|
"SELECT s.id, s.system_prompt FROM sessions s "
|
||||||
|
"WHERE s.message_count >= ? ORDER BY s.started_at DESC LIMIT 40", (args.min_msgs,)))
|
||||||
|
|
||||||
|
m0 = scrape(args.base_url)
|
||||||
|
decode_tps, used = [], 0
|
||||||
|
for sid, sysp in sess:
|
||||||
|
if used >= args.n_samples:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
msgs = build_messages(cur, sid, sysp)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not msgs:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
toks, tf, tl = stream_chat(args.base_url, args.model, msgs, args.max_tokens, args.timeout)
|
||||||
|
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e:
|
||||||
|
print(f" skip {sid[:24]}: {type(e).__name__}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
if toks and tf and tl and tl > tf:
|
||||||
|
dec = (toks - 1) / (tl - tf)
|
||||||
|
decode_tps.append(dec)
|
||||||
|
used += 1
|
||||||
|
print(f" sample {used}: {toks} tok decode={dec:5.1f} tok/s ({sid[:28]})")
|
||||||
|
m1 = scrape(args.base_url)
|
||||||
|
|
||||||
|
dacc = m1["acc"] - m0["acc"]
|
||||||
|
ddr = m1["drafts"] - m0["drafts"]
|
||||||
|
ddt = m1["dtoks"] - m0["dtoks"]
|
||||||
|
import statistics
|
||||||
|
print(f"\n=== {args.label} (n={used} real turns) ===")
|
||||||
|
if decode_tps:
|
||||||
|
print(f"decode tok/s : median {statistics.median(decode_tps):.1f} mean {statistics.mean(decode_tps):.1f} "
|
||||||
|
f"min {min(decode_tps):.1f} max {max(decode_tps):.1f}")
|
||||||
|
if ddr > 0:
|
||||||
|
print(f"accept length: {1 + dacc/ddr:.2f} (accepted {dacc:.0f} / drafts {ddr:.0f}; "
|
||||||
|
f"draft tokens {ddt:.0f}; accept rate {dacc/ddt:.1%})")
|
||||||
|
else:
|
||||||
|
print("accept length: (no draft activity captured)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Monitor SGLang container startup with an OOM auto-kill guard.
|
||||||
|
# Breaks on: READY, ERROR (traceback), OOM-GUARD (avail mem too low), or timeout.
|
||||||
|
set -u
|
||||||
|
NAME="${1:-sglang-dflash}"
|
||||||
|
MAX_ITERS="${2:-60}" # 60 * 20s = 20 min
|
||||||
|
FLOOR_MB="${3:-4096}" # kill if available memory drops below this
|
||||||
|
|
||||||
|
for i in $(seq 1 "$MAX_ITERS"); do
|
||||||
|
if ! docker ps --filter "name=$NAME" --format '{{.Names}}' | grep -q "$NAME"; then
|
||||||
|
echo "STATE=EXITED iter=$i"
|
||||||
|
echo "--- last log ---"; docker logs "$NAME" 2>&1 | tail -25
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
AVAIL=$(free -m | awk '/^Mem:/{print $7}')
|
||||||
|
if [ "$AVAIL" -lt "$FLOOR_MB" ]; then
|
||||||
|
echo "STATE=OOM-GUARD iter=$i avail_mb=$AVAIL -> killing $NAME"
|
||||||
|
docker kill "$NAME" >/dev/null 2>&1
|
||||||
|
echo "--- last log ---"; docker logs "$NAME" 2>&1 | tail -25
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
LOG=$(docker logs "$NAME" 2>&1)
|
||||||
|
if echo "$LOG" | grep -qiE "server is fired up|Application startup complete|The server is ready"; then
|
||||||
|
echo "STATE=READY iter=$i avail_mb=$AVAIL"
|
||||||
|
echo "--- tail ---"; echo "$LOG" | tail -20
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if echo "$LOG" | grep -qE "Traceback \(most recent call last\)|CUDA out of memory|RuntimeError|AssertionError|ValueError|raise NotImplementedError"; then
|
||||||
|
echo "STATE=ERROR iter=$i avail_mb=$AVAIL"
|
||||||
|
echo "--- tail ---"; echo "$LOG" | tail -40
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
LAST=$(echo "$LOG" | tail -1 | cut -c1-110)
|
||||||
|
echo "iter=$i avail_mb=$AVAIL :: $LAST"
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
echo "STATE=TIMEOUT after $MAX_ITERS iters"
|
||||||
|
docker logs "$NAME" 2>&1 | tail -20
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run the 4-workload acceptance/throughput bank on whichever server is up.
|
||||||
|
# $1 = container name (for the authoritative server-log accept_len). Uses the SAME
|
||||||
|
# prompts as the DFlash run so DFlash vs MTP is apples-to-apples.
|
||||||
|
CN="${1:-vllm-mtp}"
|
||||||
|
BU=http://127.0.0.1:8000; M=qwen
|
||||||
|
acc_tail(){ docker logs "$CN" 2>&1 | grep "Mean acceptance length" | tail -1 \
|
||||||
|
| sed -E "s/.*Mean acceptance length: ([0-9.]+).*Per-position acceptance rate: ([0-9., ]+), Avg.*/accept_len=\1 per-pos=[\2]/"; }
|
||||||
|
run(){ L="$1"; P="$2"; echo "########## $L ##########"
|
||||||
|
python3 ~/bench_decode.py --base-url $BU --model $M --max-tokens 256 --runs 4 --warmup 1 --label "$L" --prompt "$P" 2>&1 | grep -E "decode tok/s"
|
||||||
|
echo " server $(acc_tail)"; }
|
||||||
|
run "PROSE" "You are a careful writer. Write a long flowing essay about the history and philosophy of science, with no lists. Begin: "
|
||||||
|
run "CODE" "Write a complete Python implementation of a binary search tree class with insert, search, delete, and inorder traversal. Include docstrings and type hints. Begin:\n\nclass BSTNode:"
|
||||||
|
run "COUNTING" "Write all the whole numbers from 1 to 400, separated by commas, with no other text. Begin: 1, 2, 3, "
|
||||||
|
echo "########## HERMES (real agent turns) ##########"
|
||||||
|
python3 ~/hermes_bench.py --base-url $BU --model $M --label "HERMES" --n-samples 10 --max-tokens 200 2>&1 | grep -E "decode tok/s|accept length"
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Build a hybrid GPTQ-INT4 + FP8 checkpoint for Qwen3.5-122B-A10B.
|
||||||
|
|
||||||
|
Takes MoE expert weights from the GPTQ-INT4 checkpoint (0.5 bytes/param),
|
||||||
|
and dense layers (attention, shared experts, embeddings) from the official
|
||||||
|
FP8 checkpoint (1 byte/param + calibrated block scales).
|
||||||
|
|
||||||
|
Result: a checkpoint that is ~9 GB smaller than the GPTQ-INT4 original
|
||||||
|
while using properly calibrated FP8 scales (not naive cast), yielding
|
||||||
|
better decode throughput on bandwidth-limited hardware.
|
||||||
|
|
||||||
|
NOTE: Requires the hybrid FP8 dispatch patch from https://github.com/rmstxrx/vllm/tree/v0.17.1-hybrid-fp8
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python build-hybrid-checkpoint.py \
|
||||||
|
--gptq-dir ~/inference/models/hf/qwen3.5-122b-a10b-gptq-int4 \
|
||||||
|
--fp8-repo Qwen/Qwen3.5-122B-A10B-FP8 \
|
||||||
|
--output ~/inference/models/hf/qwen3.5-122b-a10b-fp8hybrid
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from huggingface_hub import hf_hub_download
|
||||||
|
from safetensors import safe_open
|
||||||
|
from safetensors.torch import load_file, save_file
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_fp8_non_expert_manifest(fp8_repo: str) -> dict[str, str]:
|
||||||
|
"""Get the weight map for non-expert tensors from the FP8 checkpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fp8_repo: Hugging Face repo ID for the FP8 checkpoint.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mapping of tensor names to shard filenames for non-expert tensors.
|
||||||
|
"""
|
||||||
|
idx_path = hf_hub_download(fp8_repo, "model.safetensors.index.json")
|
||||||
|
with open(idx_path, encoding="utf-8") as f:
|
||||||
|
idx = json.load(f)
|
||||||
|
|
||||||
|
wm = idx["weight_map"]
|
||||||
|
return {k: v for k, v in wm.items() if ".experts." not in k}
|
||||||
|
|
||||||
|
|
||||||
|
def download_fp8_shards(fp8_repo: str, shards: set[str], cache_dir: Path) -> dict[str, Path]:
|
||||||
|
"""Download only the needed FP8 shards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fp8_repo: Hugging Face repo ID for the FP8 checkpoint.
|
||||||
|
shards: Shard filenames that contain non-expert tensors.
|
||||||
|
cache_dir: Local cache directory for downloaded shards.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mapping of shard filename to downloaded local path.
|
||||||
|
"""
|
||||||
|
shard_paths: dict[str, Path] = {}
|
||||||
|
for shard in sorted(shards):
|
||||||
|
logger.info(" Downloading %s...", shard)
|
||||||
|
path = hf_hub_download(fp8_repo, shard, local_dir=str(cache_dir))
|
||||||
|
shard_paths[shard] = Path(path)
|
||||||
|
logger.info(" -> %s", path)
|
||||||
|
return shard_paths
|
||||||
|
|
||||||
|
|
||||||
|
def extract_fp8_tensors(shard_paths: dict[str, Path], wanted: dict[str, str]) -> dict[str, torch.Tensor]:
|
||||||
|
"""Extract the requested FP8 tensors from downloaded shards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shard_paths: Mapping of shard filename to local path.
|
||||||
|
wanted: Mapping of tensor name to shard filename.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mapping of tensor name to loaded FP8 tensor.
|
||||||
|
"""
|
||||||
|
tensors: dict[str, torch.Tensor] = {}
|
||||||
|
for shard_name, shard_path in shard_paths.items():
|
||||||
|
keys_in_shard = [k for k, v in wanted.items() if v == shard_name]
|
||||||
|
if not keys_in_shard:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(" Extracting %d tensors from %s...", len(keys_in_shard), shard_name)
|
||||||
|
with safe_open(str(shard_path), framework="pt") as f:
|
||||||
|
for k in keys_in_shard:
|
||||||
|
tensors[k] = f.get_tensor(k)
|
||||||
|
|
||||||
|
return tensors
|
||||||
|
|
||||||
|
|
||||||
|
def find_model_safetensors_files(directory: Path) -> list[Path]:
|
||||||
|
"""Find model safetensors files in a GPTQ checkpoint directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Directory containing GPTQ checkpoint files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of shard paths, or a single-element list for single-file checkpoints.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If no supported model safetensors files are found.
|
||||||
|
"""
|
||||||
|
gptq_shards = sorted(directory.glob("model.safetensors-*"))
|
||||||
|
if gptq_shards:
|
||||||
|
return gptq_shards
|
||||||
|
|
||||||
|
# AutoRound / HF naming: model-NNNNN-of-NNNNN.safetensors
|
||||||
|
gptq_shards = sorted(directory.glob("model-*-of-*.safetensors"))
|
||||||
|
if gptq_shards:
|
||||||
|
return gptq_shards
|
||||||
|
|
||||||
|
single_file = directory / "model.safetensors"
|
||||||
|
if single_file.is_file():
|
||||||
|
return [single_file]
|
||||||
|
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"No model.safetensors files found in {directory}. Expected a sharded "
|
||||||
|
"checkpoint (model.safetensors-NNNNN-of-NNNNN)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_gptq_input(gptq_dir: Path) -> None:
|
||||||
|
"""Validate the GPTQ checkpoint directory before any downloads.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gptq_dir: Path to the local GPTQ checkpoint directory.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If the directory or required files do not exist.
|
||||||
|
NotADirectoryError: If the path exists but is not a directory.
|
||||||
|
"""
|
||||||
|
if not gptq_dir.exists():
|
||||||
|
raise FileNotFoundError(f"GPTQ directory does not exist: {gptq_dir}")
|
||||||
|
if not gptq_dir.is_dir():
|
||||||
|
raise NotADirectoryError(f"GPTQ path is not a directory: {gptq_dir}")
|
||||||
|
if not any(path.is_file() for path in gptq_dir.glob("*.safetensors*")):
|
||||||
|
raise FileNotFoundError(f"No .safetensors files found in {gptq_dir}")
|
||||||
|
if not (gptq_dir / "config.json").is_file():
|
||||||
|
raise FileNotFoundError(f"Missing config.json in {gptq_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_output_dir(output_dir: Path, force: bool) -> None:
|
||||||
|
"""Validate the output directory before building.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Path where the hybrid checkpoint will be written.
|
||||||
|
force: Whether destructive cleanup is allowed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileExistsError: If the output directory is non-empty without `force`.
|
||||||
|
NotADirectoryError: If the output path exists but is not a directory.
|
||||||
|
"""
|
||||||
|
if output_dir.exists() and not output_dir.is_dir():
|
||||||
|
raise NotADirectoryError(f"Output path is not a directory: {output_dir}")
|
||||||
|
|
||||||
|
if output_dir.exists() and any(output_dir.iterdir()) and not force:
|
||||||
|
raise FileExistsError(
|
||||||
|
f"Output directory {output_dir} exists and is not empty. Use --force "
|
||||||
|
"to remove existing model.safetensors* and config.json files before building."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_output_dir(output_dir: Path, force: bool) -> None:
|
||||||
|
"""Create or clean the output directory before writing files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Path where the hybrid checkpoint will be written.
|
||||||
|
force: Whether destructive cleanup is allowed.
|
||||||
|
"""
|
||||||
|
if not output_dir.exists():
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
return
|
||||||
|
|
||||||
|
delete_targets = sorted(
|
||||||
|
{
|
||||||
|
path
|
||||||
|
for pattern in ("model.safetensors*", "config.json")
|
||||||
|
for path in output_dir.glob(pattern)
|
||||||
|
if path.is_file()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for path in delete_targets:
|
||||||
|
path.unlink()
|
||||||
|
logger.info(" Deleted %s", path)
|
||||||
|
|
||||||
|
|
||||||
|
def build_hybrid_checkpoint(
|
||||||
|
gptq_dir: Path,
|
||||||
|
fp8_tensors: dict[str, torch.Tensor],
|
||||||
|
output_dir: Path,
|
||||||
|
force: bool,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
|
"""Build the hybrid checkpoint from GPTQ and FP8 tensors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gptq_dir: Path to the local GPTQ checkpoint directory.
|
||||||
|
fp8_tensors: FP8 tensors keyed by tensor name.
|
||||||
|
output_dir: Output directory for rewritten checkpoint shards.
|
||||||
|
force: Whether to continue if many unexpected FP8 tensors are unmatched.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of replaced tensor count, added scale tensor count, and bytes saved.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If no supported GPTQ model files are found.
|
||||||
|
RuntimeError: If too many FP8 tensors cannot be matched and `force` is not set.
|
||||||
|
ValueError: If a matched FP8 tensor has an incompatible shape.
|
||||||
|
"""
|
||||||
|
# 1. Copy non-safetensors files
|
||||||
|
for f in gptq_dir.iterdir():
|
||||||
|
if f.suffix != ".safetensors" and not f.name.startswith("."):
|
||||||
|
if f.is_file():
|
||||||
|
shutil.copy2(f, output_dir / f.name)
|
||||||
|
|
||||||
|
# 2. Process each GPTQ shard
|
||||||
|
gptq_shards = find_model_safetensors_files(gptq_dir)
|
||||||
|
total_replaced = 0
|
||||||
|
total_added = 0
|
||||||
|
total_saved_bytes = 0
|
||||||
|
|
||||||
|
# Track which FP8 tensors have been placed
|
||||||
|
placed_fp8: set[str] = set()
|
||||||
|
|
||||||
|
for i, shard_path in enumerate(gptq_shards):
|
||||||
|
shard_name = shard_path.name
|
||||||
|
logger.info(" [%d/%d] %s...", i + 1, len(gptq_shards), shard_name)
|
||||||
|
|
||||||
|
gptq_tensors = load_file(str(shard_path))
|
||||||
|
output_tensors: dict[str, torch.Tensor] = {}
|
||||||
|
replaced = 0
|
||||||
|
|
||||||
|
for name, tensor in gptq_tensors.items():
|
||||||
|
if name in fp8_tensors:
|
||||||
|
# Replace BF16 tensor with FP8 version
|
||||||
|
fp8_tensor = fp8_tensors[name]
|
||||||
|
if tensor.shape != fp8_tensor.shape:
|
||||||
|
raise ValueError(
|
||||||
|
f"Shape mismatch for {name}: GPTQ={tensor.shape}, FP8={fp8_tensor.shape}"
|
||||||
|
)
|
||||||
|
old_bytes = tensor.numel() * tensor.element_size()
|
||||||
|
new_bytes = fp8_tensor.numel() * fp8_tensor.element_size()
|
||||||
|
total_saved_bytes += old_bytes - new_bytes
|
||||||
|
output_tensors[name] = fp8_tensor
|
||||||
|
placed_fp8.add(name)
|
||||||
|
replaced += 1
|
||||||
|
|
||||||
|
# Also add the scale tensor if it exists
|
||||||
|
scale_name = name.replace(".weight", ".weight_scale_inv")
|
||||||
|
if scale_name in fp8_tensors and scale_name != name:
|
||||||
|
output_tensors[scale_name] = fp8_tensors[scale_name]
|
||||||
|
placed_fp8.add(scale_name)
|
||||||
|
total_added += 1
|
||||||
|
else:
|
||||||
|
output_tensors[name] = tensor
|
||||||
|
|
||||||
|
total_replaced += replaced
|
||||||
|
|
||||||
|
save_file(output_tensors, str(output_dir / shard_name))
|
||||||
|
logger.info(" replaced=%d, tensors=%d", replaced, len(output_tensors))
|
||||||
|
|
||||||
|
# 3. Check for any FP8 tensors not yet placed (e.g. scale tensors
|
||||||
|
# for weights that exist in shards we already processed)
|
||||||
|
unplaced = set(fp8_tensors.keys()) - placed_fp8
|
||||||
|
if unplaced:
|
||||||
|
expected_unplaced = {
|
||||||
|
name
|
||||||
|
for name in unplaced
|
||||||
|
if name.endswith(".weight_scale_inv")
|
||||||
|
and f"{name.removesuffix('.weight_scale_inv')}.weight" in placed_fp8
|
||||||
|
}
|
||||||
|
unexpected_unplaced = sorted(unplaced - expected_unplaced)
|
||||||
|
|
||||||
|
for name in unexpected_unplaced:
|
||||||
|
logger.warning("WARNING: unexpected unmatched FP8 tensor %s", name)
|
||||||
|
|
||||||
|
if len(unexpected_unplaced) > 5 and not force:
|
||||||
|
message = (
|
||||||
|
f"{len(unexpected_unplaced)} FP8 tensors could not be matched to GPTQ tensors. "
|
||||||
|
"This likely indicates a naming mismatch between the GPTQ and FP8 checkpoints. "
|
||||||
|
"Use --force to proceed anyway."
|
||||||
|
)
|
||||||
|
logger.error("ERROR: %s", message)
|
||||||
|
raise RuntimeError(message)
|
||||||
|
if len(unexpected_unplaced) > 5 and force:
|
||||||
|
logger.warning(
|
||||||
|
"WARNING: proceeding despite %d unexpected unmatched FP8 tensors because --force was provided",
|
||||||
|
len(unexpected_unplaced),
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_unplaced:
|
||||||
|
logger.info(" Adding %d expected unplaced FP8 scale tensors to final shard...", len(expected_unplaced))
|
||||||
|
|
||||||
|
if expected_unplaced:
|
||||||
|
# Load last shard, add expected scale tensors, re-save
|
||||||
|
last_shard = output_dir / gptq_shards[-1].name
|
||||||
|
existing = load_file(str(last_shard))
|
||||||
|
for name in sorted(expected_unplaced):
|
||||||
|
existing[name] = fp8_tensors[name]
|
||||||
|
total_added += 1
|
||||||
|
save_file(existing, str(last_shard))
|
||||||
|
|
||||||
|
return total_replaced, total_added, total_saved_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def update_safetensors_index(output_dir: Path) -> None:
|
||||||
|
"""Rebuild `model.safetensors.index.json` from actual shard contents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Directory containing rewritten model shards.
|
||||||
|
"""
|
||||||
|
weight_map: dict[str, str] = {}
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
for shard_path in find_model_safetensors_files(output_dir):
|
||||||
|
with safe_open(str(shard_path), framework="pt") as f:
|
||||||
|
for key in f.keys():
|
||||||
|
weight_map[key] = shard_path.name
|
||||||
|
tensor = f.get_tensor(key)
|
||||||
|
total_size += tensor.numel() * tensor.element_size()
|
||||||
|
|
||||||
|
index = {
|
||||||
|
"metadata": {"total_size": total_size},
|
||||||
|
"weight_map": weight_map
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(output_dir / "model.safetensors.index.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(index, f, indent=2, sort_keys=True)
|
||||||
|
|
||||||
|
logger.info(" Index rebuilt: %d tensors, %.2f GB", len(weight_map), total_size / 1e9)
|
||||||
|
|
||||||
|
|
||||||
|
def update_config(output_dir: Path) -> None:
|
||||||
|
"""Update `config.json` with hybrid quantization metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Directory containing the hybrid checkpoint.
|
||||||
|
"""
|
||||||
|
config_path = output_dir / "config.json"
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
config["_hybrid_quant_info"] = {
|
||||||
|
"description": "Hybrid GPTQ-INT4 + FP8 checkpoint for single-GPU deployment",
|
||||||
|
"moe_experts": "GPTQ INT4 (group_size=128, sym=True, Marlin kernels)",
|
||||||
|
"dense_layers": "FP8 E4M3 block-128 (from official Qwen/Qwen3.5-122B-A10B-FP8, calibrated scales)",
|
||||||
|
"norms_gates_embeddings": "Preserved at source dtype (BF16 for norms/gates, FP8 for others)",
|
||||||
|
"source_gptq": "Qwen/Qwen3.5-122B-A10B-GPTQ-Int4",
|
||||||
|
"source_fp8": "Qwen/Qwen3.5-122B-A10B-FP8",
|
||||||
|
"vllm_patch": "https://github.com/rmstxrx/vllm/tree/v0.17.1-hybrid-fp8",
|
||||||
|
"target_hardware": "NVIDIA DGX Spark (GB10, 128GB unified, 273 GB/s)",
|
||||||
|
"converter": "build-hybrid-checkpoint.py"
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Build a hybrid GPTQ-INT4 + FP8 checkpoint.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Process exit code.
|
||||||
|
"""
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
parser = argparse.ArgumentParser(description="Build hybrid GPTQ-INT4 + FP8 checkpoint")
|
||||||
|
parser.add_argument("--gptq-dir", required=True, help="Path to GPTQ-INT4 model")
|
||||||
|
parser.add_argument("--fp8-repo", default="Qwen/Qwen3.5-122B-A10B-FP8", help="HF repo for FP8 model")
|
||||||
|
parser.add_argument("--output", required=True, help="Output directory")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Allow output cleanup and continue despite many unmatched FP8 tensors",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
gptq_dir = Path(args.gptq_dir)
|
||||||
|
output_dir = Path(args.output)
|
||||||
|
|
||||||
|
validate_gptq_input(gptq_dir)
|
||||||
|
find_model_safetensors_files(gptq_dir)
|
||||||
|
validate_output_dir(output_dir, args.force)
|
||||||
|
|
||||||
|
logger.info("=== Hybrid GPTQ-INT4 + FP8 Checkpoint Builder ===")
|
||||||
|
logger.info(" GPTQ source: %s", gptq_dir)
|
||||||
|
logger.info(" FP8 source: %s", args.fp8_repo)
|
||||||
|
logger.info(" Output: %s", output_dir)
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
|
# Step 1: Get manifest of non-expert tensors from FP8 checkpoint
|
||||||
|
logger.info("[1/5] Fetching FP8 tensor manifest...")
|
||||||
|
fp8_manifest = get_fp8_non_expert_manifest(args.fp8_repo)
|
||||||
|
shards_needed = set(fp8_manifest.values())
|
||||||
|
logger.info(" Non-expert tensors: %d", len(fp8_manifest))
|
||||||
|
logger.info(" Shards to download: %s", sorted(shards_needed))
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
logger.info("")
|
||||||
|
logger.info("[DRY RUN] Would download shards and build hybrid. Exiting.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
prepare_output_dir(output_dir, args.force)
|
||||||
|
cache_dir = output_dir / ".fp8_cache"
|
||||||
|
cache_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Step 2: Download needed FP8 shards
|
||||||
|
logger.info("")
|
||||||
|
logger.info("[2/5] Downloading %d FP8 shards...", len(shards_needed))
|
||||||
|
shard_paths = download_fp8_shards(args.fp8_repo, shards_needed, cache_dir)
|
||||||
|
|
||||||
|
# Step 3: Extract non-expert FP8 tensors
|
||||||
|
logger.info("")
|
||||||
|
logger.info("[3/5] Extracting FP8 tensors...")
|
||||||
|
fp8_tensors = extract_fp8_tensors(shard_paths, fp8_manifest)
|
||||||
|
logger.info(" Extracted: %d tensors", len(fp8_tensors))
|
||||||
|
|
||||||
|
# Show dtype breakdown
|
||||||
|
dtypes: dict[str, int] = {}
|
||||||
|
for name, t in fp8_tensors.items():
|
||||||
|
d = str(t.dtype)
|
||||||
|
dtypes[d] = dtypes.get(d, 0) + 1
|
||||||
|
logger.info(" Dtypes: %s", dtypes)
|
||||||
|
|
||||||
|
# Step 4: Build hybrid checkpoint
|
||||||
|
logger.info("")
|
||||||
|
logger.info("[4/5] Building hybrid checkpoint...")
|
||||||
|
replaced, added, saved = build_hybrid_checkpoint(
|
||||||
|
gptq_dir,
|
||||||
|
fp8_tensors,
|
||||||
|
output_dir,
|
||||||
|
args.force,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Update index and config
|
||||||
|
logger.info("")
|
||||||
|
logger.info("[5/5] Updating index and config...")
|
||||||
|
update_safetensors_index(output_dir)
|
||||||
|
update_config(output_dir)
|
||||||
|
|
||||||
|
# Cleanup downloaded FP8 shards
|
||||||
|
shutil.rmtree(cache_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=== Complete ===")
|
||||||
|
logger.info(" Tensors replaced (BF16→FP8): %d", replaced)
|
||||||
|
logger.info(" Scale tensors added: %d", added)
|
||||||
|
logger.info(" Bytes saved: %.2f GB", saved / 1e9)
|
||||||
|
logger.info(" Output: %s", output_dir)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Inspect the Intel INT4 AutoRound checkpoint to decide if albond's hybrid
|
||||||
|
INT4+FP8 build will work: it swaps BF16 *dense* (non-expert) tensors for FP8 by
|
||||||
|
name. If AutoRound quantized the dense linears to INT4 (.qweight), the swap is a
|
||||||
|
near no-op. We need attention/shared_expert dense weights stored as BF16 (.weight).
|
||||||
|
|
||||||
|
Also pulls the FP8 repo's index to confirm it exists and that names line up.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
INT4_DIR = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(
|
||||||
|
"/home/ent/.cache/huggingface/hub/models--Intel--Qwen3.5-122B-A10B-int4-AutoRound/"
|
||||||
|
"snapshots/3045d02bb737effc4581da91bddbad3be02934e4")
|
||||||
|
FP8_REPO = sys.argv[2] if len(sys.argv) > 2 else "Qwen/Qwen3.5-122B-A10B-FP8"
|
||||||
|
|
||||||
|
idx = json.loads((INT4_DIR / "model.safetensors.index.json").read_text())
|
||||||
|
wm = idx["weight_map"]
|
||||||
|
names = list(wm.keys())
|
||||||
|
|
||||||
|
# Non-expert = no ".experts." (routed experts stay INT4 in the hybrid)
|
||||||
|
nonexp = [n for n in names if ".experts." not in n]
|
||||||
|
|
||||||
|
def bucket(n):
|
||||||
|
if ".experts." in n:
|
||||||
|
return "ROUTED-EXPERT"
|
||||||
|
if "shared_expert" in n:
|
||||||
|
return "shared_expert"
|
||||||
|
if "self_attn" in n or "linear_attn" in n or ".attn" in n:
|
||||||
|
return "attention"
|
||||||
|
if "embed_tokens" in n or "lm_head" in n:
|
||||||
|
return "embed/head"
|
||||||
|
if "mtp" in n:
|
||||||
|
return "mtp"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
print(f"=== Intel INT4 checkpoint: {INT4_DIR.name} ===")
|
||||||
|
print(f"total tensors: {len(names)} non-expert: {len(nonexp)}")
|
||||||
|
|
||||||
|
# Suffix histogram tells us quant scheme: .qweight/.scales/.qzeros => INT4; .weight => dense
|
||||||
|
suffix = Counter(n.rsplit(".", 1)[-1] for n in nonexp)
|
||||||
|
print("\nnon-expert tensor SUFFIX histogram (qweight/scales/qzeros = INT4-packed; weight = dense):")
|
||||||
|
for s, c in suffix.most_common():
|
||||||
|
print(f" .{s:20s} {c}")
|
||||||
|
|
||||||
|
# For each functional group, does it have .weight (BF16 dense) or .qweight (INT4)?
|
||||||
|
print("\nper-group quant scheme (sample names):")
|
||||||
|
groups = {}
|
||||||
|
for n in nonexp:
|
||||||
|
g = bucket(n)
|
||||||
|
groups.setdefault(g, {"weight": 0, "qweight": 0, "scale": 0, "other": 0, "ex": None})
|
||||||
|
suf = n.rsplit(".", 1)[-1]
|
||||||
|
if suf == "weight":
|
||||||
|
groups[g]["weight"] += 1
|
||||||
|
elif suf == "qweight":
|
||||||
|
groups[g]["qweight"] += 1
|
||||||
|
elif "scale" in suf or suf in ("qzeros",):
|
||||||
|
groups[g]["scale"] += 1
|
||||||
|
else:
|
||||||
|
groups[g]["other"] += 1
|
||||||
|
if groups[g]["ex"] is None and suf in ("weight", "qweight"):
|
||||||
|
groups[g]["ex"] = n
|
||||||
|
for g, d in sorted(groups.items()):
|
||||||
|
scheme = "INT4(.qweight)" if d["qweight"] else ("DENSE(.weight)" if d["weight"] else "?")
|
||||||
|
print(f" {g:16s} weight={d['weight']:4d} qweight={d['qweight']:4d} scale={d['scale']:4d} -> {scheme}")
|
||||||
|
print(f" e.g. {d['ex']}")
|
||||||
|
|
||||||
|
# Dtypes of a few non-expert .weight tensors (open the shard header only).
|
||||||
|
print("\ndtypes of sample non-expert '.weight' tensors (FP8 swap needs BF16 here):")
|
||||||
|
from safetensors import safe_open # noqa: E402
|
||||||
|
sample = [n for n in nonexp if n.endswith(".weight")
|
||||||
|
and ("self_attn" in n or "shared_expert" in n or "embed" in n or "lm_head" in n)]
|
||||||
|
seen_shards = {}
|
||||||
|
shown = 0
|
||||||
|
for n in sample:
|
||||||
|
shard = wm[n]
|
||||||
|
f = seen_shards.get(shard)
|
||||||
|
if f is None:
|
||||||
|
f = safe_open(str(INT4_DIR / shard), framework="pt")
|
||||||
|
seen_shards[shard] = f
|
||||||
|
try:
|
||||||
|
t = f.get_slice(n)
|
||||||
|
print(f" {n:60s} {t.get_dtype()} {tuple(t.get_shape())}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {n:60s} <err {e}>")
|
||||||
|
shown += 1
|
||||||
|
if shown >= 12:
|
||||||
|
break
|
||||||
|
|
||||||
|
# FP8 repo: confirm exists + list its non-expert names/dtypes for name-match sanity.
|
||||||
|
print(f"\n=== FP8 repo manifest: {FP8_REPO} ===")
|
||||||
|
try:
|
||||||
|
from huggingface_hub import hf_hub_download
|
||||||
|
p = hf_hub_download(FP8_REPO, "model.safetensors.index.json")
|
||||||
|
fidx = json.loads(Path(p).read_text())
|
||||||
|
fwm = fidx["weight_map"]
|
||||||
|
fnon = [n for n in fwm if ".experts." not in n]
|
||||||
|
print(f"FP8 total tensors: {len(fwm)} non-expert: {len(fnon)}")
|
||||||
|
fsuf = Counter(n.rsplit('.', 1)[-1] for n in fnon)
|
||||||
|
print("FP8 non-expert suffix histogram:")
|
||||||
|
for s, c in fsuf.most_common(10):
|
||||||
|
print(f" .{s:20s} {c}")
|
||||||
|
# how many FP8 non-expert .weight names also exist in INT4 as .weight?
|
||||||
|
int4_weight = {n for n in nonexp if n.endswith('.weight')}
|
||||||
|
fp8_weight = {n for n in fnon if n.endswith('.weight')}
|
||||||
|
match = int4_weight & fp8_weight
|
||||||
|
print(f"\nname overlap (FP8 '.weight' that also exist as '.weight' in INT4): {len(match)} / {len(fp8_weight)} FP8 weights")
|
||||||
|
only_fp8 = sorted(fp8_weight - int4_weight)[:8]
|
||||||
|
print(f"FP8 '.weight' NOT present as '.weight' in INT4 (would not swap): {len(fp8_weight - int4_weight)}")
|
||||||
|
for n in only_fp8:
|
||||||
|
print(f" {n} (INT4 has: {'qweight' if n[:-7]+'.qweight' in set(names) else 'MISSING'})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"<FP8 repo fetch failed: {e}>")
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""v2 harness: proper bf16 GEMV baseline + a single BATCHED int8 w8a16 GEMM kernel
|
||||||
|
(one launch for any B, dot-based, pads B to >=16) vs albond's per-row loop, at the
|
||||||
|
real lm-head shape and B in {1,5,13} (base + DFlash verify-batch sizes).
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import triton
|
||||||
|
import triton.language as tl
|
||||||
|
|
||||||
|
V, H = 248320, 3072
|
||||||
|
DEV = "cuda"
|
||||||
|
torch.manual_seed(0)
|
||||||
|
|
||||||
|
W_bf16 = (torch.randn(V, H, device=DEV, dtype=torch.float32) * 0.02).to(torch.bfloat16)
|
||||||
|
scales = (W_bf16.float().abs().amax(dim=1) / 127.0).clamp(min=1e-12)
|
||||||
|
W_int8 = (W_bf16.float() / scales.unsqueeze(1)).round().clamp(-127, 127).to(torch.int8)
|
||||||
|
scales_f16 = scales.to(torch.float16)
|
||||||
|
INT8_BYTES = V * H
|
||||||
|
BF16_BYTES = V * H * 2
|
||||||
|
|
||||||
|
|
||||||
|
def bench(fn, iters=30, warmup=5):
|
||||||
|
for _ in range(warmup):
|
||||||
|
fn()
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
for _ in range(iters):
|
||||||
|
fn()
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
return (time.perf_counter() - t0) / iters
|
||||||
|
|
||||||
|
|
||||||
|
# ---- batched int8 w8a16 GEMM: out[B,N] = (x[B,K] @ (W_int8[N,K]*s[N]).T) ----
|
||||||
|
@triton.jit
|
||||||
|
def _k_batched(x_ptr, w_ptr, s_ptr, o_ptr, B, N, K,
|
||||||
|
sxb, sxk, swn, swk, sob, son,
|
||||||
|
BLOCK_B: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr):
|
||||||
|
pid_n = tl.program_id(0)
|
||||||
|
offs_b = tl.arange(0, BLOCK_B)
|
||||||
|
offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
|
||||||
|
offs_k = tl.arange(0, BLOCK_K)
|
||||||
|
x_ptrs = x_ptr + offs_b[:, None] * sxb + offs_k[None, :] * sxk
|
||||||
|
w_ptrs = w_ptr + offs_n[:, None] * swn + offs_k[None, :] * swk
|
||||||
|
acc = tl.zeros((BLOCK_B, BLOCK_N), dtype=tl.float32)
|
||||||
|
for k in range(0, K, BLOCK_K):
|
||||||
|
km = (offs_k[None, :] + k) < K
|
||||||
|
x = tl.load(x_ptrs, mask=(offs_b[:, None] < B) & km, other=0.0).to(tl.float16)
|
||||||
|
w = tl.load(w_ptrs, mask=(offs_n[:, None] < N) & km, other=0).to(tl.float16)
|
||||||
|
acc += tl.dot(x, w.T) # [BB,BK] @ [BK,BN] -> [BB,BN]
|
||||||
|
x_ptrs += BLOCK_K * sxk
|
||||||
|
w_ptrs += BLOCK_K * swk
|
||||||
|
s = tl.load(s_ptr + offs_n, mask=offs_n < N, other=0.0).to(tl.float32)
|
||||||
|
acc = acc * s[None, :]
|
||||||
|
o_ptrs = o_ptr + offs_b[:, None] * sob + offs_n[None, :] * son
|
||||||
|
tl.store(o_ptrs, acc.to(tl.float16), mask=(offs_b[:, None] < B) & (offs_n[None, :] < N))
|
||||||
|
|
||||||
|
|
||||||
|
def run_batched(x, BLOCK_N=128, BLOCK_K=64, num_warps=4, num_stages=3):
|
||||||
|
B = x.shape[0]
|
||||||
|
BLOCK_B = max(16, triton.next_power_of_2(B))
|
||||||
|
out = torch.empty(B, V, dtype=torch.float16, device=DEV)
|
||||||
|
xf = x.to(torch.float16)
|
||||||
|
grid = ((V + BLOCK_N - 1) // BLOCK_N,)
|
||||||
|
_k_batched[grid](xf, W_int8, scales_f16, out, B, V, H,
|
||||||
|
xf.stride(0), xf.stride(1), W_int8.stride(0), W_int8.stride(1),
|
||||||
|
out.stride(0), out.stride(1),
|
||||||
|
BLOCK_B=BLOCK_B, BLOCK_N=BLOCK_N, BLOCK_K=BLOCK_K,
|
||||||
|
num_warps=num_warps, num_stages=num_stages)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
for B in (1, 5, 13):
|
||||||
|
print(f"\n=== B={B} ===")
|
||||||
|
x = torch.randn(B, H, device=DEV, dtype=torch.bfloat16) * 0.1
|
||||||
|
ref_bf16 = (x.float() @ W_bf16.float().T)
|
||||||
|
ref_deq = (x.float() @ (W_int8.float() * scales.unsqueeze(1)).T)
|
||||||
|
am_floor = (ref_bf16.argmax(-1) == ref_deq.argmax(-1)).float().mean().item()
|
||||||
|
print(f" quant floor: argmax_match={am_floor*100:.1f}% maxerr={(ref_deq-ref_bf16).abs().max():.4f}")
|
||||||
|
|
||||||
|
# real bf16 GEMV baseline (stays bf16, reads 1.5GB)
|
||||||
|
dt = bench(lambda: torch.matmul(x, W_bf16.t()))
|
||||||
|
print(f" {'bf16 GEMV (real baseline)':30s} {'':33s}{dt*1e3:7.3f} ms {BF16_BYTES/dt/1e9:6.1f} GB/s")
|
||||||
|
|
||||||
|
# batched int8 kernel, a few configs
|
||||||
|
for (bn, bk, nw, ns) in [(128, 64, 4, 3), (256, 64, 8, 3), (128, 128, 4, 3), (64, 128, 4, 3)]:
|
||||||
|
try:
|
||||||
|
out = run_batched(x, bn, bk, nw, ns)
|
||||||
|
err = (out.float() - ref_deq.float()).abs().max().item()
|
||||||
|
am = (out.float().argmax(-1) == ref_bf16.argmax(-1)).float().mean().item()
|
||||||
|
dt = bench(lambda: run_batched(x, bn, bk, nw, ns))
|
||||||
|
print(f" batched N{bn}/K{bk}/w{nw}/s{ns:<2d} maxerr={err:8.4f} argmax={am*100:5.1f}% "
|
||||||
|
f"{dt*1e3:7.3f} ms {INT8_BYTES/dt/1e9:6.1f} GB/s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" batched N{bn}/K{bk}/w{nw}/s{ns}: ERR {str(e)[:70]}")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate patch_inc_hybrid.py landed on AEON 0.23's inc.py. Run AFTER the patch
|
||||||
|
in the same container: python3 /host/patch_inc_hybrid.py && python3 /host/validate_inc_patch.py
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import vllm.model_executor.layers.quantization.inc as m
|
||||||
|
|
||||||
|
C = m.INCConfig
|
||||||
|
src = inspect.getsource(m)
|
||||||
|
print("import OK")
|
||||||
|
print("sentinel in source :", "spark-dflash-hybrid-fp8" in src)
|
||||||
|
print("maybe_update_config OWN :", "maybe_update_config" in C.__dict__)
|
||||||
|
print("_is_layer_fp8 OWN :", "_is_layer_fp8" in C.__dict__)
|
||||||
|
print("maybe_update_config hasattr:", hasattr(C, "maybe_update_config"))
|
||||||
|
print("_is_layer_fp8 hasattr :", hasattr(C, "_is_layer_fp8"))
|
||||||
|
# signature must accept hf_config kw (config/vllm.py calls it that way)
|
||||||
|
try:
|
||||||
|
sig = inspect.signature(C.maybe_update_config)
|
||||||
|
print("maybe_update_config sig :", str(sig))
|
||||||
|
print("accepts hf_config kw :", "hf_config" in sig.parameters)
|
||||||
|
except Exception as e:
|
||||||
|
print("sig err:", e)
|
||||||
|
# count FP8 dispatch sites
|
||||||
|
print("Fp8LinearMethod dispatch ct:", src.count("return Fp8LinearMethod(self.fp8_config)"))
|
||||||
|
# byte-compile sanity already implied by import; show line count
|
||||||
|
print("inc.py lines :", len(src.splitlines()))
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate patch_int8_lmhead_v3 landed + the helper runs on GPU. Run AFTER the
|
||||||
|
patch in the same container."""
|
||||||
|
import torch
|
||||||
|
|
||||||
|
import vllm.model_executor.layers.logits_processor as lp
|
||||||
|
|
||||||
|
print("import OK | helpers:",
|
||||||
|
hasattr(lp, "_spark_int8_gemm"),
|
||||||
|
hasattr(lp, "_spark_int8_lmhead_apply"),
|
||||||
|
hasattr(lp, "_spark_k_int8"))
|
||||||
|
src = open(lp.__file__).read()
|
||||||
|
print("sentinel in _get_logits:", "DGX_SPARK_INT8_LMHEAD_V3: int8 w8a16" in src)
|
||||||
|
|
||||||
|
V, H = 4096, 512
|
||||||
|
torch.manual_seed(0)
|
||||||
|
W = torch.randn(V, H, device="cuda") * 0.02
|
||||||
|
s = (W.abs().amax(1) / 127).clamp(min=1e-12)
|
||||||
|
wi = (W / s.unsqueeze(1)).round().clamp(-127, 127).to(torch.int8).contiguous()
|
||||||
|
sf = s.to(torch.float16)
|
||||||
|
for B in (1, 5, 13):
|
||||||
|
x = torch.randn(B, H, device="cuda", dtype=torch.bfloat16) * 0.1
|
||||||
|
out = lp._spark_int8_gemm(x, wi, sf)
|
||||||
|
ref = x.float() @ (wi.float() * s.unsqueeze(1)).T
|
||||||
|
am = (out.argmax(-1) == ref.argmax(-1)).float().mean().item()
|
||||||
|
print(f" B={B}: out{tuple(out.shape)} {out.dtype} argmax={am*100:.0f}% "
|
||||||
|
f"maxerr={(out - ref).abs().max().item():.4f}")
|
||||||
|
|
||||||
|
# Exercise the FULL apply path (print + quantize-once + gemm) with a mock lm_head
|
||||||
|
# (vocab > 100k to trigger the int8 path). self is unused -> None.
|
||||||
|
print("--- full _spark_int8_lmhead_apply path (mock lm_head, V=131072) ---")
|
||||||
|
Vbig = 131072
|
||||||
|
|
||||||
|
class _MockLMHead:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mh = _MockLMHead()
|
||||||
|
mh.weight = (torch.randn(Vbig, H, device="cuda", dtype=torch.float32) * 0.02).to(torch.bfloat16)
|
||||||
|
hs = torch.randn(3, H, device="cuda", dtype=torch.bfloat16) * 0.1
|
||||||
|
o1 = lp._spark_int8_lmhead_apply(None, mh, hs, None) # first call: quantizes + prints
|
||||||
|
o2 = lp._spark_int8_lmhead_apply(None, mh, hs, None) # second call: reuses int8
|
||||||
|
refb = hs.float() @ mh.weight.float().T
|
||||||
|
am = (o2.argmax(-1) == refb.argmax(-1)).float().mean().item()
|
||||||
|
print(f" apply: out{tuple(o2.shape)} {o2.dtype} argmax_vs_bf16={am*100:.0f}% "
|
||||||
|
f"int8_ready={getattr(mh, '_spark_int8_ready', None)} weight_kept={mh.weight.numel() > 0}")
|
||||||
Reference in New Issue
Block a user