Files
MoE-LoRA-converter/README.md
Joey Grasty 7a66942242 Add MoE LoRA converter (Unsloth fused format -> vLLM per-expert format)
Converts an Unsloth-trained, MoE-expert-targeting LoRA adapter into
vLLM's expected per-expert format so it can be served live through
vLLM's FusedMoE LoRA support, instead of requiring a full merge-and-
reload per adapter switch.

Includes:
- moe_lora_convert_vllm.py -- the converter
- moe_lora_convert_validate.py -- three-way validation (round-trip
  bit-exactness, per-expert delta match against Unsloth's real merge
  function, key coverage)
- README.md -- usage plus the full findings report: the tensor-layout
  investigation (including a wrong turn worth recording), all five
  vLLM-serving validation gates, and the GB10 infrastructure lessons
  from developing this

Developed and validated against Qwen3-30B-A3B (48 layers, 128 experts,
r=16 LoRA).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dwQp8JCqhM2MRFHYwMyxp
2026-09-09 09:19:28 -05:00

248 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MoE LoRA Converter
Converts an Unsloth-trained, MoE-expert-targeting LoRA adapter (one fused tensor per layer
covering all experts, via PEFT's `target_parameters` mechanism) into vLLM's expected per-expert
format (`experts.{e}.{gate_proj,up_proj,down_proj}`), so it can be served live through vLLM's
FusedMoE LoRA support (PR #21229) instead of requiring a full merge-and-reload for every adapter
switch.
Developed and validated against Qwen3-30B-A3B (48 layers, 128 experts, r=16) — see the findings
report below for the full story, including a genuine wrong turn in figuring out the tensor
layout that's worth reading before trusting this on a different model shape.
## Contents
- **`moe_lora_convert_vllm.py`** — the converter. `python3 moe_lora_convert_vllm.py <src_adapter_dir> <dst_adapter_dir>`
- **`moe_lora_convert_validate.py`** — validates a converted adapter three ways (round-trip
bit-exactness, per-expert delta match against Unsloth's real merge function, key coverage).
Requires a CUDA GPU and `unsloth` installed. Edit `SRC`/`DST` at the top before running — the
checked-in paths are from the run this was developed against, not a generic default.
Both scripts assume the source adapter's expert-count/rank/layer-count via the constants at
the top of each file (`E=128, r=16`, 48 layers) — adjust if converting a different model.
---
# Findings: Serving Per-Author MoE LoRA Adapters via vLLM
**Date:** 2026-09-08/09
**Status:** Resolved — Architecture A is fully reopened.
## Bottom line
**vLLM can serve full-target (expert-covering) LoRA adapters on Qwen3-30B-A3B-MoE — correctly,
live, with multiple adapters resident and routed properly, in the actual production shape this
project uses, and at a cost that scales favorably to a 100-author roster.**
This closes a question that had been treated as settled shut: "Architecture A" — a library of
per-author LoRA adapters served live over one shared merged base — was believed unworkable
because vLLM's MoE LoRA support couldn't load Unsloth's adapter format. It turned out to be a
solvable data-layout conversion problem, not an architectural dead end. Joint training (the
fallback this project had been using) remains a valid choice, but is no longer the *only* one.
---
## 1. The blocker, and why it wasn't what it first looked like
vLLM added FusedMoE LoRA support (PR #21229) in October 2025. The first real attempt to use it
here crashed immediately:
```
AssertionError: assert isinstance(lora_a, list)
```
Unsloth stores **one fused tensor per layer covering all 128 experts at once**. vLLM's MoE LoRA
code expects a **Python list of 128 separate per-expert tensors**. This isn't a naming mismatch
— it's a genuine data-layout conversion problem: someone has to slice the fused tensor into 128
per-expert pieces and re-key them to the naming vLLM's own error message already listed as
supported (`experts.{e}.{gate_proj,up_proj,down_proj}`).
## 2. Finding the actual layout — and a wrong turn worth recording
Writing that converter required knowing exactly how each expert's slice of the fused tensor is
laid out. This took two passes, and the first one produced a wrong-but-plausible answer that's
worth recording as a caution for future work like this.
**First pass:** verified vanilla PEFT's `get_delta_weight()` method against a *live call to that
same method* — internally consistent, and concluded a "mixed" layout (one tensor's expert-blocks
contiguous, the other's strided). This felt like solid verification. It wasn't: it never checked
against how real checkpoints are actually built.
**The correction:** it turns out `save_pretrained_merged()` — the only merge path any real
checkpoint in this project has ever gone through — bypasses vanilla PEFT entirely, via a
separate hand-written implementation. Calling *that* real function directly, and
cross-validating it against the independently-coded training-time forward pass, showed:
| Comparison | Result |
|---|---|
| Real merge function vs. training-time forward path (independently coded) | agree to fp32 rounding noise |
| Real merge function vs. vanilla PEFT's `get_delta_weight()` | disagree by a large, consistent margin |
Two independently-written code paths agreeing with each other, and both disagreeing with the
"verified" reference, is about as clear a signal as this kind of question ever produces. The
actual layout: **both tensors' expert-blocks are contiguous**, not mixed. The lesson generalizes
beyond this one adapter: when a real production code path exists and differs from a general
library's own method, verifying a formula against the library's own method proves internal
consistency, not correctness against what actually matters.
## 3. The converter
`moe_lora_convert_vllm.py` implements the corrected layout: slices each layer's fused tensors
into 128 per-expert pieces across all three projections, producing 18,432 (layer, expert,
projection) pairs, re-keyed to vLLM's expected naming.
Validated three ways before it was trusted with anything downstream:
1. **Round-trip** — reassemble the fused tensors from the converted pieces, compare to the
originals. Bit-exact.
2. **Per-expert delta** — recompute each expert's delta from the converted tensors, compare to
the real Unsloth merge function's output. Matches to fp32 noise.
3. **Key coverage** — exactly 18,432 pairs, no missing, no duplicates.
The same converter was later run unchanged on three more adapters (two solo per-author CPT
adapters, and a dual-author SFT LoRA) — one piece of code, four different real adapters, all
correct.
## 4. The five gates
| Gate | Question | Result |
|---|---|---|
| **1** | Does a converted adapter load at all? | **Pass** — no assertion error, engages vLLM's FusedMoE LoRA path |
| **2** | Is it actually *applied* (live), not just loaded? | **Pass** — BPB matches its known reference to 2×10⁻⁴, 124× closer than to base's |
| **3** | Do two resident adapters route correctly per request? | **Pass** — see below, this one took two attempts |
| **4** | Does the real production shape (merged CPT base + SFT LoRA) work? | **Pass** — 4/4 generations correctly attributed by an already-validated classifier |
| **5** | What does this cost, for sizing a 100-author roster? | **Done** — favorable on all three measurements |
### Gate 2 — liveness
Scored the same held-out units through vLLM twice — once bare, once with the adapter attached —
using the exact BPB methodology already established for the HF backend.
| | vLLM | Known reference | agreement |
|---|---|---|---|
| Base (no adapter) | 0.727092 | 0.727071 | 2×10⁻⁵ |
| Adapter attached | 0.701017 | 0.701228 | 2×10⁻⁴ |
The base-case agreement validates the whole vLLM-side measurement approach; the adapter case is
unambiguously closer to its own reference than to base's.
### Gate 3 — multi-adapter routing (a worthwhile detour)
The first check (send the same prompt to two resident adapters, score the outputs with a style
classifier) came back ambiguous — both continuations got attributed to the same author. Rather
than re-running until it agreed with expectations, this was reported as what it was: most likely
a single ~550-word sample continuing an already-strongly-styled prompt is a noisy, unfair test
for a raw continuation adapter with no instruction to override the prompt's own register.
A second, more decisive check reused Gate 2's own BPB approach instead — score each author's
held-out set with their own adapter, then with the *other* author's adapter substituted:
| Author | Own adapter | Reference | Wrong adapter substituted |
|---|---|---|---|
| Nuttall | 0.702174 | 0.702232 | 0.730985 |
| Vox Day | 0.772137 | 0.772159 | 0.787210 |
Own-adapter results match references almost exactly; misapplying the wrong adapter clearly
degrades the fit past even the base model, every time. Decisive, and immune to the single-sample
bias the style check had.
### Gate 4 — the shape that actually exists
The original plan wanted per-author SFT LoRAs, which this project doesn't have — SFT here was
always a second *merge* stage on top of the CPT merge, not a second LoRA. Redefined to the real
shape: served base = the merged joint-CPT checkpoint, live adapter = the one dual-author scale-up
SFT LoRA. Generated four held-out packages and scored them with the classifier this exact model
was already validated against (0.949 Vox Day / 0.991 Nuttall separability on the HF backend) —
not a freshly-fit instrument.
| Packet | Author | Predicted | Margin |
|---|---|---|---|
| GRANDMASTER_Chapter-Sixteen_002 | Nuttall | Nuttall | 0.0673 |
| GRANDMASTER_Chapter-Fourteen_001 | Nuttall | Nuttall | 0.1286 |
| TOB_Lodi_004 | Vox Day | Vox Day | +0.1508 |
| SOS_V2_UNTRAINED_Aulan_004 | Vox Day | Vox Day | +0.2120 |
4/4, all margins clearly signed.
### Gate 5 — what it costs
**Throughput** (single-stream decode tok/s, one adapter active, one process per `max_loras`
setting):
| max_loras | tok/s |
|---|---|
| 0 | 26.13 |
| 1 | 23.74 |
| 2 | 23.95 |
| 4 | 23.57 |
Turning LoRA on costs ~9% once. Adding more *resident* adapters (not concurrently active) costs
nothing further — the four numbers from 1 to 4 sit within a 1.6% band, i.e. noise. All at or
above the llama.cpp baseline (22.723.8 tok/s), despite vLLM running clock-throttled here for
GB10 stability against an unthrottled llama.cpp figure.
**Residency memory** (vLLM's own logged KV-cache budget — measured, not estimated):
| max_loras | KV cache available | Δ |
|---|---|---|
| 0 | 18.75 GiB | — |
| 1 | 12.28 GiB | 6.47 GiB |
| 2 | 8.93 GiB | 3.35 GiB |
| 4 | 8.01 GiB | 0.46 GiB/slot |
A large fixed cost to enable LoRA at all, then a **shrinking** marginal cost per additional
resident adapter — the opposite of what would make 100 authors expensive.
**Swap latency** (`max_cpu_loras`, adapters evicted from limited GPU slots but kept in CPU
memory): the first pass at measuring this conflated two different things — some "swap" requests
turned out to be a never-before-used adapter's first load, not a real swap, and those cost
2027 seconds. The genuine swaps (an adapter used once, evicted, then re-requested) cost
~570ms — barely more than a warm hit (~523ms). **The real cost for capacity planning is the
one-time disk load the first time any given author is ever served; everything after that is
cheap.**
## 5. Infrastructure: a real, separate cost
None of the above came for free on the hardware this was developed on (an NVIDIA DGX Spark /
GB10, unified CPU+GPU memory). Getting Gate 2 to complete cost **four full-system hard reboots**
— log-less, no OOM message, no kernel panic, just gone. This turned out to be a documented
GB10/vLLM interaction, not a bug in this converter or its validation: `gpu_memory_utilization`
above 0.8 is known to hang GB10 systems, and vLLM doesn't reserve host RAM headroom on
unified-memory devices the way it would on a discrete-GPU box.
The fix that held, applied to every run since: GPU clock locked to 2200MHz, `gpu_memory_utilization`
capped at 0.65, `max_model_len` sized to the actual longest sequence in play (not a round-number
default), and page cache dropped before a big load. Zero crashes across everything that followed
— eleven more sequential vLLM engine loads, across five different workload shapes (BPB scoring,
multi-adapter routing, long generation, short-context throughput, swap-latency cycling).
One related, unresolved finding: vLLM's own CUDA-graph memory *estimator* is unreliable in these
runs, overshooting actual usage by 4001000% and, once, exhausting the entire memory budget
before profiling could even begin. The workaround (`enforce_eager=True`, or a short
`max_model_len`) is fine for correctness/liveness gates; a future throughput benchmark at longer
context will need to budget around this directly rather than bypass it.
If you're running this converter's output through vLLM on a discrete-GPU machine, none of the
above should apply — it's specific to GB10's unified-memory architecture.
## 6. What's still open
- **A throughput discrepancy, not reconciled.** The llama.cpp baseline used here (22.723.8
tok/s) came from an earlier report; a separate, more recent llama.cpp determinism-gate run
measured ~4446 tok/s for the same model. Worth resolving before quoting either number as
authoritative for a roster-sizing decision.
- **The memory/swap curves are only sampled at 0/1/2/4 adapters.** The shrinking marginal cost
is promising but should be confirmed at 816 before sizing a real `--max-loras` value for 100
authors.
- **Gate 4's classifier check used 4 generations.** Enough to confirm the shape works; not a
substitute for a full multi-seed cross-eval if a rigorous quality comparison is ever needed
through vLLM specifically.
## 7. Decision
**Architecture A — a library of full-target per-author LoRA adapters served live over one shared
merged base — is open.** All five gates pass, on both correctness and cost grounds. Joint
training is no longer required to reach the 100-novel roster; it remains available as a design
choice (e.g. for a genuinely shared cross-author capability), not as a workaround for a closed
door.