Skip to content

Commit d201348

Browse files
howard0suCopilotdavide221wozcode
authored
feat(moe): add--freq and --collect-routing, train predictor of experts (#386)
* feat: port --freq and --collect-routing from StreamMoE Add expert frequency tracking and binary routing data collection for training MLP expert predictors. New features: - --freq: prints per-layer expert frequency analysis at shutdown - --collect-routing <path>: writes binary routing data (hidden states + expert selections) for offline predictor training New files: - server/src/common/moe_routing_collector.{h,cpp}: thread-safe binary writer for per-token routing decisions - scripts/train_predictor.py: MLP/linear predictor training script (ported from StreamMoE, supports CUDA, mlp/linear/cross variants) Binary format per sample: [int32 layer_idx, int32 K, float32[n_embd] hidden, int32[K] experts] Hooks added in qwen35moe pipelined decode (cached + dynamic paths), qwen35moe prefill, laguna hybrid decode, and laguna hybrid prefill. Tested: 1K tokens → 38.5% top-4 hit rate (mlp-cross), 2x temporal baseline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(predictor): ruff lint + cubic P1/P2 review fixes CI lint (ruff): - sort imports (I001), drop unused math / torch.nn (F401) P1 (correctness): - pad expert indices with -1, not 0 (0 is a valid expert id, so 0 padding corrupts labels when K varies). Filter negatives in build_target_multilabel + every eval path, and count real experts (not padded K) in temporal/top-k/per-layer hit-rate denominators. - guard empty train/test split (exit cleanly instead of crashing). - MoeRoutingCollector::record now checks every fwrite; a short write closes the file and stops, instead of emitting misaligned/corrupt training data with an inflated sample count. P2: - int64_t sample counter (+ %lld). - moe_hybrid_routing_stats: cap top-10/30/60 to total when n_expert < bucket size (was printing 0%). - --freq/--collect-routing force routing-stats env vars when unset or empty (overwrite=0 left an empty value → silent no-op). - set_routing_collector returns bool (capability check); caller warns + closes collector when the backend can't collect; lifetime contract documented. - dedup the per-layer ffn_post D2H copy across all three decode paths when --collect-routing and cold compute are both active. - correct --hidden-dim/--num-experts/--num-layers help defaults. - validate --threshold is in (0,1); handle empty/truncated routing files. Co-Authored-By: WOZCODE <contact@withwoz.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: mrciffa <49000955+davide221@users.noreply.github.com> Co-authored-by: WOZCODE <contact@withwoz.com>
1 parent 9d61083 commit d201348

15 files changed

Lines changed: 1120 additions & 5 deletions

scripts/train_predictor.py

Lines changed: 773 additions & 0 deletions
Large diffs are not rendered by default.

server/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ add_library(dflash_common STATIC
265265
src/common/moe_hybrid_stream.cpp
266266
src/common/cold_ffn_cpu.cpp
267267
src/common/moe_hybrid_swap_manager.cpp
268+
src/common/moe_routing_collector.cpp
268269
src/qwen35/layer_split_forward.cpp
269270
src/qwen35/qwen35_target_shard_ipc.cpp
270271
src/qwen35/qwen35_target_shard_ipc_daemon.cpp

server/src/common/model_backend.h

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,25 @@ struct ModelBackend {
323323
// the server before startup.
324324
virtual bool supports_remote_draft() const { return false; }
325325

326+
// ── Routing data collection ──────────────────────────────────────
327+
// Set an external routing collector that the backend will call for each
328+
// token/layer during decode (hidden state + expert IDs). Used by
329+
// --collect-routing for predictor training data.
330+
//
331+
// Lifetime: the collector pointer is borrowed, not owned. The caller must
332+
// keep it alive until set_routing_collector(nullptr) is called (or the
333+
// backend is destroyed), and must not pass a collector to a backend that
334+
// decodes on another thread without outliving that decode.
335+
//
336+
// Returns true if the backend supports routing collection (MoE backends).
337+
// The default returns false so the server can detect unsupported backends
338+
// and warn instead of silently collecting nothing.
339+
virtual bool set_routing_collector(class MoeRoutingCollector *) { return false; }
340+
341+
// Get the current routing stats (if tracked). Returns nullptr if the
342+
// backend does not support routing stats or they are not enabled.
343+
virtual const struct MoeHybridRoutingStats * get_routing_stats() const { return nullptr; }
344+
326345
// ── Cleanup ──────────────────────────────────────────────────────
327346
// Release all resources (weights, cache, snapshots, drafter).
328347
// Called by run_daemon() before returning.

server/src/common/moe_hybrid_routing_stats.cpp

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include <algorithm>
44
#include <cstdio>
55
#include <cstdlib>
6+
#include <cstring>
67
#include <fstream>
78
#include <numeric>
89

@@ -118,6 +119,75 @@ std::vector<int> MoeHybridRoutingStats::hot_experts(int layer_idx, int hot_count
118119
return ranked;
119120
}
120121

122+
void MoeHybridRoutingStats::print_freq_analysis() const {
123+
if (n_layer <= 0 || n_expert <= 0 || counts.empty()) return;
124+
125+
uint64_t total_all = 0;
126+
for (int il = 0; il < n_layer; ++il) total_all += layer_totals[(size_t)il];
127+
if (total_all == 0) return;
128+
129+
// Estimate total tokens: layer_totals[0] / n_expert_used
130+
uint64_t total_tokens = (n_expert_used > 0 && layer_totals[0] > 0)
131+
? layer_totals[0] / (uint64_t)n_expert_used : 0;
132+
double avg_k = (n_layer > 0 && total_tokens > 0)
133+
? (double)total_all / ((double)n_layer * (double)total_tokens) : 0.0;
134+
135+
std::fprintf(stderr, "\n=== Expert Frequency Analysis ===\n");
136+
std::fprintf(stderr, "Tokens tracked: %llu, avg-K=%.2f\n\n",
137+
(unsigned long long)total_tokens, avg_k);
138+
139+
int experts_for_80_total = 0;
140+
std::vector<uint64_t> sorted((size_t)n_expert);
141+
142+
for (int il = 0; il < n_layer; ++il) {
143+
uint64_t tact = layer_totals[(size_t)il];
144+
if (tact == 0) {
145+
std::fprintf(stderr, "Layer %2d: 0 unique\n", il);
146+
continue;
147+
}
148+
for (int ie = 0; ie < n_expert; ++ie)
149+
sorted[(size_t)ie] = counts[index_of(il, ie)];
150+
std::sort(sorted.begin(), sorted.end(), std::greater<uint64_t>());
151+
152+
int unique = 0;
153+
for (int ie = 0; ie < n_expert; ++ie) if (sorted[(size_t)ie] > 0) unique++;
154+
155+
uint64_t cum = 0;
156+
uint64_t top10 = 0, top30 = 0, top60 = 0;
157+
int n50 = 0, n80 = 0, n90 = 0;
158+
for (int ie = 0; ie < n_expert; ++ie) {
159+
cum += sorted[(size_t)ie];
160+
if (ie == 9) top10 = cum;
161+
if (ie == 29) top30 = cum;
162+
if (ie == 59) top60 = cum;
163+
if (!n50 && cum * 100 >= tact * 50) n50 = ie + 1;
164+
if (!n80 && cum * 100 >= tact * 80) n80 = ie + 1;
165+
if (!n90 && cum * 100 >= tact * 90) n90 = ie + 1;
166+
}
167+
// If there are fewer experts than a bucket size, the ie==N-1 check
168+
// never fires and topN stays 0, printing a bogus 0%. cum == tact here
169+
// (all experts summed), so the bucket covers everything → 100%.
170+
if (n_expert < 10) top10 = cum;
171+
if (n_expert < 30) top30 = cum;
172+
if (n_expert < 60) top60 = cum;
173+
std::fprintf(stderr, "Layer %2d: %3d unique, top-10 %.0f%%, top-30 %.0f%%, top-60 %.0f%% "
174+
"(50%%@%d, 80%%@%d, 90%%@%d)\n",
175+
il, unique,
176+
100.0 * (double)top10 / (double)tact,
177+
100.0 * (double)top30 / (double)tact,
178+
100.0 * (double)top60 / (double)tact,
179+
n50, n80, n90);
180+
experts_for_80_total += n80;
181+
}
182+
183+
double avg80 = (n_layer > 0) ? (double)experts_for_80_total / n_layer : 0.0;
184+
std::fprintf(stderr, "\n--- Overall Summary ---\n");
185+
std::fprintf(stderr, "80%% hit rate: %d experts pinned (avg %.0f/layer)\n",
186+
experts_for_80_total, avg80);
187+
std::fprintf(stderr, "Model: %d layers x %d experts, top-%d routing\n",
188+
n_layer, n_expert, n_expert_used);
189+
}
190+
121191
bool MoeHybridRoutingStats::save_csv(const std::string & path, std::string * err) const {
122192
if (n_layer <= 0 || n_expert <= 0 || counts.size() != (size_t)n_layer * (size_t)n_expert) {
123193
if (err) *err = "routing stats not initialized";

server/src/common/moe_hybrid_routing_stats.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ struct MoeHybridRoutingStats {
3838
std::vector<int> ranked_experts(int layer_idx) const;
3939
std::vector<int> hot_experts(int layer_idx, int hot_count) const;
4040

41+
// Print StreamMoE-style frequency analysis to stderr.
42+
void print_freq_analysis() const;
43+
4144
bool save_csv(const std::string & path, std::string * err = nullptr) const;
4245
static bool load_csv(const std::string & path,
4346
MoeHybridRoutingStats & out,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#include "moe_routing_collector.h"
2+
3+
#include <cerrno>
4+
#include <cstdio>
5+
#include <cstring>
6+
7+
namespace dflash::common {
8+
9+
bool MoeRoutingCollector::open(const std::string & path) {
10+
std::lock_guard<std::mutex> lock(mu_);
11+
if (fd_) {
12+
std::fclose(fd_);
13+
fd_ = nullptr;
14+
}
15+
fd_ = std::fopen(path.c_str(), "wb");
16+
if (!fd_) {
17+
std::fprintf(stderr, "[routing-collector] failed to open '%s': %s\n",
18+
path.c_str(), std::strerror(errno));
19+
return false;
20+
}
21+
samples_ = 0;
22+
std::fprintf(stderr, "[routing-collector] collecting routing data to '%s'\n", path.c_str());
23+
return true;
24+
}
25+
26+
void MoeRoutingCollector::record(int layer_idx, const float * hidden, int n_embd,
27+
const int32_t * expert_ids, int K) {
28+
std::lock_guard<std::mutex> lock(mu_);
29+
if (!fd_) return;
30+
31+
int32_t li = layer_idx;
32+
int32_t ki = K;
33+
// A short write desyncs the fixed-layout binary stream: every subsequent
34+
// record would be misaligned and the file silently unparseable. Treat any
35+
// partial write as fatal for this file — stop writing and don't count the
36+
// sample, rather than emitting corrupt training data with an inflated count.
37+
if (std::fwrite(&li, sizeof(int32_t), 1, fd_) != 1 ||
38+
std::fwrite(&ki, sizeof(int32_t), 1, fd_) != 1 ||
39+
std::fwrite(hidden, sizeof(float), (size_t)n_embd, fd_) != (size_t)n_embd ||
40+
std::fwrite(expert_ids, sizeof(int32_t), (size_t)K, fd_) != (size_t)K) {
41+
std::fprintf(stderr, "[routing-collector] write error after %lld samples: %s\n",
42+
(long long)samples_, std::strerror(errno));
43+
std::fclose(fd_);
44+
fd_ = nullptr;
45+
return;
46+
}
47+
samples_++;
48+
}
49+
50+
void MoeRoutingCollector::close() {
51+
std::lock_guard<std::mutex> lock(mu_);
52+
if (!fd_) return;
53+
std::fclose(fd_);
54+
fd_ = nullptr;
55+
std::fprintf(stderr, "[routing-collector] closed, %lld samples written\n",
56+
(long long)samples_);
57+
}
58+
59+
} // namespace dflash::common
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// MoE routing data collector — writes per-token binary routing data for
2+
// predictor training (gate input hidden states + expert selections).
3+
//
4+
// Binary format per sample (matches StreamMoE train_predictor.py format):
5+
// int32 layer_idx
6+
// int32 K (number of selected experts)
7+
// float32[n_embd] hidden_state (gate input / post-norm hidden for this layer)
8+
// int32[K] expert_indices (actual routing result)
9+
//
10+
// Enable via --collect-routing <path> on the server CLI or via env var
11+
// DFLASH_COLLECT_ROUTING=<path>.
12+
13+
#pragma once
14+
15+
#include <cstdint>
16+
#include <cstdio>
17+
#include <mutex>
18+
#include <string>
19+
20+
namespace dflash::common {
21+
22+
class MoeRoutingCollector {
23+
public:
24+
MoeRoutingCollector() = default;
25+
~MoeRoutingCollector() { close(); }
26+
27+
MoeRoutingCollector(const MoeRoutingCollector &) = delete;
28+
MoeRoutingCollector & operator=(const MoeRoutingCollector &) = delete;
29+
30+
// Open the output file. Returns false on failure.
31+
bool open(const std::string & path);
32+
33+
// Record one routing sample: gate input + expert selection for one token at one layer.
34+
// hidden must point to n_embd floats; expert_ids to K int32s.
35+
void record(int layer_idx, const float * hidden, int n_embd,
36+
const int32_t * expert_ids, int K);
37+
38+
// Close and flush the output file. Prints summary to stderr.
39+
void close();
40+
41+
bool is_open() const { return fd_ != nullptr; }
42+
int64_t sample_count() const { return samples_; }
43+
44+
private:
45+
std::FILE * fd_ = nullptr;
46+
int64_t samples_ = 0; // wide: collection runs can exceed 2^31 samples
47+
std::mutex mu_;
48+
};
49+
50+
} // namespace dflash::common

server/src/laguna/laguna_backend.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include "../common/moe_hybrid_storage.h"
2020
#include "../common/moe_hybrid_routing_stats.h"
2121
#include "../common/moe_hybrid_swap_manager.h"
22+
#include "../common/moe_routing_collector.h"
2223
#include "common/step_graph.h"
2324

2425
#include "ggml-cuda.h"
@@ -1426,6 +1427,15 @@ bool LagunaBackend::hybrid_forward_one_token(int32_t tok, int kv_pos,
14261427
}
14271428
}
14281429

1430+
// Collect routing data for predictor training
1431+
if (routing_collector_) {
1432+
std::vector<float> gate_input((size_t)w_.n_embd);
1433+
ggml_backend_tensor_get(layer_sg.ffn_post, gate_input.data(), 0,
1434+
sizeof(float) * (size_t)w_.n_embd);
1435+
routing_collector_->record(il, gate_input.data(), w_.n_embd,
1436+
selected.data(), (int)selected.size());
1437+
}
1438+
14291439
// Hybrid FFN: hot on GPU, cold on CPU, combine on GPU
14301440
auto & storage = moe_hybrid_->layers[(size_t)il];
14311441
{ int _lc=0; for (int _k=0;_k<(int)selected.size();++_k){ int _g=selected[(size_t)_k];
@@ -1661,6 +1671,18 @@ GenerateResult LagunaBackend::generate_hybrid(const GenerateRequest & req,
16611671
}
16621672
}
16631673

1674+
// Collect routing data for predictor training (prefill path)
1675+
if (routing_collector_) {
1676+
for (int i = 0; i < chunk_len; ++i) {
1677+
routing_collector_->record(
1678+
il,
1679+
chunk_post.data() + (size_t)i * (size_t)w_.n_embd,
1680+
w_.n_embd,
1681+
chunk_selected.data() + (size_t)i * (size_t)n_expert_used,
1682+
n_expert_used);
1683+
}
1684+
}
1685+
16641686
// Batched hybrid FFN evaluation
16651687
auto & storage = moe_hybrid_->layers[(size_t)il];
16661688
MoeHybridConfig chunk_cfg = make_moe_hybrid_config(w_);

server/src/laguna/laguna_backend.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include "../common/moe_hybrid_routing_stats.h"
1818
#include "../common/moe_hybrid_swap_manager.h"
1919
#include "../common/moe_hybrid_stream.h"
20+
#include "../common/moe_routing_collector.h"
2021

2122
#include "ggml.h"
2223
#include "ggml-backend.h"
@@ -73,6 +74,9 @@ class LagunaBackend : public ModelBackend {
7374

7475
void shutdown() override;
7576

77+
bool set_routing_collector(MoeRoutingCollector * c) override { routing_collector_ = c; return true; }
78+
const MoeHybridRoutingStats * get_routing_stats() const override { return routing_stats_.get(); }
79+
7680
private:
7781
LagunaBackendArgs args_;
7882
ggml_backend_t backend_ = nullptr;
@@ -98,6 +102,7 @@ class LagunaBackend : public ModelBackend {
98102
MoeHybridSwapPolicy swap_policy_;
99103
bool hybrid_telemetry_ = false;
100104
MoeHybridStreamEngine stream_engine_;
105+
MoeRoutingCollector * routing_collector_ = nullptr;
101106

102107
bool ensure_slot(int slot);
103108

server/src/qwen35moe/qwen35moe_backend.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ bool Qwen35MoeBackend::ensure_pipe_state(int kv_start) {
394394
pipe_state_.reset();
395395
return false;
396396
}
397+
pipe_state_->routing_collector = routing_collector_;
397398
return true;
398399
}
399400

@@ -867,6 +868,18 @@ GenerateResult Qwen35MoeBackend::generate_impl(const GenerateRequest & req,
867868
}
868869
}
869870

871+
// Collect routing data for predictor training (prefill path)
872+
if (routing_collector_) {
873+
for (int i = 0; i < chunk_len; ++i) {
874+
routing_collector_->record(
875+
il,
876+
chunk_post.data() + (size_t)i * (size_t)hidden,
877+
hidden,
878+
chunk_selected.data() + (size_t)i * (size_t)n_expert_used,
879+
n_expert_used);
880+
}
881+
}
882+
870883
// Hybrid FFN — skip batched path when cold experts exist (CUDA mul_mat_id bug on sm_75)
871884
MoeHybridConfig chunk_cfg = make_moe_hybrid_config(target_weights());
872885
MoeLayerDesc chunk_desc = make_moe_layer_desc(target_weights().layers[(size_t)il]);

0 commit comments

Comments
 (0)