Skip to content

Commit 9dcefcf

Browse files
authored
Support integer and boolean CPU tensors (#24191)
Summary: CPU partitioning and delegate I/O required every tensor to be FP32, leaving indices, masks and integer outputs outside the delegate. Accept static uint8, int8, int16, int32, int64 and bool tensors, and validate runtime I/O against the serialized dtype. Existing allocation and ET kernel routes preserve the values without conversion through FP32. Integer delegation also exposes `index.Tensor`/`index_put`, whose indices are `Tensor?[]`. Native serialization emits a plain tensor list when every index is present, but ET's kernels unbox a list of optional tensors. Mark those Reference routes with `optional_tensor_lists` so the CPU runtime binds optional lists for them; the shared serializer and the other Native engines are unchanged. Add exact runtime regressions for integer precision, indices, mixed outputs, constants and optional index lists. Dynamic shapes/scalars and quantized execution remain separate. The Native schema and storage contract do not change. Authored with AI assistance using Codex and Claude Code. Reviewed By: digantdesai Differential Revision: D123332329 Pull Request resolved: #24191
1 parent e821f4c commit 9dcefcf

10 files changed

Lines changed: 208 additions & 27 deletions

File tree

‎backends/cpu/BUCK‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,16 @@ fbcode_target(
5757
"//executorch/extension/pybindings:portable_lib", # @manual
5858
],
5959
)
60+
61+
fbcode_target(
62+
_kind = runtime.python_test,
63+
name = "test_runtime",
64+
srcs = ["test/test_runtime.py"],
65+
preload_deps = [":cpu_backend"],
66+
deps = [
67+
":lib",
68+
"//caffe2:torch",
69+
"//executorch/exir:lib",
70+
"//executorch/extension/pybindings:portable_lib",
71+
],
72+
)

‎backends/cpu/README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
The initial workload is pretrained ConvNeXt-Tiny in FP32 on a Linux server.
77
The registered `CPURecipeType.FP32` (`cpu_fp32`) Recipe retains a semantic Native
88
graph inside one `CpuBackend` delegate in a regular ExecuTorch `.pte`.
9-
This first delegate version supports pure operations on statically shaped,
10-
contiguous FP32 tensors.
9+
The partitioner supports pure operations on statically shaped, contiguous FP32,
10+
uint8, int8, int16, int32, int64 and boolean tensors. Integer and boolean operations
11+
use the existing ET kernel routes, subject to each kernel's operator and dtype
12+
support. Runtime-computed scalar arguments and symbolic tensor shapes remain
13+
unsupported. Ordinary integer tensors do not imply quantized execution support.
1114

1215
The `cpu_delegate_version` compile spec stores `CPU_DELEGATE_VERSION` as a
1316
four-byte little-endian unsigned integer. The loader accepts explicitly
@@ -44,7 +47,7 @@ allocation. Sequential steps share the maximum declared scratch capacity. XNNPAC
4447
shares compatible convolution-weight conversions within a plan and borrows weights
4548
whose physical order already matches.
4649

47-
For a loaded method, shapes, FP32 dtype, contiguous layout, threadpool identity,
50+
For a loaded method, shapes, tensor dtypes, contiguous layout, threadpool identity,
4851
thread count and ISA capabilities remain fixed. Input/output pointers may change
4952
and are validated on every invocation. Changing an execution property requires
5053
reloading the method; failed validation executes no provider. The trace includes

‎backends/cpu/partitioner.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,19 @@
2323

2424
# Serialized as a little-endian uint32. Bump for incompatible artifact contracts.
2525
CPU_DELEGATE_VERSION = 1
26+
CPU_SUPPORTED_DTYPES = (
27+
torch.float32,
28+
torch.uint8,
29+
torch.int8,
30+
torch.int16,
31+
torch.int32,
32+
torch.int64,
33+
torch.bool,
34+
)
2635

2736

2837
class CPUSemanticOperators(OperatorSupportBase):
29-
"""Pure tensor operations representable by the initial static FP32
30-
delegate version.
38+
"""Pure static tensor operations on FP32, integer and boolean values.
3139
3240
Runtime providers determine implementation coverage. An unresolved operation
3341
is reported by name when the CPU runtime prepares the graph.
@@ -64,7 +72,7 @@ def is_node_supported(
6472
if any(related_node.meta.get("val") is None for related_node in related_nodes):
6573
return False
6674
return all(
67-
value.dtype == torch.float32
75+
value.dtype in CPU_SUPPORTED_DTYPES
6876
and all(isinstance(size, int) for size in value.shape)
6977
and value.is_contiguous()
7078
for related_node in related_nodes
@@ -74,7 +82,7 @@ def is_node_supported(
7482

7583

7684
class CPUPartitioner(Partitioner):
77-
"""Retain the static FP32 semantic graph for runtime provider selection."""
85+
"""Retain the static semantic graph for runtime provider selection."""
7886

7987
def ops_to_not_decompose(self, ep: torch.export.ExportedProgram):
8088
support = CPUSemanticOperators()

‎backends/cpu/preprocess.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
from copy import deepcopy
88

99
import torch
10-
from executorch.backends.cpu.partitioner import CPU_DELEGATE_VERSION
10+
from executorch.backends.cpu.partitioner import (
11+
CPU_DELEGATE_VERSION,
12+
CPU_SUPPORTED_DTYPES,
13+
)
1114
from executorch.backends.native.serialization import serialize_graph
1215
from executorch.exir._serialize._named_data_store import NamedDataStore
1316
from executorch.exir.backend.backend_details import (
@@ -35,7 +38,7 @@ def _validate_boundary_layouts(graph_module: torch.fx.GraphModule) -> None:
3538

3639

3740
class CpuBackend(BackendDetails):
38-
"""Serialize provider-neutral Native semantics with contiguous FP32 storage.
41+
"""Serialize provider-neutral Native semantics with contiguous tensor storage.
3942
4043
The delegate version fixes 64-byte alignment and 64 initialized readable
4144
tail bytes. Named constants include the tail in their recorded buffer capacity.
@@ -73,9 +76,9 @@ def preprocess(
7376
)
7477
data = NamedDataStore()
7578
for name, tensor in constants.items():
76-
if tensor.dtype != torch.float32 or tensor.device.type != "cpu":
79+
if tensor.dtype not in CPU_SUPPORTED_DTYPES or tensor.device.type != "cpu":
7780
raise ValueError(
78-
f"CpuBackend requires FP32 CPU constant {name}, "
81+
f"CpuBackend requires supported CPU constant {name}, "
7982
f"got dtype {tensor.dtype} on {tensor.device}"
8083
)
8184
if not tensor.is_contiguous():

‎backends/cpu/runtime/CPUBackend.cpp‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class CPUExecution {
6767
ET_CHECK_OR_RETURN_ERROR(
6868
spec.kind == ptn::OutputKind::UserOutput,
6969
NotSupported,
70-
"CpuBackend static FP32 delegate does not support mutation");
70+
"CpuBackend static delegate does not support mutation");
7171
}
7272
buffers_.resize(graph.values.size());
7373
bytes_.reserve(graph.values.size());
@@ -171,7 +171,8 @@ class CPUExecution {
171171
const auto& tensor = args[index]->toTensor();
172172
const auto& expected = method_->graph.value(id).tensor_meta();
173173
ET_CHECK_OR_RETURN_ERROR(
174-
tensor.scalar_type() == aten::ScalarType::Float &&
174+
tensor.scalar_type() ==
175+
static_cast<aten::ScalarType>(expected.dtype) &&
175176
tensor.dim() == expected.sizes.size() &&
176177
std::equal(
177178
expected.sizes.begin(),

‎backends/cpu/runtime/KernelProvider.cpp‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// LICENSE file in the root directory of this source tree.
66

77
#include <executorch/backends/cpu/runtime/KernelProvider.h>
8+
#include <executorch/backends/native/runtime/graph/ScalarType.h>
89

910
#include <algorithm>
1011
#include <cstring>
@@ -90,10 +91,16 @@ Result<size_t> tensor_bytes(const ptn::Value& value) {
9091
value.name.c_str());
9192
const auto& meta = value.tensor_meta();
9293
ET_CHECK_OR_RETURN_ERROR(
93-
meta.dtype == ptn::ScalarType::Float && meta.is_contiguous() &&
94-
meta.sizes.size() <= 16,
94+
(meta.dtype == ptn::ScalarType::Float ||
95+
meta.dtype == ptn::ScalarType::Byte ||
96+
meta.dtype == ptn::ScalarType::Char ||
97+
meta.dtype == ptn::ScalarType::Short ||
98+
meta.dtype == ptn::ScalarType::Int ||
99+
meta.dtype == ptn::ScalarType::Long ||
100+
meta.dtype == ptn::ScalarType::Bool) &&
101+
meta.is_contiguous() && meta.sizes.size() <= 16,
95102
NotSupported,
96-
"CPU static delegate requires contiguous FP32 rank <= 16: %s",
103+
"CPU static delegate requires supported dtype and contiguous rank <= 16: %s",
97104
value.name.c_str());
98105
size_t elements = 1;
99106
for (auto dimension = meta.sizes.rbegin(); dimension != meta.sizes.rend();
@@ -107,7 +114,13 @@ Result<size_t> tensor_bytes(const ptn::Value& value) {
107114
value.name.c_str());
108115
elements *= size;
109116
}
110-
return elements * sizeof(float);
117+
const size_t element_size = ptn::element_size(meta.dtype);
118+
ET_CHECK_OR_RETURN_ERROR(
119+
elements <= SIZE_MAX / element_size,
120+
NotSupported,
121+
"CPU tensor byte size overflows: %s",
122+
value.name.c_str());
123+
return elements * element_size;
111124
}
112125

113126
Result<StorageRequirements> KernelImplementation::requirements(

‎backends/cpu/runtime/KernelProvider.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ class KernelImplementation {
131131
return false;
132132
}
133133
/// Pure metadata query, before compile can read constants or allocate
134-
/// storage. The default declares contiguous FP32 storage for every region
134+
/// storage. The default declares contiguous tensor storage for every region
135135
/// boundary.
136136
virtual runtime::Result<StorageRequirements> requirements(
137137
const KernelRegion& region,

‎backends/cpu/runtime/providers/reference/ReferenceProvider.cpp‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ struct KernelSpec {
2929
bool tensor_list_output = false;
3030
// Schema positions declared float or float?, rather than Scalar.
3131
std::string_view float_args{};
32+
// Tensor-list inputs are declared Tensor?[]. The serializer emits a plain
33+
// tensor list when every entry is present, but the kernel unboxes optionals.
34+
bool optional_tensor_lists = false;
3235
};
3336

3437
aten::ScalarType to_aten(ptn::ScalarType dtype) {
@@ -296,8 +299,22 @@ constexpr auto kKernels = std::to_array<KernelSpec>({
296299
{"torch.ops.aten.gt.Scalar", "aten::gt.Scalar_out", 2, 1, nullptr},
297300
{"torch.ops.aten.gt.Tensor", "aten::gt.Tensor_out", 2, 1, nullptr},
298301
{"torch.ops.aten.hardtanh.default", "aten::hardtanh.out", 3, 1, nullptr},
299-
{"torch.ops.aten.index.Tensor", "aten::index.Tensor_out", 2, 1, nullptr},
300-
{"torch.ops.aten.index_put.default", "aten::index_put.out", 4, 1, nullptr},
302+
{"torch.ops.aten.index.Tensor",
303+
"aten::index.Tensor_out",
304+
2,
305+
1,
306+
nullptr,
307+
false,
308+
{},
309+
true},
310+
{"torch.ops.aten.index_put.default",
311+
"aten::index_put.out",
312+
4,
313+
1,
314+
nullptr,
315+
false,
316+
{},
317+
true},
301318
{"torch.ops.aten.index_select.default",
302319
"aten::index_select.out",
303320
3,
@@ -1037,11 +1054,16 @@ class ReferenceExecutable final : public Executable {
10371054
if (input.arg.kind() == ptn::ArgKind::TensorList ||
10381055
input.arg.kind() == ptn::ArgKind::OptionalTensorList) {
10391056
auto argument = std::make_unique<ArgumentStorage>();
1040-
const bool optional =
1057+
const bool serialized_optional =
10411058
input.arg.kind() == ptn::ArgKind::OptionalTensorList;
1042-
const auto& ids = optional ? input.arg.as_optional_tensor_list().ids
1043-
: input.arg.as_tensor_list().ids;
1044-
const auto error = bind_tensor_list(ids, optional, false, *argument);
1059+
const auto& ids = serialized_optional
1060+
? input.arg.as_optional_tensor_list().ids
1061+
: input.arg.as_tensor_list().ids;
1062+
const auto error = bind_tensor_list(
1063+
ids,
1064+
serialized_optional || spec->optional_tensor_lists,
1065+
false,
1066+
*argument);
10451067
if (error != Error::Ok) {
10461068
return error;
10471069
}

‎backends/cpu/test/test_aot.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ def test_preprocess_rejects_unsupported_constants(self):
274274
)
275275
with self.assertRaisesRegex(
276276
ValueError,
277-
f"CpuBackend requires FP32 CPU constant weight, "
277+
f"CpuBackend requires supported CPU constant weight, "
278278
f"got dtype {dtype} on {device}",
279279
):
280280
CpuBackend.preprocess(
@@ -404,7 +404,7 @@ def test_getitem_validates_output_metadata(self):
404404
node.meta["val"] = producer.meta["val"][0]
405405
support = CPUSemanticOperators()
406406
self.assertTrue(support.is_node_supported({}, node))
407-
for val in (None, 1, (), torch.ones(2, dtype=torch.int32)):
407+
for val in (None, 1, (), torch.ones(2, dtype=torch.float64)):
408408
with self.subTest(val=val):
409409
node.meta["val"] = val
410410
self.assertFalse(support.is_node_supported({}, node))
@@ -414,7 +414,7 @@ class Add(torch.nn.Module):
414414
def forward(self, x):
415415
return x + x
416416

417-
ep = torch.export.export(Add(), (torch.ones(2, dtype=torch.int32),))
417+
ep = torch.export.export(Add(), (torch.ones(2, dtype=torch.float64),))
418418
edge = to_edge_transform_and_lower(ep, partitioner=[CPUPartitioner()])
419419
self.assertEqual(
420420
len(edge.to_executorch().executorch_program.execution_plan[0].delegates), 0

‎backends/cpu/test/test_runtime.py‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
from __future__ import annotations
8+
9+
import unittest
10+
from contextlib import contextmanager
11+
12+
import torch
13+
from executorch.backends.cpu.partitioner import CPUPartitioner
14+
from executorch.exir import to_edge_transform_and_lower
15+
from executorch.extension.pybindings.portable_lib import (
16+
_load_for_executorch_from_buffer,
17+
)
18+
19+
20+
class CPURuntimeTest(unittest.TestCase):
21+
@contextmanager
22+
def _lower(self, model, inputs):
23+
edge = to_edge_transform_and_lower(
24+
torch.export.export(model, inputs), partitioner=[CPUPartitioner()]
25+
)
26+
program = edge.to_executorch()
27+
plan = program.executorch_program.execution_plan[0]
28+
self.assertEqual([delegate.id for delegate in plan.delegates], ["CpuBackend"])
29+
self.assertEqual(plan.operators, [])
30+
# The runtime borrows the serialized program buffer.
31+
yield _load_for_executorch_from_buffer(program.buffer)
32+
33+
def test_integer_inputs_and_outputs_preserve_precision(self):
34+
class Add(torch.nn.Module):
35+
def forward(self, x, y):
36+
return x + y
37+
38+
for dtype, values in (
39+
(torch.uint8, [0, 128, 250]),
40+
(torch.int8, [-100, 0, 100]),
41+
(torch.int16, [-30000, 0, 30000]),
42+
(torch.int32, [-(2**24 + 1), 0, 2**24 + 1]),
43+
(torch.int64, [-(2**40 + 3), 0, 2**40 + 3]),
44+
):
45+
with self.subTest(dtype=dtype):
46+
model = Add()
47+
x = torch.tensor(values, dtype=dtype)
48+
y = torch.ones_like(x)
49+
with self._lower(model, (x, y)) as runtime:
50+
for inputs in ((x, y), (y, x)):
51+
actual = runtime.forward(inputs)[0]
52+
torch.testing.assert_close(
53+
actual, model(*inputs), rtol=0, atol=0
54+
)
55+
56+
def test_integer_indices_and_mixed_outputs(self):
57+
class Select(torch.nn.Module):
58+
def forward(self, x, indices):
59+
selected = torch.index_select(x, 1, indices)
60+
positive = selected > 0
61+
return (
62+
torch.where(positive, selected, -selected),
63+
selected.argmax(dim=1),
64+
positive,
65+
)
66+
67+
model = Select()
68+
x = torch.tensor([[-3.0, 5.0, 2.0], [7.0, -2.0, -4.0]])
69+
for dtype in (torch.int32, torch.int64):
70+
with self.subTest(dtype=dtype):
71+
indices = torch.tensor([2, 0], dtype=dtype)
72+
with self._lower(model, (x, indices)) as runtime:
73+
for current in (indices, indices.flip(0)):
74+
actual = runtime.forward((x, current))
75+
for output, expected in zip(
76+
actual, model(x, current), strict=True
77+
):
78+
torch.testing.assert_close(output, expected, rtol=0, atol=0)
79+
80+
def test_boolean_inputs_and_integer_constants(self):
81+
class MaskedAdd(torch.nn.Module):
82+
def __init__(self):
83+
super().__init__()
84+
self.register_buffer("offset", torch.tensor([2**40 + 3, -7]))
85+
86+
def forward(self, x, mask):
87+
return torch.where(mask, x + self.offset, x), torch.logical_not(mask)
88+
89+
model = MaskedAdd()
90+
x = torch.tensor([2**40 + 1, -(2**40 + 5)])
91+
mask = torch.tensor([True, False])
92+
with self._lower(model, (x, mask)) as runtime:
93+
for current in (mask, ~mask):
94+
actual = runtime.forward((x, current))
95+
for output, expected in zip(actual, model(x, current), strict=True):
96+
torch.testing.assert_close(output, expected, rtol=0, atol=0)
97+
98+
def test_optional_integer_index_lists(self):
99+
class Index(torch.nn.Module):
100+
def __init__(self, accumulate):
101+
super().__init__()
102+
self.accumulate = accumulate
103+
104+
def forward(self, x, indices, values):
105+
updated = torch.index_put(x, (indices,), values, self.accumulate)
106+
return updated, updated[indices], updated[:, indices]
107+
108+
x = torch.arange(9, dtype=torch.float32).reshape(3, 3)
109+
indices = torch.tensor([0, 2])
110+
values = torch.tensor([[10.0, 20.0, 30.0], [-1.0, -2.0, -3.0]])
111+
inputs = (x, indices, values)
112+
for accumulate in (False, True):
113+
with self.subTest(accumulate=accumulate):
114+
model = Index(accumulate)
115+
with self._lower(model, inputs) as runtime:
116+
actual = runtime.forward(inputs)
117+
for output, expected in zip(actual, model(*inputs), strict=True):
118+
torch.testing.assert_close(output, expected, rtol=0, atol=0)

0 commit comments

Comments
 (0)