Skip to content

Commit a439afd

Browse files
authored
Optimize CUDA export host memory usage (#21617)
This PR reduces peak host memory usage during CUDA export by streaming and file-backing serialized weight data instead of materializing large in-memory copies. For Gemma 4 31B Q4_K_M, cold-start peak host memory decreased from 110.7 GB to 54.9 GB (50.35%), with identical output and no increase in GPU VRAM usage.
1 parent 6a44f59 commit a439afd

3 files changed

Lines changed: 233 additions & 3 deletions

File tree

backends/cuda/cuda_backend.py

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77

88
import contextlib
9+
import copy
10+
import ctypes
11+
import functools
12+
import gc
913
import logging
1014
import os
1115
import shutil
@@ -25,6 +29,7 @@
2529
from executorch.backends.cuda.triton.replacement_pass import (
2630
ReplaceEdgeOpWithTritonOpPass,
2731
)
32+
from executorch.exir._serialize._cord import FileBackedData
2833
from executorch.exir._warnings import experimental
2934
from executorch.exir.backend.backend_details import BackendDetails
3035
from executorch.exir.backend.compile_spec_schema import CompileSpec
@@ -60,6 +65,14 @@ def _is_cpu_clone_active() -> bool:
6065
return getattr(_CPU_CLONE_GUARD, "active", False)
6166

6267

68+
def _trim_host_memory() -> None:
69+
gc.collect()
70+
try:
71+
ctypes.CDLL(None).malloc_trim(0)
72+
except AttributeError:
73+
pass
74+
75+
6376
def _full_zeros_preserving_strides(x: torch.Tensor, device) -> torch.Tensor:
6477
"""Allocate a zero-filled tensor matching ``x``'s size/stride/dtype on ``device``.
6578
@@ -82,18 +95,29 @@ def _is_emptied(x) -> bool:
8295
)
8396

8497

98+
def _tensor_properties_for_low_memory(tensor, original):
99+
if _is_emptied(tensor):
100+
return None
101+
return original(tensor)
102+
103+
85104
@contextlib.contextmanager
86105
def _compile_time_cpu_clones(target_device: torch.device):
87106
"""Force AOTI's mutated-buffer clones onto CPU while preserving the
88107
serialized constants' target device."""
89-
from torch._inductor import compile_fx as _cfx, graph as _graph
108+
from torch._inductor import (
109+
codecache as _codecache,
110+
compile_fx as _cfx,
111+
graph as _graph,
112+
)
90113
from torch._inductor.codegen.cpp_wrapper_cpu import CppWrapperCpu as _Cpp
91114
from torch._inductor.graph import GraphLowering as _GL
92115

93116
orig_clone = _cfx.clone_preserve_strides
94117
orig_codegen_device = _Cpp.codegen_device
95118
orig_get_const = _GL.get_original_value_of_constant
96119
orig_is_same = _graph.is_same_tensor
120+
orig_tensor_properties = _codecache.TensorProperties
97121

98122
def _is_same_skip_emptied(data, value):
99123
# KV buffers freed via resize_(0) all have data_ptr 0, so the stock
@@ -152,6 +176,9 @@ def _codegen_device_target_aware(self, device):
152176
_Cpp.codegen_device = _codegen_device_target_aware
153177
_GL.get_original_value_of_constant = _get_const_synthesize_zeros
154178
_graph.is_same_tensor = _is_same_skip_emptied
179+
_codecache.TensorProperties = functools.partial(
180+
_tensor_properties_for_low_memory, original=orig_tensor_properties
181+
)
155182
prev_active = getattr(_CPU_CLONE_GUARD, "active", False)
156183
_CPU_CLONE_GUARD.active = True
157184
try:
@@ -162,6 +189,7 @@ def _codegen_device_target_aware(self, device):
162189
_Cpp.codegen_device = orig_codegen_device
163190
_GL.get_original_value_of_constant = orig_get_const
164191
_graph.is_same_tensor = orig_is_same
192+
_codecache.TensorProperties = orig_tensor_properties
165193

166194

167195
def _is_kv_buffer(name, v) -> bool:
@@ -270,6 +298,38 @@ def _on_off_compile_spec_value(spec: CompileSpec) -> bool:
270298
return value == "ON"
271299

272300

301+
def _write_aoti_weights_blob(weights, blob_path: str) -> None:
302+
_trim_host_memory()
303+
tensors = [tensor for tensor, _ in weights.values()]
304+
all_cuda = all(tensor.is_cuda for tensor in tensors)
305+
chunk_size = 8 * 1024 * 1024
306+
307+
with open(blob_path, "wb") as output:
308+
for tensor in tensors:
309+
if tensor.is_mkldnn:
310+
raise RuntimeError("MKLDNN constants are not supported by CUDA AOTI")
311+
storage = tensor.untyped_storage()
312+
nbytes = storage.nbytes()
313+
if nbytes and tensor.is_cuda:
314+
byte_tensor = torch.empty(
315+
0, dtype=torch.uint8, device=tensor.device
316+
).set_(storage, 0, (nbytes,), (1,))
317+
for offset in range(0, nbytes, chunk_size):
318+
cpu_chunk = byte_tensor[offset : offset + chunk_size].cpu()
319+
output.write(memoryview(cpu_chunk.numpy()))
320+
del byte_tensor, cpu_chunk
321+
elif nbytes:
322+
raw_array = (ctypes.c_ubyte * nbytes).from_address(storage.data_ptr())
323+
raw_view = memoryview(raw_array).cast("B")
324+
for offset in range(0, nbytes, chunk_size):
325+
output.write(raw_view[offset : offset + chunk_size])
326+
del raw_view, raw_array
327+
if not all_cuda and (padding := (-nbytes) % 64):
328+
output.write(bytes(padding))
329+
del storage
330+
_trim_host_memory()
331+
332+
273333
@final
274334
@experimental(
275335
"This API and all of cuda backend related functionality are experimental."
@@ -384,6 +444,56 @@ def save_data_externally(cls) -> bool:
384444
"""
385445
return True
386446

447+
@classmethod
448+
def load_weights_blob(
449+
cls, blob_path: str, compile_specs: List[CompileSpec]
450+
) -> tuple[Any, str]:
451+
if not cls._is_low_memory_mode(compile_specs):
452+
return super().load_weights_blob(blob_path, compile_specs)
453+
blob_data = FileBackedData.move_from(blob_path)
454+
return blob_data, blob_data.sha256().hex()
455+
456+
@classmethod
457+
def materialize_weights_blob(
458+
cls, paths: Any, compile_specs: List[CompileSpec]
459+
) -> Any:
460+
if not cls._is_low_memory_mode(compile_specs) or not isinstance(paths, list):
461+
return paths
462+
463+
from torch.export.pt2_archive._package_weights import Weights
464+
465+
weights = [path for path in paths if isinstance(path, Weights)]
466+
if not weights:
467+
return paths
468+
if len(weights) != 1:
469+
raise RuntimeError(
470+
f"Expected one CUDA AOTI weights output, got {len(weights)}"
471+
)
472+
473+
so_path = next(
474+
path
475+
for path in paths
476+
if isinstance(path, str) and path.endswith(".wrapper.so")
477+
)
478+
blob_path = os.path.splitext(so_path)[0] + "_weights.blob"
479+
_write_aoti_weights_blob(weights[0], blob_path)
480+
return [path for path in paths if not isinstance(path, Weights)] + [blob_path]
481+
482+
@classmethod
483+
def copy_exported_program_for_preprocess(
484+
cls, edge_program, compile_specs: List[CompileSpec]
485+
):
486+
if not cls._is_low_memory_mode(compile_specs):
487+
return copy.deepcopy(edge_program)
488+
489+
tensor_memo = {
490+
id(tensor): tensor
491+
for values in (edge_program.state_dict, edge_program.constants)
492+
for tensor in values.values()
493+
if isinstance(tensor, torch.Tensor)
494+
}
495+
return copy.deepcopy(edge_program, tensor_memo)
496+
387497
@classmethod
388498
def get_supported_fallback_kernels(cls) -> Dict[str, Any]:
389499
return {
@@ -459,7 +569,9 @@ def get_aoti_compile_options(
459569
"aot_inductor.package": True,
460570
"aot_inductor.package_constants_in_so": False,
461571
# Store weight constants on disk in a binary blob
462-
"aot_inductor.package_constants_on_disk_format": "binary_blob",
572+
"aot_inductor.package_constants_on_disk_format": cls._weights_format(
573+
compile_specs
574+
),
463575
# Enable maximum automatic tuning for optimal performance
464576
"max_autotune": True,
465577
# Use TRITON for GEMM (General Matrix Multiply) operations tuning only to avoid using operators in libtorch
@@ -594,6 +706,7 @@ def _combined():
594706
stack.enter_context(
595707
_compile_time_cpu_clones(torch.device(cls.get_device_name()))
596708
)
709+
_trim_host_memory()
597710
yield
598711

599712
return _combined()
@@ -606,6 +719,14 @@ def _is_low_memory_mode(compile_specs: List[CompileSpec]) -> bool:
606719
return spec.value.decode("utf-8").upper() == "ON"
607720
return False
608721

722+
@classmethod
723+
def _weights_format(cls, compile_specs: List[CompileSpec]) -> str:
724+
return (
725+
"pickle_weights"
726+
if cls._is_low_memory_mode(compile_specs)
727+
else "binary_blob"
728+
)
729+
609730
@classmethod
610731
def move_program_to_device(
611732
cls,

backends/cuda/tests/test_cuda_export.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
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 os
8+
import tempfile
79
import unittest
810
from typing import Tuple
911

@@ -130,9 +132,23 @@ def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
130132
inputs = (torch.randn(3, 4), torch.randn(3, 4))
131133

132134
# Test export
133-
edge_program_manager = self._export_to_cuda_with_lower(module, inputs)
135+
edge_program_manager = self._export_to_cuda_with_lower(
136+
module,
137+
inputs,
138+
[
139+
CudaBackend.generate_method_name_compile_spec("forward"),
140+
CompileSpec("low_memory_mode", b"ON"),
141+
],
142+
)
134143
self.assertIsNotNone(edge_program_manager, "Simple add operation export failed")
135144

145+
et_program = edge_program_manager.to_executorch()
146+
with tempfile.TemporaryDirectory() as output_dir:
147+
et_program.write_tensor_data_to_file(output_dir)
148+
ptd_path = os.path.join(output_dir, "aoti_cuda_blob.ptd")
149+
self.assertTrue(os.path.isfile(ptd_path))
150+
self.assertGreater(os.path.getsize(ptd_path), 0)
151+
136152
def test_conv2d(self):
137153
"""Test CUDA export for 2D convolution."""
138154

backends/cuda/tests/test_cuda_partitioner.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,112 @@
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 operator
9+
import os
10+
import tempfile
811
import unittest
912
from typing import Tuple
1013

1114
import torch
15+
from executorch.backends.cuda.cuda_backend import CudaBackend
1216
from executorch.backends.cuda.cuda_partitioner import CudaPartitioner
17+
from executorch.exir._serialize._cord import FileBackedData
18+
from executorch.exir.backend.compile_spec_schema import CompileSpec
1319
from executorch.exir.backend.partitioner import PartitionResult
1420
from executorch.exir.delegate import executorch_call_delegate
1521
from torch._export.utils import is_buffer, is_lifted_tensor_constant, is_param
1622
from torch.export import export
23+
from torch.export.pt2_archive._package_weights import TensorProperties, Weights
1724
from torch.fx.passes.utils.fuser_utils import validate_partition
1825

1926

27+
class TestCudaWeightsBlob(unittest.TestCase):
28+
def test_low_memory_blob_stays_file_backed(self) -> None:
29+
data = b"cuda weights"
30+
with tempfile.TemporaryDirectory() as directory:
31+
path = os.path.join(directory, "weights.blob")
32+
with open(path, "wb") as f:
33+
f.write(data)
34+
35+
blob, digest = CudaBackend.load_weights_blob(
36+
path, [CompileSpec("low_memory_mode", b"ON")]
37+
)
38+
39+
self.assertIsInstance(blob, FileBackedData)
40+
self.assertEqual(hashlib.sha256(data).hexdigest(), digest)
41+
self.assertEqual(data, blob.to_bytes())
42+
self.assertFalse(os.path.exists(path))
43+
44+
def test_default_blob_behavior_is_unchanged(self) -> None:
45+
data = b"cuda weights"
46+
with tempfile.TemporaryDirectory() as directory:
47+
path = os.path.join(directory, "weights.blob")
48+
with open(path, "wb") as f:
49+
f.write(data)
50+
51+
blob, digest = CudaBackend.load_weights_blob(path, [])
52+
53+
self.assertIsInstance(blob, bytes)
54+
self.assertEqual(data, blob)
55+
self.assertEqual(hashlib.sha256(data).hexdigest(), digest)
56+
self.assertFalse(os.path.exists(path))
57+
58+
def test_low_memory_weights_are_streamed_in_binary_blob_format(self) -> None:
59+
first = torch.tensor([1, 2, 3], dtype=torch.int16)
60+
second = torch.tensor([4, 5], dtype=torch.int32)
61+
weights = Weights(
62+
{
63+
"first": (first, TensorProperties(first)),
64+
"second": (second, TensorProperties(second)),
65+
}
66+
)
67+
68+
with tempfile.TemporaryDirectory() as directory:
69+
so_path = os.path.join(directory, "model.wrapper.so")
70+
paths = CudaBackend.materialize_weights_blob(
71+
[so_path, weights], [CompileSpec("low_memory_mode", b"ON")]
72+
)
73+
blob_path = os.path.join(directory, "model.wrapper_weights.blob")
74+
75+
self.assertEqual([so_path, blob_path], paths)
76+
with open(blob_path, "rb") as blob:
77+
data = blob.read()
78+
expected = (
79+
bytes(first.untyped_storage())
80+
+ bytes(58)
81+
+ bytes(second.untyped_storage())
82+
+ bytes(56)
83+
)
84+
self.assertEqual(expected, data)
85+
86+
def test_low_memory_program_copy_shares_tensor_storage(self) -> None:
87+
module = torch.nn.Linear(4, 3)
88+
program = export(module, (torch.randn(2, 4),), strict=True)
89+
90+
copied = CudaBackend.copy_exported_program_for_preprocess(
91+
program, [CompileSpec("low_memory_mode", b"ON")]
92+
)
93+
94+
self.assertIsNot(program, copied)
95+
self.assertIsNot(program.graph_module, copied.graph_module)
96+
self.assertIs(program.state_dict["weight"], copied.state_dict["weight"])
97+
copied._state_dict["weight"] = torch.nn.Parameter(torch.zeros(3, 4))
98+
self.assertFalse(torch.count_nonzero(program.state_dict["weight"]) == 0)
99+
100+
def test_default_program_copy_has_independent_tensor_storage(self) -> None:
101+
module = torch.nn.Linear(4, 3)
102+
program = export(module, (torch.randn(2, 4),), strict=True)
103+
104+
copied = CudaBackend.copy_exported_program_for_preprocess(program, [])
105+
106+
self.assertIsNot(program.state_dict["weight"], copied.state_dict["weight"])
107+
self.assertNotEqual(
108+
program.state_dict["weight"].data_ptr(),
109+
copied.state_dict["weight"].data_ptr(),
110+
)
111+
112+
20113
class TestCudaPartitioner(unittest.TestCase):
21114
"""
22115
Test CUDA partitioner functionality.

0 commit comments

Comments
 (0)