Skip to content

Commit ac2756c

Browse files
committed
Lower mean.dim beyond 4D pooling as an XNNPACK static mean
## Ulterior Motive Run RMSNorm in LeRobot policies on XNNPACK (#24667), where `mean.dim` fails to partition with "only supports averaging 4D tensors, got tensor of rank 3". ## Rationale **What**: Add an `XNNStaticMean` node backed by `xnn_define_static_reduce(xnn_reduce_mean)`, and lower every `mean.dim` that is not Global Average Pooling to it: ranks 1 to 6, any dims, and either `keepdim`. **Why**: XNNPACK only lowered a 4D mean over dims [2, 3] with `keepdim=True`. RMSNorm averages the last dim of a 3-D tensor, so its mean stayed on the portable kernel and split the delegate around every norm. ## Details Review order: the serialization schema and `XNNCompiler.cpp`, then `op_mean_dim.py` and `is_global_avg_pool_mean`, then `ChannelsLastTaggedReshapePass`, then `MeanDimConfig`, then the tests. - The 4D, dims [2, 3], `keepdim=True` case still lowers to Global Average Pooling on NHWC. For those programs the `.pte` is byte-identical to main (fp32, fp16, qs8, conv-bn-hardtanh-mean, and a CNN ending in `AdaptiveAvgPool2d(1)`), and the tests check which XNNPACK node each mean serializes to. - Every other `mean.dim` reduces over NCHW dims, so `ChannelsLastTaggedReshapePass` gives it an NCHW input, and the visitor refuses an NHWC one. After a convolution that is the same NHWC to NCHW copy main made at the delegate boundary. Remapping the reduction axes to read NHWC directly would remove that copy for heads like `x.mean([2, 3])` and `x.mean(1, keepdim=True)`; that is left for a follow-up. - `is_global_avg_pool_mean` takes the rank from the mean's own output, which with `keepdim=True` equals the input's, because inside `ChannelsLastTaggedReshapePass` the mean's input can be a newly inserted node without `"val"`. - The node uses a shared `_XNNStaticReduce` table, so other XNNPACK reductions can reuse it and `defineGenericReduceNode`. It is appended to `XNodeUnion`, so existing node IDs do not change. Practically every re-exported XNNPACK LLM program contains it, because RMSNorm (`examples/models/llama/norm.py`) and QK-norm lower to it, and those programs fail to load on runtimes built before this change, including released prebuilt runtimes. The release note should say so. - An empty dim list averages over every dim, as in PyTorch. - Latency against main, branch time over main time (below 1 is faster), each a median over at least 1000 timed runs per program, alternating main and branch programs on one runtime, on an Apple M-series CPU (12 performance + 4 efficiency cores) with the default 16 threads: RMSNorm 1x128x2048 0.48x, 3-D last-dim mean 0.45x, RMSNorm then Linear 0.77x, 4 pre-norm residual MLP blocks 0.99x for decode (1 token) and 0.80x for prefill (64 tokens), conv then mean over channels 0.88x. - XNNPACK splits a reduction with more than one output into threadpool tasks, so a small mean with several outputs can cost more in XNNPACK than on the portable kernel when the pool has many threads. For RMSNorm on 10 shapes from 1x1x128 to 1x32x2048, every shape is 0.43x to 0.97x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.55x (1.5 to 2.7 us more), and the rest 0.44x to 0.93x. At 16 threads those small shapes are 3.7x to 5.7x (21 to 23 us more), and the rest 0.70x to 0.98x. Inside a model with neighbouring delegated ops the cost can remain: decode QK-norm between two linears, 8 heads of 64, goes from 11.8 us to 39.1 us at 16 threads, and 16 heads of 128 from 222 us to 241 us. A mean alone in its delegate pays the same wake-up (a bare 1x128x128 mean is 3.26x at 16 threads and 1.14x at 4), as does any op lowered alone, such as `add` (2.95x at 1x128x128 and 16 threads on main). These means are lowered like every other op the XNNPACK partitioner takes, rather than gated by size, which would be tuned to one thread count and treat static and dynamic exports differently. - The LoRA CI scripts check the start of the 8da4w-quantized Qwen3 base model's greedy output with a regex. With RMSNorm and QK-norm now in XNNPACK, its first token can tip from `:\n` to `:` (`assistant: me` instead of `assistant:\n<think>`): unquantized, main and this branch generate the same tokens with logits within 1e-4, but 8da4w re-rounds activations and the two candidates are within about 0.5 logits. The regex accepts exactly those two continuations, and the scripts print it when it does not match. - Tests cover 1-D to 6-D inputs, all dims, `keepdim=False`, fp16 and qs8 over the innermost and a middle dim, dynamic kept and reduced dims over 5 random shapes, RMSNorm, fp32 and qs8 convolutions followed by a mean, the serialized XNNPACK nodes (moved `xnnpack_node_types` from the layout pass tests to the XNNPACK `Tester`), and the layout copies the pass inserts. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.py backends/xnnpack/test/passes/test_channels_last_tagged_reshape.py python -m pytest backends/xnnpack/test/ops backends/xnnpack/test/passes backends/xnnpack/test/test_xnnpack_partitioner.py backends/xnnpack/test/serialization backends/xnnpack/test/quantizer ``` Authored with AI using Claude Code.
1 parent bdc7c35 commit ac2756c

15 files changed

Lines changed: 475 additions & 169 deletions

File tree

‎.ci/scripts/test_lora.sh‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,18 @@ import re
4646
import sys
4747
4848
text = pathlib.Path(sys.argv[1]).read_text()
49+
# Under 8da4w, the base model's first token is a near-tie between ":\n" and ":"
50+
# that tips with small numeric changes, such as RMSNorm and QK-norm moving into
51+
# XNNPACK. Accept both observed continuations.
4952
pattern = re.compile(
50-
r"^<\|im_start\|>user Calculate 15% of 80\?<\|im_end\|><\|im_start\|>assistant:\n"
51-
r"(?:<think>\n)+"
53+
r"^<\|im_start\|>user Calculate 15% of 80\?<\|im_end\|><\|im_start\|>assistant:"
54+
r"(?:\n(?:<think>\n)+| me\n)"
5255
r"Okay, so I need to calculate 15% of 80\.",
5356
re.MULTILINE,
5457
)
55-
sys.exit(0 if pattern.match(text) else 1)
58+
if not pattern.match(text):
59+
print(f"Expected a match for: {pattern.pattern}")
60+
sys.exit(1)
5661
PY
5762
}
5863

‎.ci/scripts/test_lora_multimethod.sh‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,18 @@ import re
4646
import sys
4747
4848
text = pathlib.Path(sys.argv[1]).read_text()
49+
# Under 8da4w, the base model's first token is a near-tie between ":\n" and ":"
50+
# that tips with small numeric changes, such as RMSNorm and QK-norm moving into
51+
# XNNPACK. Accept both observed continuations.
4952
pattern = re.compile(
50-
r"^<\|im_start\|>user Calculate 15% of 80\?<\|im_end\|><\|im_start\|>assistant:\n"
51-
r"(?:<think>\n)+"
53+
r"^<\|im_start\|>user Calculate 15% of 80\?<\|im_end\|><\|im_start\|>assistant:"
54+
r"(?:\n(?:<think>\n)+| me\n)"
5255
r"Okay, so I need to calculate 15% of 80\.",
5356
re.MULTILINE,
5457
)
55-
sys.exit(0 if pattern.match(text) else 1)
58+
if not pattern.match(text):
59+
print(f"Expected a match for: {pattern.pattern}")
60+
sys.exit(1)
5661
PY
5762
}
5863

‎backends/xnnpack/_passes/channels_last_tagged_reshape_pass.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
get_param_tensor,
2525
is_dtype_cast,
2626
is_foldable_broadcast_operand,
27+
is_global_avg_pool_mean,
2728
is_param_node,
2829
)
2930
from executorch.exir.dialects._ops import ops as exir_ops
@@ -69,7 +70,6 @@ class ChannelsLastTaggedReshapePass(XNNPACKPass):
6970
exir_ops.edge.aten.convolution.default,
7071
exir_ops.edge.aten.upsample_bilinear2d.vec,
7172
exir_ops.edge.aten.upsample_nearest2d.vec,
72-
exir_ops.edge.aten.mean.dim,
7373
exir_ops.edge.aten.max_pool2d.default,
7474
exir_ops.edge.aten.amax.default,
7575
exir_ops.edge.aten.max.dim,
@@ -316,11 +316,17 @@ def layout_inputs(self, node: torch.fx.Node) -> List[torch.fx.Node]:
316316
]
317317

318318
def requires_nhwc_input(self, node: torch.fx.Node) -> bool:
319+
if node.target == exir_ops.edge.aten.mean.dim:
320+
return is_global_avg_pool_mean(node)
319321
return node.target in self.memory_sensitive_ops_nhwc
320322

321323
def requires_nchw_inputs(self, node: torch.fx.Node) -> bool:
322324
if node.target == exir_ops.edge.aten.view_copy.default:
323325
return True
326+
# A mean.dim that is not Global Average Pooling reduces over the dims
327+
# as given, which index the NCHW layout.
328+
if node.target == exir_ops.edge.aten.mean.dim:
329+
return not is_global_avg_pool_mean(node)
324330

325331
return node.target in self.memory_sensitive_ops_nchw
326332

‎backends/xnnpack/operators/op_mean_dim.py‎

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from typing import cast, Dict
99

1010
import torch
11+
from executorch.backends.xnnpack._passes.channels_last_tagged_reshape_pass import (
12+
ChannelsLastTaggedReshapePass,
13+
)
1114
from executorch.backends.xnnpack.operators.node_visitor import (
1215
check_or_raise,
1316
get_tensor_value,
@@ -17,19 +20,24 @@
1720
from executorch.backends.xnnpack.serialization.xnnpack_graph_schema import (
1821
XNNGlobalAvgPooling2d,
1922
XNNGraph,
23+
XNNStaticMean,
2024
XNode,
2125
)
22-
from executorch.backends.xnnpack.utils.utils import normalize_mean_dims
26+
from executorch.backends.xnnpack.utils.utils import (
27+
get_input_node,
28+
is_global_avg_pool_mean,
29+
normalize_mean_dims,
30+
)
2331
from executorch.backends.xnnpack.utils.xnnpack_constants import XNN_FLAG_KEEP_DIMS
2432

2533

2634
@register_node_visitor
2735
class MeanDim(NodeVisitor):
2836
"""
29-
XNNPACK only supports the special case of mean.dim that can be lowered
30-
to Global Average Pooling. The input tensor must be 4D, keepdim must be
31-
True, and the reduced dimensions must normalize to [2, 3] (for example
32-
[2, 3], [3, 2], [-1, -2], or [-2, -1]).
37+
mean.dim over the innermost dimensions of a 4D tensor with keepdim=True
38+
(dims normalizing to [2, 3]) is lowered to Global Average Pooling on an
39+
NHWC input. Any other mean.dim is lowered to a static mean reduction on
40+
the NCHW input.
3341
"""
3442

3543
target = "aten.mean.dim"
@@ -44,44 +52,38 @@ def define_node(
4452
vals_to_ids: Dict[torch.fx.Node, int],
4553
debug_handle: int,
4654
) -> None:
47-
self.define_nodes_tensor_inputs_outputs(
48-
node, xnn_graph, vals_to_ids, convert_to_nhwc=True
49-
)
50-
# input
51-
input_id = vals_to_ids[cast(torch.fx.Node, node.args[0])]
52-
53-
# output
54-
output_id = vals_to_ids[node]
55-
56-
input_shape = get_tensor_value(xnn_graph.xvalues[input_id]).dims
57-
check_or_raise(
58-
len(input_shape) == 4, "Require input to mean.dim be 4 dimensional"
59-
)
60-
61-
# This visitor serializes mean.dim as Global Average Pooling, which has
62-
# no field for an explicit dtype override.
6355
check_or_raise(
6456
node.kwargs.get("dtype") is None,
6557
"XNNPACK does not support mean.dim with dtype",
6658
)
6759

68-
# mean dims
69-
mean_dims = normalize_mean_dims(node.args[1], len(input_shape))
70-
check_or_raise(
71-
sorted(mean_dims) == [2, 3],
72-
"XNNPACK only supports mean.dim across the innermost dimensions",
73-
)
74-
75-
# keep dims
76-
check_or_raise(
77-
len(node.args) == 3 and bool(node.args[2]),
78-
"XNNPACK only supports mean.dim that keeps dims",
60+
global_avg_pool = is_global_avg_pool_mean(node)
61+
self.define_nodes_tensor_inputs_outputs(
62+
node, xnn_graph, vals_to_ids, convert_to_nhwc=global_avg_pool
7963
)
64+
input_id = vals_to_ids[cast(torch.fx.Node, node.args[0])]
65+
output_id = vals_to_ids[node]
8066

81-
ser_node = XNode(
82-
xnode_union=XNNGlobalAvgPooling2d(
67+
if global_avg_pool:
68+
xnode_union = XNNGlobalAvgPooling2d(
8369
input_id=input_id, output_id=output_id, flags=XNN_FLAG_KEEP_DIMS
84-
),
85-
debug_handle=debug_handle,
86-
)
70+
)
71+
else:
72+
input_node = get_input_node(node, 0)
73+
check_or_raise(
74+
not input_node.meta.get(
75+
ChannelsLastTaggedReshapePass.XNN_NHWC_NODE, False
76+
),
77+
"mean.dim reduces over NCHW dims, but its input is NHWC",
78+
)
79+
input_rank = len(get_tensor_value(xnn_graph.xvalues[input_id]).dims)
80+
keepdim = len(node.args) >= 3 and bool(node.args[2])
81+
xnode_union = XNNStaticMean(
82+
reduction_axes=sorted(normalize_mean_dims(node.args[1], input_rank)),
83+
input_id=input_id,
84+
output_id=output_id,
85+
flags=XNN_FLAG_KEEP_DIMS if keepdim else 0,
86+
)
87+
88+
ser_node = XNode(xnode_union=xnode_union, debug_handle=debug_handle)
8789
xnn_graph.xnodes.append(ser_node)

‎backends/xnnpack/partition/config/generic_node_configs.py‎

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -714,44 +714,25 @@ class MeanDimConfig(GenericNodePartitionerConfig):
714714

715715
def check_constraints(self, node: torch.fx.Node, ep: ExportedProgram) -> bool:
716716
"""
717-
Mean Dim currently only supports averaging 4D tensors across the innermost
718-
dimensions
717+
Mean Dim lowers to Global Average Pooling when it averages a 4D tensor
718+
across the innermost dimensions with keepdim=True, and to a static mean
719+
reduction otherwise.
719720
"""
720721
if not self.check_common_constraints(node, ep):
721722
return False
722723

723-
input_rank = get_input_node(node, 0).meta["val"].dim()
724-
if input_rank != 4:
725-
why(
726-
node,
727-
reason=f"mean.dim only supports averaging 4D tensors, got tensor of rank {input_rank}",
728-
)
729-
return False
730-
731-
# This path lowers mean.dim to XNNPACK Global Average Pooling, which
732-
# cannot encode an explicit dtype override.
724+
# Neither lowering can encode an explicit dtype override.
733725
if node.kwargs.get("dtype") is not None:
734726
why(node, reason="mean.dim does not support dtype")
735727
return False
736728

737-
keepdim = len(node.args) >= 3 and bool(node.args[2])
729+
input_node = get_input_node(node, 0)
738730
try:
739-
dims = normalize_mean_dims(node.args[1], input_rank)
731+
normalize_mean_dims(node.args[1], input_node.meta["val"].dim())
740732
except ValueError as error:
741733
why(node, reason=f"mean.dim has invalid dims: {error}")
742734
return False
743735

744-
if sorted(dims) != [2, 3]:
745-
why(
746-
node,
747-
reason="mean.dim only supports averaging 4D tensors across the innermost dimensions",
748-
)
749-
return False
750-
751-
if not keepdim:
752-
why(node, reason="mean.dim only supports keepdim=True")
753-
return False
754-
755736
return True
756737

757738
def supported_precision_types(self) -> List[ConfigPrecisionType]:

‎backends/xnnpack/runtime/XNNCompiler.cpp‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2037,6 +2037,60 @@ Error defineGenericBinaryNode(
20372037
node->debug_handle()); \
20382038
}
20392039

2040+
// Generic helper function for reduce operations
2041+
Error defineGenericReduceNode(
2042+
xnn_subgraph_t subgraph_ptr,
2043+
const std::unordered_map<uint32_t, uint32_t>& remapped_ids,
2044+
const fb_xnnpack::_XNNStaticReduce* graph_node,
2045+
xnn_reduce_operator op_type,
2046+
fb_xnnpack::XNodeUnion node_type,
2047+
uint32_t debug_handle) noexcept {
2048+
ET_CHECK_OR_RETURN_ERROR(
2049+
graph_node->reduction_axes() != nullptr,
2050+
InvalidProgram,
2051+
"%s: reduction_axes is null",
2052+
fb_xnnpack::EnumNameXNodeUnion(node_type));
2053+
std::vector<size_t> reduction_axes =
2054+
flatbufferDimsToVector(graph_node->reduction_axes());
2055+
2056+
REMAP_ID(remapped_ids, graph_node->input_id(), reduce_input);
2057+
REMAP_ID(remapped_ids, graph_node->output_id(), reduce_output);
2058+
2059+
xnn_status status = xnn_define_static_reduce(
2060+
subgraph_ptr,
2061+
op_type,
2062+
reduction_axes.size(),
2063+
reduction_axes.data(),
2064+
reduce_input,
2065+
reduce_output,
2066+
graph_node->flags());
2067+
ET_CHECK_OR_RETURN_ERROR(
2068+
status == xnn_status_success,
2069+
Internal,
2070+
"Failed to create %s node %i with code: %s",
2071+
fb_xnnpack::EnumNameXNodeUnion(node_type),
2072+
debug_handle,
2073+
xnn_status_to_string(status));
2074+
2075+
return Error::Ok;
2076+
}
2077+
2078+
#define _DEFINE_REDUCE_NODE(name, op_type) \
2079+
Error define##name##Node( \
2080+
xnn_subgraph_t subgraph_ptr, \
2081+
const std::unordered_map<uint32_t, uint32_t>& remapped_ids, \
2082+
const NodePtr node, \
2083+
const fb_xnnpack::XNNGraph* graph) noexcept { \
2084+
MAYBE_UNUSED(graph); \
2085+
return defineGenericReduceNode( \
2086+
subgraph_ptr, \
2087+
remapped_ids, \
2088+
node->xnode_union_as_XNN##name(), \
2089+
op_type, \
2090+
node->xnode_union_type(), \
2091+
node->debug_handle()); \
2092+
}
2093+
20402094
/*
20412095
Returns the pointer to the defineNode function that handles the given
20422096
XNode type
@@ -2080,6 +2134,9 @@ _DEFINE_BINARY_NODE_WITH_MINMAX(Div, xnn_binary_divide)
20802134
_DEFINE_BINARY_NODE_NO_PARAMS(Minimum, xnn_binary_minimum)
20812135
_DEFINE_BINARY_NODE_NO_PARAMS(Maximum, xnn_binary_maximum)
20822136

2137+
// Reduce Ops
2138+
_DEFINE_REDUCE_NODE(StaticMean, xnn_reduce_mean)
2139+
20832140
DefineNodeFunc getDefineNodeFunc(fb_xnnpack::XNodeUnion nodeType) {
20842141
switch (nodeType) {
20852142
// Binary ops
@@ -2125,6 +2182,7 @@ DefineNodeFunc getDefineNodeFunc(fb_xnnpack::XNodeUnion nodeType) {
21252182
_DEFINE(MaxPooling2d)
21262183
_DEFINE(Convert)
21272184
_DEFINE(GlobalAvgPooling2d)
2185+
_DEFINE(StaticMean)
21282186
_DEFINE(StaticReshape)
21292187
_DEFINE(DepthToSpace2d)
21302188
_DEFINE(ArgMaxPooling2d)

‎backends/xnnpack/serialization/runtime_schema.fbs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ union XNodeUnion {
161161
XNNCos: _XNNNode1x1,
162162
XNNApproxGelu: _XNNNode1x1,
163163
XNNDepthToSpace2d,
164+
XNNStaticMean: _XNNStaticReduce,
164165
}
165166

166167
union XValueUnion {
@@ -306,6 +307,15 @@ table XNNDepthToSpace2d {
306307
flags: uint;
307308
}
308309

310+
// A reduction over static axes
311+
// Not meant to be used directly
312+
table _XNNStaticReduce {
313+
reduction_axes: [uint];
314+
input_id: uint;
315+
output_id: uint;
316+
flags: uint;
317+
}
318+
309319
table XNNStaticSlice {
310320
num_dims:uint;
311321
offsets:[uint];

‎backends/xnnpack/serialization/schema.fbs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ union XNodeUnion {
157157
XNNCos: _XNNNode1x1,
158158
XNNApproxGelu: _XNNNode1x1,
159159
XNNDepthToSpace2d,
160+
XNNStaticMean: _XNNStaticReduce,
160161
}
161162

162163
union XValueUnion {
@@ -302,6 +303,15 @@ table XNNDepthToSpace2d {
302303
flags: uint;
303304
}
304305

306+
// A reduction over static axes
307+
// Not meant to be used directly
308+
table _XNNStaticReduce {
309+
reduction_axes: [uint];
310+
input_id: uint;
311+
output_id: uint;
312+
flags: uint;
313+
}
314+
305315
table XNNStaticSlice {
306316
num_dims:uint;
307317
offsets:[uint];

‎backends/xnnpack/serialization/xnnpack_graph_schema.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,19 @@ class XNNDepthToSpace2d:
210210
flags: int
211211

212212

213+
@dataclass
214+
class XNNStaticReduce:
215+
reduction_axes: List[int]
216+
input_id: int
217+
output_id: int
218+
flags: int
219+
220+
221+
@dataclass
222+
class XNNStaticMean(XNNStaticReduce):
223+
pass
224+
225+
213226
@dataclass
214227
class XNNStaticSlice:
215228
num_dims: int
@@ -436,6 +449,7 @@ class XNNScaledDotProductAttention:
436449
XNNCos,
437450
XNNApproxGelu,
438451
XNNDepthToSpace2d,
452+
XNNStaticMean,
439453
]
440454

441455

0 commit comments

Comments
 (0)