Skip to content

Commit e5c4836

Browse files
committed
Lower bf16 mean.dim to XNNPACK as an fp32 static mean
Keep bf16 RMSNorm in XNNPACK for bf16 models, such as the bf16 LeRobot policies in #24667, where bf16 ops that XNNPACK does not support fall back to the portable kernels. **What**: With `enable_bf16=True`, lower a bf16 `mean.dim` to the `XNNStaticMean` from #24741. The serialized graph stays bf16; when the program loads, the runtime reduces in fp32 between a convert from bf16 and a convert back, as it already does for bf16 matmul. **Why**: XNNPACK has no bf16 reduction, so `MeanDimConfig` rejected every bf16 mean, and a bf16 RMSNorm split the delegate around its mean. XNNPACK does convert between bf16 and fp32, and when the mean has lowered neighbours the two converts cost less than leaving the mean outside the delegate. Stacked on #24741, which adds `XNNStaticMean`; only the last commit is new. - `defineGenericReduceNode` adds the converts when the serialized input and output are bf16, next to `defineBf16MatMulResult`. The fp32 tensor definition is shared with the matmul path as `defineFp32Like`, and `convertBf16MatMulResult` becomes `convertBf16Result`. Because the program does not encode the converts, a runtime can switch to a native bf16 reduction if XNNPACK adds one, without re-exporting. The XNNPACK subgraph is the same as with the converts serialized: outputs are bitwise identical and latency is within noise. Each bf16 mean adds an fp32 copy of its input to the XNNPACK workspace (1 MiB for 1x128x2048). - A bf16 mean never takes the Global Average Pooling path, which has no bf16 kernel. A 4D mean over dims [2, 3] with `keepdim=True` is a static mean like any other bf16 mean, so a channels-last input is copied to NCHW for it. - bf16 means are lowered at any size, as fp16 and fp32 means are in #24741. fp32 and fp16 programs are byte-identical to #24741. - Latency, 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: bf16 mean over the last dim of 1x128x2048 0.42x, bf16 RMSNorm 1x128x2048 0.56x, decode RMSNorm 1x1x2048 0.80x, 4 residual blocks of `x + Linear(RMSNorm(x))` at width 2048 0.87x for prefill (64 tokens) and 0.98x for decode (1 token). bf16 RMSNorm on 13 static shapes from 1x1x128 to 1x32x2048 is 0.47x to 0.96x at 1 thread. At 4 threads the shapes under 8K elements with several outputs are 1.19x to 1.57x (1.6 to 3.0 us more) and the rest 0.57x to 0.96x; at 16 threads those small shapes are 3.8x to 5.4x (22 to 24 us more) and the rest 0.70x to 0.99x, the same threadpool cost as for fp32 in #24741. - A mean alone in its delegate, with nothing else lowered around it, pays the threadpool wake-up on its own and can be slower than the portable kernel at high thread counts. At 1x128x128 and 16 threads, a lone fp32 mean is 3.2x (as in #24741) and a lone bf16 mean 5.7x, because of its two converts; at 4 threads the bf16 mean is 1.35x, and at 1 thread it is faster. Any op lowered alone pays this, such as `add` (2.95x at 1x128x128 and 16 threads on main). - Accuracy against fp32 eager is unchanged for single ops. It improves when the means were the only places a model's delegate split: for the 4 blocks above at prefill, max abs error drops from 4.6e-2 to 1.8e-2, because fewer intermediates are rounded to bf16 at delegate boundaries. - An RMSNorm that casts to fp32 and back, as Hugging Face Llama does, already has an fp32 mean. Its remaining cost is the bf16 <-> fp32 casts, which #24745 lowers for programs exported with dim order, and #24768 for programs exported with `skip_dim_order`. - `test_enable_bf16_leaves_ops_xnnpack_cannot_define_in_bf16` no longer lists `mean`, which is now delegated in bf16. Test plan: ``` python -m pytest backends/xnnpack/test/ops/test_mean_dim.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 e578110 commit e5c4836

7 files changed

Lines changed: 205 additions & 61 deletions

File tree

‎backends/xnnpack/operators/op_mean_dim.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@
3434
@register_node_visitor
3535
class MeanDim(NodeVisitor):
3636
"""
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.
37+
A mean.dim that is not bf16, over the innermost dimensions of a 4D tensor
38+
with keepdim=True (dims normalizing to [2, 3]), is lowered to Global
39+
Average Pooling on an NHWC input. Any other mean.dim is lowered to a
40+
static mean reduction on the NCHW input, which the runtime computes in
41+
fp32 for bf16.
4142
"""
4243

4344
target = "aten.mean.dim"

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -710,13 +710,12 @@ def supported_precision_types(self) -> List[ConfigPrecisionType]:
710710

711711
class MeanDimConfig(GenericNodePartitionerConfig):
712712
target_name = "mean.dim"
713-
supports_bf16 = False
714713

715714
def check_constraints(self, node: torch.fx.Node, ep: ExportedProgram) -> bool:
716715
"""
717716
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.
717+
that is not bf16 across the innermost dimensions with keepdim=True, and
718+
to a static mean reduction otherwise, computed in fp32 for bf16.
720719
"""
721720
if not self.check_common_constraints(node, ep):
722721
return False

‎backends/xnnpack/runtime/XNNCompiler.cpp‎

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -818,10 +818,42 @@ const fb_xnnpack::XNNTensorValue* getSerializedTensorValue(
818818
return nullptr;
819819
}
820820

821+
/*
822+
Define an fp32 value with the dims of a serialized bf16 tensor, for an op that
823+
XNNPACK only runs in fp32.
824+
*/
825+
Error defineFp32Like(
826+
xnn_subgraph_t subgraph_ptr,
827+
const fb_xnnpack::XNNTensorValue* value,
828+
const NodePtr node,
829+
uint32_t* fp32_id) noexcept {
830+
// Like defineTensor(), treat a null dims vector as rank 0.
831+
std::vector<size_t> dims;
832+
if (value->dims() != nullptr) {
833+
dims = flatbufferDimsToVector<size_t>(value->dims());
834+
}
835+
xnn_status status = xnn_define_tensor_value(
836+
subgraph_ptr,
837+
xnn_datatype_fp32,
838+
dims.size(),
839+
dims.data(),
840+
/*data=*/nullptr,
841+
/*external_id=*/XNN_INVALID_VALUE_ID,
842+
/*flags=*/0,
843+
fp32_id);
844+
ET_CHECK_OR_RETURN_ERROR(
845+
status == xnn_status_success,
846+
Internal,
847+
"Failed to define an fp32 value for bf16 node %i: %s",
848+
node->debug_handle(),
849+
xnn_status_to_string(status));
850+
return Error::Ok;
851+
}
852+
821853
/*
822854
XNNPACK multiplies bf16 matrices only into an fp32 result. When the serialized
823855
graph multiplies bf16 matrices into a bf16 output, define an fp32 value for the
824-
op to write instead, which convertBf16MatMulResult() then converts into the
856+
op to write instead, which convertBf16Result() then converts into the
825857
output, so the delegate boundary stays bf16. Otherwise the op writes the output
826858
itself.
827859
*/
@@ -844,34 +876,15 @@ Error defineBf16MatMulResult(
844876
output->datatype() != DataType::xnn_datatype_bf16) {
845877
return Error::Ok;
846878
}
847-
// Like defineTensor(), treat a null dims vector as rank 0.
848-
std::vector<size_t> dims;
849-
if (output->dims() != nullptr) {
850-
dims = flatbufferDimsToVector<size_t>(output->dims());
851-
}
852-
xnn_status status = xnn_define_tensor_value(
853-
subgraph_ptr,
854-
xnn_datatype_fp32,
855-
dims.size(),
856-
dims.data(),
857-
/*data=*/nullptr,
858-
/*external_id=*/XNN_INVALID_VALUE_ID,
859-
/*flags=*/0,
860-
result_id);
861-
ET_CHECK_OR_RETURN_ERROR(
862-
status == xnn_status_success,
863-
Internal,
864-
"Failed to define the fp32 result of bf16 node %i: %s",
865-
node->debug_handle(),
866-
xnn_status_to_string(status));
867-
return Error::Ok;
879+
return defineFp32Like(subgraph_ptr, output, node, result_id);
868880
}
869881

870882
/*
871-
Converts the fp32 result that defineBf16MatMulResult() defined into the bf16
872-
output. Does nothing when the op wrote the output itself.
883+
Converts the fp32 result of a bf16 node, as defined by defineBf16MatMulResult()
884+
or defineGenericReduceNode(), into the bf16 output. Does nothing when the op
885+
wrote the output itself.
873886
*/
874-
Error convertBf16MatMulResult(
887+
Error convertBf16Result(
875888
xnn_subgraph_t subgraph_ptr,
876889
uint32_t result_id,
877890
uint32_t remapped_output_id,
@@ -934,7 +947,7 @@ Error defineFullyConnectedNode(
934947
node->debug_handle(),
935948
xnn_status_to_string(status));
936949

937-
return convertBf16MatMulResult(subgraph_ptr, fc_result, fc_output, node);
950+
return convertBf16Result(subgraph_ptr, fc_result, fc_output, node);
938951
};
939952

940953
/*
@@ -1793,7 +1806,7 @@ Error defineBatchMatrixMultiplyNode(
17931806
node->debug_handle(),
17941807
xnn_status_to_string(status));
17951808

1796-
return convertBf16MatMulResult(subgraph_ptr, bmm_result, bmm_out, node);
1809+
return convertBf16Result(subgraph_ptr, bmm_result, bmm_out, node);
17971810
}
17981811

17991812
/*
@@ -2043,36 +2056,61 @@ Error defineGenericReduceNode(
20432056
const std::unordered_map<uint32_t, uint32_t>& remapped_ids,
20442057
const fb_xnnpack::_XNNStaticReduce* graph_node,
20452058
xnn_reduce_operator op_type,
2046-
fb_xnnpack::XNodeUnion node_type,
2047-
uint32_t debug_handle) noexcept {
2059+
const NodePtr node,
2060+
const fb_xnnpack::XNNGraph* graph) noexcept {
2061+
const char* name = fb_xnnpack::EnumNameXNodeUnion(node->xnode_union_type());
20482062
ET_CHECK_OR_RETURN_ERROR(
20492063
graph_node->reduction_axes() != nullptr,
20502064
InvalidProgram,
20512065
"%s: reduction_axes is null",
2052-
fb_xnnpack::EnumNameXNodeUnion(node_type));
2066+
name);
20532067
std::vector<int64_t> reduction_axes =
20542068
flatbufferDimsToVector<int64_t>(graph_node->reduction_axes());
20552069

20562070
REMAP_ID(remapped_ids, graph_node->input_id(), reduce_input);
20572071
REMAP_ID(remapped_ids, graph_node->output_id(), reduce_output);
20582072

2059-
xnn_status status = xnn_define_static_reduce_v2(
2073+
// XNNPACK has no bf16 reduction, so a bf16 input is reduced in fp32 and the
2074+
// result converted back, keeping the delegate boundary bf16.
2075+
uint32_t reduce_source = reduce_input;
2076+
uint32_t reduce_result = reduce_output;
2077+
const auto* input = getSerializedTensorValue(graph, graph_node->input_id());
2078+
const auto* output = getSerializedTensorValue(graph, graph_node->output_id());
2079+
xnn_status status;
2080+
if (input != nullptr && output != nullptr &&
2081+
input->datatype() == DataType::xnn_datatype_bf16 &&
2082+
output->datatype() == DataType::xnn_datatype_bf16) {
2083+
ET_CHECK_OK_OR_RETURN_ERROR(
2084+
defineFp32Like(subgraph_ptr, input, node, &reduce_source));
2085+
status = xnn_define_convert(
2086+
subgraph_ptr, reduce_input, reduce_source, /*flags=*/0);
2087+
ET_CHECK_OR_RETURN_ERROR(
2088+
status == xnn_status_success,
2089+
Internal,
2090+
"Failed to convert the input of bf16 node %i to fp32: %s",
2091+
node->debug_handle(),
2092+
xnn_status_to_string(status));
2093+
ET_CHECK_OK_OR_RETURN_ERROR(
2094+
defineFp32Like(subgraph_ptr, output, node, &reduce_result));
2095+
}
2096+
2097+
status = xnn_define_static_reduce_v2(
20602098
subgraph_ptr,
20612099
op_type,
20622100
reduction_axes.size(),
20632101
reduction_axes.data(),
2064-
reduce_input,
2065-
reduce_output,
2102+
reduce_source,
2103+
reduce_result,
20662104
graph_node->flags());
20672105
ET_CHECK_OR_RETURN_ERROR(
20682106
status == xnn_status_success,
20692107
Internal,
20702108
"Failed to create %s node %i with code: %s",
2071-
fb_xnnpack::EnumNameXNodeUnion(node_type),
2072-
debug_handle,
2109+
name,
2110+
node->debug_handle(),
20732111
xnn_status_to_string(status));
20742112

2075-
return Error::Ok;
2113+
return convertBf16Result(subgraph_ptr, reduce_result, reduce_output, node);
20762114
}
20772115

20782116
#define _DEFINE_REDUCE_NODE(name, op_type) \
@@ -2081,14 +2119,13 @@ Error defineGenericReduceNode(
20812119
const std::unordered_map<uint32_t, uint32_t>& remapped_ids, \
20822120
const NodePtr node, \
20832121
const fb_xnnpack::XNNGraph* graph) noexcept { \
2084-
MAYBE_UNUSED(graph); \
20852122
return defineGenericReduceNode( \
20862123
subgraph_ptr, \
20872124
remapped_ids, \
20882125
node->xnode_union_as_XNN##name(), \
20892126
op_type, \
2090-
node->xnode_union_type(), \
2091-
node->debug_handle()); \
2127+
node, \
2128+
graph); \
20922129
}
20932130

20942131
/*

‎backends/xnnpack/test/ops/test_mean_dim.py‎

Lines changed: 118 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import unittest
99

1010
import torch
11-
from executorch.backends.xnnpack.test.tester import Tester
11+
from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner
12+
from executorch.backends.xnnpack.test.tester import Tester, ToEdgeTransformAndLower
13+
from executorch.export.utils import is_fbcode
1214

1315

1416
class TestMeanDim(unittest.TestCase):
@@ -30,6 +32,15 @@ def forward(self, x):
3032
z = torch.mean(y, self.dims, keepdim=self.keepdim, dtype=self.dtype)
3133
return z
3234

35+
class GraphInputMeanDim(torch.nn.Module):
36+
def __init__(self, dims, keepdim=True):
37+
super().__init__()
38+
self.dims = dims
39+
self.keepdim = keepdim
40+
41+
def forward(self, x):
42+
return torch.mean(x, self.dims, keepdim=self.keepdim)
43+
3344
class RMSNorm(torch.nn.Module):
3445
def __init__(self, dim, eps=1e-6):
3546
super().__init__()
@@ -98,22 +109,36 @@ def _test_mean_dim(
98109
dynamic_shapes=None,
99110
global_avg_pool=False,
100111
num_runs=1,
112+
module=None,
113+
xnnpack_nodes=None,
101114
):
115+
"""
116+
A bf16 mean serializes as a bf16 XNNStaticMean with no converts; the
117+
runtime computes it in fp32.
118+
"""
119+
bf16 = inputs[0].dtype == torch.bfloat16
120+
module = module or self.MeanDim(dims, keepdim=keepdim)
121+
if xnnpack_nodes is None:
122+
xnnpack_nodes = self._xnnpack_mean_nodes(global_avg_pool)
123+
if bf16:
124+
module = module.to(torch.bfloat16)
125+
xnnpack_nodes = {"XNNConvert": 0, **xnnpack_nodes}
126+
tol = 1e-2 if bf16 else 1e-3
102127
(
103-
Tester(
104-
self.MeanDim(dims, keepdim=keepdim),
105-
inputs,
106-
dynamic_shapes=dynamic_shapes,
107-
)
128+
Tester(module, inputs, dynamic_shapes=dynamic_shapes)
108129
.export()
109130
.check_count({"torch.ops.aten.mean.dim": 1})
110-
.to_edge_transform_and_lower()
131+
.to_edge_transform_and_lower(
132+
ToEdgeTransformAndLower(
133+
partitioners=[XnnpackPartitioner(enable_bf16=bf16)]
134+
)
135+
)
111136
.check_count({"torch.ops.higher_order.executorch_call_delegate": 1})
112137
.check_not(["executorch_exir_dialects_edge__ops_aten_mean_dim"])
113-
.check_xnnpack_node_count(self._xnnpack_mean_nodes(global_avg_pool))
138+
.check_xnnpack_node_count(xnnpack_nodes)
114139
.to_executorch()
115140
.serialize()
116-
.run_method_and_compare_outputs(num_runs=num_runs)
141+
.run_method_and_compare_outputs(num_runs=num_runs, atol=tol, rtol=tol)
117142
)
118143

119144
def _test_qs8_mean_dim(
@@ -342,6 +367,90 @@ def test_conv_mean_meets_nhwc(self):
342367
.run_method_and_compare_outputs(atol=2e-2 if quantize else 1e-3)
343368
)
344369

370+
@unittest.skipIf(is_fbcode(), "skipping for fbcode")
371+
def test_bf16_mean_dim_last_dim_3d(self):
372+
inputs = (torch.randn(2, 64, 128).to(torch.bfloat16),)
373+
self._test_mean_dim(inputs, dims=(-1,))
374+
375+
@unittest.skipIf(is_fbcode(), "skipping for fbcode")
376+
def test_bf16_mean_dim_middle_dim_keepdim_false(self):
377+
inputs = (torch.randn(2, 64, 128).to(torch.bfloat16),)
378+
self._test_mean_dim(inputs, dims=(1,), keepdim=False)
379+
380+
@unittest.skipIf(is_fbcode(), "skipping for fbcode")
381+
def test_bf16_mean_dim_global_avg_pool_shape(self):
382+
"""XNNPACK has no bf16 Global Average Pooling."""
383+
for shape in ((1, 16, 32, 32), (1, 5, 4, 4)):
384+
with self.subTest(shape=shape):
385+
inputs = (torch.randn(*shape).to(torch.bfloat16),)
386+
self._test_mean_dim(inputs)
387+
388+
@unittest.skipIf(is_fbcode(), "skipping for fbcode")
389+
def test_bf16_mean_dim_channels_last_input(self):
390+
"""A channels_last input is copied to NCHW for the static mean."""
391+
inputs = (
392+
torch.randn(1, 16, 8, 8)
393+
.to(torch.bfloat16)
394+
.to(memory_format=torch.channels_last),
395+
)
396+
self._test_mean_dim(
397+
inputs,
398+
module=self.GraphInputMeanDim((-1, -2)),
399+
xnnpack_nodes={
400+
"XNNGlobalAvgPooling2d": 0,
401+
"XNNStaticMean": 1,
402+
"XNNStaticTranspose": 1,
403+
},
404+
)
405+
406+
@unittest.skipIf(is_fbcode(), "skipping for fbcode")
407+
def test_bf16_mean_dim_small(self):
408+
for shape in ((1, 1, 64), (2, 7, 16)):
409+
with self.subTest(shape=shape):
410+
inputs = (torch.randn(*shape).to(torch.bfloat16),)
411+
self._test_mean_dim(inputs, dims=(-1,))
412+
413+
@unittest.skipIf(is_fbcode(), "skipping for fbcode")
414+
def test_bf16_mean_dim_all_dims(self):
415+
inputs = (torch.randn(2, 7, 16).to(torch.bfloat16),)
416+
for keepdim in (True, False):
417+
with self.subTest(keepdim=keepdim):
418+
self._test_mean_dim(inputs, dims=None, keepdim=keepdim)
419+
420+
@unittest.skipIf(is_fbcode(), "skipping for fbcode")
421+
def test_bf16_mean_dim_of_graph_input(self):
422+
inputs = (torch.randn(2, 64, 128).to(torch.bfloat16),)
423+
self._test_mean_dim(inputs, module=self.GraphInputMeanDim((-1,)))
424+
425+
@unittest.skipIf(is_fbcode(), "skipping for fbcode")
426+
def test_bf16_mean_dim_dynamic(self):
427+
seq = torch.export.Dim("seq", min=1, max=32)
428+
for dims in ((-1,), (1,)):
429+
with self.subTest(dims=dims):
430+
inputs = (torch.randn(2, 7, 16).to(torch.bfloat16),)
431+
self._test_mean_dim(
432+
inputs, dims=dims, dynamic_shapes=({1: seq},), num_runs=5
433+
)
434+
435+
@unittest.skipIf(is_fbcode(), "skipping for fbcode")
436+
def test_bf16_rms_norm(self):
437+
inputs = (torch.randn(2, 64, 128).to(torch.bfloat16),)
438+
# The one convert casts the eps constant to bf16.
439+
self._test_mean_dim(
440+
inputs,
441+
module=self.RMSNorm(128),
442+
xnnpack_nodes={"XNNStaticMean": 1, "XNNConvert": 1},
443+
)
444+
445+
def test_bf16_mean_dim_not_partitioned_without_enable_bf16(self):
446+
inputs = (torch.randn(2, 64, 128).to(torch.bfloat16),)
447+
(
448+
Tester(self.MeanDim((-1,)).to(torch.bfloat16), inputs)
449+
.export()
450+
.to_edge_transform_and_lower()
451+
.check_count({"executorch_exir_dialects_edge__ops_aten_mean_dim": 1})
452+
)
453+
345454
def test_fp16_mean_dim_dtype_argument(self):
346455
"""
347456
An fp16 mean with an fp32 dtype passes the generic dtype checks, so

0 commit comments

Comments
 (0)