ggml: treat experts as cache residents during MoE offloading - #23170
ggml: treat experts as cache residents during MoE offloading#23170avifenesh wants to merge 2 commits into
Conversation
|
How do the perplexity numbers look like? |
|
@ssakar The problem was that the resident-expert state could outlive the scheduler's temporary buffer contents. I pushed a fix that scopes the resident bitset to the current scheduler compute epoch. After the fix, PPL matches the parent commit exactly on my run:
The chunk values also match exactly: |
|
@ssakar I checked the docs and didn't see other checks that I missed. Anything else that you think I should run to validate no other blasts? |
|
@avifenesh, Hi, I was thinking on something similar to this, and then found this PR, as it looks like a very good starting point. I had a look at your patch. I rebased it locally onto the current upstream master and added some simple telemetry around the MoE resident-copy path. tl;dr: with the NaN fix in place, I am not seeing the copy-skipping gains in my tests. The resident bitsets are populated, and many later experts would have hit, indeed, but the epoch invalidation clears the state before it can be used, so you lose any speed gain. Long Explanation I do not have the branch online at the moment, but I can share the local telemetry diff or logs if useful. The telemetry is inserted in The key location is just before/around the existing computation of: missing_ids = used_ids & ~loaded_ids;I added counters roughly like this: // after used_ids has been built, and before copy_experts() mutates loaded_ids
bool has_missing_ids = false;
size_t resident_hits = 0;
size_t resident_misses = 0;
missing_ids.resize(loaded_id_size);
ggml_bitset_t * missing_ids_data = missing_ids.data();
ggml_bitset_t * used_ids_data = used_ids.data();
for (size_t i = 0; i < loaded_id_size; ++i) {
missing_ids_data[i] = used_ids_data[i] & ~loaded_ids[i];
if (sched->moe_cache_trace_enabled) {
const ggml_bitset_t hit_ids = used_ids_data[i] & loaded_ids[i];
resident_hits += ggml_backend_sched_bitset_popcount(&hit_ids, 1);
resident_misses += ggml_backend_sched_bitset_popcount(&missing_ids_data[i], 1);
}
has_missing_ids = has_missing_ids || missing_ids_data[i] != 0;
}
if (sched->moe_cache_trace_enabled) {
sched->moe_cache_trace.ops_seen++;
sched->moe_cache_trace.used_experts_unique +=
ggml_backend_sched_bitset_popcount(used_ids_data, loaded_id_size);
sched->moe_cache_trace.loaded_experts_before +=
ggml_backend_sched_bitset_popcount(loaded_ids, loaded_id_size);
sched->moe_cache_trace.resident_hits += resident_hits;
sched->moe_cache_trace.resident_misses += resident_misses;
sched->moe_cache_trace.skipped_experts += resident_hits;
sched->moe_cache_trace.skipped_bytes_estimate += resident_hits * expert_size;
if (!has_missing_ids) {
sched->moe_cache_trace.empty_copy_ops++;
}
}I added diagnostic counters around the invalidation path as well, to check if resident state existed just before being cleared: const bool invalid_epoch =
loaded->epoch != moe_loaded_epoch;
const bool invalid_n =
loaded->n_expert != n_expert;
const bool invalid_size =
loaded->expert_size != expert_size;
const bool invalid_src =
loaded->src_data != input_data;
const bool invalid_dst =
loaded->dst_data != input_cpy_data;
if (sched->moe_cache_trace_enabled &&
(invalid_epoch || invalid_n || invalid_size || invalid_src || invalid_dst)) {
const size_t pre_invalid_loaded =
ggml_backend_sched_bitset_popcount(loaded_ids, loaded_id_size);
const size_t pre_invalid_hit =
ggml_backend_sched_bitset_intersection_popcount(used_ids_data, loaded_ids, loaded_id_size);
sched->moe_cache_trace.pre_invalid_loaded += pre_invalid_loaded;
sched->moe_cache_trace.pre_invalid_hit += pre_invalid_hit;
if (pre_invalid_loaded > 0) {
sched->moe_cache_trace.invalid_loaded++;
}
if (invalid_epoch) sched->moe_cache_trace.invalid_epoch++;
if (invalid_n) sched->moe_cache_trace.invalid_n++;
if (invalid_size) sched->moe_cache_trace.invalid_size++;
if (invalid_src) sched->moe_cache_trace.invalid_src++;
if (invalid_dst) sched->moe_cache_trace.invalid_dst++;
}and inside if (sched->moe_cache_trace_enabled) {
sched->moe_cache_trace.copy_ranges++;
sched->moe_cache_trace.copied_experts += last_expert - first_expert + 1;
sched->moe_cache_trace.copied_bytes += expert_size_copy + padding_end;
}The telemetry is enabled with: LLAMA_MOE_CACHE_TRACE=1
LLAMA_MOE_CACHE_TRACE_EVERY=100In my tests, with the NaN fix, the resident bitset is invalidated on every MoE copy opportunity. So the safe version appears to be correct, but unfortunately, I am not seeing any actual resident hits. Example result: After I added the invalidation diagnostics above, they show that the cache state does exist before invalidation: So the patch populates the resident bitsets as expected, and many later selected experts would have hit, but the epoch invalidation clears the state before I tried ignoring epoch-only invalidation while still invalidating on structural changes ( But, again, as expected, it is not correct: My current understanding is:
So I am not yet seeing evidence that the safe version of the patch still supports the performance claims in this configuration. There may be another workload or configuration where reuse happens within the same scheduler compute epoch, but in this CPU-MoE/op-offload run I get zero actual skipped copies. It may be useful to add resident-hit / skipped-copy telemetry to the PR itself, or rerun the benchmarks against the commit that includes the NaN fix. My test command was roughly: LLAMA_MOE_CACHE_TRACE=1 \
LLAMA_MOE_CACHE_TRACE_EVERY=100 \
../llama-build/bin/llama-bench \
-v \
-m "$MODEL" \
-ngl 999 \
-ncmoe 999 \
-nopo 0 \
-fa 1 \
-ctk q8_0 \
-ctv q8_0 \
-sm layer \
-p 2048 \
-n 128 \
-b 1024 \
-ub 1024Model: My guess is that the NaN problem comes from The resident bitset can survive and still say “expert X is loaded”, and the So my guess is that cross-epoch reuse needs one of two designs (please, correct me if I am wrong):
OR
I was tempted to implement option 1, because it might be closer to your current patch, but it has memory ownership / allocator-lifetime implications that I do not understand well enough. Option 2 looks cleaner architecturally, though it is a bigger change because compact slots require ID remapping or an op-level/cache-aware path. In any case, I think the immediate useful next step for this PR would be adding resident-hit / skipped-copy telemetry after the NaN fix, because correctness alone does not show whether the optimization is still active. Happy to share the local telemetry diff if it helps. |
|
@ernestuz-b Hi, yes, I checked the numbers, and your observation is correct. |
…Erkenntnissen aktualisiert AtomicBot-ai#58 KV Cache Size Limiting: - Status ☐→⏭️ (verschoben) - PR ggml-org#18747 ist noch OPEN (nicht gemerged) - TurboQuant (bereits vorhanden) ist komplementär und höhere Priorität - PR liefert nur Infrastruktur, keine echten PagedAttention-Benefits AtomicBot-ai#61 Persistent VRAM Expert Cache: - PR ggml-org#23170 ist 'no-op when made correct' (RFC ggml-org#24528) - Eigentliche Lösung: PR ggml-org#24524 (closed, 2222 Zeilen, invertiertes execution model, MUL_MAT_ID auf CPU, GPU cached rows parallel) - 10% Experten → 80% cache hits, Top 30% → 95% hits - arXiv: ProMoE, MoE-Infinity, DuoServe-MoE, Caching Analysis - Empfehlung: PR ggml-org#24524 Design manuell portieren (1-2 Wochen)
Overview
In short, this PR improves TPS and latency while using MoE models by making experts cache residents and avoiding copy and overwrite when an expert is already cached.
When MoE weights are offloaded from host memory, the scheduler copies the experts used by the current
GGML_OP_MUL_MAT_IDsplit into a backend-side staging tensor. In long prompt-cache workloads, the same staging tensor can be reused across turns, so experts copied for earlier tokens may still be resident.This change tracks resident MoE experts per scheduler tensor copy and treats the staging tensor as an expert cache:
ggml_bitset_tmissing_ids = used_ids & ~loaded_idsThe change is internal to
ggml/src/ggml-backend.cpp. It does not add a user-facing flag, public API, or backend-specific implementation.Benchmark Setup
upstream/masteratb64739ea30fcbeeaa6build-cuda13-clean/bin/llama-server, CUDA graphs off--cpu-moe,-c 65536,-ngl auto,-np 1--no-cache-promptexcept for the medium-turns context-fill suiteOnly CPU-MoE op-offload measurements are included below because that is the path changed by this PR.
Results
64k Multi-Prompt Op-Offload
This is the short steady-state control: 60s measured duration after ~10s warmup, comparing the resident-expert cache with a local cache-disabled control build on the same op-offload path.
Deltas:
+5.36%, avg latency-5.09%+5.43%, avg latency-3.49%Long-Context Prefill Pressure
Single cold request, no warmup, prompt-cache disabled, generated prompt calibrated through
/tokenize,max_tokens=128.Deltas:
7.03xfaster, latency-85.77%3.91xfaster, latency-74.45%Medium-Turn Context Fill
Sequential ~5k-token turns with prompt-cache enabled, stopping around 60k prompt tokens. This simulates a long chat where the earlier prompt state is reused and reports processed prompt tokens separately from cached tokens.
Deltas:
4.34xfaster, processed prompt tok/s4.29x, latency-77.29%2.65xfaster, processed prompt tok/s2.62x, latency-62.61%Validation
git diff --checkcmake --build build-cuda13-clean --target llama-server test-backend-ops -j 10build-cuda13-clean/bin/test-backend-ops test -o MUL_MAT_ID:764/764 tests passedAI usage disclosure: YES - Codex gpt 5.5 xhigh was used to write this code. It assisted with code writing, local code review, benchmarking, cleanup, and preparing the MD-styled parts of this PR, like the tables.
While an LLM wrote the code, I did the design, the research, and the codebase reading, I directed, reviewed and steered during development, designed the benchmarks, reviewed the code more than once and instructed changes, and I'm doing the final signing on this code and PR.
I own this code, and I'm responsible for the output of the tools I use.