ggml-cpu : add mirror NUMA strategy (replicate weights on each node) - #27986
ggml-cpu : add mirror NUMA strategy (replicate weights on each node)#27986matteoscalabrini wants to merge 7 commits into
Conversation
Assisted-by: Claude Fable 5
Assisted-by: Claude Fable 5
|
Hi @matteoscalabrini, thanks for your contribution! Per our contribution guidelines, the automated PR checker found the following issue(s) that need your attention:
Please note that maintainers reserve the right to make final decisions on PRs. If you believe there is a mistake, please comment below. |
Assisted-by: Claude Fable 5
# Conflicts: # tools/cli/README.md # tools/completion/README.md # tools/server/README.md
Assisted-by: Claude Fable 5
|
It's an interesting idea, but for me on a 2S Xeon system it halves pp (900 t/s -> 500 t/s), while not improving tg at all (14 t/s -> 14 t/s) compared to a model loaded & bound to a single socket. |
Can i ask what model did you try and if you did hybrid or cpu only? It works best in cpu only or hybrid with high reliance on cpu. |
Qwen3.8-Flash-Next-UD-Q3_K_XL-00001-of-00003.gguf, --n-cpu-moe 40, rest offloaded to 5090. |
A couple of diagnostics would help distinguish the two cases here: Which commit did you build — before or after 81139af? For tg, I think your configuration is genuinely inside the regime where binding to one socket wins. The current PR wording around “large memory pools” is probably too vague there, and I can make that crossover more explicit. |
After. I have the following [1] checked out and merged into master, which was on [2].
Yes.
Yes, it correctly takes x2 RAM, split between nodes.
Only tomorrow. 90% of the time this machine runs Windows VM with 5090, and it's a rather hideous process to switch video back and forth between the host and passthrough :(
I've tried membind=0, -N 0 -l, -N 1 -l (and it fails with the warning you mentioned in docs). After some thinking, my best suspicion is that, since you force initial load on socket == 0, and only later copy to socket == 1, and can't do the reverse (that warning), it may be not prepared that my card is not on socket 0, but on socket 1. You mentioned that you try to use the correct buffer to talk to GPU, but maybe not in all cases? Still, this might explain decreased pp, but doesn't explain same tg. I'll also try tomorrow with --cpu-moe instead, to keep all experts on CPU. [1] * 81139af 30 Aug 16:44 Okoku - (pr/27986) ggml-cpu : set a home-node memory policy automatically in mirror mode |
I re-traced the code to confirm the consequence: with everything default, primary lands on node 0 and uploads cross UPI. Your workaround — numactl -N 1 -l — actively breaks: the mirror still treats node 0 as home, tries to migrate the primary away from node 1, hits pinned pages, and fires the warning you saw. Under that config replica[0] is the primary sitting physically on node 1, so node-0 threads read remote and the node-1 replica duplicates data already there. The home node needs to be selectable and ideally auto-picked as the GPU's node. I'm actively working on auto selecting home node using the kernel's PCI device tree in sysfs |
Assisted-by: Claude Fable 5
|
It's curious the word drop_caches doesn't appear anywhere in the PR descr, and mmap appears only once. This hints the author may be unaware of this protocol and that the improvements are being compared to an incorrectly initialized --numa distribute. A correctly initialized |
As replied on reddit I report my words for completeness:
You are right and I was unaware of this protocol. I am informing myself thoroughly now. and running test to update the data displayed in the pr text. The error:
The tests that im now running, (might take a couple of hours):
|
Drop replicas when the mirrored buffer is freed (notify from ggml_backend_buffer_free), serialize registry mutation, bounds-check the remap node, and reset off-home workers to the default memory policy so KV and scratch first-touch stays local. Assisted-by: Claude Fable 5
Update after running the tests: First of all, thanks, your critique was correct. Both baseline arms in the old table were "wrapped" in numactl --membind=0 dirtying the test of --numa distribute. I rerun the full comparison using drop-caches before every arm, mmap enable, no numactl, and pure --numa distribute with warming across 4 runs.
Your explaination of the moe behaviour makes sense. The distribute sequence made 9.11 -> 11.40 -> 11.56 -> 11.55. ; warming first-touch placement improves it by about 27%. On that model, converged first-touch distribute also beats interleave (11.55 vs 10.29), which is exactly what you've pointed out. The stale-cache is real as well. Running distribute against the page cache left by a differentl run gives 2.88 on the dense model, about 42% below interleave. With those effects accounted for, mirror is still +57% over fully converged distribute on the MoE model and +60% over the best baseline on the dense model. I also checked the current code path. For these mul_mat_id decode shapes, you end up with exactly nth chunks, so the steal loop exits immediately. Plain mul_mat also takes the static chunk path under ggml_is_numa() which makes the thread-to-row mapping deterministic, so repeated execution can indeed first-touch those mmap-backed pages onto the node whose threads later consume them. There is still one part I am having difficulties understand: I'm not sure yet how much either of those explains, especially the size of the gap. If you have a read on which one is more likely or if i'm completely missing something (might be this is a bit out of my comfort zone) I'd genuinely be interested. I've updated the PR body with the corrected table and methodology. The four lifecycle/race/policy issues from the code review are also fixed in 108f651. |
|
The problem with the |
|
@DocShotgun there is a DMA solution for this here. It will give full pcie5 transfer rates for offloading by overlapping a NUMA gather operation for layer N+1 while layer N is transferred via DMA (CUDA pinned mem). This gives --distribute decode speed with CUDA pinned mem offload speed for a small cost in addl system ram for a pair of CUDA pinned buffers for each of gate/up/down. |
|
I reconfigured with NPS1 and ran a comparison. MXFP4 weights from sokan mxfp4 gguf conversion llama.cpp master Invocation: Llama.cpp warmup is broken, so first 10-20 minutes of "tell me a story about a cat and a doubly linked list in spanish" etc. Idea: Is it possible you read from here the tg= value at this point? It's an easy mistake to make. Eventually there are no more page faults, and the system can be considered to be warmed up. Now decode at low context, e.g. 500tok or so. (where cpu-moe is at its highest ratio of attn/ffn duty) main = ~34.3t/s Now for the mirror PR #27986 Invocation: Interesting NUMA startup messages: No warmup is needed since this system has an explcict numa load mechanism. And a request: ~36.4t/s I used 48 threads (out of 192) because I've previously measured this to be optimal for single-token decode on llama.cpp + MXFP4. So the mirror at least hints at (in this simple n=1 test) about +6% decode throughput. If you are repacking the FFN weights, it may explain all of the difference.
|
Interesting... so in theory implementing this would allow for the PP speed of EDIT: So I had DS flash implement the linked hack into my local copy for testing. Doesn't seem to have a speed benefit at least for running DS flash itself, where a majority of the weights were residing on GPU (for DS flash, binding the process to a single socket was still faster in both PP and TG). I'll need to try it using a bigger model, but sheesh stuff is slow to load with mmap... Also is it normal for the first request after server start to be insanely slow? I thought warmup would already pull all the mmap'd weights into RAM? |
|
@DocShotgun Precisely. The barrier to 50GB/s PCIe is getting the layer data into something CUDA can DMA w/o the OS. CUDA has pinned buffers, which ik_llama switched to by default some time ago. I'm not sure what llama.cpp does today. The problem these buffers solve is DMA. (But they make a new problem for NUMA) For the GPU to retrieve from mem directly it needs a buffer that doesn't require Linux kernel negotiation to read. A physical mem mapping that is guaranteed not to be paged/evicted/etc. AFAIK there is no CUDA API to create these w/NUMA awareness. The idea is to allocate a pair of CUDA pinned buffers to hold A/B copies of one layer of gate/up/down tensors. When GGML wants to copy layer N to GPU for offload op, grab it from one of the CUDA pinned buffers. While that copies (via DMA, i.e. 50GB/sec pcie5) run e.g. 10 threads of memcpy to get layer N+1 into the second buffer. When layer #Last is reached, layer #First is preloaded into the buffers so it's ready on the next forward pass. The xgmi links have more bandwidth than the pcie transfer so the gather memcpy to fill the next layer's buffers during the DMA is no problem. |
Hmmm... I'm not getting very good results in terms of speed (it's just slower than isolating to a single socket), whether I use DS v4 flash or GLM 5.3 perhaps there's an issue with my invocation? Dropping caches and then: |
|
@DocShotgun it looks correct at a glance, but the disable-numa-balancing call in the middle of a line-continuation seems odd, I'm not sure if that's what you intended or if it's copy-paste residue. You should be able to set GGML_CUDA_EXPS_READAHEAD_DEBUG=1 and get some feedback. expect 100% "cache miss" noise during the first prefill batch (learn phase) then "hit" afterwards. |
|
Thanks a lot for running the test @usrlocalben. I actually think your result is a very useful second data point: +6% decode on a VERY high-bandwidth machine, with zero warmup, versus a machine that needs the 15+ minute first-touch procedure before it gets there.
is probably the description of what this buys on hardware like yours. I also think the difference between the gain you see and the gain I see is due to the different generations of machines. My machine is basically the opposite case. Im already bandwidth limited, and measure roughly a 2.1x local vs remote bandwidth ratio. That is pretty much the environment this code grew out of: necessity to squeeze all I can out of my limited hwd. So to me the 6% on your machine and the +/- 50% on mine aren't contradictory results; they're two points on the same curve: locality matters more as available memory bandwidth gets tighter. There is one other part to test I'd be curious to see from you which is totally speculation on my side: you already mentioned that the optimal thread count may move with the mirror, and I think that's quite plausible. t=48 was tuned on master. There adding threads also means adding more cross-socket traffic: once both sockets have a local copy of the weights, that tradeoff changes, so there is no particular reason the optimum should stay at the same thread count. I see exactly that on my system. Without locality, keeping 32 cores on one socket beats spreading 64 across both: (18.88 tg/s vs 14.80 tg/s) with the mirror,the 64-thread run wins at 20.81 tg/s. Let me know what you think might be funny to see such result. |
|
Not much to add as @usrlocalben pretty much said what I've found to be optimal: https://reddit.com/comments/1w2hlm8/comment/p6ug4xx This seems to work for both my dual At one time I got better performance by not using hypercores (either by disabling them in BIOS or via thread pinning). Recently I revisited this and found that I get significantly better PP (30%+) and marginally better TG (~10%) by using all core+hypercores, but I'm not sure if this is quant-type-specific. |
|
This might also be of interest: as I think the default If you do this then the This means there isn't actually any NUMA link cross-traffic at all! |
|
The results from my long warmup tests just landed. You were right about the warmup. Measured your way (llama-server, thousands of warm tokens, major faults tracked until they go quiet), fully warmed numa distribute reaches 16.9 t/s on this box and mirror reaches 16.4. On MoE decode that is a tie. The +57% in my table was distribute measured mid-warmup -- im sorry for causing confusion but it was quite difficult and time consuming to test it as I did not understand the extent the warm up needed to be until I saw your numbers this morning. The table is getting corrected for the third time. The reason llama-bench could not see this is worth spelling out. Each invocation generates only a few hundred tokens, and the warmup tail is enormous by comparison: major faults were still trickling in at 8,000 tokens, 36M faults total, roughly 138 GB read from disk. There is a second defect on top of that. llama-bench runs pp before tg, and at batch 64 the op-offload path reads the weights from a single upload thread, so the first pages fault in an arrangement that the 64 decode threads never chose. The page cache then freezes that arrangement for every later run. Short bursts plus the wrong first toucher gives you a baseline that cannot converge inside that harness, no matter how many repetitions you run. Dense tells the same story, and it gets there faster. One generation pass was enough to fault in the entire 16 GB of weights (4.24M major faults, then a delta of 8 on the following pass), which is what you would expect when every token touches everything. So the correction extends to dense as well: neither mode wins on steady-state throughput, on either model class. If throughput is equal at steady state, then the case for mirror is not throughput. It is the cost of reaching that steady state and of keeping it. Distribute pays 10 to 15 minutes of degraded serving after every cold start. That is the story-prompt ritual you described. Mirror front-loads the identical work into the replica build, about a minute on this model class, after which the first request already runs at full speed. The "first-touch" arrangement breaks silently. A stale page cache costs 42% here, measured. A forgotten drop_caches does it, a change in thread count does it, and nothing in the output tells you it happened. Mirror has no dependency on cache state or on the order in which pages were first touched. Thread tuning: mirror sits flat at 18.1 to 18.5 t/s across t=32/48/64, while the no-locality configs swing between 14.8 and 18.9. You mentioned in your comment how expensive it is to probe for the optimal thread count. With mirror that probe should not be necessary. There is one more property that I think matters more than anything above, and it concerns prompt processing on GPU setups. First-touch NUMA and CUDA pinned memory are mutually exclusive. Pinning faults every page on the loader thread at load time, which destroys the first-touch placement you were trying to establish, so a distribute-based system is permanently locked out of DMA-speed uploads and pays the pageable-copy bounce on every expert upload during prefill. Mirror breaks the exclusivity: locality comes from the replicas, so the primary is free to be pinned. You get full-rate DMA prefill and node-local decode reads at the same time, without choosing. On my "production "stack the pinning half alone was worth +81% on prefill, 85 to 153 t/s on a 302 GB model. That is a production measurement, not from the A/B table in this PR. I would point out, respectfully, that this is the same problem your readahead-pool patch builds a manual solution for, with gather threads and double pinned staging buffers. Mirror gets the property by construction instead, and the price for skipping that machinery is the extra RAM. (which im not implying its a small tradeoff) 2x RAM for the CPU-resident weights, and, on a box where the operator performs the ritual correctly and can afford the warmup, no steady-state decode advantage to show for it. The table correction is going into the PR body. @jukofyork 's independent numbers on the older Xeons would be the most informative next point, since everything above comes from one (mine) machine. |
|
@jukofyork Indeed w/codebook, trellis quants IQ* I see a completely different characterization wrt. threads than e.g. Q4_K. A quant like IQ3_XS can see increased throughput w/threads to the full capacity of the chip, where Q quants will reach their optimum with very few, and more or less proportional to their size/flops ratio. SMT=on gives bizarre charts with zig-zag shaped long-tail, I leave it off. ik_llama added PR 2202 which makes experimenting with different thread/tensor alignment very simple. I added 8 counters (counter per numa) and from there one can change the arrangement to expert parallel, row parallel or probably any layout you may want to try. I thought that EP might be viable in conjunction with speculative, since a batch of N tokens should give ~N*n_routed_exps for better "coverage" but trying this with ik_ and GLM-5.2 didn't work out as I hoped. If one doesn't mind the binary blob, the fastest system for a number of models (not all) is Lsglang. For the same DSv4-Flash test measured above in this thread, Lsglang gives 50-60 tok/sec decode and with MTP 75-100 or more, and the KV-caches and compute buffers are tiny compared to llama. I find it very impressive. It gives the impression of fundamental differences compared to [ik]llama, especially wrt. MTP results. For GLM-5.2 however I found prefill to be lacking and I preferred ik_llama for that model. I see @DocShotgun trying GLM-5.3 with the DMA mod so I'll grab that and try it out sometime this week to ensure I'm giving correct advice on running it. |
|
I gave a go to Lsglang. Its definetely impressive and decode is 50% higher than what I can archive with the best tuned llama.cpp instance I have. Unfortunately prefill is a half of what i'm getting with my current llama setup, thats a shame. I tried tuning it but im quite new to sglang therefore I might have not done the best of jobs. Worth keeping an eye on. especially for older machines with multiple nodes like mine. I decided to continue the work on numa mirror privately and update this draft as I'm discovering something worth posting. I still remain of the idea that numa mirror is the best solution for hybrid gpu-cpu inference with cards split on 2 nodes. I'm currently tuning the Big GLM 5.3 in Q4_K_XL and while it barely fits my 500gb per node cap (mirror occupies a total of 870 gb or ram) it shows promising results especially since i have cards split across nodes which can read weights locally without crossing. Currently getting 10.5tk/s in decode and around 150 in prefill. with 256k context at q8 and a couple of experts offloaded. |
The results below were measured on the development branch, which shares the performance-relevant mirror implementation with this PR.
At steady state, decode throughput matches a fully-converged first-touch
--numa distributesetup on this hardware (see the corrected results below). The case for replicas is structural instead: first-touch NUMA and CUDA pinned host memory are mutually exclusive, so a hybrid CPU/GPU setup is forced to choose between DMA-speed prefill and NUMA-local decode - the mirror is the only strategy that provides both (see "Why replicas instead of --numa distribute" below). On top of that, the steady state arrives immediately and unconditionally: no 10-15 minute warmup convergence after every cold start, no page-cache state to manage, no thread-count sensitivity.What is this?
GGML_NUMA_STRATEGY_MIRROR- enum value 4, already reserved inggml/include/ggml-cpu.hbut previously unwired.Why replicas instead of
--numa distribute--numa distributegets its locality from the kernel's first-touch rule: a page lands on the NUMA node of the thread that first touches it, so when the compute threads fault the weights in, each page lands next to its reader. CUDA pinned host memory breaks that mechanism: when the CPU-resident weights load through the pinned host-buffer path (--no-mmapon a CUDA build places them in aCUDA_Hostbuffer), every page is faulted by the loader thread at load time - placement is frozen before the compute threads have touched anything, and pinned pages cannot be migrated afterwards. Distribute's locality and pinned memory are mutually exclusive. An operator running hybrid CPU/GPU MoE inference (the DeepSeek/GLM class these machines actually serve) therefore faces a forced choice:--no-mmap) - DMA-speed expert uploads, but placement frozen wherever the loader ran, orNever both. The mirror removes the choice: locality comes from the replicas rather than from fault order, so the primary copy stays pinnable. Full-rate DMA prefill and node-local decode reads coexist by construction. This is structural, not a benchmark result that better measurement could overturn.
Verified directly on this PR branch - DeepSeek-V4-Flash UD-Q8_K_XL (151 GB), 5x RTX 3090,
-ngl 99 -ot exps=CPU,llama-server, one binary, one day; default batching (-b 2048 -ub 512), 9112-token prefill probes and greedy decode probes per arm. Decode is batch-1 and ubatch-independent; the prefill deltas are ub-512-regime numbers - at larger ubatch the upload cost amortizes and the relative pinning gain should compress:--no-mmap)--no-mmap)Pinning buys +86-108% prefill (corroborating a +81% production measurement on a different, 302 GB model). Pinned distribute pays -34% decode, reproduced across two cold-cache runs (11.5 and 11.3 t/s):
numastatat server health shows the pinned buffer frozen on the node the loader thread ran on (121/141 GB and 142/142 GB, before any request), and it is byte-identical after generation - nothing migrates, so no warmup ritual can fix pinned placement. Pinned mirror posts the best prefill and the best decode of the entire matrix simultaneously: the self-bound loader places the pinned primary on the home node, the replica covers the other socket (numastat142.0/141.5 GB), and the replica build costs 9.3 s (memcpy from resident pinned memory). The steady-state decode tie shown in Results is measured in the unpinned regimes; the moment prefill matters enough to pin, distribute forfeits a third of its decode and the mirror forfeits nothing.Key points
tensor->datais never modified. The primary copy remains on node 0; KV cache, activations, and all other backends are unaffected.Adds one extra weight copy per additional NUMA node. On a dual-socket system, NPS2/NPS4 therefore require 3x/7x additional copies; NPS1 is recommended.
GPU expert-weight uploads (op offload) read from the replica local to the destination GPU's PCIe NUMA affinity. The NUMA node is obtained from sysfs using
cudaDeviceGetPCIBusId, resolved throughget_proc_address, and cached per backend. The bytes are identical; only transfer locality changes. GPUs on the home node retain the existing behavior.On the development branch, forcing the wrong source node reduced op-offload prefill throughput by ~48% (
1375.7 -> 711.0 pp), while remaining bit-exact (160/160). Test hook:GGML_NUMA_MIRROR_SRC_NODE=<n>.Remapping occurs only at CPU GEMM read sites (
mul_mat,mul_mat_id, llamafile SGEMM x2, repack x2) throughggml_numa_mirror_remap().Each thread selects its replica using
getcpu()at entry. This remains correct even if a thread migrates, so explicit CPU pinning is not required for correctness.Replica discovery is lazy at
graph_compute; no allocator hooks are required. Allocation uses anonymousmmapplus rawmbind, with nolibnumadependency.No
numactlwrapper is needed. Mirror mode sets anMPOL_PREFERREDpolicy for the home node at backend init, before any weight is allocated, so the primary copy lands on the home node by itself. An explicit policy set by the user (numactl etc., detected viaget_mempolicy) is never overridden. PREFERRED rather than BIND, so a model larger than one node degrades gracefully instead of failing allocation.The home node is picked automatically: a sysfs PCI scan finds the discrete GPUs (display-class devices from NVIDIA/AMD, which excludes the BMC VGA server boards carry) and takes the node holding the majority of them, since GPU uploads read the primary copy. Falls back to node 0 with no GPUs, logs its choice, and
GGML_NUMA_MIRROR_HOME=<n>overrides it. The scan runs before any backend is initialized, which is why it reads sysfs instead of asking the GPU backend.If the primary still lands off-home (e.g. a pre-existing conflicting policy), placement self-heals using
move_pages()sampling followed by a 1 GiB probe before full migration; unmovable pinned pages produce a warning instead.Repack/AMX extra buffer types are mirrored as well.
Emits a one-shot coverage report and warns when coverage is below 50%.
Disabled mode, non-Linux platforms, and single-node systems are no-ops. The hot remap path adds one atomic load per call.
Limitations
Currently Linux-only, with a maximum of 16 mirrored buffers.
Related work
This PR builds on the long-running NUMA work discussed in #1437.
The
--numa mirrorflag name and the mirror concept come from #16000 by @dbsanfte.The implementation here uses a different mechanism. #16000 turns every tensor->data access into an accessor across 123 files (+4692 lines). This PR leaves tensor->data untouched and confines remapping to the CPU GEMM read sites plus the scheduler's expert-upload source pointer (+516/-10 across 11 files). The sole backend-side addition is a small CUDA helper in ggml-cuda.cu that resolves a GPU's PCIe NUMA node through sysfs; no backend compute or copy path is otherwise modified.
It is also complementary to the sharded-prefetch experiments discussed in #16000. Sharding keeps memory usage at 1x and primarily targets GPU-offload prefill. Mirroring instead targets CPU decode reads: a shard cannot simultaneously provide node-local access to cores on both sockets, whereas a replica can.
Results
Test system: 2x AMD EPYC 7532 (Zen 2, 32 cores/socket), NPS1, 16x64 GB DDR4-2666 (1 DIMM per channel across all 16 channels), 1 TB total RAM, and 5x RTX 3090.
Measured memory read bandwidth:
The main head-to-head table (prefill and decode, distribute vs mirror, mmap and pinned) is in "Why replicas instead of
--numa distribute" above.GLM-5.3-Flash 321B UD-Q4_K_XL
186 GB.
Using the same binary with only
--numa mirrortoggled and position-matched prompts, decode improved by +46% to +76%, depending on context depth.The mirror arm ran second and was therefore thermally disadvantaged, making these results conservative. Absolute pairs were not recorded; a fresh
llama-benchvalidation table on the PR branch should supersede these measurements when available.Coverage sensitivity
Mirror decode gain scales monotonically with the fraction of GEMM weight bytes actually mirrored:
At 0% coverage, mirroring produces a net loss because the node-0 placement penalty remains without any replicas to compensate for it.
This is why the implementation reports coverage and warns below 50%. Mirroring the repack/AMX extra buffers also makes coverage effectively quant-independent: repack on/off was measured within noise (
44.08vs43.52).Mechanism microbenchmark
With identical socket and thread count and only weight placement changed:
26.39 t/s12.46 t/sThis matches the platform's measured ~2.1x local-to-remote memory read-bandwidth ratio.
Validation on this PR branch (same system)
This section has been corrected twice, both times following methodology critiques by @usrlocalben in the discussion below - the history is preserved there. First: an earlier version of the table wrapped both arms in
numactl --membind=0, which prevented first-touch page distribution and handicapped the distribute baseline. Second, and more fundamental: llama-bench structurally cannot converge a first-touch distribute baseline. Each invocation generates only a few hundred tokens while major faults keep trickling for thousands (~36M faults / ~138 GB observed before quiescence), and it runs pp before tg, so at prompt batch sizes the op-offload upload thread faults pages in an arrangement the decode threads never chose - and the page cache freezes that arrangement for every later run.The llama-bench numbers are therefore kept below as an honest picture of the cold-start / short-run regime (which is what any llama-bench user or fresh server start experiences), and the server-level steady-state comparison is the fair throughput race.
llama-bench tg128,
-t 64(cold-start / short-run regime; no numactl wrapper,drop_cachesbefore every arm, mmap on, distribute warmed across four sequential runs):-ngl 0)-ngl 99 -ot exps=CPU)llama-server steady state (protocol from the discussion:
--no-warmup, thousands of warm generation tokens per arm, major faults tracked to quiescence, throughput read from short greedy requests only):The dense mirror figure was reproduced from a cold start with only ~50 warm tokens before measuring: the replica build (17.5 s for 16.1 GiB) is the entire warmup. The distribute arm needs its ~3000-token convergence ritual to post its number.
At steady state neither mode wins on decode throughput on this box. On a bandwidth-rich 2S Genoa-class system (@usrlocalben's independent test in the discussion), fully-warmed distribute reached 34.3 t/s vs 36.4 for the mirror with zero warmup.
(Measurement note: these are cool-DIMM numbers. Sustained multi-minute dense decode on this box drops 30-45% from DIMM heating regardless of NUMA mode - it affects both arms equally and recovers after a few idle minutes. Anyone reproducing with long back-to-back sessions will see it.)
What the mirror buys is the cost of reaching and keeping that steady state:
drop_caches, or a thread-count change. The mirror has no cache-state or run-ordering dependencies.The cost is unchanged: 2x RAM for the CPU-resident weights - and, for an operator who performs the warmup ritual correctly and can afford it on every restart, no steady-state MoE decode throughput advantage on this hardware.
Bit-exactness re-verified on this branch: greedy 200-token outputs are byte-identical with the mirror on vs off, with
numastatconfirming the replica engaged (154.6 GB on node 0 + 153.1 GB on node 1). The upload-sourcing path was exercised with a 4,003-token prompt while forcing the wrong source node (GGML_NUMA_MIRROR_SRC_NODE=1): output stays byte-identical.The non-default home-node path was validated by forcing
GGML_NUMA_MIRROR_HOME=1on this box (whose GPUs majority-vote node 0): the policy follows the forced node,numastatshows the primary on node 1 with the replica on node 0, greedy output is byte-identical to the home-0 run, and decode matches within noise (7.72 vs 7.88 t/s on the dense CPU-only case). This is the configuration a machine with its GPU on socket 1 now gets automatically.The registry lifecycle was also validated directly: two sequential model loads in one process with the mirror on peak at 33.9 GB resident (one mirrored model plus KV) - replicas are dropped when their buffer is freed, and freed table slots are reused.
Correctness
Mirror on vs. off:
320/320top-20 logprobs identical480/480identical on the instrumented repack pathThe replicas contain identical bytes and are consumed by the same kernels; only their NUMA locality differs.
Testing
Further community testing on diverse hardware is needed, particularly on Intel CPUs and AMD/Vulkan systems.
Requirements