Field Notes
docs/dgx-spark-notes.mdSoftware discoveries and gotchas from running LLM inference on the DGX Spark (GB10, aarch64, 128 GB unified LPDDR5x, DGX OS / Ubuntu 24.04). Raw material for future site content — append as things come up, with dates.
OS & drivers
- DGX OS updates can strand the GPU driver (2026-07-05). The system
updater installed the 6.17 kernel and removed the NVIDIA modules for the
running 6.11 kernel. Until the next reboot into 6.17,
nvidia-smifails with "couldn't communicate with the NVIDIA driver" and/dev/nvidia*is gone. Diagnosis: compareuname -ragainstdpkg -l | grep linux-modules-nvidia. Fix:sudo apt full-upgrade, reboot, confirm the new kernel. - No passwordless sudo on a default DGX OS install; automation must hand privileged steps back to a human terminal.
aarch64 (ARM) surprises
curl | bashinstallers often ship x86-64 binaries. firebase.tools does — "cannot execute binary file: Exec format error". Anything Node-based installs fine vianpm install -ginstead. Check for arm64 builds before trusting any install script.- System Node is ancient (18.x from Ubuntu). Use nvm; Node 20.11+ is
needed for
import.meta.dirname. - PEP 668: Ubuntu 24.04 blocks
pip install --user; every Python tool needs a venv. - npm optional-dependency bug bites native ARM bindings (2026-07-06).
Tailwind 4 dies with
Cannot find module '@tailwindcss/oxide-linux-arm64-gnu'ifnode_moduleswas installed under a different Node setup. Fix:rm -rf node_modules && npm install, and clear.nextif the error persists in cached build chunks.
GB10 GPU specifics
- CUDA arch is
sm_121— build llama.cpp with-DCMAKE_CUDA_ARCHITECTURES=121(CUDA toolkit 13 ships with DGX OS at/usr/local/cuda, present even when the driver is broken). nvidia-smicannot report memory usage on unified memory ("Not Supported"). Read/proc/meminfo(MemTotal − MemAvailable) instead.power.drawandutilization.gpuwork fine.- Unified memory changes engine defaults: vLLM's
--gpu-memory-utilizationdefaults to 0.9 — of the whole 128 GB pool, which would starve the OS. Set it explicitly (0.5 leaves ~25 GB KV cache for a 35 GB model). - OOM on unified memory takes the whole machine down (2026-07-06). DGX OS
ships with no swap, so when a big vLLM run (72 GB BF16 weights,
--gpu-memory-utilization 0.75≈ 91 GiB target) coincided with first-run Triton kernel compilation (cicc— NVIDIA's CUDA JIT compiler — was what invoked the oom-killer), the kernel spent 5 hours killing desktop processes and the box hard-reset. Rules of thumb: leave ≥20 GiB headroom for model load + JIT compile on first run of a large model, remember GPU allocations and the OS fight for the same pool, and consider a small swapfile purely as an OOM shock absorber. --max-num-seqsis the hidden memory lever in vLLM. Startup profiling simulates the maximum batch (default 256 sequences); on a 72 GB model the resulting activation spike ate 30+ GiB beyond the configured target within seconds. If you're serving one user or benchmarking small concurrency, set it to ~2× your real concurrency and the spike shrinks proportionally.- Multimodal models hide a second profiling spike. Qwen3.5 is
image-text-to-text; vLLM profiles the vision encoder with maximum-size dummy
images on top of the LLM profiling pass. For text-only serving of a large
VLM, pass
--limit-mm-per-prompt '{"image": 0, "video": 0}'— it skips encoder profiling entirely and saves GBs of transient memory. - vLLM's torch.compile cache can go stale across config changes (0.24.0).
After adding
--limit-mm-per-prompt '{"image": 0}'to a model previously started without it, the cached compiled graph loads and crashes withAttributeError: 'NoneType' object has no attribute 'size'. The cache key evidently doesn't cover multimodal limits —rm -rf ~/.cache/vllm/torch_compile_cacheafter changing such flags. - Throttle compile parallelism when weights nearly fill the pool. Inductor
and ninja default to one compile worker per core — 20 on GB10 — and each
ciccinstance holds 3–4 GiB, so first-run JIT can burst ~70 GiB on top of resident weights.TORCHINDUCTOR_COMPILE_THREADS=2 MAX_JOBS=4makes first startup slower but keeps the burst to ~10 GiB. This was the common root cause behind repeated OOM crashes loading a 72 GB model. - A memory watchdog for big runs must SIGKILL, not SIGTERM — a vLLM engine mid-allocation shrugs off SIGTERM and the memory stays pinned. Poll ≤5s: the profiling spike can go from 40 GiB free to 0 in under 10 seconds.
Engines
- llama.cpp: builds clean from source with CUDA on aarch64 (~15 min,
-j 20). Compiling needs only the toolkit, not a working driver — useful while waiting on a reboot. - vLLM: pip wheels for aarch64+CUDA 13 exist and work (0.24.0, torch
2.11). Gotcha: Triton JIT-compiles helpers at import time with the system
gcc and needs
python3.12-dev(apt) or every model load fails with a misleading "model architecture failed to be inspected" error — the real error (Python.h: No such file or directory) is buried in a subprocess. - vLLM FP8 MoE on GB10 needs
VLLM_USE_DEEP_GEMM=0(2026-07-05, vLLM 0.24.0). DeepGEMM targets datacenter Blackwell; on GB10 (sm_121) loading an FP8 MoE model crashes withAssertion error ... Unknown SF transformation. vLLM auto-disables DeepGemm for the dense layers but the MoE backend still selects DEEPGEMM — disable it globally and it falls back to CUTLASS/Triton. - Invoke vLLM with its venv on PATH, not just by absolute path.
flashinfer JIT-compiles sampling kernels at startup and shells out to
ninja; calling~/venvs/vllm/bin/vllmdirectly leaves the venv's bin off PATH and the engine dies at KV-cache profiling withFileNotFoundError: 'ninja'. UsePATH="$HOME/venvs/vllm/bin:$PATH" vllm serve .... - Gemma 4 assistant (speculative decoding) on llama.cpp, 2026-07-06 state:
support landed in master this week and has sharp edges. (1) Community GGUFs
may carry arch
gemma4_assistant(underscore) while llama.cpp expectsgemma4-assistant— convert from Google's safetensors yourself. (2) The assistant conditions on target hidden states: use--spec-type draft-mtp, notdraft-simple(which dies with "requires ctx_other"). (3) Acceptance rates are implausibly low even on trivially predictable output (27% on number sequences; should be 90%+), making spec decoding a net 38% slowdown. Likely immature upstream wiring — re-test after llama.cpp updates before concluding anything about speculative decoding on this hardware. Confirmed via vLLM (same draft, same day): vLLM's gemma4_assistant support gets 45–51% acceptance and a 28–46% decode speedup (--speculative-config '{"model": ..., "num_speculative_tokens": 4}'). Speculative decoding works on Spark; llama.cpp's brand-new assistant path doesn't yet. Acceptance counters come from vLLM's /metrics endpoint (vllm:spec_decode_num_{draft,accepted}_tokens_total). - "NVFP4" is not one thing — the ignore-list is a decode-speed knob
(2026-07-06). Two NVFP4 checkpoints of the same Qwen3.6-35B-A3B arch, same
nvfp4-pack-quantizedformat, same W4A4 scheme, near-identical memory (67.5 vs 69.6 GB): official NVIDIA decoded at 77 tok/s, a community quant (AEON-7 heretic) at 42 — 46% slower — while prefill was identical (~6,230). Cause: the community recipe leaves the hybrid model's linear-attention projections (in_proj_a/b/z,out_proj) in BF16 via a longerignorelist. Those unquantized layers run a slower per-token path that throttles decode but is hidden in (parallel, compute-bound) prefill. Lesson: when benchmarking a quant, diffquantization_config.ignorein config.json — same format label can mean very different decode speed. Not just a quality difference. - vLLM 0.24 can't run external-draft spec decoding on hybrid models
(2026-07-06). DFlash (and any
draft_model/eagle-style external draft) against Qwen3.6-35B-A3B dies at engine init withAssertionErrorinunify_kv_cache_spec_page_size. Cause: Qwen3.6 is hybrid — its linear-attention layers hold a fixed-size recurrent state whosepage_size_bytesdoesn't scale with block size, so it can't be padded to match the attention pages the draft adds. vLLM does support DFlash — just not with a hybrid target here. Corollary: this is likely why Qwen ships MTP variants (self-speculation reuses the model's own layers, no foreign page size to reconcile). Workaround: use llama.cpp (--spec-type draft-dflash), which handles the hybrid target; convert the DFlash drafter withconvert_hf_to_gguf.py --target-model-dir <target>. - Thinking models (Qwen3 family): tokens stream as
reasoning_contentbeforecontent— benchmark clients that only watchcontentmeasure TTFT wrong. See bench/README.md for the other benchmarking pitfalls (prefix caching,ignore_eos, per-slot context).
Models larger than RAM (disk streaming)
- Unified memory doesn't help you load a too-big model — it hurts (2026-07-08).
A 557 GB checkpoint (Hy3, 295 B params) can't be loaded on a 128 GB Spark, and
because CPU and GPU share one pool, the usual "load to CPU RAM, move blocks to
GPU" trick fails two ways: loading the whole thing to
cudaOOMs, and evendevice_map="cpu"overflows the pool. Loading a big model tocudawhile something else holds the pool can also hang the CUDA allocator, not just OOM. - Stream from the NVMe instead. For layer-wise workloads (REAP calibration,
and its prune), build the model on
meta(0 resident) and materialize each decoder layer's weights from the on-disk safetensors shards only while that layer is processed, then release it back tometa. Peak RAM is ~one layer (~7 GB for Hy3) regardless of the 557 GB total — the 3.7 TB NVMe is the real capacity limit, not the 128 GB RAM. See the Training page for the REAP recipe. - Two Sparks (242 GB) still can't hold a 557 GB model — combining memory helps only in the 128–242 GB band (model-parallel). Above that, disk streaming is mandatory regardless of node count; the second Spark buys throughput (data-parallel calibration) or, with pipeline-parallel, more disk capacity.
- Clear
__pycache__after editing library code (2026-07-08). A long cuda/cpu device-mismatch hunt turned out to be stale.pyc: a running process kept executing old bytecode and ignoring source fixes.find src -name __pycache__ -type d -exec rm -rf {} +when a fix "isn't taking."
Clustering: two-Spark pipeline-parallel inference (vLLM)
Running the 157 GB REAP-pruned Hy3 (too big for one Spark, fits across two) with
vllm serve --pipeline-parallel-size 2 over Ray + the 200G fabric. The model
loads fine; the friction is all environment (2026-07-08):
-
Copy the venv, don't rebuild. The Sparks are mirror-image (same aarch64/GB10, same
/home/joemullerpaths, Python 3.12), sorsync -aof the working~/venvs/vllmto the peer gives a functioning vLLM+Ray+torch with no compile — the.so/paths all resolve. Saved a from-scratch vLLM build. -
vLLM 0.24.0 supports
HYV3ForCausalLMnatively (in the model registry). -
NCCL over RoCE isn't turnkey for vLLM — PP group init died with "NCCL error: unhandled system error".
NCCL_IB_DISABLE=1+NCCL_SOCKET_IFNAME=the fabric netdev forces TCP over the 200G link, which is plenty for PP's light inter-stage traffic. (RoCEib_write_bwworks; wiring it into NCCL is a separate GID/HCA tuning job not worth it for a functional check.) -
ninjamust be on the Ray worker's PATH — flashinfer JIT-compiles a CUTLASS fused-MoE kernel (gen_cutlass_fused_moe_sm120) on first forward and shells out toninja(needsnvcctoo — both present under/usr/local/cuda- a
pip install ninja). Ray raylets inherit the login environment, and actors get PATH from there — not from theray startshell's exports. Fix: symlinkninjainto~/.local/bin(the stock~/.profileadds it when the dir exists) and start raylets via login shells (bash -lc). Verify with a@ray.remoteprobe ofshutil.which("ninja")on each node.
- a
-
Disable Ray's memory monitor on unified memory —
RAY_memory_monitor_refresh_ms=0. It counts the 75 GB of GPU weights as system RAM, decides the node is full, and OOM-kills a worker after the model already loaded and KV cache allocated. -
Hard-reset Ray between failed attempts — killed vLLM runs leak placement groups that keep the GPUs "reserved", so the next launch hangs on "Waiting for creating a placement group".
ray stop --force+pkill -9 -f 'EngineCore| RayWorkerProc'on both nodes, then confirmray statusshows0.0/2.0 GPUwith no "reserved in placement groups". -
Gloo needs the same interface pinning as NCCL for multi-node vLLM (2026-07-09). PP2 launch died with
Gloo connectFullMesh failed ... Connection refused, remote=[127.0.0.1]— the Sparks resolve their own hostnames to loopback, so torch's CPU (gloo) process group advertises 127.0.0.1 to the peer. Fix:GLOO_SOCKET_IFNAME=enp1s0f1np1+ per-nodeVLLM_HOST_IP=<fabric ip>on both raylets and the vllm serve process (raylets pass env to Ray actors; setting it only on the server is not enough). -
SM120 SASS cubins run unmodified on GB10/sm_121 (2026-07-09). The vLLM-Moet hand-written kernels (2-bit MoE QMMA.SF GEMMs, assembled by
cubitwith a mercury compat stub) load viacuModuleLoadand op-validate bit-deterministically at rel err 2-3e-3 on GB10 — CUDA treats 12.0/12.1 as one SASS family. Don't assume "SM120-only" kernel projects exclude Spark; test with a 30-line driver-API loader first. -
DeepGEMM nv-dev builds clean on aarch64 (
a6b593d2, ~7 min, needs only pybind11/wheel + system CUDA 13 headers). The vLLM-0.24-vendored copy lacks family-120 host paths; the site-packages wheel shadows it. -
flashinfer-jit-cachecu130 wheels are x86-only — on Spark, flashinfer 0.6.14 falls back to JIT-compiling every kernel on first use (slow first request; same ninja/nvcc PATH caveats as the Ray note above). -
Pinned-host "offload" tiers double-allocate on unified memory — designs that keep a pinned-host weight store + a GPU cache pool (vLLM-Moet's delta & base-cache tiers) allocate the same physical LPDDR twice on GB10. Run such stacks in fully-resident mode with the offload tiers disabled (
VLLM_MOE_W2_DELTA_GB=0, noBASE_CACHE_GB); "GPU-resident" already means "in the one memory pool". -
The GB10 unified-memory load war (2026-07-09, vLLM-Moet port; full details in
~/Dev/vLLM-Moetbranchspark-gb10). Getting a 149 GiB checkpoint converted+loaded on a 121 GiB unified box surfaced six distinct platform behaviors, each capable of OOMing the load on its own:cudaMalloctakes system RAM but does not trigger page-cache reclaim — streaming a big checkpoint fills the cache and starves the allocator ("754 MiB free" CUDA OOM with 40+ GiB of clean cache). Fix:posix_fadvise(DONTNEED)consumed shards during load.- Pageable H2D copies are CPU-executed writes into UVM — destination pool pages become host-resident and device-backed, and allocator churn accumulates them (~2-3 GiB/layer). Fix: pinned bounce + one persistent device buffer per transfer shape.
cudaHostAlloc(pinned) is shmem-backed with power-of-2 rounding — a 20 GiB pinned ask costs ~33 GiB physical.- Plain pageable host memory is readable by GPU kernels via ATS at full bandwidth — the winning place to keep big read-only weights.
- glibc arena free-lists retain tens of GiB of ~10 MB loader buffers;
MALLOC_MMAP_THRESHOLD_=65536returns them. - The kernel OOM killer will take the desktop/session with it — run
model engines under
systemd-run --user --scope -p MemoryMax=...(a cgroupMemoryHighthrottle can stall a loader indefinitely; prefer a hard MemoryMax). The durable architecture: convert weights offline once (prepacked planes on disk) and serve-load into pageable host memory — no staging, no requant, no churn.
-
vLLM KV sizing on GB10 counts system-wide usage: budget =
gpu_memory_utilization × total − used, where "used" includes host allocations (even other processes). Tune util to the system picture, not the model's CUDA footprint. -
DGX OS ships
memlockhard-limited to 8 MB — this is why NCCL-over-RoCE "mysteriously" fails (2026-07-10).ibv_reg_mr(RDMA memory registration) needs locked pages; NCCL surfaces the 8 MB ceiling as "unhandled system error" at init. Fix: limits.d memlock unlimited + start the process tree from a FRESH login session (inherited shells keep the old limit; self-sshworks). Solved the hy3-era mystery. -
DGX OS also starts sessions with
PR_SET_THP_DISABLE— madvise'd transparent hugepages silently no-op (THP_enabled: 0in /proc/self/status). One unprivilegedprctl(41, 0)per process re-enables. Also:CONFIG_READ_ONLY_THP_FOR_FSis not set, so file-backed THP (MADV_COLLAPSE on mmap'd weights) is unavailable on the stock kernel. -
vLLM decode-step profiling on 2-node PP (
--profiler-config '{"profiler":"torch",...}'+ VLLM_SERVER_DEV_MODE): under CUDA graphs, in-graph kernels don't emit individual trace events — bucket totals still attribute wall time. GLM-5.2 PP2 decode: ~30% expert GEMMs, ~70% comm/pipeline-wait — which is why kernel-side tuning moved little and RoCE/batching moved more.