Skip to content

Commit 6a44f59

Browse files
authored
Add file-backed serialization support (#21626)
This PR adds file-backed serialization support to ExecuTorch, allowing large named-data blobs to be hashed, deduplicated, and written in chunks without loading them entirely into memory. It also introduces backend hooks for controlling program copying and AOTI weight materialization while preserving existing behavior by default.
1 parent 7811c69 commit 6a44f59

9 files changed

Lines changed: 253 additions & 38 deletions

File tree

backends/aoti/aoti_backend.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,23 @@ def codesign_so(cls, so_path: str, compile_specs: List[CompileSpec]) -> None:
112112
"""
113113
return
114114

115+
@classmethod
116+
def load_weights_blob(
117+
cls, blob_path: str, compile_specs: List[CompileSpec]
118+
) -> tuple[Any, str]:
119+
"""Load an AOTI weights blob and return its data and SHA-256 digest."""
120+
with open(blob_path, "rb") as f:
121+
blob_data = f.read()
122+
os.remove(blob_path)
123+
return blob_data, hashlib.sha256(blob_data).hexdigest()
124+
125+
@classmethod
126+
def materialize_weights_blob(
127+
cls, paths: Any, compile_specs: List[CompileSpec]
128+
) -> Any:
129+
"""Materialize backend-specific weight outputs into an AOTI blob."""
130+
return paths
131+
115132
@classmethod
116133
def move_program_to_device(
117134
cls,
@@ -257,6 +274,8 @@ def preprocess(
257274
edge_program_module, tuple(user_input_placeholders), options=options
258275
)
259276

277+
paths = cls.materialize_weights_blob(paths, compile_specs)
278+
260279
if len(missing_fallback_kernels) > 0:
261280
formatted_kernels = "\n - ".join(sorted(missing_fallback_kernels))
262281
method_name = cls.method_name_from_compile_specs(compile_specs)
@@ -290,9 +309,7 @@ def preprocess(
290309
with open(so_path, "rb") as f:
291310
so_data = f.read()
292311

293-
# Read weights blob
294-
with open(blob_path, "rb") as f:
295-
blob_data = f.read()
312+
blob_data, weights_blob_hash = cls.load_weights_blob(blob_path, compile_specs)
296313

297314
# Create named data store
298315
named_data_store = NamedDataStore()
@@ -301,7 +318,7 @@ def preprocess(
301318
# keys (a method-name-only key collides). Runtime recovers them from
302319
# processed_bytes below.
303320
so_blob_key = hashlib.sha256(so_data).hexdigest() + "_so_blob"
304-
weights_blob_key = hashlib.sha256(blob_data).hexdigest() + "_weights_blob"
321+
weights_blob_key = weights_blob_hash + "_weights_blob"
305322

306323
named_data_store.add_named_data(so_blob_key, so_data, 1, None)
307324
# Determine whether to save named data externally based on backend setting
@@ -314,7 +331,6 @@ def preprocess(
314331

315332
# Clean up the generated files
316333
os.remove(so_path)
317-
os.remove(blob_path)
318334

319335
# Release device memory held by tensors that ``move_to_device_pass``
320336
# placed on the target device. Default impl is a no-op; concrete

exir/_serialize/_cord.py

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,88 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7+
import hashlib
78
import io
9+
import os
10+
import shutil
11+
import tempfile
12+
import weakref
813
from typing import List, Optional, Union
914

1015

16+
class FileBackedData:
17+
"""A byte buffer that stays on disk until explicitly closed."""
18+
19+
_COPY_CHUNK_SIZE = 8 * 1024 * 1024
20+
21+
def __init__(self, path: str, cleanup: bool = False) -> None:
22+
self._path = path
23+
self._size = os.path.getsize(path)
24+
self._sha256: Optional[bytes] = None
25+
self._finalizer = (
26+
weakref.finalize(self, self._remove, path) if cleanup else None
27+
)
28+
29+
@staticmethod
30+
def _remove(path: str) -> None:
31+
try:
32+
os.remove(path)
33+
except OSError:
34+
pass
35+
36+
@classmethod
37+
def move_from(cls, path: str) -> "FileBackedData":
38+
"""Take ownership of ``path`` without loading its contents."""
39+
directory = os.path.dirname(path) or "."
40+
fd, owned_path = tempfile.mkstemp(
41+
prefix=".executorch_", suffix=".data", dir=directory
42+
)
43+
os.close(fd)
44+
try:
45+
os.replace(path, owned_path)
46+
except Exception:
47+
os.remove(owned_path)
48+
raise
49+
return cls(owned_path, cleanup=True)
50+
51+
def __len__(self) -> int:
52+
return self._size
53+
54+
def prefix(self, size: int) -> bytes:
55+
with open(self._path, "rb") as f:
56+
return f.read(size)
57+
58+
def sha256(self) -> bytes:
59+
if self._sha256 is None:
60+
digest = hashlib.sha256()
61+
with open(self._path, "rb") as f:
62+
while chunk := f.read(self._COPY_CHUNK_SIZE):
63+
digest.update(chunk)
64+
self._sha256 = digest.digest()
65+
return self._sha256
66+
67+
def to_bytes(self) -> bytes:
68+
with open(self._path, "rb") as f:
69+
return f.read()
70+
71+
def write_to_file(self, outfile: io.BufferedIOBase) -> None:
72+
with open(self._path, "rb") as f:
73+
shutil.copyfileobj(f, outfile, length=self._COPY_CHUNK_SIZE)
74+
75+
def close(self) -> None:
76+
if self._finalizer is not None:
77+
self._finalizer()
78+
79+
def __enter__(self) -> "FileBackedData":
80+
return self
81+
82+
def __exit__(self, exc_type, exc_value, traceback) -> None:
83+
self.close()
84+
85+
86+
CordBuffer = Union[bytes, FileBackedData]
87+
88+
1189
class Cord:
1290
"""A `bytes`-like sequence of bytes, stored non-contiguously.
1391
@@ -16,9 +94,9 @@ class Cord:
1694
`bytes` or `bytearray` object.
1795
"""
1896

19-
def __init__(self, data: Optional[Union[bytes, "Cord"]] = None) -> None:
97+
def __init__(self, data: Optional[Union[CordBuffer, "Cord"]] = None) -> None:
2098
"""Initialize Cord data structure."""
21-
self._buffers: List[bytes] = []
99+
self._buffers: List[CordBuffer] = []
22100
self._byte_size: int = 0
23101

24102
if data is not None:
@@ -30,20 +108,28 @@ def __len__(self):
30108

31109
def __bytes__(self) -> bytes:
32110
"""Return the contents of the Cord as a single `bytes` object."""
33-
return b"".join(self._buffers)
111+
return b"".join(
112+
item if isinstance(item, bytes) else item.to_bytes()
113+
for item in self._buffers
114+
)
34115

35-
def append(self, data: Union[bytes, "Cord"]) -> None:
116+
def append(self, data: Union[CordBuffer, "Cord"]) -> None:
36117
"""Append a bytes or Cord to the current Cord."""
37-
if isinstance(data, bytes):
118+
if isinstance(data, (bytes, FileBackedData)):
38119
self._buffers.append(data)
39120
self._byte_size += len(data)
40121
elif isinstance(data, Cord):
41122
self._buffers.extend(data._buffers)
42123
self._byte_size += len(data)
43124
else:
44-
raise TypeError(f"Can only append bytes or Cords, received {type(data)}")
125+
raise TypeError(
126+
f"Can only append bytes, FileBackedData, or Cords, received {type(data)}"
127+
)
45128

46129
def write_to_file(self, outfile: io.BufferedIOBase) -> None:
47130
"""Write the Cord to a file."""
48131
for item in self._buffers:
49-
outfile.write(item)
132+
if isinstance(item, bytes):
133+
outfile.write(item)
134+
else:
135+
item.write_to_file(outfile)

exir/_serialize/_named_data_store.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Dict, List, Optional, Tuple, Union
1212

1313
import torch
14+
from executorch.exir._serialize._cord import CordBuffer, FileBackedData
1415
from executorch.exir._serialize.data_serializer import DataEntry
1516
from executorch.exir.tensor_layout import TensorLayout
1617

@@ -47,7 +48,7 @@ class NamedDataStoreOutput:
4748
from {filename: {key: DataEntry}}.
4849
"""
4950

50-
buffers: List[bytes]
51+
buffers: List[CordBuffer]
5152
pte_data: Dict[str, DataEntry]
5253
external_data: Dict[str, Dict[str, DataEntry]]
5354

@@ -68,7 +69,7 @@ class NamedDataStore:
6869
"""
6970

7071
# List of unique blobs.
71-
buffers: List[bytes]
72+
buffers: List[CordBuffer]
7273
# Named data stored inside the PTE file. Map of {key: DataEntry}.
7374
pte_data: Dict[str, DataEntry]
7475
# Named data stored outside of the PTE file.
@@ -93,17 +94,29 @@ def __init__(self) -> None:
9394
self.buffer_sha256 = {}
9495
self.key_to_buffer_idx = {}
9596

97+
@staticmethod
98+
def _sha256(data: CordBuffer) -> bytes:
99+
if isinstance(data, FileBackedData):
100+
return data.sha256()
101+
return hashlib.sha256(data).digest()
102+
103+
@staticmethod
104+
def _prefix(data: CordBuffer, size: int) -> bytes:
105+
if isinstance(data, FileBackedData):
106+
return data.prefix(size)
107+
return data[:size]
108+
96109
def _get_buffer_sha256(self, buffer_idx: int) -> bytes:
97110
sha = self.buffer_sha256.get(buffer_idx)
98111
if sha is None:
99-
sha = hashlib.sha256(self.buffers[buffer_idx]).digest()
112+
sha = self._sha256(self.buffers[buffer_idx])
100113
self.buffer_sha256[buffer_idx] = sha
101114
return sha
102115

103116
def _add_named_data_to_map(
104117
self,
105118
key: str,
106-
data: bytes,
119+
data: CordBuffer,
107120
alignment: int,
108121
local_key_to_buffer_idx: Dict[str, DataEntry],
109122
tensor_layout: Optional[TensorLayout] = None,
@@ -127,7 +140,9 @@ def _add_named_data_to_map(
127140
# Check if the key exists.
128141
buffer_idx = self.key_to_buffer_idx.get(key, -1)
129142
if buffer_idx != -1:
130-
if data != self.buffers[buffer_idx]:
143+
if len(data) != len(self.buffers[buffer_idx]) or self._sha256(
144+
data
145+
) != self._get_buffer_sha256(buffer_idx):
131146
raise ValueError(
132147
f"Duplicate key {key} with different data. "
133148
f"Existing data size: {len(self.buffers[buffer_idx])} bytes. "
@@ -136,10 +151,10 @@ def _add_named_data_to_map(
136151
else:
137152
# Two-level dedup: cheap fingerprint rejects non-matches fast,
138153
# SHA-256 confirms matches without full byte comparison.
139-
fingerprint = (len(data), data[:32])
154+
fingerprint = (len(data), self._prefix(data, 32))
140155
candidates = self.fingerprint_to_buffer_idx.get(fingerprint)
141156
if candidates is not None:
142-
new_sha = hashlib.sha256(data).digest()
157+
new_sha = self._sha256(data)
143158
for candidate in candidates:
144159
if new_sha == self._get_buffer_sha256(candidate):
145160
buffer_idx = candidate
@@ -162,7 +177,7 @@ def _add_named_data_to_map(
162177
def add_named_data(
163178
self,
164179
key: str,
165-
data: Union[bytes, torch.Tensor],
180+
data: Union[bytes, FileBackedData, torch.Tensor],
166181
alignment: Optional[int] = 1,
167182
external_tag: Optional[str] = None,
168183
tensor_layout: Optional[TensorLayout] = None,
@@ -171,7 +186,8 @@ def add_named_data(
171186
Adds a named blob to the NamedDataStore.
172187
Args:
173188
key (str): key associated with the data.
174-
data (Union[bytes, torch.Tensor]): Union of bytes, or torch.Tensor to serialize. Note: if a tensor is passed, it must have contiguous memory layout. The tensor_layout will be inferred from the tensor and should not be passed in.
189+
data: Bytes, file-backed data, or a torch.Tensor to serialize. If a
190+
tensor is passed, its layout is inferred.
175191
alignment (int): alignment for bytes to be serialized with.
176192
external (Optional[str]): the external filename that this data is saved to.
177193
tensor_layout (Optional[TensorLayout]): layout of the tensor, if applicable.
@@ -194,8 +210,10 @@ def add_named_data(
194210
)
195211
tensor_layout = real_tensor_layout
196212
byte_data = _tensor_to_bytes(data)
197-
else:
213+
elif isinstance(data, (bytes, FileBackedData)):
198214
byte_data = data
215+
else:
216+
raise TypeError(f"Unsupported named data type: {type(data)}")
199217

200218
if external_tag is None:
201219
self._add_named_data_to_map(

exir/_serialize/data_serializer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from dataclasses import dataclass
33
from typing import Dict, Optional, Sequence
44

5-
from executorch.exir._serialize._cord import Cord
5+
from executorch.exir._serialize._cord import Cord, CordBuffer
66
from executorch.exir.tensor_layout import TensorLayout
77

88

@@ -36,7 +36,7 @@ class DataPayload:
3636
key_to_data: a map from unique keys to serializable data.
3737
"""
3838

39-
buffers: Sequence[bytes]
39+
buffers: Sequence[CordBuffer]
4040
named_data: Dict[str, DataEntry]
4141

4242

exir/_serialize/test/test_cord.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77

88
import io
9+
import os
10+
import tempfile
911
import unittest
1012

11-
from executorch.exir._serialize._cord import Cord
13+
from executorch.exir._serialize._cord import Cord, FileBackedData
1214

1315

1416
class TestCord(unittest.TestCase):
@@ -61,3 +63,22 @@ def test_cord_write_to_file(self) -> None:
6163
outfile = io.BytesIO()
6264
cord.write_to_file(outfile)
6365
self.assertEqual(b"HelloWorld", outfile.getvalue())
66+
67+
def test_file_backed_data(self) -> None:
68+
with tempfile.TemporaryDirectory() as directory:
69+
source_path = os.path.join(directory, "source.bin")
70+
with open(source_path, "wb") as f:
71+
f.write(b"FileBacked")
72+
73+
with FileBackedData.move_from(source_path) as data:
74+
self.assertFalse(os.path.exists(source_path))
75+
self.assertEqual(10, len(data))
76+
77+
cord = Cord(b"Prefix")
78+
cord.append(data)
79+
outfile = io.BytesIO()
80+
cord.write_to_file(outfile)
81+
self.assertEqual(b"PrefixFileBacked", outfile.getvalue())
82+
self.assertEqual(b"PrefixFileBacked", bytes(cord))
83+
84+
self.assertEqual([], os.listdir(directory))

0 commit comments

Comments
 (0)