Skip to content

Support for Multi-GPU and --ssd-streaming#568

Draft
iSevenDays wants to merge 4 commits into
antirez:mainfrom
iSevenDays:mgpu-ssd-merge-main
Draft

Support for Multi-GPU and --ssd-streaming#568
iSevenDays wants to merge 4 commits into
antirez:mainfrom
iSevenDays:mgpu-ssd-merge-main

Conversation

@iSevenDays

@iSevenDays iSevenDays commented Jul 16, 2026

Copy link
Copy Markdown

Correctness Tests.

  1. Single-GPU SSD (CUDA_VISIBLE_DEVICES=0, --ctx 131072): "What is 8+8?" -> content="16"
  2. 2-GPU SSD (--gpu-vram 45,45, --ctx 131072): "What is 8+8?" -> content="16", nvidia-smi confirms both GPUs active (28.5GB / 28.0GB)

Test 1 — Single-GPU SSD:

Server (background):

cd /root/ds4-mgpu-ssd && CUDA_VISIBLE_DEVICES=0 ./ds4-server --cuda --ssd-streaming --ssd-streaming-cache-experts 16GB --ctx 131072 -m /root/antirez/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf --host 0.0.0.0 --port 8012 &

(I actually used v4 flash official q2 quant, so you can just ln (link) it directly)

Request (separate call, once "listening on" appeared in logs):

curl -s http://localhost:8012/v1/chat/completions -H "Content-Type: application/json" -d '{"model":"ds4","messages":[{"role":"user","content":"What is 8+8? Just the number."}],"max_tokens":48}'

Test 2 — 2-GPU SSD:

Server (background):

cd /root/ds4-mgpu-ssd && ./ds4-server --cuda --ssd-streaming --ssd-streaming-cache-experts 16GB --gpu-vram 45,45 --ctx 131072 -m /root/antirez/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf --host 0.0.0.0 --port 8013 &

Request:

curl -s http://localhost:8013/v1/chat/completions -H "Content-Type: application/json" -d '{"model":"ds4","messages":[{"role":"user","content":"What is 8+8? Just the number."}],"max_tokens":48}'

Both returned "content": "16".

context window of 131k is supported on just a single 4090d (48Gb VRAM)

Quickstart:

./download_model.sh q2-imatrix
make cuda CUDA_ARCH=sm_89
./ds4-server --cuda --ssd-streaming --ssd-streaming-cache-experts 16GB --ctx 131072 --gpu-vram 45,45

curl -s -w "\nTTFT: %{time_starttransfer}s\nTotal: %{time_total}s\n" http://localhost:8013/v1/chat/completions -H "Content-Type: application/json" -d @/tmp/bench_short_req.json

Info: thanks user @cchuter for initial multi-gpu support. This MR resolves issues with main branch, and adds ssd-streaming support.

@iSevenDays
iSevenDays force-pushed the mgpu-ssd-merge-main branch 2 times, most recently from c231111 to 2c5c235 Compare July 17, 2026 07:50
@iSevenDays
iSevenDays marked this pull request as draft July 17, 2026 08:51
iSevenDays and others added 4 commits July 21, 2026 10:58
Brings multi-tier (2-GPU) SSD streaming to antirez main's tensor-parallel
mgpu layer. Previously SSD streaming was single-device only: one selected-
expert cache, one pinned staging pool, one upload stream, all on device 0.
antirez main refused the combination upfront:

  ds4.c: if (e->ssd_streaming && e->multi_tier) {
      "--ssd-streaming is not compatible with multi-GPU placement"
  }

ds4_cuda.cu loader had a second guard:

  if (g_n_gpus != 1) { "CUDA SSD streaming requires single-GPU placement" }

Both gates removed for the non-TP path so:
  --ssd-streaming --gpu-vram 45,45 --ctx 131072
loads the 81GB q4k model across 2 GPUs (streaming, low resident VRAM/card).

Adapts the cchuter feat/mgpu-ssd port (commit 23245bb + 9522bac) to antirez
main's symbols/structure. antirez main's SSD is simpler than the cchuter
fork (the expert LRU cache is stubbed), so the per-tier struct only carries
the selected_cache + 4-slot staging pool + upload stream — no runtime caps.

ds4_cuda.cu
- New per-tier struct ds4_ssd_ctx { selected_cache, stage_raw/stage/
  stage_event/stage_bytes, upload_stream } and static g_ssd[DS4_MAX_GPUS].
  ssd_current() maps cudaGetDevice() -> g_gpu[t].device_id -> g_ssd[t]
  (fallback g_ssd[0]).
- Converted all ~60 references to the old per-device globals
  (g_stream_selected_cache, g_stream_selected_stage*, and
  g_stream_selected_upload_stream) to fields on ssd_current(). The master
  switch (g_ssd_streaming_mode) stays GLOBAL.
- ds4_gpu_set_ssd_streaming / _release_resident loop all tiers
  (cudaSetDevice per tier) so each g_ssd[t] resets/frees on the device
  that owns it.
- cuda_stream_selected_stage_pool_alloc creates the upload stream on the
  CURRENT device (per-layer dispatch has cudaSetDevice'd).
- cuda_stream_selected_cache_begin_load derives logical_tier from
  cudaGetDevice() (was hardcoded to 0); removed the g_n_gpus != 1 refuse.
- routed_moe_launch consumer re-affirms cudaSetDevice(g_gpu[logical_tier])
  before ssd_current() so the ambient device matches the OUT tensor's tier.
- ds4_gpu_cleanup adds per-tier SSD teardown inside the existing g_gpu[]
  loop (selected_cache + staging pool freed on each device).
- Embed-device fix (critical correctness, ported from 9522bac):
  ds4_gpu_embed_token_hc_tensor and ds4_gpu_embed_tokens_hc_tensor wrap
  their kernel launches in WITH_DEVICE(out_hc's device) so they never run
  on a stale ambient device. Without this, a prefill after a decode faults
  with cudaErrorIllegalAddress (embed runs on the stale head-tier device
  while reading an emb-tier pointer).

ds4.c
- Gate relaxed: --ssd-streaming is now refused ONLY under CUDA tensor
  parallelism (e->cuda_tensor_parallel). TP keeps half the routed experts
  resident per tier, which genuinely conflicts with SSD streaming; plain
  multi-tier (pipeline layer-split from --gpu-vram) is allowed.
- Multi-tier init branch wires ds4_gpu_set_ssd_streaming + auto-cache +
  budget + slab bytes (mirrors the single-tier sequence, AFTER
  ds4_gpu_init_multi populates g_gpu[]).
- Placement packer made SSD-aware:
  * tensor_is_ssd_streamed_routed_expert() identifies the
    .ffn_{gate,up,down}_exps.weight suffix.
  * engine_compute_entry_bytes excludes routed-expert tensors under SSD
    (non-TP) so they are not charged against the resident budget.
  * engine_install_per_device_caches skips them so they are not registered
    resident (would blow the budget and defeat streaming).
  * engine_balance_placement_for_ssd splits the transformer layer range
    across all requested GPUs budget-proportionally (contiguous) so
    streaming work is balanced instead of collapsing onto tier 0.
- metal_graph_ssd_assert_tier_device(g, il) helper re-affirms the CUDA
  device to the layer's home tier before each SSD producer stage (ambient
  device drifts across attention/router kernels). Inserted before every
  ds4_gpu_stream_expert_cache_{begin_selected_load,prepare_selected_batch,
  seed_selected,seed_experts} call.
- Embed-device repoint: in metal_graph_prefill_layer_major (2 sites) and
  metal_graph_verify_suffix_tops_impl, repoint g->active_tier = g->emb_tier
  before metal_graph_upload_prompt_embeddings_hc so the embed kernel runs
  on the device that owns prefill_tokens + token_embd. No-op single-tier.
- metal_graph_selected_async_load (SSD async staging worker): added
  home_tier field to the job struct, threaded through _start_tensor.
  Worker thread defaults to device 0; it now calls
  ds4_gpu_set_current_device(job->home_tier) at run() entry so the per-tier
  SSD cache is resolved correctly when staging from a service pthread.

Build (required gate): clean.
  PATH=/usr/local/cuda/bin:$PATH make -j$(nproc) cuda CUDA_ARCH=sm_89
  => 0 errors, 0 warnings in ds4.c / ds4_cuda.cu (only the pre-existing
     rax.c -Wuse-after-free warning remains).

Live 2-GPU verification pending (GPUs held by production service on :8002);
this commit is CODE + clean BUILD only.

Co-Authored-By: Claude <noreply@anthropic.com>
…al cache

The CUDA fault: every short /v1/chat/completions (<=18 tokens by default, so
most chat turns) returned HTTP 500 "cuda prefill failed" in ~0.02 s with NO
CUDA error logged at default verbosity.

Root cause (PRIMARY), in ds4.c metal_graph_eval_token_raw_swa_streaming:
The per-token decode-style prefill path reads metal_graph_cur_hc(g), which
expands via DS4_GPU_GRAPH_CLASS_P_ACCESSOR to cur_hc_by_tier[g->active_tier].
Multi-tier init leaves active_tier at the sentinel -1 (ds4.c:16747), and this
function never repointed it before the embed. cur_hc_by_tier[-1] is an
out-of-bounds slot (cur_hc_by_tier is the FIRST field of the struct), so
ds4_gpu_embed_token_hc_tensor got out_hc=NULL, silently returned 0, ok went
false, and the prefill returned false. Hence "cuda prefill failed" with zero
CUDA state set (the silent log). After the first successful token, active_tier
was left at the last layer tier (typically 1 = head tier), so the next token
embed wrote cur_hc to the WRONG tier.

Same class of bug as the earlier batch-embed device-mismatch (fixed by
WITH_DEVICE on ds4_gpu_embed_token_hc_tensor plus active_tier=emb_tier repoint
in metal_graph_prefill_layer_major), but on the per-token decode-streaming
path that the earlier fix did not cover.

Root cause (SECONDARY), in ds4_cuda.cu g_current_logical_tier:
cudaSetDevice is per-thread, but g_current_logical_tier was a process-global
cache. The async staging worker (metal_graph_selected_async_load_run in
ds4.c) read the main thread just-set tier from the cache and SKIPPED its own
cudaSetDevice, leaving the worker on device 0 while it believed it was on
tier 1. It staged experts into g_ssd[0].selected_cache (wrong device) while
the consumer ran on tier 1 and found g_ssd[1].selected_cache zero. So
"CUDA streaming selected experts are unavailable for layer 21".

Fix (PRIMARY), ds4.c metal_graph_eval_token_raw_swa_streaming: at function
entry, call metal_graph_set_active_tier_no_copy(g, g->emb_tier). This sets
the active_tier field AND calls cudaSetDevice(emb_tier), so the ambient
device matches the field. The per-layer decode dispatcher early-return
(tier == active_tier) then no-ops correctly because the ambient device is
already right. No-op for single-tier (placement == NULL).

Fix (SECONDARY), ds4_cuda.cu: declare g_current_logical_tier as thread_local
so each thread tracks its own device. The worker ds4_gpu_set_current_device
now misses the cache on first call (its own thread-local is -1) and actually
calls cudaSetDevice.

Also ports the reference fail-fast from feat/mgpu-ssd (commit 2c5c235):
cuda_err_is_sticky plus _exit(1) inside cuda_ok on cudaErrorIllegalAddress and
friends, gated by DS4_CUDA_NO_FAIL_FAST. Without this, a single transient
illegal-access poisoned the context forever and the server kept returning
HTTP 500 until manual restart; with it, a supervisor (systemd) restarts the
process and service self-recovers.

Observability (the bug was silent at default verbosity): gated behind
DS4_SSD_DEBUG=1, off by default.
- ds4_gpu_debug_probe(where, il, tier) in ds4_cuda.cu: logs the ambient CUDA
  device, the expected tier physical device, and any pending CUDA error after
  a forced cudaDeviceSynchronize on EVERY device (cross-device illegal-access
  errors hide from an ambient-only sync).
- ds4_ssd_dbg() in ds4.c mirrors the gate.
- Probes cover the entire multi-tier prefill path: reset_prefill_state,
  set_active_tier_batch entry/done, encode_layer_batch entry/post-attn/
  post-ffn, the embed upload (both split_commands branches), the SSD expert
  staging (cuda_stream_prefill_batch_selected_load + decode_cuda_selected_load
  + ssd_assert_tier_device), the routed-MoE consumer, and the per-token
  eval_token_raw_swa_streaming path including the batched static decode loop.
- moe_check FAIL dump in routed_moe_launch prints every field of the
  selected_cache comparison so the next reader of a sticky-error log sees
  exactly which condition failed.

Verified: 3 chat requests on port 8012 with
  --ssd-streaming --ssd-streaming-cache-experts 40GB --gpu-vram 45,45 --ctx 262144
all return HTTP 200 with valid completions (was: HTTP 500 cuda prefill failed
in 0.02 s). /v1/models reports context_length 262144. Zero CUDA errors in the
log across all three requests. Decode is slow (~30-60 s/short reply) because
the SSD-streaming expert cache is cold and only ~12 GiB per GPU is resident;
that is the streaming-mode tradeoff, not a defect.

Co-Authored-By: Claude <noreply@anthropic.com>
…SD streaming

The antirez-main SSD streaming port worked (correct output at 262k ctx) but
re-read every routed expert from SSD on every decoded token: ~0.45 tok/s with
VRAM stuck at ~12.6 GiB/card. Port the persistent resident LRU expert cache
from feat/mgpu-ssd so warm experts stay resident in VRAM.

ds4_cuda.cu — the LRU port (per-tier in g_ssd[t].expert_cache):
  * Structs: cuda_stream_expert_cache_slot, cuda_stream_expert_cache
    (capacity/count/tick + device slabs gate_ptr/up_ptr/down_ptr +
    std::vector<slots>), added before struct ds4_ssd_ctx.
  * Extended ds4_ssd_ctx with expert_cache + per-tier runtime_cap,
    runtime_gate/down_bytes, memory_cap_notice (the OOM-driven cap state).
  * Enum DS4_CUDA_STREAM_EXPERT_DEFAULT/MAX + g_stream_expert_budget_override.
  * Ported the full LRU function set (verbatim from feat/mgpu-ssd, adapted to
    resolve the active tier via ssd_current()): release_all, invalidate,
    requested_budget, configured_budget, budget_visible_to_shared,
    reserve_bytes, live_budget (the cudaMemGetInfo minus-reserve sizer that
    emits the "capped from X to Y experts (available Z GiB, reserve W GiB)"
    log), expert_bytes, note_size, shrunken_cap, note_oom_cap, try_alloc,
    prepare, find, lru_slot, copy_to_compact, load_slot, seed_one.
  * Wired cuda_stream_selected_cache_begin_load to consult the LRU: on hit,
    D2D-copy gate/up/down from the LRU slab to the per-request compact cache
    (no SSD read); on miss, stream from SSD into the LRU's LRU slot, then
    D2D to compact; falls back to direct SSD-to-compact on LRU failure.
  * Updated the extern SSD API: ds4_gpu_set_ssd_streaming (per-tier cap reset
    + free expert_cache on disable), ds4_gpu_set_streaming_expert_cache_budget
    (sets g_stream_expert_budget_override + invalidates/releases every tier),
    ds4_gpu_stream_expert_cache_{configured,current}_count,
    release_resident, budget_for_expert_size, seed_selected.
  * Added a no-op cuda_model_load_progress_finish stub (progress meter exists
    in mgpu-ssd but not antirez main; lets the LRU logging port verbatim).

ds4.c — fix the multi-tier byte-budget conversion gap:
  The multi-tier init path called ds4_engine_configure_streaming_auto_cache
  but skipped ds4_engine_configure_streaming_cache_budget, so
  --ssd-streaming-cache-experts NGB left ssd_streaming_cache_experts=0 and
  the LRU's budget_visible_to_shared() gate kept the cache disabled. Added
  the missing call (single-tier path has always done this).

VERIFIED on 2x RTX 4090 D (sm_89), ctx=262144:
  Before: 0.45 tok/s warm, VRAM 12.6 GiB/card (no LRU; every expert re-read).
  After:  warm gen avg 10-14 t/s (peak chunk 38 t/s), VRAM 44.4 GiB/card.
  Server log on the user's exact command
  (DS4_CUDA_STREAMING_EXPERT_CACHE_RESERVE_GB=4 ... --ssd-streaming-cache-experts 40GB ...):
    "CUDA streaming expert cache capped from 5556 to 4701/4717 experts
     (available 34.99 GiB, reserve 4.00 GiB, 6.75 MiB/expert)"
  /v1/models context_length=262144; correctness: 8+8 -> 16.

Build: PATH=/usr/local/cuda/bin:$PATH make -j$(nproc) cuda CUDA_ARCH=sm_89 clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Under multi-tier SSD streaming the DSpark support pack and drafter kernels
run on tier 1 (the configured executor tier), but metal_graph_configure_dspark_capture
allocated every scratch tensor (dspark_target_hidden, dspark_hc_mean_weights,
dspark_draft_hc, dspark_raw_cache, etc.) via ds4_gpu_tensor_alloc(bytes),
which hard-codes tier 0 (ds4_cuda.cu:2592). The first decode token then ran
metal_graph_dspark_capture_decode_layer(40) on tier 1: hc_weighted_sum_tensor
read cur_hc (tier 1) against dspark_target_hidden and dspark_hc_mean_weights
(tier 0), raising a sticky CUDA "illegal memory access" the moment layer 41's
rms_norm_plain launched. The fail-fast gate restarted the service on every
chat request.

Also removes the upstream --ssd-streaming + --mtp incompatibility gate
(ds4_engine_open_internal) so the support model can load resident while the
main model streams.

Fix: resolve exec_tier from g->dspark_exec_tier in metal_graph_configure_dspark_capture
and use ds4_gpu_tensor_alloc_ptr_on(exec_tier, ...) for all DSpark scratch.
Single-tier (Metal/CPU) builds are unchanged because alloc_ptr_on(0, ...)
collapses to the legacy allocator there.

Verified: 3/3 chat requests return content "13"; VRAM ~46.5 GiB/card;
DSpark engaged (layers 40,41,42 capture clean); no CUDA errors in probes.

Co-Authored-By: Claude <noreply@anthropic.com>
@iSevenDays
iSevenDays force-pushed the mgpu-ssd-merge-main branch from 0f8fa24 to e26e2f0 Compare July 21, 2026 15:14
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