Skip to content

Add native per-request LoRA adapters to the DeepSeek-V4.1 CUDA engine - #47

Draft
GoatInAHat wants to merge 9 commits into
Elevatormusic:deepseek-v41-tp2from
GoatInAHat:openclaw/ds41-native-adapters
Draft

GoatInAHat wants to merge 9 commits into
Elevatormusic:deepseek-v41-tp2from
GoatInAHat:openclaw/ds41-native-adapters

Conversation

@GoatInAHat

Copy link
Copy Markdown

What this changes

Adds native per-request LoRA selection to the DeepSeek-V4.1 CUDA engine. --adapter NAME=FOLDER registers an adapter model ID, with optional --adapter-strength; requests select it by model ID or the adapter field. Base and adapter requests share the existing lane rounds, CUDA graphs, tensor-parallel base weights and KV pool.

Supported writers are attention wo_b and shared-expert w2, with per-module rank and scaling. Unsupported writers and MTP edits are rejected at startup. Adapter factors are immutable for the lifetime of the engine. Prompt reuse and disk spill are scoped by adapter identity; tensor-parallel ranks check the registry and per-request selection.

Draft: hardware correctness and performance are not established. This does not change checkpoint bytes or provide an abliterated checkpoint. Running a quantized base plus serialized factors is not proof of equivalence to independently requantizing another checkpoint.

The attention delta is added in fp32 before the existing cross-rank reduction. The packed shared-expert path reuses the rounded fp16 hidden scratch produced by the existing expert kernel and folds the adapter's A factor to that representation. Agreement with the intended activation-domain reference remains a hardware validation requirement. The stock drafter remains unchanged.

Without configured adapters there are no factor tables or adapter kernel launches. The startup rank-agreement packet is extended. The engine-source identity changes, so existing disk spills are invalidated by the existing mechanism; preserve them before a first start if needed. Dynamic adapter loading and --parallel 1 are not supported.

See docs/adapters.md. The target repository has issues disabled, so this draft also provides the design discussion for the arithmetic extension requested by CONTRIBUTING.md.

Type

  • type: fix: a bug fix, or a fix after review findings
  • type: feature: new code (a feature, tool or add-on)
  • type: docs: docs only
  • type: results: run results or a close-out
  • type: research: a study or failure research, with no fix
  • type: spec: a plan or spec before the work

Receipt

  • Environment: Linux x86_64, Python 3.12, PyTorch 2.14.1 CPU and Triton 3.8.0; a separate environment uses the repository's CPU CI dependencies without PyTorch. Base commit a9526f4c1b579bab51ca76a920c9d2f4e804b02b; PR head b3e6ef794f0c23f6e3c1b8982af400838e213515. No GPU or checkpoint was used.
  • Exactness (bench_concurrent.py --alone --serial, draft-on versus draft-off token hashes): not run. tools/dsv41/adapter_mix.py provides a mixed-model comparison against each model's solo and draft-off run.
  • Decode throughput before/after: not measured.
  • Cold prefill throughput and TTFT before/after: not measured.
  • Adapter tests after review fixes: 198 passed with PyTorch and the Triton CPU interpreter; 157 passed, 6 skipped without PyTorch. Malformed-input, activated-LoRA and rank-order regressions fail before their fixes; the earlier allocation-order regression also fails before its fix and passes after it.
  • Broader DeepSeek PyTorch subset after review fixes: 559 passed, 52 skipped, 35 failed. All 35 failures are the same existing lane-fault fixture issue: it constructs MultiDecoder without slack0. The fixture is byte-identical to base, the memory-admission methods are unchanged, and all 35 fail identically on pristine base a9526f4c.
  • Earlier full PyTorch suite: 4438 passed, 258 skipped, 138 deselected, 36 failed (35 lane-fault fixture failures plus the memory-sensitive test below). Final CI-selected full suite without PyTorch: 3662 passed, 750 skipped, 138 deselected, 35 subtests passed, 1 failed (test_cuda_memory_plan::test_the_drafter_loads_after_the_target). That test also fails on pristine base under the same low host-memory conditions: it requires about 1.78 GiB of staging while the host reports 1.15–1.27 GiB. These failures were not waived or changed.
  • Changed-file CI lint and diff checks pass. GPU tests were not run; an isolated hardware validation window is still needed.
  • Not run: all hardware acceptance checks, real-checkpoint inference, two-node TP2, mixed-lane token hashes, CUDA graph behavior on hardware, memory headroom, drafter acceptance, and alternating before/after benchmarks. GPU tests are included for kernels, graph slot changes, packed shared-expert inputs and two-rank algebra, but their presence does not establish a pass.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. Hardware proof pending.
  • No precision traded for speed. Arithmetic and the packed shared-expert rounding boundary are described above; hardware/reference validation pending.
  • Prompt processing is no slower than the last release. Measurements pending.
  • New tests exercise the added behavior and skip cleanly without their dependency.
  • No personal data, machine names, internal hosts or local paths.

…a rank

A folder of PEFT files (and an optional tensorfold_adapter.json that must agree
with them) is checked against the checkpoint's sizes. It may edit attention
wo_b and the shared expert's w2 of a backbone layer, at any rank and scale per
module. Every other module, MTP blocks included, and anything that is not a
plain LoRA pair is refused by name instead of dropped.

The registry is fixed at start and gives each adapter an identity key that
follows the weights' bytes, the module set and every module's scale. Placement
cuts A by columns and keeps B whole, which is what both modules need under
tensor parallelism; the packed shared expert's A is rewritten once to read the
rotated hidden state the expert kernels leave for its down projection.

tools/dsv41/adapter_check.py runs the same checks offline and prints the memory
a rank would hold.
--adapter NAME=FOLDER (repeatable) and --adapter-strength NAME=NUMBER are
checked before any weight is downloaded: the CUDA backend, a family that serves
adapters, --parallel 2 or more, and names that do not take --name or an --alias.
Each adapter is listed in /v1/models as a model id of its own with the base
model as parent. A request picks one by its model id or its "adapter" field; an
unknown or contradictory choice is a 400 before a stream opens, never the base
model's answer. A server started without --adapter, and every other family,
behave exactly as before.

The choice rides on the request (PreparedRequest, Scheduler.submit, Stream) as
an opaque reference and reaches the engine as a generate() argument that is sent
only for an adapter.
…nd ranks

The delta scale * B @ (A @ x) is added to the fp32 partial sum of attention
wo_b and of the shared expert's w2, before the cross-rank reduction the layer
already makes. A concurrent round's input buffer gets a sixth row holding each
row's adapter slot, read at replay by two Triton kernels, so one captured graph
serves any mixture of adapters and padded rows copy their original's slot.
Prompt chunks run one slot for all their rows. The kernels take one program a
row and tile with fp32 sums in a fixed order and one tile shape for every
table, so a row's bits depend on its own input and its adapter's factors only;
a row of slot 0 is not read or written, and a layer no adapter edits launches
nothing. The packed shared expert's w2 reads the rotated, scaled fp16 hidden
state still in the expert scratch (factors folded at load); an expert outside
the pack reads its bf16 hidden state. The drafter always runs the base model.

A kept prompt and a spilled manifest carry the identity key of the adapter that
made their rows, and a prompt continues only a prompt of the same key. The
spill's prefix hashes start with the key (the base model's are unchanged), and
a manifest naming something that is not a hex digest is dropped at start.

Ranks: engine start gathers a digest of the base config and every adapter's
slot, name and key, and refuses to serve if two ranks differ. An admission
names the adapter's slot and key after the grammar, a follower checks that it
holds the same adapter, and every step's agreement check includes the slots of
the live streams, so a rank that disagrees fails the step on every rank before
a collective.

Without --adapter nothing here runs: every new argument defaults to the stock
path, the round input buffer keeps five rows, and the ops between ranks and the
spill's manifests and hashes are what they were.
docs/adapters.md: what an adapter may edit and what is refused, the folder
format and manifest, tensor-parallel placement and the shared-expert form, graphs
and rows, cache identity, memory and the limits. README option row and the API
page's adapter selection.
tools/dsv41/adapter_mix.py rotates a list of model ids (the base model's and the
adapters') over a burst of concurrent streams and checks each stream's token hash
against its own solo run, and each solo run against "draft": false. Standard
library only; its tests run it against a stand-in server.
What start refuses (one stream at a time, the torch path, the exact-matmul test
mode), that it loads, warms and returns the registry, how a name resolves to a
reference, and what generate does with an adapter with and without a
concurrent scheduler.
… traceback

A manifest whose files, files["adapter_model.safetensors"], base or
provenance is not an object, a safetensors header that is not an object (or
whose dtype is not a string, or whose shape or offsets hold Infinity), an
rank_pattern or alpha_pattern of the wrong type, and a lora_alpha or alpha_pattern
value that is not a finite number now raise AdapterError, so the server and
tools/dsv41/adapter_check.py give the reason. A non-object "files" was skipped
without checking the weights' hash; it is refused now.

adapter_config.json with alora_invocation_tokens set (PEFT's activated LoRA,
which edits only after its invocation tokens) is refused instead of being
served as an always-on LoRA. The null PEFT writes for a plain LoRA is accepted.
…er must match across ranks

docs/api.md and docs/adapters.md no longer state that a request without an
adapter runs "bit for bit as without the flag" and that a row's bits never
depend on its neighbours as results. Both follow from the kernels' construction
(a slot-0 row is not read or written; one program per row, fixed sum order).
The CPU tests check them under Triton's interpreter; the compiled kernels,
graph replay, prompt chunks and a larger rank beside an adapter, and a server
with the flag against one without, are the GPU tests' and the running-server
checks' to show.

The start-up check that every rank holds the same adapters now says "files,
names, strengths and order" and is a function the CPU tests call: a swapped
--adapter order was already refused through the slot digest, without saying
why. docs/adapters.md states the rule.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant