Skip to content

Commit a24c2a7

Browse files
authored
[executorch][cuda] Write a weight storage the methods share only once
Stack from [ghstack](https://github.com/ezyang/ghstack/tree/0.15.0) (oldest at bottom): * #23787 * #23740 * #23739 * __->__ #23738 * #23737 * #23736 A multi-method CUDA export compiles every method against one `CudaWeightCollector`, so a storage the methods share (all of the model's weights, for a batching export) is stored under one `cuda_fqn_weight:<device>:<FQN>` key. But every method's `materialize` still copied each storage from the GPU to the host in 8 MB chunks, SHA-256'd it on one core, and wrote it to a temp file; only then did the `NamedDataStore` drop the duplicate. For an 18 GB model that is ~90 s per method (measured on 2 GiB: copy 1.9 s, SHA-256 5.9 s, write 1.3 s), all of it thrown away after the first method. The collector now remembers a fingerprint of each CUDA storage it has written, by storage key. A later method's storage under the same key is fingerprinted on the GPU first; if it matches, its write is skipped and the method's manifest points at the stored entry. Otherwise it is written and added exactly as before, so a changed storage still fails with the store's "Duplicate key ... with different data". The fingerprint is the storage's size, two independent random linear hashes of its 8-byte words (int64, chunk order kept significant) and its trailing bytes: ~0.8 s for 18 GB on an A100, and a single flipped bit changes it. Library-local constants (`_tensor_constant*`, keyed per shared library), off-graph KV entries and CPU storages keep the existing path. Full Muse Glimmer 30B batching export (`export_solo_batching.py`, 10 methods; A100; empty Triton and Inductor caches), stacked on D124496888: | | before | this diff | |---|---|---| | total | 135.1 min | 125.7 min | | main thread in `cuda_weight_collector` | ~21.4 min | ~7.7 min | | per method after the first (AOTI), min | 13.2 / 13.3 / 9.7 / 12.3 / 14.5 / 19.0 / 10.6 / 2.6 | 13.6 / 12.1 / 8.3 / 11.3 / 13.2 / 17.0 / 8.9 / 1.0 | (forward_2 .. forward_64, forward_others, sample. forward_1 writes everything once, as before.) Most of what is left in later methods is the collector's `trim_host_memory` (`gc.collect` + `malloc_trim`). The weight file is byte-identical with and without this diff (`aoti_cuda_blob.ptd`, 17,856,903,936 bytes, same SHA-256), and so is every method's weight manifest; only the methods' compiled libraries differ, as between any two cold exports. The export generates the same tokens (greedy, a 512-token prompt and 4 x 2k prompts) at the same speed (62.5 vs 62.7 tok/s; 110.0 vs 111.3). Differential Revision: D124713681 Pull Request resolved: #23738
1 parent ab952dc commit a24c2a7

3 files changed

Lines changed: 240 additions & 7 deletions

File tree

‎backends/cuda/cuda_weight_collector.py‎

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
import struct
1313
import tempfile
1414
import threading
15-
from dataclasses import dataclass, replace
16-
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
15+
from dataclasses import dataclass, field, replace
16+
from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
1717

1818
import torch
1919
from executorch.backends.cuda.passes.lower_offgraph_kv import OFFGRAPH_KV_FQN_PREFIX
@@ -45,6 +45,9 @@ class CudaWeightEntry:
4545
class CudaWeightArtifact:
4646
entries: List[CudaWeightEntry]
4747
storages: Dict[str, FileBackedData]
48+
# Storage keys an earlier method of this collector already stored with the
49+
# same bytes; they are not written again.
50+
stored: Set[str] = field(default_factory=set)
4851

4952

5053
@dataclass(frozen=True)
@@ -126,6 +129,22 @@ def _materialize_storage(tensor: torch.Tensor, directory: str) -> FileBackedData
126129
raise
127130

128131

132+
def _storage_bytes(tensor: torch.Tensor) -> torch.Tensor:
133+
storage = tensor.untyped_storage()
134+
return torch.empty(0, dtype=torch.uint8, device=tensor.device).set_(
135+
storage, 0, (storage.nbytes(),), (1,)
136+
)
137+
138+
139+
def _same_cuda_storage(first: torch.Tensor, second: torch.Tensor) -> bool:
140+
"""Whether two CUDA tensors' whole storages hold the same bytes, compared on the device."""
141+
if first.device != second.device:
142+
return False
143+
if first.untyped_storage().nbytes() != second.untyped_storage().nbytes():
144+
return False
145+
return torch.equal(_storage_bytes(first), _storage_bytes(second))
146+
147+
129148
def _required_view_nbytes(
130149
fqn: str,
131150
sizes: Tuple[int, ...],
@@ -365,6 +384,11 @@ class CudaWeightCollector:
365384
def __init__(self) -> None:
366385
self._store = NamedDataStore()
367386
self._results: List[PreprocessResult] = []
387+
# The CUDA storages already written to the store, by storage key. Methods
388+
# share their weights, so a later method's identical storage is compared
389+
# with the first one on the device and not written again. The weights
390+
# must not change until finish() releases them.
391+
self._stored_storages: Dict[str, torch.Tensor] = {}
368392

369393
@contextlib.contextmanager
370394
def capture(
@@ -397,6 +421,7 @@ def materialize(
397421
trim_host_memory()
398422
entries: List[CudaWeightEntry] = []
399423
storages: Dict[str, FileBackedData] = {}
424+
stored: Set[str] = set()
400425

401426
for fqn, (tensor, properties) in weights.items():
402427
is_offgraph_kv = _is_offgraph_kv_fqn(fqn)
@@ -429,11 +454,22 @@ def materialize(
429454

430455
storage_key = _storage_key(fqn, device_type)
431456
if not is_offgraph_kv:
432-
data = _materialize_storage(tensor, directory)
433-
if storage_key in storages:
434-
data.close()
457+
if storage_key in storages or storage_key in stored:
435458
raise RuntimeError(f"Duplicate CUDA FQN weight key for {fqn!r}")
436-
storages[storage_key] = data
459+
# Library-local constants are keyed per shared library, so
460+
# only FQN weights can match an earlier method's storage.
461+
shareable = tensor.is_cuda and not _is_aoti_library_local_fqn(fqn)
462+
first = self._stored_storages.get(storage_key)
463+
if (
464+
shareable
465+
and first is not None
466+
and _same_cuda_storage(first, tensor)
467+
):
468+
stored.add(storage_key)
469+
else:
470+
storages[storage_key] = _materialize_storage(tensor, directory)
471+
if shareable:
472+
self._stored_storages.setdefault(storage_key, tensor)
437473

438474
sizes = tuple(
439475
int(size) for size in getattr(properties, "shape", tensor.shape)
@@ -478,7 +514,7 @@ def materialize(
478514
)
479515

480516
trim_host_memory()
481-
return CudaWeightArtifact(entries=entries, storages=storages)
517+
return CudaWeightArtifact(entries=entries, storages=storages, stored=stored)
482518

483519
def _merge_aoti_data(
484520
self,
@@ -564,6 +600,11 @@ def add_preprocess_result(
564600
)
565601
if data is not None:
566602
self._add_weight(entry, data, external_tag)
603+
elif entry.storage_key in artifact.stored:
604+
if entry.storage_key not in self._store.key_to_buffer_idx:
605+
raise RuntimeError(
606+
f"CUDA FQN weight {entry.fqn!r} was reused but never stored"
607+
)
567608
elif not _is_offgraph_kv_fqn(entry.fqn):
568609
raise RuntimeError(f"Missing CUDA FQN weight storage for {entry.fqn!r}")
569610
serialized_entries.append(entry)
@@ -578,3 +619,4 @@ def finish(self) -> None:
578619
shared_output = self._store.get_named_data_store_output()
579620
for result in self._results:
580621
result.data_store_output = shared_output
622+
self._stored_storages.clear()

‎backends/cuda/tests/targets.bzl‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,26 @@ def define_common_targets(is_fbcode = False):
306306
],
307307
)
308308

309+
python_unittest_remote_gpu(
310+
name = "test_cuda_weight_reuse",
311+
srcs = [
312+
"test_cuda_weight_reuse.py",
313+
],
314+
visibility = [
315+
"//executorch/...",
316+
],
317+
deps = [
318+
"//caffe2:torch",
319+
"//executorch/backends/cuda:cuda_backend",
320+
"//executorch/exir:lib",
321+
],
322+
keep_gpu_sections = True,
323+
remote_execution = re_test_utils.remote_execution(
324+
platform = "gpu-remote-execution",
325+
subplatform = "A100-exclusive",
326+
),
327+
)
328+
309329
python_unittest(
310330
name = "test_cuda_partitioner",
311331
srcs = [
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
"""CudaWeightCollector writes a weight storage the methods share only once."""
8+
9+
import tempfile
10+
import unittest
11+
from unittest import mock
12+
13+
import torch
14+
from executorch.backends.cuda import cuda_weight_collector
15+
from executorch.backends.cuda.cuda_weight_collector import (
16+
AOTI_DEVICE_TYPE_CUDA,
17+
CudaWeightCollector,
18+
decode_cuda_aoti_metadata,
19+
)
20+
from executorch.exir._serialize._named_data_store import NamedDataStore
21+
from executorch.exir.backend.backend_details import PreprocessResult
22+
from torch.export.pt2_archive._package_weights import TensorProperties, Weights
23+
24+
25+
def _weights(**tensors: torch.Tensor) -> Weights:
26+
return Weights({fqn: (t, TensorProperties(t)) for fqn, t in tensors.items()})
27+
28+
29+
def _parent_result(so_key: str) -> PreprocessResult:
30+
store = NamedDataStore()
31+
store.add_named_data(so_key, so_key.encode())
32+
store.add_named_data("empty_weights", b"", external_tag="aoti_cuda_blob")
33+
return PreprocessResult(
34+
processed_bytes=f"{so_key}\nempty_weights".encode(),
35+
data_store_output=store.get_named_data_store_output(),
36+
)
37+
38+
39+
def _as_bytes(buffer) -> bytes:
40+
return buffer if isinstance(buffer, bytes) else buffer.to_bytes()
41+
42+
43+
class CudaWeightReuseTest(unittest.TestCase):
44+
def setUp(self) -> None:
45+
if not torch.cuda.is_available():
46+
self.skipTest("CUDA required")
47+
self.directory = tempfile.TemporaryDirectory()
48+
self.addCleanup(self.directory.cleanup)
49+
50+
def _method(self, collector, so_key, weights):
51+
"""One method's preprocess: materialize, then add to the shared store."""
52+
artifact = collector.materialize(
53+
weights, self.directory.name, lambda _: AOTI_DEVICE_TYPE_CUDA
54+
)
55+
result = _parent_result(so_key)
56+
collector.add_preprocess_result(result, artifact, "cuda")
57+
return artifact, result
58+
59+
def test_a_storage_shared_by_methods_is_written_once(self) -> None:
60+
weight = torch.randn(1024, 33, device="cuda")
61+
bias = torch.arange(7, dtype=torch.int8, device="cuda")
62+
collector = CudaWeightCollector()
63+
writes = []
64+
materialize_storage = cuda_weight_collector._materialize_storage
65+
66+
def counting(tensor, directory):
67+
writes.append(tensor.untyped_storage().nbytes())
68+
return materialize_storage(tensor, directory)
69+
70+
with mock.patch.object(cuda_weight_collector, "_materialize_storage", counting):
71+
first, first_result = self._method(
72+
collector, "so-0", _weights(w=weight, b=bias)
73+
)
74+
# Another method's copies: same bytes, other tensors.
75+
second, second_result = self._method(
76+
collector, "so-1", _weights(w=weight.clone(), b=bias.clone())
77+
)
78+
collector.finish()
79+
self.assertEqual(len(writes), 2)
80+
self.assertEqual(set(first.storages), {e.storage_key for e in first.entries})
81+
self.assertEqual(second.storages, {})
82+
self.assertEqual(second.stored, {e.storage_key for e in second.entries})
83+
for entry in first.entries:
84+
self.assertIn(entry.storage_key, collector._store.key_to_buffer_idx)
85+
first_manifest = decode_cuda_aoti_metadata(first_result.processed_bytes)
86+
second_manifest = decode_cuda_aoti_metadata(second_result.processed_bytes)
87+
self.assertEqual(second_manifest.entries, first_manifest.entries)
88+
89+
def test_the_stored_bytes_match_writing_every_method(self) -> None:
90+
tensors = {
91+
"w": torch.randn(4096, 129, device="cuda"),
92+
"odd": torch.arange(13, dtype=torch.uint8, device="cuda"),
93+
}
94+
95+
def stored_data(reuse: bool):
96+
collector = CudaWeightCollector()
97+
same_storage = (
98+
cuda_weight_collector._same_cuda_storage
99+
if reuse
100+
# Never equal, so every method writes its storages.
101+
else (lambda first, second: False)
102+
)
103+
with mock.patch.object(
104+
cuda_weight_collector, "_same_cuda_storage", same_storage
105+
):
106+
for index in range(3):
107+
self._method(
108+
collector,
109+
f"so-{index}",
110+
_weights(**{k: v.clone() for k, v in tensors.items()}),
111+
)
112+
collector.finish()
113+
store = collector._store
114+
return {
115+
key: _as_bytes(store.buffers[index])
116+
for key, index in store.key_to_buffer_idx.items()
117+
}
118+
119+
self.assertEqual(stored_data(reuse=True), stored_data(reuse=False))
120+
121+
def test_changed_bytes_are_written_and_rejected_as_before(self) -> None:
122+
weight = torch.randn(256, 8, device="cuda")
123+
changed = weight.clone()
124+
changed[3, 5] += 1
125+
collector = CudaWeightCollector()
126+
self._method(collector, "so-0", _weights(w=weight))
127+
with self.assertRaisesRegex(ValueError, "with different data"):
128+
self._method(collector, "so-1", _weights(w=changed))
129+
130+
def test_library_local_constants_are_written_per_library(self) -> None:
131+
constant = torch.arange(5, dtype=torch.float32, device="cuda")
132+
collector = CudaWeightCollector()
133+
first, first_result = self._method(
134+
collector, "so-0", _weights(_tensor_constant0=constant)
135+
)
136+
second, second_result = self._method(
137+
collector, "so-1", _weights(_tensor_constant0=constant.clone())
138+
)
139+
self.assertEqual(len(first.storages), 1)
140+
self.assertEqual(len(second.storages), 1)
141+
self.assertEqual(second.stored, set())
142+
first_key = decode_cuda_aoti_metadata(first_result.processed_bytes).entries[0]
143+
second_key = decode_cuda_aoti_metadata(second_result.processed_bytes).entries[0]
144+
self.assertNotEqual(first_key.storage_key, second_key.storage_key)
145+
146+
def test_storage_differing_in_two_high_bits_is_rejected(self) -> None:
147+
weight = torch.arange(3 << 20, dtype=torch.int64, device="cuda")
148+
changed = weight.clone()
149+
view = changed.view(torch.uint8)
150+
view[7] ^= 0x80
151+
view[-1] ^= 0x80
152+
collector = CudaWeightCollector()
153+
self._method(collector, "so-0", _weights(w=weight))
154+
with self.assertRaisesRegex(ValueError, "with different data"):
155+
self._method(collector, "so-1", _weights(w=changed))
156+
157+
def test_same_cuda_storage_tells_one_flipped_bit_apart(self) -> None:
158+
base = torch.randint(0, 255, (3 << 20,), dtype=torch.uint8, device="cuda")
159+
same = cuda_weight_collector._same_cuda_storage
160+
self.assertTrue(same(base, base.clone()))
161+
for index in (0, 12345, base.numel() - 1):
162+
flipped = base.clone()
163+
flipped[index] ^= 1
164+
self.assertFalse(same(base, flipped), index)
165+
166+
def test_same_cuda_storage_rejects_other_sizes_and_empty_storages(self) -> None:
167+
same = cuda_weight_collector._same_cuda_storage
168+
base = torch.zeros(16, dtype=torch.uint8, device="cuda")
169+
self.assertFalse(same(base, base[:8].clone()))
170+
empty = torch.empty(0, dtype=torch.uint8, device="cuda")
171+
self.assertTrue(same(empty, empty.clone()))

0 commit comments

Comments
 (0)