Skip to content

Commit 7829c5a

Browse files
committed
Let each CUDA program name its own weights file
The CUDA backend writes every program's weights to a file named aoti_cuda_blob.ptd. Exporting two programs into one folder therefore makes the second overwrite the first one's weights. Since the weights are keyed by their content, the first program then fails to load instead of running with the wrong weights. Renaming each file right after its export works, because the program does not store the file name, but it is an extra step that is easy to miss. Add a data name option. A program lowered with CudaPartitioner([..., CudaBackend.generate_data_name_compile_spec("encoder")]) writes its weights to encoder.ptd. Without the option the file is still aoti_cuda_blob.ptd, so existing exports, scripts and runners are unchanged, and existing files load as before. The name becomes a file name, so it may use only up to 200 ASCII letters, digits, '_', '-' and '.'. It may not start with '.' or '-' (a leading '-' reads as an option, for example in merge_ptes --input-ptd), may not be a Windows device name such as NUL, and may not end with '.ptd'. One of the two data file writers adds '.ptd' only when it is missing, so "encoder" and "encoder.ptd" would write the same file. The check also runs where the name is used, so a hand-written compile spec cannot point outside the output folder. All methods of one program share one weights file, so they must use the same name. A backend that keeps its data in the .pte, such as Metal, refuses the option instead of ignoring it. merge_ptes ignores the name when it compares variants, like cuda_include_ptx, so variants exported with different names still merge. The CUDA backend docs describe the option, its rules, and how to load a program with its data file. The TensorRT page no longer says the name is fixed or that a clash gives wrong answers, and its tip on sharing the CUDA data file with the engines covers a custom name. A new test in test_missing_weights_blob.py exports two programs with their own names, and one with no constants, into one folder, checks the data file names, then loads and runs the first two against eager PyTorch. test_cuda_export.py checks the name rules, including non-ASCII letters, path separators, a leading '-' and a '.ptd' ending, checks that good names and the default are accepted, that a backend which keeps its data in the .pte refuses a name, and the one name per program rule. The existing merge test now merges variants with different names.
1 parent e62fd44 commit 7829c5a

9 files changed

Lines changed: 215 additions & 17 deletions

File tree

‎backends/aoti/aoti_backend.py‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,29 @@
2828

2929
class COMPILE_SPEC_KEYS(Enum):
3030
METHOD_NAME = "method_name"
31+
DATA_NAME = "data_name"
32+
33+
34+
# The name becomes a file name on every platform, so keep it short, to portable
35+
# characters, and clear of Windows device names such as NUL. Without re.ASCII,
36+
# IGNORECASE lets [A-Za-z] match letters like the Kelvin sign. A leading '-'
37+
# reads as an option on a command line. Some writers add .ptd only when it is
38+
# missing, so "encoder" and "encoder.ptd" could both write encoder.ptd.
39+
_DATA_NAME_PATTERN = re.compile(
40+
r"(?!(?:CON|PRN|AUX|NUL|COM\d|LPT\d)(?:\.|$))(?!.*\.ptd$)"
41+
r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,199}",
42+
re.IGNORECASE | re.ASCII,
43+
)
44+
45+
46+
def _checked_data_name(data_name: str) -> str:
47+
if not _DATA_NAME_PATTERN.fullmatch(data_name):
48+
raise ValueError(
49+
f"Invalid data name {data_name!r}: use up to 200 ASCII letters, "
50+
"digits, '_', '-' and '.', do not start with '.' or '-', do not end "
51+
"with '.ptd', and avoid Windows device names such as NUL"
52+
)
53+
return data_name
3154

3255

3356
@experimental(
@@ -368,7 +391,9 @@ def preprocess(
368391
# Determine whether to save named data externally based on backend setting
369392
# External: save to separate .ptd file, otherwise merge with .pte file
370393
external_tag = (
371-
f"aoti_{device_name}_blob" if cls.save_data_externally() else None
394+
cls.data_name_from_compile_specs(compile_specs)
395+
if cls.save_data_externally()
396+
else None
372397
)
373398

374399
named_data_store.add_named_data(weights_blob_key, blob_data, 1, external_tag)
@@ -404,6 +429,33 @@ def generate_method_name_compile_spec(
404429
method_name.encode("utf-8"),
405430
)
406431

432+
@classmethod
433+
def generate_data_name_compile_spec(cls, data_name: str) -> CompileSpec:
434+
"""
435+
Generate a CompileSpec that names the external data file, written as
436+
``<data_name>.ptd``, so several programs can share a folder.
437+
"""
438+
if not cls.save_data_externally():
439+
raise ValueError(
440+
f"{cls.__name__} keeps its data in the .pte file, so it takes no "
441+
"data name"
442+
)
443+
return CompileSpec(
444+
COMPILE_SPEC_KEYS.DATA_NAME.value,
445+
_checked_data_name(data_name).encode("utf-8"),
446+
)
447+
448+
@classmethod
449+
def data_name_from_compile_specs(cls, compile_specs: List[CompileSpec]) -> str:
450+
"""
451+
The external data file name, without ``.ptd``, set by
452+
generate_data_name_compile_spec, or ``aoti_<device>_blob`` by default.
453+
"""
454+
for spec in compile_specs:
455+
if spec.key == COMPILE_SPEC_KEYS.DATA_NAME.value:
456+
return _checked_data_name(spec.value.decode("utf-8"))
457+
return f"aoti_{cls.get_device_name()}_blob"
458+
407459
@classmethod
408460
def method_name_from_compile_specs(
409461
cls,

‎backends/cuda/cuda_backend.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ def _preprocess_with_weight_collector(
704704
cls.get_device_name(),
705705
target_sm=target_sm,
706706
ptx_compute=ptx_compute,
707+
external_tag=cls.data_name_from_compile_specs(compile_specs),
707708
)
708709
return result
709710

@@ -726,6 +727,17 @@ def preprocess_multimethod(
726727
compile_specs: Dict[str, List[List[CompileSpec]]],
727728
) -> Dict[str, List[PreprocessResult]]:
728729
"""Compile every method against one global ``(device, FQN)`` store."""
730+
# The methods share one weights file, so they must agree on its name.
731+
data_names = {
732+
cls.data_name_from_compile_specs(specs)
733+
for method_specs in compile_specs.values()
734+
for specs in method_specs
735+
}
736+
if len(data_names) > 1:
737+
raise ValueError(
738+
"Every method of a program must use the same data name, got "
739+
f"{sorted(data_names)}"
740+
)
729741
collector = CudaWeightCollector()
730742
results: Dict[str, List[PreprocessResult]] = {}
731743
for method_name, programs in edge_programs.items():

‎backends/cuda/cuda_weight_collector.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@ def add_preprocess_result(
570570
device_name: str,
571571
target_sm: Optional[int] = None,
572572
ptx_compute: int = 0,
573+
external_tag: Optional[str] = None,
573574
) -> None:
574575
if result.data_store_output is None:
575576
raise RuntimeError("CUDA AOTI preprocess returned no named data")
@@ -589,7 +590,7 @@ def add_preprocess_result(
589590
keep_compatibility_blob=not artifact.entries,
590591
)
591592

592-
external_tag = f"aoti_{device_name}_blob"
593+
external_tag = external_tag or f"aoti_{device_name}_blob"
593594
serialized_entries = []
594595
for entry in artifact.entries:
595596
data = artifact.storages.get(entry.storage_key)
@@ -601,7 +602,7 @@ def add_preprocess_result(
601602
),
602603
)
603604
if data is not None:
604-
# Every program writes its weights to the same file name, so the
605+
# Programs can write their weights to the same file name, so the
605606
# key carries the content: another program's file then fails to
606607
# load instead of running this program with its weights.
607608
content_key = f"{entry.storage_key}:{data.sha256().hex()}"

‎backends/cuda/merge_ptes.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ def _load_artifact(source: CudaPteInput) -> _Artifact:
208208
return _Artifact(source, pte, _named_data_bytes(pte.named_data), ptd, delegates)
209209

210210

211+
# Compile specs that only shape how a variant was exported, so variants that
212+
# differ in them still merge.
213+
_EXPORT_ONLY_SPEC_KEYS = ("cuda_include_ptx", "data_name")
214+
215+
211216
def _normalized_program(program: Program) -> Program:
212217
normalized = copy.deepcopy(program)
213218
payloads = []
@@ -219,7 +224,7 @@ def _normalized_program(program: Program) -> Program:
219224
delegate.compile_specs = [
220225
spec
221226
for spec in delegate.compile_specs
222-
if spec.key != "cuda_include_ptx"
227+
if spec.key not in _EXPORT_ONLY_SPEC_KEYS
223228
]
224229
delegate.processed = BackendDelegateDataReference(
225230
location=DataLocation.INLINE, index=len(payloads)
@@ -534,7 +539,7 @@ def _prepare_merged_output(reference: _Artifact) -> Tuple[Program, NamedDataStor
534539
delegate.compile_specs = [
535540
spec
536541
for spec in delegate.compile_specs
537-
if spec.key != "cuda_include_ptx"
542+
if spec.key not in _EXPORT_ONLY_SPEC_KEYS
538543
]
539544

540545
merged_store = NamedDataStore()

‎backends/cuda/tests/test_cuda_export.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,60 @@ def test_invalid_cuda_include_ptx_compile_spec(self):
236236
[CompileSpec(key="cuda_include_ptx", value=b"MAYBE")]
237237
)
238238

239+
def test_invalid_data_name_compile_spec(self):
240+
for name in (
241+
"",
242+
"..",
243+
".hidden",
244+
"../outside",
245+
"a/b",
246+
"a\\b",
247+
"a/../../outside",
248+
"C:model",
249+
"model?",
250+
"nul",
251+
"COM1.txt",
252+
"a" * 201,
253+
# Non-ASCII letters that IGNORECASE alone would let through.
254+
"\u212a" * 200,
255+
"\u017fizes",
256+
"\u0130d",
257+
"\u0131d",
258+
"-enc",
259+
# One writer turns "encoder.ptd" into the same file as "encoder".
260+
# The check ignores case, like the rest of the pattern.
261+
"encoder.ptd",
262+
"encoder.PTD",
263+
):
264+
with self.subTest(name=name):
265+
with self.assertRaisesRegex(ValueError, "Invalid data name"):
266+
CudaBackend.generate_data_name_compile_spec(name)
267+
with self.assertRaisesRegex(ValueError, "Invalid data name"):
268+
CudaBackend.data_name_from_compile_specs(
269+
[CompileSpec(key="data_name", value=name.encode())]
270+
)
271+
# A backend that keeps its data in the .pte would ignore the name.
272+
with patch.object(CudaBackend, "save_data_externally", return_value=False):
273+
with self.assertRaisesRegex(ValueError, "takes no data name"):
274+
CudaBackend.generate_data_name_compile_spec("encoder")
275+
276+
def test_valid_data_name_compile_spec(self):
277+
self.assertEqual(CudaBackend.data_name_from_compile_specs([]), "aoti_cuda_blob")
278+
for name in ("encoder", "model_sm80", "v1.2-final", "a" * 200, "x.ptd.bak"):
279+
with self.subTest(name=name):
280+
spec = CudaBackend.generate_data_name_compile_spec(name)
281+
self.assertEqual(CudaBackend.data_name_from_compile_specs([spec]), name)
282+
283+
def test_methods_of_one_program_must_share_a_data_name(self):
284+
def specs(name):
285+
return [[CudaBackend.generate_data_name_compile_spec(name)]]
286+
287+
with self.assertRaisesRegex(ValueError, "same data name"):
288+
CudaBackend.preprocess_multimethod(
289+
{"forward": [None], "second": [None]},
290+
{"forward": specs("first"), "second": specs("second")},
291+
)
292+
239293

240294
class TestCudaExport(unittest.TestCase):
241295
"""Test CUDA export functionality for various operations using to_edge_transform_and_lower."""

‎backends/cuda/tests/test_merge_ptes.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def _write_artifact(
5858
fqn: str = "model.weight",
5959
weight_key: str = "cuda_fqn_weight:cuda:model.weight",
6060
ptx_compute: int | None = None,
61+
data_name: str | None = None,
6162
) -> CudaPteInput:
6263
if ptx_compute is None:
6364
ptx_compute = target_sm
@@ -79,6 +80,8 @@ def _write_artifact(
7980
compile_specs.append(
8081
CompileSpec("cuda_include_ptx", b"ON" if ptx_compute else b"OFF")
8182
)
83+
if data_name is not None:
84+
compile_specs.append(CompileSpec("data_name", data_name.encode()))
8285
delegate = BackendDelegate(
8386
id="CudaBackend",
8487
processed=BackendDelegateDataReference(DataLocation.INLINE, 0),
@@ -148,8 +151,12 @@ def test_merges_variants_and_keeps_one_weight_manifest(self) -> None:
148151
sm80.mkdir()
149152
sm120.mkdir()
150153
inputs = [
151-
self._write_artifact(sm80, 80, b"sm80-so", b"shared-weight"),
152-
self._write_artifact(sm120, 120, b"sm120-so", b"shared-weight"),
154+
self._write_artifact(
155+
sm80, 80, b"sm80-so", b"shared-weight", data_name="model_sm80"
156+
),
157+
self._write_artifact(
158+
sm120, 120, b"sm120-so", b"shared-weight", data_name="model_sm120"
159+
),
153160
]
154161

155162
merged = deserialize_pte_binary(bytes(merge_cuda_pte_files(inputs)))
@@ -176,14 +183,14 @@ def test_merges_variants_and_keeps_one_weight_manifest(self) -> None:
176183
hashlib.sha256(b"sm120-so").hexdigest() + "_so_blob",
177184
},
178185
)
179-
self.assertNotIn(
180-
"cuda_include_ptx",
186+
self.assertEqual(
181187
{
182188
spec.key
183189
for spec in merged.program.execution_plan[0]
184190
.delegates[0]
185191
.compile_specs
186192
},
193+
{"method_name"},
187194
)
188195

189196
def test_uses_ptx_only_from_explicit_fallback(self) -> None:

‎backends/cuda/tests/test_missing_weights_blob.py‎

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import os
2323
import tempfile
2424
import unittest
25+
from typing import Optional
2526

2627
import torch
2728
from executorch.backends.cuda.cuda_backend import CudaBackend
@@ -51,14 +52,25 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
5152
"loads through the Python runtime, which the ROCm build does not include",
5253
)
5354
class TestMissingWeightsBlob(unittest.TestCase):
54-
def _lower(self, outdir: str, seed: int = 0, name: str = "model.pte") -> str:
55+
def _lower(
56+
self,
57+
outdir: str,
58+
seed: int = 0,
59+
name: str = "model.pte",
60+
data_name: Optional[str] = None,
61+
module: Optional[torch.nn.Module] = None,
62+
) -> str:
5563
torch.manual_seed(seed)
56-
module = ModuleWithConstants().eval().cuda()
64+
if module is None:
65+
module = ModuleWithConstants()
66+
module = module.eval().cuda()
5767
inputs = (torch.randn(4, 64, device="cuda"),)
5868
exported = export(module, inputs)
59-
spec = CudaBackend.generate_method_name_compile_spec("forward")
69+
specs = [CudaBackend.generate_method_name_compile_spec("forward")]
70+
if data_name is not None:
71+
specs.append(CudaBackend.generate_data_name_compile_spec(data_name))
6072
lowered = to_edge_transform_and_lower(
61-
exported, partitioner=[CudaPartitioner([spec])]
73+
exported, partitioner=[CudaPartitioner(specs)]
6274
)
6375
program = lowered.to_executorch()
6476
path = os.path.join(outdir, name)
@@ -177,6 +189,39 @@ def test_load_fails_with_another_programs_blob(self) -> None:
177189
):
178190
program.load_method("forward")
179191

192+
def test_programs_with_their_own_data_names_share_a_folder(self) -> None:
193+
from executorch.runtime import Runtime
194+
195+
with tempfile.TemporaryDirectory() as outdir:
196+
for seed, name in ((0, "first"), (1, "second")):
197+
self._lower(outdir, seed=seed, name=f"{name}.pte", data_name=name)
198+
# A program with no constants still writes a data file.
199+
self._lower(
200+
outdir, name="third.pte", data_name="third", module=torch.nn.ReLU()
201+
)
202+
self.assertEqual(
203+
sorted(f for f in os.listdir(outdir) if f.endswith(".ptd")),
204+
["first.ptd", "second.ptd", "third.ptd"],
205+
)
206+
for seed, name in ((0, "first"), (1, "second")):
207+
# The same draws _lower made, so the same weights and input.
208+
torch.manual_seed(seed)
209+
module = ModuleWithConstants().eval().cuda()
210+
value = torch.randn(4, 64, device="cuda")
211+
method = (
212+
Runtime.get()
213+
.load_program(
214+
os.path.join(outdir, f"{name}.pte"),
215+
data_path=os.path.join(outdir, f"{name}.ptd"),
216+
)
217+
.load_method("forward")
218+
)
219+
out = method.execute([value.cpu()])
220+
torch.testing.assert_close(
221+
(out[0] if isinstance(out, (list, tuple)) else out).cpu(),
222+
module(value).cpu(),
223+
)
224+
180225
def test_load_succeeds_when_the_model_has_no_constants(self) -> None:
181226
"""A model with nothing to bind still loads without its weights blob.
182227

‎docs/source/backends/cuda/cuda-overview.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,25 @@ save_pte_program(exec_program, model_name, "./output_dir")
8181
```
8282
This generates `.pte` and `.ptd` files that can be executed on CUDA devices.
8383

84+
The weights go to a data file named `aoti_cuda_blob.ptd`. To export several programs into one folder, give each program its own
85+
data file name. This partitioner writes `encoder.ptd`:
86+
87+
```python
88+
partitioner = CudaPartitioner([
89+
CudaBackend.generate_method_name_compile_spec("forward"),
90+
CudaBackend.generate_data_name_compile_spec("encoder"),
91+
])
92+
```
93+
94+
The name can have up to 200 ASCII letters, digits, `_`, `-` and `.`. It cannot start with `.` or `-`, cannot end with `.ptd`
95+
(the export adds it), and cannot be a Windows device name such as `NUL`. Load each program with its own data file:
96+
97+
```python
98+
from executorch.runtime import Runtime
99+
100+
program = Runtime.get().load_program("encoder.pte", data_path="encoder.ptd")
101+
```
102+
84103
For a complete working example, see the [CUDA export script](https://github.com/pytorch/executorch/blob/main/examples/cuda/scripts/export.py).
85104

86105

‎docs/source/backends/tensorrt/tensorrt-overview.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,11 @@ The Torch-TensorRT docs call such a program *coalesced*.
170170

171171
The export writes the weights of the CUDA part to a data file, `aoti_cuda_blob.ptd`, next to the
172172
`.pte`. Pass that file when you load the program: if the CUDA part has weights, loading fails
173-
without it. The file has the same name for every model, so a second export into the same directory
174-
overwrites the first one's weights, and the first model can then return wrong answers with no
175-
error. Save each model into its own directory.
173+
without it. By default the file has the same name for every model, so a second export into the
174+
same directory overwrites the first one's weights, and the first model then fails to load. To keep
175+
several models in one directory, give each its own data file name with
176+
`CudaBackend.generate_data_name_compile_spec`, as the
177+
[CUDA backend page](../cuda/cuda-overview.md) shows, or save each model into its own directory.
176178

177179
### Keeping inputs and outputs on the GPU
178180

@@ -432,7 +434,8 @@ shows how to set the options that apply at run time, from C++ or from Python.
432434
Pass `external_engine_data="engines"` to `torch_tensorrt.save` to write the engines to
433435
`engines.ptd` instead of the `.pte`, and pass that file when you load the program.
434436
The Python loader takes one data file, so for a program that also uses the CUDA backend, pass
435-
`external_engine_data="aoti_cuda_blob"` to share its file.
437+
the name of the CUDA data file to share it: `external_engine_data="aoti_cuda_blob"` by default,
438+
or the name given to `CudaBackend.generate_data_name_compile_spec`.
436439
- [**Optimization profiles.**](https://docs.pytorch.org/TensorRT/user_guide/runtime_performance/executorch.html#optimization-profiles)
437440
One engine can be tuned for several ranges of input shapes, for example one for decoding one
438441
token at a time and one for a long prompt. A C++ application picks the profile for each call

0 commit comments

Comments
 (0)