Skip to content

Add --prefetch-experts to stream mmap'd MoE experts into page cache#2101

Merged
ikawrakow merged 6 commits into
ikawrakow:mainfrom
dmaivel:moe-prefetch
Jul 11, 2026
Merged

Add --prefetch-experts to stream mmap'd MoE experts into page cache#2101
ikawrakow merged 6 commits into
ikawrakow:mainfrom
dmaivel:moe-prefetch

Conversation

@dmaivel

@dmaivel dmaivel commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

This PR introduces --prefetch-experts; it allows for ~2x gains in PP performance for those running models that do not fully fit in RAM (as such, get paged in from storage).

When expert weights live in a host mmap (e.g. -ot exps=CPU with a model larger than RAM), the consumers of those weights (the CPU MoE matmuls at decode, the scheduler's host-to-device expert copies for offloaded batches) stall on page faults, as they are serviced one miss at a time. This PR adds a small pool of pread() workers that warm the page cache ahead of use. This gives us two behaviors:

  • Selective (decode): as soon as a split's routing ids are host-visible, the selected expert slices of its up/gate/down tensors are enqueued. The kernels also self-enqueue at matmul entry, covering pure-CPU graphs.
  • Lookahead (prefill): while split i computes, the next few MoE-bearing splits' expert tensors are streamed in full (batch graphs activate most experts anyway). Freshly streamed pages are MADV_COLD-ed after use so this one-shot traffic is reclaimed ahead of the resident working set.

Also added env vars for tuning, though the current defaults gave me the best performance:

  • GGML_MOE_PREFETCH_THREADS: default min(8, hw)
  • GGML_MOE_PREFETCH_AHEAD: splits to stream ahead, default 3
  • GGML_MOE_PREFETCH_DEBUG: print counters at shutdown

I tested on a system that has i9-14900F, RTX 4080 SUPER, 64 GB RAM, NVMe (kyber scheduler). As for models, I tested these two:

Step 3.7 Flash just slightly spills out of memory, while GLM 5.2 is double my RAM capacity.

Command:

GGML_CUDA_NO_PINNED=1 llama-cli -m <model> -f <prompt> -n 32 -c 16000 -ngl 99 \
  -ot exps=CPU -ot output.weight=CPU --threads 8 -fa on -mla 1 -b 4096 -ub 4096 \
  -ctk q8_0 -wgt 1 --defer-experts --ignore-eos [--prefetch-experts]

All numbers are cold-start. The prompts I used were just varied natural-language text.

Step-3.7-Flash UD-IQ3_XXS @ ~73.6 GB:

n_prompt pp (no prefetch) pp (w/ prefetch) speedup tg (no prefetch) tg (w/ prefetch)
2048 69.2 t/s 149.3 t/s 2.16x 5.46 t/s 6.29 t/s
4096 130.4 t/s 271.9 t/s 2.09x 7.69 t/s 7.22 t/s
8192 115.5 t/s 276.6 t/s 2.39x 8.36 t/s 8.14 t/s

GLM-5.2 1.630bpw @ ~143 GB:

n_prompt pp (no prefetch) pp (w/ prefetch) speedup tg (no prefetch) tg (w/ prefetch)
2048 27.5 t/s 73.8 t/s 2.69x 1.32 t/s 1.60 t/s
4096 45.6 t/s 108.8 t/s 2.39x 1.39 t/s 1.46 t/s
8192 43.9 t/s 82.7 t/s 1.88x 1.48 t/s 1.60 t/s

Changes in TG speeds are likely noise.

Farmadupe added a commit to Farmadupe/ik_llama.cpp that referenced this pull request Jul 9, 2026
Farmadupe added a commit to Farmadupe/ik_llama.cpp that referenced this pull request Jul 9, 2026
Comment thread src/llama-mmap.cpp Outdated
// register with the MoE expert prefetch engine, which needs (fd, offset)
// to stream expert weights into the page cache with pread() workers.
// The mapping starts at file offset 0, so VA -> offset is direct.
prefetch_fd = dup(fd);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You want to do this unconditionally? Even when not using the prefetch option?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it affected anything when not using the prefetch option, but regardless, it's not the right spot for it so I'll move this logic elsewhere.

@ikawrakow

Copy link
Copy Markdown
Owner

I don't have enough disk space left to download a model that is larger than the RAM I have. Hence, would appreciate if at least one more user tested and confirmed that it works for them.

Farmadupe added a commit to Farmadupe/ik_llama.cpp that referenced this pull request Jul 9, 2026
@Farmadupe

Farmadupe commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Notes:

  • I initially tested without setting --defer-experts, so this probably failed to test your feature at all. Results included since I collected them. Turn out it doesn't matter. it's faster even without --defer-experts.
  • The box I'm testing on has enough system RAM to hold the majority of the weights, so this may just be a disk read test. nope! It's still faster even with enough host ram to hold the weights.
  • My log output has various vibed instrumented timings so it looks weird. I don't guarantee they're measuring what they say they measure. They shoudln't be misleading though.

box

/proc/cpuinfo:

processor       : 383
vendor_id       : GenuineIntel
cpu family      : 6
model           : 173
model name      : Intel(R) Xeon(R) 6952P

lspci | grep -i nvme:

root@b1e9706ce9ec:~# lspci | grep -i nvme
19:00.0 Non-Volatile memory controller: Samsung Electronics Co Ltd NVMe SSD Controller PM9A1/PM9A3/980PRO
5d:00.0 Non-Volatile memory controller: Samsung Electronics Co Ltd NVMe SSD Controller PM9DXa
5e:00.0 Non-Volatile memory controller: Samsung Electronics Co Ltd NVMe SSD Controller PM9DXa
5f:00.0 Non-Volatile memory controller: Samsung Electronics Co Ltd NVMe SSD Controller PM9DXa
60:00.0 Non-Volatile memory controller: Samsung Electronics Co Ltd NVMe SSD Controller PM9DXa
root@b1e9706ce9ec:~# 

Host ram:

565GB

Disk benchmark:

fio --filename=/fio_scratch.bin --size=10G --direct=1 --ioengine=libaio \
    --group_reporting --name=layout --rw=write --bs=1M --iodepth=8 \
    --name=seqread-qd1  --stonewall --rw=read     --bs=1M --iodepth=1 --runtime=15 --time_based \
    --name=seqread-qd8  --stonewall --rw=read     --bs=1M --iodepth=8 --runtime=15 --time_based \
    --name=randread-qd1 --stonewall --rw=randread --bs=4k --iodepth=1 --runtime=15 --time_based \
    --name=randread-qd8 --stonewall --rw=randread --bs=4k --iodepth=8 --runtime=15 --time_based
Test Result
Sequential read (1M, QD1) 4,773 MiB/s
Sequential read (1M, QD8) 21.0 GiB/s
Random read (4k, QD1) 12.9k IOPS
Random read (4k, QD8) 102k IOPS

GPU stats (note, I rented three GPUs by accident. I only enabled visibility of one for the test.)

root@b1e9706ce9ec:~# nvidia-smi
Thu Jul  9 17:16:19 2026       
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.126.09             Driver Version: 580.126.09     CUDA Version: 13.3     |
+-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA RTX PRO 6000 Blac...    On  |   00000000:BA:00.0 Off |                    0 |
| N/A   31C    P0             82W /  450W |   24707MiB /  97887MiB |      0%      Default |
|                                         |                        |             Disabled |
+-----------------------------------------+------------------------+----------------------+
|   1  NVIDIA RTX PRO 6000 Blac...    On  |   00000000:CC:00.0 Off |                    0 |
| N/A   25C    P8             30W /  450W |       0MiB /  97887MiB |      0%      Default |
|                                         |                        |             Disabled |
+-----------------------------------------+------------------------+----------------------+
|   2  NVIDIA RTX PRO 6000 Blac...    On  |   00000000:CD:00.0 Off |                    0 |
| N/A   24C    P8             30W /  450W |       0MiB /  97887MiB |      0%      Default |
|                                         |                        |             Disabled |
+-----------------------------------------+------------------------+----------------------+

cmdline:

export CUDA_VISIBLE_DEVICES="0"
export GGML_CUDA_NO_PINNED="1"

exec /usr/local/bin/llama-server \
    --model /runpod-store/Kimi-K2.7-Code-GGUF/Q4_X/Kimi-K2.7-Code-Q4_X-00001-of-00014.gguf \
    --mmproj /runpod-store/Kimi-K2.7-Code-GGUF/mmproj-Kimi-K2.7-Code-BF16.gguf \
    --chat-template-file /root/runpod_assets/deploy/chat_template_kimik2.6.jinja \
    --reasoning off \
    --image-max-tokens 512 \
    --gpu-fit-margin 0,28000,1,28000,2,28000 \
    --host 127.0.0.1 \
    --port 8000 \
    --alias default \
    --threads 32 \
    --parallel 1 \
    --ctx-size 120000 \
    --n-cpu-moe 999 \
    --reasoning off \
    --batch-size 16384 \
    --ubatch-size 16384 \
    --prefetch-experts \
    --no-context-shift \
    --image-max-tokens 512 \
    --jinja

Bench (with --defer-experts accidentally disabled)

with patch

prompt tokens: 34642 total. 34639 prefill
slot 0: multimodal prefill 16282/34642 tokens (47%), 124.5 tok/s
slot 0: multimodal prefill 32591/34642 tokens (94%), 130.9 tok/s
slot 0: multimodal prefill 34642/34642 tokens (100%), 94.0 tok/s
INFO [      log_server_request] request | tid="137737898004480" timestamp=1783614915 remote_addr="127.0.0.1" remote_port=41778 status=200 method="POST" path="/v1/chat/completions" params={}
slot print_timing: id  0 | task 31 | 
prompt eval time =  368386.58 ms / 34639 tokens (   10.64 ms per token,    94.03 tokens per second)
       eval time =  179465.45 ms /   160 tokens ( 1121.66 ms per token,     0.89 tokens per second)
      total time =  547852.03 ms / 34799 tokens
INFO [           release_slots] slot released | tid="138334128070656" timestamp=1783615094 id_slot=0 id_task=31 n_ctx=120064 n_past=34801 n_system_tokens=0 n_cache_tokens=34801 truncated=false
INFO [              slots_idle] all slots are idle | tid="138334128070656" timestamp=1783615094

without patch

prompt tokens: 34642 total. 34639 prefill
slot 0: multimodal prefill 16282/34642 tokens (47%), 49.7 tok/s, eta 06:09
srv          stop: cancel task, id_task = 13
<http request timed out>

tldr: it works, even with prefilled host ram

@Farmadupe

Farmadupe commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

I did not repeat with --defer-experts enabled. I believe what I measured is a harder test, and this patch is already >2x faster.

Comment thread ggml/src/ggml-moe-prefetch.cpp Outdated
} else {
size_t done = 0;
while (done < j.len) {
ssize_t n = pread(j.fd, buf.data(), std::min<size_t>(j.len - done, buf.size()), (off_t)(j.off + done));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would madvise(MADV_POPULATE_READ) work here? I think it would avoid copying out of the page cache info buf, only for buf to be immediately dropped?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some testing and MADV_POPULATE_READ does work here; I think its a better call over using pread and we don't have to dance around the descriptors

@Farmadupe

Farmadupe commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

shower thought (and fantasy request). I think this PR does the following host2host memcopies:

  • disk -> (1)page_cache -> (2)anonymous_memory (dropped, but still in page cache) -> (3)cuda_bounce_buffer

This can be reduced to two with MADV_POPULATE_WRITE:

  • disk -> (1)page_cache -> (2)GPU (using bounce buffer)

The fantasy request is: This PR contains a generic prefetch engine that already teleports weights into the application ahead of usage. With that capability in mind, is it foreseeable to be able to prefetch directly into pinned memory, and then from there, directly DMA the expert straight into the GPU? that would reduce the host2host memcopies to 0,as weights would go straight from NVME into pinned memory, and straight from pinned memory into GPU.


asking because I observed on the rented box that:

  • The available host RAM can just about fit the entirety of kimi k2.7 in page cache so disk page churn probably wasn't huge (napkin maths is below)
  • The first batch of 16k tokens and the second batch of 16k tokens were prefilled at the same speed (124tok/sec, then 130 tok/sec), which suggests that the limiting factor is neither compute, nor pcie bandwidth
  • Prefill for 16k tokens was achieved at 124 tok/sec, but disk read averaged out to 800MiB/sec during that prefill
    • That means that roughly 1/4 of the the model was sourced from disk during each batch, with the remainder surviging in page cache between batches
    • fio says that the maximum sequential perf on that box is 20GiB/sec
  • From experience with similar inferencing with most of the weights in pinned memory, the same 16k tokens can prefill at 500 tok/sec, and there second batch will be slower with the classic logarithmic dropoff with expanding kv

So with all those observations in mind, I wonder if it's possible to imagine an extension to this PR where the weights are prefetched into a vector of pinned buffers (eg 4 buffers * sizeof(expert_tensor)), and from there immediately DMAd into the GPU.

@dmaivel

dmaivel commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for testing; I'll have to do some more experimenting to see how much further I can push this beyond swapping out pread for MADV_POPULATE_READ and to see how many of these ideas are possible. I suspect that on my drive, I am already nearing the limit, but I think it's possible to get more from this.

@Farmadupe

Copy link
Copy Markdown
Contributor

Don't let my silly fantasies stop get in the way of what you want to merge, doubling performance for disk streaming prefill is already an achievement!

@dmaivel

dmaivel commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Ah, no worries. I have been doing some experiments regarding IO, but I don't think it's the right move for this PR. I'm not seeing too much more improvement, at least for my hardware, so I think it would just be adding unnecessary complexity.

However, I do realize that batch and ubatch sizes are also helping greatly (I think with models closer to the RAM capacity). With Step 3.7 Flash I'm seeing PP increase from ~284 t/s at -b/-ub 4096 to ~419 t/s at 8192 and ~788 t/s at 16384.

@ikawrakow

Copy link
Copy Markdown
Owner

Overall LGTM, but I would like to propose that we do not expose the ggml_moe_prefetch_* functions to the public API, unless you foresee use cases in the future where this could become useful.

As it stands, ggml_moe_prefetch_* is used in src/llama.cpp and in src/llama-mmap.cpp.

The usage in llama-mmapa.cpp can be replaced with a call to a new ggml-backend function (e.g., ggml_backend_prefetch_unregister_mapping(const void *), which just calls ggml_moe_prefetch_unregister_mapping within #ifdef __linux__.

The usage in src/llama.cpp can be replaced with two new ggml_backend methods:

  • bool ggml_backend_prefetch_init(): instantiates the singleton, sets up the number of threads, starts the prefetch workers, returns true on success, false otherwise. If one would want to also be able to set the nuumber of prefetch threads via a command line argument, then this function could have int n_threads as argument, and behave the way ot does now if n_threads <= 0.
  • void ggml_backend_prefetch_register_mapping(const void * addr, size_t size)

With that the code in src/llama.cpp would become

    if (params.prefetch_experts) {
        if (ggml_backend_prefetch_init()) {
            LLAMA_LOG_INFO("%s: enabling MoE expert read-ahead (prefetch_experts) via threaded populate engine\n", __func__);
        } else {
            LLAMA_LOG_INFO("%s: failed to initialize MoE expert read-ahead -> using madvise fallback\n");
        }
        for (const auto & mapping : model->mappings) {
            ggml_backend_prefetch_register_mapping(mapping->addr(), mapping->size());
        }
    }

With that, the ggml-moe-prefetch.h header can be moved from ggml/include to ggml/src.

As it stands, there is a singleton prefetch_state. If for whatever reason it became necessary to have a prefetch state per context (or per back-end scheduler), one could add the context scheduler as argument to ggml_backend_prefetch_init and have a prefetch_state per scheduler.

@dmaivel

dmaivel commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Moved the header into ggml/src and added the ggml_backend_prefetch_* wrappers, so llama.cpp/llama-mmap.cpp only go through ggml-backend.h now.

I also made threads an argument (--prefetch-experts-threads) instead of keeping it as the env var; it makes more sense that it's exposed this way because it can lead to varying performance depending on one's drive (for me, ~4-8 is saturation; for others this may be different).

@ikawrakow ikawrakow merged commit 6a909f4 into ikawrakow:main Jul 11, 2026
@rog77

rog77 commented Jul 12, 2026

Copy link
Copy Markdown

shower thought (and fantasy request). I think this PR does the following host2host memcopies:

  • disk -> (1)page_cache -> (2)anonymous_memory (dropped, but still in page cache) -> (3)cuda_bounce_buffer

This can be reduced to two with MADV_POPULATE_WRITE:

  • disk -> (1)page_cache -> (2)GPU (using bounce buffer)

The fantasy request is: This PR contains a generic prefetch engine that already teleports weights into the application ahead of usage. With that capability in mind, is it foreseeable to be able to prefetch directly into pinned memory, and then from there, directly DMA the expert straight into the GPU? that would reduce the host2host memcopies to 0,as weights would go straight from NVME into pinned memory, and straight from pinned memory into GPU.

asking because I observed on the rented box that:

  • The available host RAM can just about fit the entirety of kimi k2.7 in page cache so disk page churn probably wasn't huge (napkin maths is below)

  • The first batch of 16k tokens and the second batch of 16k tokens were prefilled at the same speed (124tok/sec, then 130 tok/sec), which suggests that the limiting factor is neither compute, nor pcie bandwidth

  • Prefill for 16k tokens was achieved at 124 tok/sec, but disk read averaged out to 800MiB/sec during that prefill

    • That means that roughly 1/4 of the the model was sourced from disk during each batch, with the remainder surviging in page cache between batches
    • fio says that the maximum sequential perf on that box is 20GiB/sec
  • From experience with similar inferencing with most of the weights in pinned memory, the same 16k tokens can prefill at 500 tok/sec, and there second batch will be slower with the classic logarithmic dropoff with expanding kv

So with all those observations in mind, I wonder if it's possible to imagine an extension to this PR where the weights are prefetched into a vector of pinned buffers (eg 4 buffers * sizeof(expert_tensor)), and from there immediately DMAd into the GPU.

If this were possible, then running a quad x4 nvme pcie card should be as fast as the link to the gpu, so maybe it's a way to both gain speed and use an awful lot less RAM? Predictive expert prefetch would definitely then become the new hotness...

@Farmadupe

Farmadupe commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

This is 100% my fantasy!

Although it totally depends on your motherboard's exact allocation of PCIE lanes and speeds, my home desktop board (relatively cheap AM5 board, but not uncommon) can't actually saturate its GPU slot with bandwidth direct from disk (I believe there are desktop class boards that are less stingy where this is possible), and totally doable with worksation and server class hardware.

image

And even with an awful lot less RAM, whatever you do have can still be filled to the brim with weights to take pressure off the disk streaming setup.

This is obviously great for prefill (with a moderate GPU and huge batch sizes, GLM5.2 would prefill at hundreds of tokens per second 🤤), but as with any weight streaming, doesn't really move the needle at all for decode.

@rog77

rog77 commented Jul 12, 2026

Copy link
Copy Markdown

This is 100% my fantasy!

Although it totally depends on your motherboard's exact allocation of PCIE lanes and speeds, my home desktop board (relatively cheap AM5 board, but not uncommon) can't actually saturate its GPU slot with bandwidth direct from disk (I believe there are desktop class boards that are less stingy where this is possible), and totally doable with worksation and server class hardware.

image And even with an _awful_ lot less RAM, whatever you do have can still be filled to the brim with weights to take pressure off the disk streaming setup.

This is obviously great for prefill (with a moderate GPU and huge batch sizes, GLM5.2 would prefill at hundreds of tokens per second 🤤), but as with any weight streaming, doesn't really move the needle at all for decode.

I am on older kit, so limited to pcie3 :-( but I can get full 4 x4 NVME and have 512GB RAM but only DDR4 - it's my dream too to get larger models running on my v100 32gb or 4080, but I think it really needs that predictive expert prefetch to work well. But it seems to me if younhave enough PCIE lanes, large amounts of RAM might not be necessary at all, I mean for GPU you're always capped at what you can send over pcie anyway, yes?

@Farmadupe

Copy link
Copy Markdown
Contributor

For decode/token-generation, yes. WIth a dense model, you're forced to stream the entire model for every token, but for MoE, where you might only need to load 2-5% of the model per token, it becomes really hard to predict which parts you'd need next. And you you ahve to fetch a different set of experts at every layer of every tokens. It's really hard!

But for prefill, the trick is to set the batch size insanely high (16k, 32k, whatever), as this amortizes the amount of ewight transfer that you actually need to do. This is where this PR hits the sweet spot -- the faster you can get weights into your GPU, the less ridiculous your batch size needs to be to get good prefill performance!

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.

4 participants