Skip to content

Commit b7ae293

Browse files
Support large model export using multi-gpu (#17990)
### Description This PR is to implemente a exporter which works for large language models(LLM). It works for models like Llama2-70b or gpt-175. The main idea is to utilize multiple-GPU and dispatch differnet layers to different GPU, in short, it symply implemented auto pipeline parallelism. For example : to export Llama2-70b, you need 8x V100-32GB or 4x A100-80G or More GPU memories. It would expect to export decoder-only models. For encoder-decoder arch-like models, we didn't test it yet. ### Motivation and Context <!-- - Why is this change required? What problem does it solve? - If it fixes an open issue, please link to the issue here. --> --------- Co-authored-by: Justin Chu <justinchuby@users.noreply.github.com>
1 parent 444a0ed commit b7ae293

1 file changed

Lines changed: 385 additions & 0 deletions

File tree

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
# --------------------------------------------------------------------------
5+
6+
"""
7+
Export LLM to onnx
8+
"""
9+
import argparse
10+
import inspect
11+
import math
12+
import os
13+
import tempfile
14+
from pathlib import Path
15+
from typing import Optional
16+
17+
import onnx
18+
import torch
19+
import transformers
20+
from torch import nn
21+
22+
23+
def disable_huggingface_init():
24+
"""do not init model twice as it slow initialization"""
25+
26+
torch.nn.init.kaiming_uniform_ = lambda x, *args, **kwargs: x
27+
torch.nn.init.uniform_ = lambda x, *args, **kwargs: x
28+
torch.nn.init.normal_ = lambda x, *args, **kwargs: x
29+
torch.nn.init.constant_ = lambda x, *args, **kwargs: x
30+
torch.nn.init.xavier_uniform_ = lambda x, *args, **kwargs: x
31+
torch.nn.init.xavier_normal_ = lambda x, *args, **kwargs: x
32+
torch.nn.init.kaiming_normal_ = lambda x, *args, **kwargs: x
33+
torch.nn.init.orthogonal_ = lambda x, *args, **kwargs: x
34+
35+
36+
def get_model_parameter_size(model: nn.Module):
37+
"""to calculate how much memory this model needs"""
38+
param_size = 0
39+
param_sum = 0
40+
for param in model.parameters():
41+
param_size += param.nelement() * param.element_size()
42+
param_sum += param.nelement()
43+
buffer_size = 0
44+
buffer_sum = 0
45+
for buffer in model.buffers():
46+
buffer_size += buffer.nelement() * buffer.element_size()
47+
buffer_sum += buffer.nelement()
48+
all_size = (param_size + buffer_size) / 1024 / 1024
49+
return all_size
50+
51+
52+
def initialize_model_and_sample_inputs(hf_model: str, cache_dir: Optional[str], tokenizer=None):
53+
"""
54+
get the pretrained torch model from hugginface,
55+
and sample model-inputs
56+
"""
57+
58+
disable_huggingface_init()
59+
60+
model = transformers.AutoModelForCausalLM.from_pretrained( # type: ignore
61+
hf_model, torch_dtype=torch.float16, cache_dir=cache_dir, trust_remote_code=True
62+
)
63+
if tokenizer is None:
64+
tokenizer = hf_model
65+
tokenizer = transformers.AutoTokenizer.from_pretrained(tokenizer) # type: ignore
66+
67+
sample_inputs = tuple(tokenizer("Hello, my dog is cute", return_tensors="pt").values())
68+
return model, sample_inputs
69+
70+
71+
def auto_pipeline_parallel(model: nn.Module, gpulist: list, sample_inputs: tuple):
72+
"""Make the model executable across multiple GPUs."""
73+
74+
def input_gpu_device_hook(mod, inputs, kwargs):
75+
modifyed_inputs = []
76+
first_dev = None
77+
for layer_input in inputs:
78+
if type(layer_input) is not torch.Tensor:
79+
modifyed_inputs.append(layer_input)
80+
elif hasattr(mod, "weight"):
81+
modifyed_inputs.append(layer_input.to(mod.weight.device))
82+
elif hasattr(mod, "parameters"):
83+
device = next(mod.parameters(), layer_input).device
84+
modifyed_inputs.append(layer_input.to(device))
85+
elif hasattr(next(mod.children(), None), "weight"):
86+
modifyed_inputs.append(layer_input.to(next(mod.children()).weight.device))
87+
elif first_dev is not None and layer_input.device != first_dev:
88+
modifyed_inputs.append(layer_input.to(first_dev))
89+
else:
90+
modifyed_inputs.append(layer_input)
91+
if first_dev is None:
92+
first_dev = modifyed_inputs[0].device
93+
for key, value in kwargs.items():
94+
if type(value) is torch.Tensor:
95+
kwargs[key] = value.to(first_dev)
96+
97+
return (tuple(modifyed_inputs), kwargs)
98+
99+
def move_layer_to_device_rurc(mod, dev):
100+
mod.to(dev)
101+
for layer in mod.named_children():
102+
move_layer_to_device_rurc(layer[1], dev)
103+
104+
model = model.half()
105+
all_hooks = []
106+
all_hooks.append(model.register_forward_pre_hook(input_gpu_device_hook, with_kwargs=True))
107+
pre_fix = next(iter(model.named_children()))[0]
108+
for top_name, top_module in model.named_children():
109+
for name, module in top_module.named_children():
110+
all_hooks.append(module.register_forward_pre_hook(input_gpu_device_hook, with_kwargs=True))
111+
if type(module) in [torch.nn.ModuleList]:
112+
num_layers_on_each_gpu = math.floor(len(module) / len(gpulist))
113+
for idx, attn_layer in enumerate(module):
114+
all_hooks.append(attn_layer.register_forward_pre_hook(input_gpu_device_hook, with_kwargs=True))
115+
116+
to_dev = gpulist[min(idx // num_layers_on_each_gpu, len(gpulist))]
117+
attn_layer.to(to_dev)
118+
move_layer_to_device_rurc(attn_layer, to_dev)
119+
print(f"move {pre_fix}.{name}.{idx} to {to_dev}")
120+
else:
121+
module.to(gpulist[0])
122+
print(f"move {pre_fix}.{name} to {gpulist[0]}")
123+
if len(list(top_module.named_children())) == 0:
124+
top_module.to(gpulist[0])
125+
print(f"move {top_name} to {gpulist[0]}")
126+
127+
with torch.no_grad():
128+
model(sample_inputs[0], attention_mask=sample_inputs[1])
129+
return model
130+
131+
132+
def retrieve_onnx_inputs(model: nn.Module, sample_inputs: tuple, with_past: bool):
133+
"""
134+
auto retrieve onnx inputs from torch model as we can't enumlate all possibilities
135+
for all models
136+
"""
137+
user_inputs = []
138+
139+
def hook_for_inputs(_, inputs, kwargs):
140+
user_inputs.append((inputs, kwargs))
141+
return user_inputs[0]
142+
143+
hook_handle = model.register_forward_pre_hook(hook_for_inputs, with_kwargs=True)
144+
145+
forward_params = inspect.signature(model.forward).parameters
146+
input_keys = list(forward_params.keys())
147+
default_values = [forward_params.get(key).default for key in input_keys]
148+
out = model(sample_inputs[0], attention_mask=sample_inputs[1])
149+
hook_handle.remove()
150+
user_inputs = user_inputs[0]
151+
onnx_inputs = default_values
152+
for idx, _val in enumerate(user_inputs[0]):
153+
onnx_inputs[idx] = user_inputs[0][idx]
154+
for key, value in user_inputs[1].items():
155+
idx = input_keys.index(key)
156+
onnx_inputs[idx] = value
157+
for idx, (key, value) in enumerate(zip(input_keys, onnx_inputs)):
158+
if type(value) is torch.Tensor:
159+
value.to(model.device)
160+
# Didn't touch past_key_value now, please change it if you want
161+
if "use_cache" in key:
162+
onnx_inputs[idx] = with_past
163+
164+
return input_keys, onnx_inputs, out.past_key_values
165+
166+
167+
def move_to_approprate_device(model: nn.Module, sample_inputs_tp: tuple) -> nn.Module:
168+
"""
169+
According to the model size, we will upload it to
170+
CPU if has no GPU or enough GPU memory,
171+
Single GPU if has only one GPU in local or model size is enough to fit one GPU
172+
Multiple GPU if there is more than one gpu in local and model is too large
173+
"""
174+
total_mem_per_cpu = torch.cuda.get_device_properties(0).total_memory / 1024 / 1024
175+
176+
print(f"Model_Size = {get_model_parameter_size(model)/1024} GB")
177+
print(f"total_mem_per_cpu = {total_mem_per_cpu/1024} GB")
178+
if get_model_parameter_size(model) > total_mem_per_cpu * 0.45:
179+
device_collection = [torch.device(i) for i in range(torch.cuda.device_count())]
180+
if len(device_collection) > 1:
181+
print(
182+
f"{len(device_collection)} GPUs are used to export onnx, \
183+
Please set CUDA_VISIBLE_DEVICES to use specific GPU group"
184+
)
185+
model = auto_pipeline_parallel(model, device_collection, sample_inputs_tp)
186+
else:
187+
print("!!!! convert model to float and export onnx using CPU")
188+
model = model.cpu().float()
189+
else:
190+
print("Export model on a single GPU")
191+
model = model.cuda().half()
192+
return model
193+
194+
195+
def adapt_inputs_to_device(sample_inputs: tuple, device: torch.device) -> tuple:
196+
"""move inputs to device"""
197+
sample_inputs_ = []
198+
for sample_int in sample_inputs:
199+
if isinstance(sample_int, torch.Tensor):
200+
sample_inputs_.append(sample_int.to(device))
201+
else:
202+
sample_inputs_.append(sample_int)
203+
return tuple(sample_inputs_)
204+
205+
206+
def fetch_onnx_inputs_outputs_name(
207+
model: nn.Module,
208+
onnx_inputs: list,
209+
torch_input_names: tuple,
210+
past_key_values: tuple,
211+
with_past: bool,
212+
input_with_past: bool,
213+
):
214+
"""fetch onnx inputs and outputs name"""
215+
num_of_past_key = 0
216+
kv_cache_axis = {0: "batch_size"}
217+
# try get num_of_past_key and shape of past_key_value
218+
if past_key_values is not None:
219+
num_of_past_key = len(past_key_values)
220+
seq_index = (torch.tensor(past_key_values[0][0].shape) == onnx_inputs[0].shape[-1]).nonzero().view(-1)
221+
assert seq_index.numel() == 1
222+
kv_cache_axis = {0: "batch_size", seq_index.item(): "seq_len"}
223+
224+
if not num_of_past_key:
225+
num_of_past_key = model.config.num_hidden_layers
226+
227+
onnx_inp_names = ("input_ids", "attention_mask")
228+
onnx_out_names = ("logits",)
229+
onnx_dynamic_axes = {
230+
"input_ids": {0: "batch_size", 1: "seq_len"},
231+
"attention_mask": {0: "batch_size", 1: "seq_len"},
232+
}
233+
if input_with_past:
234+
for i in range(num_of_past_key):
235+
onnx_inp_names += (f"present_key.{i}",)
236+
onnx_inp_names += (f"present_values.{i}",)
237+
238+
onnx_dynamic_axes[onnx_inp_names[-1]] = kv_cache_axis
239+
onnx_dynamic_axes[onnx_inp_names[-2]] = kv_cache_axis
240+
241+
if with_past or input_with_past:
242+
for i in range(num_of_past_key):
243+
onnx_out_names += (f"past_key.{i}",)
244+
onnx_out_names += (f"past_values.{i}",)
245+
onnx_dynamic_axes[onnx_out_names[-1]] = kv_cache_axis
246+
onnx_dynamic_axes[onnx_out_names[-2]] = kv_cache_axis
247+
248+
for idx, name in enumerate(torch_input_names):
249+
if input_with_past:
250+
if name == "past_key_values":
251+
onnx_inputs[idx] = past_key_values
252+
elif name == "attention_mask":
253+
attn_mask = onnx_inputs[idx]
254+
onnx_inputs[idx] = torch.cat(
255+
(attn_mask, torch.ones((attn_mask.shape[0], 1), device=attn_mask.device)), dim=1
256+
)
257+
elif name == "input_ids":
258+
input_ids = onnx_inputs[idx]
259+
onnx_inputs[idx] = input_ids[:, -1:]
260+
261+
return onnx_inp_names, onnx_out_names, onnx_dynamic_axes
262+
263+
264+
def do_export_internal(model: nn.Module, onnx_io_tuple: tuple, onnx_inputs: tuple, onnx_path: Path, opset: int):
265+
"""do export with torch.onnx.export"""
266+
onnx_model_name = onnx_path.name
267+
onnx_inp_names, onnx_out_names, onnx_dynamic_axes = onnx_io_tuple
268+
# two step to export onnx
269+
# 1. export onnx with lots of pieces of weights
270+
# 2. save all weights to external data
271+
with tempfile.TemporaryDirectory() as tmpdirname:
272+
tmp_onnx = os.path.join(tmpdirname, "tmp.onnx")
273+
274+
torch.onnx.export(
275+
model=model,
276+
args=tuple(onnx_inputs),
277+
f=tmp_onnx,
278+
verbose=False,
279+
opset_version=opset,
280+
input_names=onnx_inp_names,
281+
output_names=onnx_out_names,
282+
dynamic_axes=onnx_dynamic_axes,
283+
)
284+
285+
onnx_path.unlink(missing_ok=True)
286+
(onnx_path.parent / f"{onnx_model_name}_ext.data").unlink(missing_ok=True)
287+
288+
onnx_model = onnx.load(str(tmp_onnx))
289+
onnx.save_model(
290+
onnx_model,
291+
str(onnx_path),
292+
save_as_external_data=(len(os.listdir(tmpdirname)) > 1),
293+
all_tensors_to_one_file=True,
294+
location=f"{onnx_model_name}_ext.data",
295+
size_threshold=1024,
296+
convert_attribute=False,
297+
)
298+
299+
300+
@torch.no_grad()
301+
def export_onnx(hf_model: str, cache_dir: Optional[str], onnx_path_str: str, with_past: bool, opset: int):
302+
"""
303+
do export
304+
model: torch model
305+
onnx_path: where the onnx model saved to
306+
sample_inputs_tp: inputs for torch model
307+
"""
308+
model, sample_inputs_tp = initialize_model_and_sample_inputs(hf_model, cache_dir)
309+
310+
model = move_to_approprate_device(model, sample_inputs_tp)
311+
312+
sample_inputs = adapt_inputs_to_device(sample_inputs_tp, next(model.parameters()).device)
313+
314+
# input_keys would be usesful if the model has some special inputs
315+
input_keys, onnx_inputs, past_key_value = retrieve_onnx_inputs(model, sample_inputs, with_past)
316+
317+
onnx_io_tuple = fetch_onnx_inputs_outputs_name(model, onnx_inputs, input_keys, past_key_value, with_past, False)
318+
319+
onnx_model_name = "model.onnx"
320+
onnx_path: Path = Path(onnx_path_str).absolute()
321+
if onnx_path.suffix != ".onnx":
322+
onnx_path = onnx_path / onnx_model_name
323+
324+
do_export_internal(model, onnx_io_tuple, onnx_inputs, onnx_path, opset)
325+
if not with_past:
326+
return
327+
328+
onnx_io_tuple = fetch_onnx_inputs_outputs_name(model, onnx_inputs, input_keys, past_key_value, with_past, True)
329+
330+
onnx_model_name = "model_with_past.onnx"
331+
onnx_path = onnx_path.parent / onnx_model_name
332+
333+
do_export_internal(model, onnx_io_tuple, onnx_inputs, onnx_path, opset)
334+
335+
336+
def parse_arguments():
337+
"""arguments parsing."""
338+
parser = argparse.ArgumentParser()
339+
340+
parser.add_argument(
341+
"-m",
342+
"--model",
343+
required=True,
344+
type=str,
345+
default=["meta-llama/Llama-2-70b-hf"],
346+
help="Pre-trained models in huggingface model hub",
347+
)
348+
parser.add_argument(
349+
"-s",
350+
"--saved_path",
351+
required=False,
352+
type=str,
353+
default="./onnx_models/",
354+
help="where the onnx model will be saved",
355+
)
356+
parser.add_argument(
357+
"--cache_dir",
358+
required=False,
359+
type=str,
360+
default=None,
361+
help=("cache directy of huggingface, by setting this to avoid useless downloading if you have one"),
362+
)
363+
parser.add_argument(
364+
"--with_past",
365+
action="store_true",
366+
default=False,
367+
help=("The tool will export onnx without past-key-value by default"),
368+
)
369+
parser.add_argument(
370+
"--opset",
371+
required=False,
372+
type=int,
373+
default=17,
374+
help=(
375+
"the opset to save onnx model, \
376+
try to increase it if this opset doens't have new features you want"
377+
),
378+
)
379+
return parser.parse_args()
380+
381+
382+
if __name__ == "__main__":
383+
args = parse_arguments()
384+
385+
export_onnx(args.model, args.cache_dir, args.saved_path, args.with_past, args.opset)

0 commit comments

Comments
 (0)