Skip to content

Commit d181029

Browse files
authored
Refuse a PTE or PTD file whose schema version is newer than the runtime can read (pytorch#22114)
### Summary Every exported PTE file and every PTD data file carries a schema version. The exporters stamp it, and nothing in the runtime ever read it back. The only gate on a file was its four byte identifier, `ET12` for a program and `FT01` for data. That says "this is an ExecuTorch file" and says nothing about which shape of file it is. A file written by an exporter newer than the runtime was therefore accepted, then misread field by field, and failed later at load or during execution with an error that points somewhere else. This matters more now that the two halves can come from different builds. The wheel ships prebuilt runtime libraries, so a user can export with one installation of ExecuTorch and run with a runtime that was never built next to it. This PR reads the version in both readers, right after the root table is obtained, and refuses anything above the highest version the runtime supports. `Program::load` returns `InvalidProgram`, `FlatTensorDataMap::load` returns `InvalidExternalData`, which is what the checks around each of them return. Both name the two numbers: ``` Program schema version 1 is newer than the highest this runtime supports (0). Export the model with an older ExecuTorch, or update the runtime. ``` The comparison is "less than or equal", not "equal", because the project promises that an older file keeps working on a newer runtime, and the schemas only ever grow by appending optional fields. Only a file from the future is refused. Each new constant sits next to the class it guards, and points in a comment at the writer constants for the same number: `EXECUTORCH_SCHEMA_VERSION` for the program, `kSchemaVersion` and `_FLAT_TENSOR_VERSION` for the data file. A bump of one without the other is then easy to spot. Nothing writes a version other than zero today, so no file in the wild changes behavior. This is the reader side that a future version bump needs in order to mean anything. One limit worth stating plainly: a runtime that is already deployed has no check at all, so this protects runtimes built from this change onward. That is inherent to adding a reader. ### Test plan Four new tests, two per format. Each pair builds a minimal file in memory through the real FlatBuffer builder, one stamped at the supported version and one above it. The first is expected to load and the second to be refused. The positive test is the control: without it, a rejection could just as well come from a malformed fixture as from the version. The program test asks for the cheapest verification level, which shows the check does not depend on full verification being enabled. The data test follows the byte layout that `save_ptd()` writes, with no segments. Built and ran both suites on Linux x86_64, before and after the change: ``` cmake -S . -B cmake-out -DEXECUTORCH_BUILD_TESTS=ON \ -DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \ -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \ -DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON cmake --build cmake-out -j ./cmake-out/runtime/executor/test/program_test ./cmake-out/extension/flat_tensor/test/extension_flat_tensor_test ``` The existing tests pass unchanged in both runs, plus the four new ones. As a control, deleting either check while keeping the tests makes that format's negative test fail, with the newer file loading and reporting no error, so the check is what catches it. Binary size, measured on the two object files. `program.cpp.o` grows by 24 bytes with logging disabled and 239 bytes with logging enabled. `flat_tensor_data_map.cpp.o` grows by 32 bytes with logging disabled and 243 bytes with logging enabled.
1 parent 1afd07f commit d181029

10 files changed

Lines changed: 342 additions & 9 deletions

File tree

‎exir/version.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,12 @@
66

77
# pyre-strict
88

9+
# Stamped into Program.version of every exported PTE file. Keep in sync with
10+
# Program::kMaxSupportedSchemaVersion in //executorch/runtime/executor/program.h,
11+
# which is the highest version the C++ runtime agrees to load.
12+
#
13+
# Bump this only for a change that an older runtime would misread if it went
14+
# unnoticed: a semantic change to an existing field, or a new field the runtime
15+
# must understand to execute correctly. Purely additive optional fields stay
16+
# backward/forward compatible (see schema/README.md) and need no bump.
917
EXECUTORCH_SCHEMA_VERSION = 0

‎extension/flat_tensor/flat_tensor_data_map.cpp‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,21 @@ ET_NODISCARD Result<const char*> FlatTensorDataMap::get_key(
293293
const flat_tensor_flatbuffer::FlatTensor* flat_tensor =
294294
flat_tensor_flatbuffer::GetFlatTensor(flat_tensor_data->data());
295295

296+
// The file identifier above ("FT01") is bumped by convention only on a
297+
// backward-incompatible schema change, so it selects a schema family. The
298+
// version is the finer gate within that family: a file written by a newer
299+
// exporter is refused here instead of being misread field by field. Older
300+
// files stay loadable because the schema only grows by appending optional
301+
// fields.
302+
ET_CHECK_OR_RETURN_ERROR(
303+
flat_tensor->version() <= kMaxSupportedSchemaVersion,
304+
InvalidExternalData,
305+
"FlatTensor schema version %u is newer than the highest this runtime "
306+
"supports (%u). Export the data with an older ExecuTorch, or update the "
307+
"runtime.",
308+
flat_tensor->version(),
309+
kMaxSupportedSchemaVersion);
310+
296311
// Validate flat_tensor.
297312
ET_CHECK_OR_RETURN_ERROR(
298313
flat_tensor->named_data() != nullptr,

‎extension/flat_tensor/flat_tensor_data_map.h‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ namespace extension {
3535
class FlatTensorDataMap final
3636
: public executorch::ET_RUNTIME_NAMESPACE::NamedDataMap {
3737
public:
38+
/**
39+
* The highest data schema version that this runtime can read.
40+
*
41+
* Keep in sync with kSchemaVersion in
42+
* //executorch/extension/flat_tensor/serialize/serialize.h and with
43+
* _FLAT_TENSOR_VERSION in
44+
* //executorch/extension/flat_tensor/serialize/serialize.py, which are the
45+
* versions that the two writers stamp into every PTD file.
46+
*/
47+
static constexpr uint32_t kMaxSupportedSchemaVersion = 0;
48+
3849
/**
3950
* Creates a new DataMap that wraps FlatTensor data.
4051
*

‎extension/flat_tensor/serialize/serialize.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ namespace flat_tensor {
1919

2020
/**
2121
* Schema version of the .ptd format. Should be kept in sync with serialize.py
22+
* and with FlatTensorDataMap::kMaxSupportedSchemaVersion in
23+
* //executorch/extension/flat_tensor/flat_tensor_data_map.h, which is the
24+
* highest version the runtime agrees to load.
2225
*/
2326
constexpr uint32_t kSchemaVersion = 0;
2427

‎extension/flat_tensor/serialize/serialize.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@
4242
# Alignment of the flatbuffer (after the header).
4343
_FLATBUFFER_ALIGNMENT: int = 16
4444

45-
# Current version. Keep in sync with c++ version number in serialize.
45+
# Current version. Keep in sync with c++ version number in serialize, and with
46+
# FlatTensorDataMap::kMaxSupportedSchemaVersion in
47+
# //executorch/extension/flat_tensor/flat_tensor_data_map.h, which is the
48+
# highest version the runtime agrees to load.
4649
_FLAT_TENSOR_VERSION: int = 0
4750

4851

‎extension/flat_tensor/test/flat_tensor_data_map_test.cpp‎

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,8 @@
1818
#include <gtest/gtest.h>
1919

2020
using namespace ::testing;
21-
using executorch::extension::BufferDataLoader;
22-
using executorch::extension::FileDataLoader;
23-
using executorch::extension::FlatTensorDataMap;
24-
using executorch::runtime::DataLoader;
25-
using executorch::runtime::Error;
26-
using executorch::runtime::FreeableBuffer;
27-
using executorch::runtime::Result;
28-
using executorch::runtime::TensorLayout;
21+
using namespace executorch::extension;
22+
using namespace executorch::runtime;
2923

3024
class FlatTensorDataMapTest : public ::testing::Test {
3125
protected:
@@ -181,3 +175,94 @@ TEST_F(FlatTensorDataMapTest, LoadAndCheckSize) {
181175
FlatTensorDataMap::load(&truncated_loader);
182176
ASSERT_EQ(truncated_program.error(), Error::InvalidExternalData);
183177
}
178+
179+
namespace {
180+
181+
constexpr size_t kAlignment = 16;
182+
183+
size_t aligned_up(size_t size) {
184+
return (size + kAlignment - 1) & ~(kAlignment - 1);
185+
}
186+
187+
// Builds the smallest PTD file that FlatTensorDataMap::load() accepts, stamped
188+
// with the given schema version and holding no data. Follows the layout written
189+
// by save_ptd(): the header is embedded in the flatbuffer region, so the offset
190+
// to the root table shifts by the size of the header.
191+
std::vector<uint8_t> CreateDataWithVersion(uint32_t version) {
192+
flatbuffers::FlatBufferBuilder builder;
193+
auto flat_tensor = flat_tensor_flatbuffer::CreateFlatTensor(
194+
builder,
195+
version,
196+
builder.CreateVector(
197+
std::vector<
198+
flatbuffers::Offset<flat_tensor_flatbuffer::DataSegment>>{}),
199+
builder.CreateVector(
200+
std::vector<
201+
flatbuffers::Offset<flat_tensor_flatbuffer::NamedData>>{}));
202+
builder.Finish(flat_tensor, flat_tensor_flatbuffer::FlatTensorIdentifier());
203+
204+
const uint8_t* flatbuffer = builder.GetBufferPointer();
205+
const size_t flatbuffer_size = builder.GetSize();
206+
const size_t header_size =
207+
aligned_up(FlatTensorHeader::kHeaderExpectedLength);
208+
209+
std::vector<uint8_t> data;
210+
auto append = [&data](const void* bytes, size_t size) {
211+
const uint8_t* begin = static_cast<const uint8_t*>(bytes);
212+
data.insert(data.end(), begin, begin + size);
213+
};
214+
215+
uint32_t root_table_offset = *reinterpret_cast<const uint32_t*>(flatbuffer) +
216+
static_cast<uint32_t>(header_size);
217+
append(&root_table_offset, sizeof(root_table_offset));
218+
append(flatbuffer + sizeof(root_table_offset), 4); // File identifier.
219+
220+
append(FlatTensorHeader::kMagic, sizeof(FlatTensorHeader::kMagic));
221+
uint32_t header_length = FlatTensorHeader::kHeaderExpectedLength;
222+
append(&header_length, sizeof(header_length));
223+
uint64_t header_fields[] = {
224+
header_size, // Offset to the flatbuffer.
225+
flatbuffer_size,
226+
header_size + aligned_up(flatbuffer_size), // Offset to the segments.
227+
0, // Segment data size.
228+
};
229+
append(header_fields, sizeof(header_fields));
230+
data.resize(sizeof(root_table_offset) + 4 + header_size, 0);
231+
232+
// The first eight bytes of the flatbuffer were written above, before the
233+
// header.
234+
append(flatbuffer + 8, flatbuffer_size - 8);
235+
data.resize(header_size + aligned_up(flatbuffer_size), 0);
236+
237+
return data;
238+
}
239+
240+
} // namespace
241+
242+
TEST_F(FlatTensorDataMapTest, SupportedSchemaVersionLoads) {
243+
std::vector<uint8_t> data =
244+
CreateDataWithVersion(FlatTensorDataMap::kMaxSupportedSchemaVersion);
245+
246+
alignas(16) uint8_t aligned_buffer[512];
247+
ASSERT_LE(data.size(), sizeof(aligned_buffer));
248+
memcpy(aligned_buffer, data.data(), data.size());
249+
250+
BufferDataLoader loader(aligned_buffer, data.size());
251+
Result<FlatTensorDataMap> data_map = FlatTensorDataMap::load(&loader);
252+
253+
EXPECT_EQ(data_map.error(), Error::Ok);
254+
}
255+
256+
TEST_F(FlatTensorDataMapTest, NewerSchemaVersionFailsToLoad) {
257+
std::vector<uint8_t> data =
258+
CreateDataWithVersion(FlatTensorDataMap::kMaxSupportedSchemaVersion + 1);
259+
260+
alignas(16) uint8_t aligned_buffer[512];
261+
ASSERT_LE(data.size(), sizeof(aligned_buffer));
262+
memcpy(aligned_buffer, data.data(), data.size());
263+
264+
BufferDataLoader loader(aligned_buffer, data.size());
265+
Result<FlatTensorDataMap> data_map = FlatTensorDataMap::load(&loader);
266+
267+
EXPECT_EQ(data_map.error(), Error::InvalidExternalData);
268+
}

‎runtime/executor/program.cpp‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,21 @@ Result<executorch_flatbuffer::ExecutionPlan*> get_execution_plan(
226226
const executorch_flatbuffer::Program* flatbuffer_program =
227227
executorch_flatbuffer::GetProgram(program_data->data());
228228

229+
// The file identifier above ("ET12") is bumped by convention only on a
230+
// backward-incompatible schema change, so it selects a schema family. The
231+
// version is the finer gate within that family: a file written by a newer
232+
// exporter is refused here instead of being misread field by field. Older
233+
// files stay loadable because the schema only grows by appending optional
234+
// fields.
235+
ET_CHECK_OR_RETURN_ERROR(
236+
flatbuffer_program->version() <= kMaxSupportedSchemaVersion,
237+
InvalidProgram,
238+
"Program schema version %u is newer than the highest this runtime "
239+
"supports (%u). Export the model with an older ExecuTorch, or update "
240+
"the runtime.",
241+
flatbuffer_program->version(),
242+
kMaxSupportedSchemaVersion);
243+
229244
// Instantiate PteDataMap if named_data is present.
230245
const auto named_data = flatbuffer_program->named_data();
231246
std::optional<internal::PteDataMap> pte_data_map = std::nullopt;

‎runtime/executor/program.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ class Program final {
7575
InternalConsistency,
7676
};
7777

78+
/**
79+
* The highest program schema version that this runtime can read.
80+
*
81+
* Keep in sync with EXECUTORCH_SCHEMA_VERSION in
82+
* //executorch/exir/version.py, which is the version that the exporter stamps
83+
* into every PTE file.
84+
*/
85+
static constexpr uint32_t kMaxSupportedSchemaVersion = 0;
86+
7887
/**
7988
* Loads a Program from the provided loader. The Program will hold a pointer
8089
* to the loader, which must outlive the returned Program instance.

‎runtime/executor/test/program_test.cpp‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,96 @@ TEST_F(ProgramTest, BadMagicFailsToLoad) {
210210
}
211211
}
212212

213+
namespace {
214+
215+
// Builds the smallest program that Program::load() accepts, stamped with the
216+
// given schema version.
217+
std::vector<uint8_t> CreateProgramWithVersion(uint32_t version) {
218+
flatbuffers::FlatBufferBuilder builder(1024);
219+
220+
auto plan_name = builder.CreateString("forward");
221+
auto empty_values = builder.CreateVector(
222+
std::vector<flatbuffers::Offset<executorch_flatbuffer::EValue>>{});
223+
auto empty_inputs = builder.CreateVector(std::vector<int32_t>{});
224+
auto empty_outputs = builder.CreateVector(std::vector<int32_t>{});
225+
auto empty_chains = builder.CreateVector(
226+
std::vector<flatbuffers::Offset<executorch_flatbuffer::Chain>>{});
227+
auto empty_operators = builder.CreateVector(
228+
std::vector<flatbuffers::Offset<executorch_flatbuffer::Operator>>{});
229+
auto empty_delegates = builder.CreateVector(
230+
std::vector<
231+
flatbuffers::Offset<executorch_flatbuffer::BackendDelegate>>{});
232+
auto buffer_sizes = builder.CreateVector(std::vector<int64_t>{0});
233+
234+
auto execution_plan = executorch_flatbuffer::CreateExecutionPlan(
235+
builder,
236+
plan_name,
237+
/*container_meta_type=*/0,
238+
empty_values,
239+
empty_inputs,
240+
empty_outputs,
241+
empty_chains,
242+
empty_operators,
243+
empty_delegates,
244+
buffer_sizes);
245+
auto execution_plans = builder.CreateVector(
246+
std::vector<flatbuffers::Offset<executorch_flatbuffer::ExecutionPlan>>{
247+
execution_plan});
248+
249+
// A constant segment holding only the placeholder offset means "no
250+
// constants", which keeps this program off the deprecated constant_buffer
251+
// path that OSS builds compile out.
252+
auto constant_segment = executorch_flatbuffer::CreateSubsegmentOffsets(
253+
builder,
254+
/*segment_index=*/0,
255+
builder.CreateVector(std::vector<uint64_t>{0}));
256+
257+
auto program = executorch_flatbuffer::CreateProgram(
258+
builder,
259+
version,
260+
execution_plans,
261+
/*constant_buffer=*/0,
262+
/*backend_delegate_data=*/0,
263+
/*segments=*/0,
264+
constant_segment);
265+
builder.Finish(program, executorch_flatbuffer::ProgramIdentifier());
266+
267+
const uint8_t* data = builder.GetBufferPointer();
268+
return std::vector<uint8_t>(data, data + builder.GetSize());
269+
}
270+
271+
} // namespace
272+
273+
TEST_F(ProgramTest, SupportedSchemaVersionLoads) {
274+
std::vector<uint8_t> data =
275+
CreateProgramWithVersion(Program::kMaxSupportedSchemaVersion);
276+
277+
alignas(16) uint8_t aligned_buffer[2048];
278+
ASSERT_LE(data.size(), sizeof(aligned_buffer));
279+
memcpy(aligned_buffer, data.data(), data.size());
280+
281+
BufferDataLoader data_loader(aligned_buffer, data.size());
282+
Result<Program> program = Program::load(&data_loader, kDefaultVerification);
283+
284+
EXPECT_EQ(program.error(), Error::Ok);
285+
}
286+
287+
TEST_F(ProgramTest, NewerSchemaVersionFailsToLoad) {
288+
std::vector<uint8_t> data =
289+
CreateProgramWithVersion(Program::kMaxSupportedSchemaVersion + 1);
290+
291+
alignas(16) uint8_t aligned_buffer[2048];
292+
ASSERT_LE(data.size(), sizeof(aligned_buffer));
293+
memcpy(aligned_buffer, data.data(), data.size());
294+
295+
// Use minimal verification to show that even the cheapest level catches it.
296+
BufferDataLoader data_loader(aligned_buffer, data.size());
297+
Result<Program> program =
298+
Program::load(&data_loader, Program::Verification::Minimal);
299+
300+
EXPECT_EQ(program.error(), Error::InvalidProgram);
301+
}
302+
213303
// These tests require ET_ENABLE_PROGRAM_VERIFICATION to be enabled.
214304
// In Release builds, verification is disabled by default to save binary size.
215305
#ifndef ET_ENABLE_PROGRAM_VERIFICATION

0 commit comments

Comments
 (0)