Skip to content

Commit 5f01724

Browse files
cavusmustafaclaude
andcommitted
ggml-openvino: auto-enable full-MoE GPU path + dodge GPU rms_fusion bug
Two coupled changes that make gemma4 26B MoE run correctly on the GPU device with no manual flag. 1. Auto-detect MoE and stop fragmenting the GPU graph. The per-node "force this MoE op to CPU on GPU" gates fragment the graph into dozens of submodels with cross-boundary copies (which mis-handle e.g. the layer-5 argsort indices and crash). GGML_OPENVINO_GPU_FULL_MOE kept the whole MoE on one OV submodel but had to be set by hand. Now ggml_openvino_gpu_full_moe_enabled() auto-enables it when running a MoE model on GPU: a GGML_OP_MUL_MAT_ID op (the expert-routed matmul, the defining op of a MoE model) latches a process-global flag from supports_op() at op-placement time. The scheduler queries placement before the expert weights are streamed in and makes several placement passes, so the first pass that sees MUL_MAT_ID sets the flag and later passes converge on the full-MoE layout. The GGML_OPENVINO_GPU_FULL_MOE env var still overrides (non-zero forces on, "0" forces off as an escape hatch). CPU/NPU behavior is unchanged: the gates are GPU-guarded, and the auto path only fires on GPU. 2. Dodge the GPU rms_fusion bug on that path. OpenVINO's rms_fusion folds Power(x, 2) -> ... into the internal RMS op; on the GPU plugin that fused RMS primitive's dynamic multi-token kernel writes only token 0 (tokens 1..N read back as 0). For gemma4 this collapsed the per-layer router RMSNorm (~7x summed over the prefill tokens), flattening the router softmax and flipping the top-8 expert selection, so the GPU output drifted ("France" -> " only"). On the GPU full-MoE path only, compute the square as Multiply(x, x): algebraically identical, but it does not match the fusion pattern, so the GPU runs the unfused primitives and writes every token. Every other configuration keeps the fused fast path (CPU, NPU, and non-MoE GPU models such as Llama-3.2-1B). Verified: gemma4 26B MoE on GPU with NO flag now matches CPU byte-for-byte on prefill and multi-token decode; the GGML_OPENVINO_GPU_FULL_MOE=0 escape hatch restores the old (fragmented) path; gemma4 CPU output unchanged; dense Llama-3.2-1B on GPU still correct with rms_fusion active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c8538a2 commit 5f01724

4 files changed

Lines changed: 89 additions & 12 deletions

File tree

ggml/src/ggml-openvino/ggml-openvino-extra.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ void ggml_openvino_device_config::init() {
4545
"GGML_OPENVINO_DISABLE_CACHE",
4646
"GGML_OPENVINO_DISABLE_KV_SLICE",
4747
"GGML_OPENVINO_MANUAL_GQA_ATTN",
48+
"GGML_OPENVINO_GPU_FULL_MOE",
4849
};
4950

5051
for (const char * const & env_var : env_var_names) {
@@ -173,6 +174,30 @@ bool ggml_openvino_is_npu() {
173174
return ggml_openvino_get_device_config().is_npu;
174175
}
175176

177+
// Latched true once a MUL_MAT_ID op is seen during op placement; see header. Plain
178+
// non-atomic bool: placement runs single-threaded before the multi-threaded compute
179+
// that reads it, and the flag only ever transitions false->true (idempotent).
180+
static bool g_has_moe_expert_weights = false;
181+
182+
void ggml_openvino_note_moe_expert_weight() {
183+
g_has_moe_expert_weights = true;
184+
}
185+
186+
bool ggml_openvino_has_moe_expert_weights() {
187+
return g_has_moe_expert_weights;
188+
}
189+
190+
bool ggml_openvino_gpu_full_moe_enabled() {
191+
// Explicit env override (allowlisted): non-zero forces ON, "0" forces OFF.
192+
if (const char * v = ggml_openvino_getenv_str("GGML_OPENVINO_GPU_FULL_MOE")) {
193+
return std::atoi(v) != 0;
194+
}
195+
// Auto: keep the whole MoE on one OV submodel when running a quant-MoE model on
196+
// GPU. On CPU/NPU the per-node gates are no-ops anyway (they are GPU-guarded), so
197+
// leaving this OFF there preserves the existing behavior exactly.
198+
return ggml_openvino_get_device_name() == "GPU" && ggml_openvino_has_moe_expert_weights();
199+
}
200+
176201
// Get the remote context for the current device (returns empty optional for CPU)
177202
std::optional<ov::RemoteContext> ggml_openvino_get_remote_context() {
178203
return ggml_openvino_get_device_config().remote_context;

ggml/src/ggml-openvino/ggml-openvino-extra.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,26 @@ int ggml_openvino_getenv_int(const char * var, int default_value = 0);
9999
// Check if running on NPU
100100
bool ggml_openvino_is_npu();
101101

102+
// MoE detection. ggml_openvino_note_moe_expert_weight() latches a process-global flag
103+
// that ggml_openvino_has_moe_expert_weights() reports. It is called from supports_op()
104+
// the first time a GGML_OP_MUL_MAT_ID (the expert-routed matmul) is seen, which is the
105+
// defining op of a MoE model. The latch is set at op-placement time (not weight load):
106+
// the scheduler queries op placement before the expert weights are streamed in, and it
107+
// makes multiple placement passes, so the first pass that encounters MUL_MAT_ID sets the
108+
// flag and subsequent passes converge on the full-MoE layout. This lets the backend
109+
// recognize "this is a MoE model" without any architecture name.
110+
void ggml_openvino_note_moe_expert_weight();
111+
bool ggml_openvino_has_moe_expert_weights();
112+
113+
// Whether to keep the whole MoE on one OV submodel instead of fragmenting at every
114+
// MoE node (see the per-node "force to CPU on GPU" gates). Resolution order:
115+
// * GGML_OPENVINO_GPU_FULL_MOE set to non-zero -> force ON (any device)
116+
// * GGML_OPENVINO_GPU_FULL_MOE set to "0" -> force OFF (escape hatch)
117+
// * unset -> AUTO: ON when running on GPU and the model has 3D quantized expert
118+
// weights (a quant-MoE model), OFF otherwise.
119+
// CPU/NPU behavior is unchanged unless the env var is explicitly set.
120+
bool ggml_openvino_gpu_full_moe_enabled();
121+
102122
// Get requantization type for a tensor type (returns nullopt if no requant needed)
103123
std::optional<ExtraQuantType> ggml_openvino_get_requant_type(const ggml_tensor * tensor, bool no_requant = false);
104124

ggml/src/ggml-openvino/ggml-openvino.cpp

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -906,17 +906,17 @@ static bool checked_mul_size(size_t a, size_t b, size_t & out) {
906906
return true;
907907
}
908908

909-
// When set (env GGML_OPENVINO_GPU_FULL_MOE), keep the entire MoE — including the routing
910-
// gather/softmax/argsort/normalization and the expert matmuls — on the OpenVINO device so
911-
// the whole model compiles as ONE submodel instead of fragmenting at every MoE node. The
912-
// per-node "force to CPU on GPU" gates below were added to work around GPU-plugin numerical
913-
// issues, but they fragment the graph into dozens of submodels with cross-boundary tensor
914-
// copies (which mis-handles e.g. the layer-5 argsort indices). With the dynamic-shape
915-
// frontend fix in place the un-fragmented graph is numerically correct, so this toggle lets
916-
// us run the whole MoE on one OV submodel.
909+
// Keep the entire MoE — including the routing gather/softmax/argsort/normalization and the
910+
// expert matmuls — on the OpenVINO device so the whole model compiles as ONE submodel instead
911+
// of fragmenting at every MoE node. The per-node "force to CPU on GPU" gates below were added
912+
// to work around GPU-plugin numerical issues, but they fragment the graph into dozens of
913+
// submodels with cross-boundary tensor copies (which mis-handles e.g. the layer-5 argsort
914+
// indices). With the dynamic-shape frontend fix in place the un-fragmented graph is
915+
// numerically correct, so this keeps the whole MoE on one OV submodel. Auto-enabled for
916+
// quant-MoE models on GPU; see ggml_openvino_gpu_full_moe_enabled() for the resolution order
917+
// and the GGML_OPENVINO_GPU_FULL_MOE override.
917918
static bool gpu_full_moe_enabled() {
918-
static const bool v = getenv("GGML_OPENVINO_GPU_FULL_MOE") != nullptr;
919-
return v;
919+
return ggml_openvino_gpu_full_moe_enabled();
920920
}
921921

922922
static bool mul_mat_id_requires_large_tmp(const ggml_tensor * op) {
@@ -1265,6 +1265,13 @@ static bool is_op_unsupported_case(const ggml_tensor * op) {
12651265
static bool ggml_backend_openvino_device_supports_op(ggml_backend_dev_t dev, const ggml_tensor * op) {
12661266
GGML_ASSERT(dev->reg != nullptr);
12671267

1268+
// A MUL_MAT_ID op is the expert-routed matmul: its presence means this is a MoE
1269+
// model. Latch it here (placement time) rather than at weight load, because the
1270+
// scheduler queries op placement before the expert weights are streamed in.
1271+
if (op->op == GGML_OP_MUL_MAT_ID) {
1272+
ggml_openvino_note_moe_expert_weight();
1273+
}
1274+
12681275
static std::unordered_set<ggml_type> supported_types{
12691276
GGML_TYPE_F32, GGML_TYPE_F16, GGML_TYPE_BF16, GGML_TYPE_I64, GGML_TYPE_I32, GGML_TYPE_Q4_0,
12701277
GGML_TYPE_Q4_1, GGML_TYPE_Q4_K, GGML_TYPE_Q5_1, GGML_TYPE_Q5_K, GGML_TYPE_Q8_0, GGML_TYPE_Q6_K};

ggml/src/ggml-openvino/openvino/op/rms_norm.cpp

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "../node_context.h"
22
#include "../op_table.h"
33
#include "../utils.h"
4+
#include "ggml-openvino/ggml-openvino-extra.h"
45

56
#include <memory>
67
#include <openvino/op/add.hpp>
@@ -20,8 +21,32 @@ OutputVector translate_rms_norm(const NodeContext & context) {
2021
num_inputs_check(context, 1, 1);
2122

2223
auto input_node = process_view_input_new(context, 0);
23-
auto square = std::make_shared<ov::op::v1::Power>(
24-
input_node, ov::op::v0::Constant::create(ov::element::f32, ov::Shape{1}, {2.0f}));
24+
25+
// Build the mean-of-squares numerator. Normally use Power(x, 2): the OpenVINO
26+
// rms_fusion pass matches that Power node and folds the whole decomposition into
27+
// the internal RMS op (a perf win, e.g. dense Llama on GPU), so we keep it by
28+
// default for every model and device.
29+
//
30+
// EXCEPTION — quant-MoE model on the GPU full-MoE path: the fused GPU RMS primitive's
31+
// dynamic multi-token kernel writes only token 0 (tokens 1..N read back as 0). That
32+
// silently collapses the per-layer MoE router RMSNorm summed over the prefill tokens
33+
// (~7x), flattening the router softmax and flipping the top-8 expert selection, so the
34+
// GPU output drifts from CPU (task #16). On that exact path only, compute the square as
35+
// Multiply(x, x) — algebraically identical, but it does not match the rms_fusion
36+
// pattern, so the GPU runs the unfused primitives and writes every token. Keyed to the
37+
// same predicate as the full-MoE GPU path (auto-enabled for quant-MoE on GPU; see
38+
// ggml_openvino_gpu_full_moe_enabled), so it never affects CPU/NPU or non-MoE GPU
39+
// models, which keep the fused fast path.
40+
static const bool dodge_rms_fusion =
41+
ggml_openvino_get_device_name() == "GPU" && ggml_openvino_gpu_full_moe_enabled();
42+
43+
std::shared_ptr<ov::Node> square;
44+
if (dodge_rms_fusion) {
45+
square = std::make_shared<ov::op::v1::Multiply>(input_node, input_node);
46+
} else {
47+
square = std::make_shared<ov::op::v1::Power>(
48+
input_node, ov::op::v0::Constant::create(ov::element::f32, ov::Shape{1}, {2.0f}));
49+
}
2550

2651
auto mean = std::make_shared<ov::op::v1::ReduceMean>(
2752
square, ov::op::v0::Constant::create(ov::element::i64, ov::Shape{1}, {-1}), true);

0 commit comments

Comments
 (0)