benchmark_throughput.py 22.6 KB
Newer Older
1
# SPDX-License-Identifier: Apache-2.0
2
"""Benchmark offline inference throughput."""
3
import argparse
4
import dataclasses
5
import json
6
import os
7
8
import random
import time
9
from functools import cache
10
from typing import Any, Optional, Union
11

12
import torch
13
import uvloop
14
from benchmark_utils import convert_to_pytorch_benchmark_format, write_to_json
15
from PIL import Image
16
from tqdm import tqdm
17
18
from transformers import (AutoModelForCausalLM, AutoTokenizer,
                          PreTrainedTokenizerBase)
19

20
from vllm.engine.arg_utils import AsyncEngineArgs, EngineArgs
21
22
from vllm.entrypoints.openai.api_server import (
    build_async_engine_client_from_engine_args)
23
from vllm.inputs import TextPrompt, TokensPrompt
24
25
from vllm.lora.request import LoRARequest
from vllm.lora.utils import get_adapter_absolute_path
26
from vllm.multimodal import MultiModalDataDict
27
from vllm.sampling_params import BeamSearchParams
28
from vllm.transformers_utils.tokenizer import AnyTokenizer, get_lora_tokenizer
29
from vllm.utils import FlexibleArgumentParser, merge_async_iterators
30

31

32
33
34
35
36
37
38
39
@dataclasses.dataclass
class SampleRequest:
    """A class representing a single inference request for benchmarking.

    Attributes:
        prompt: The input text prompt for the model.
        prompt_len: The length of the prompt in tokens.
        expected_output_len: The expected length of the output in tokens.
40
41
42
        multi_modal_data: Optional dictionary containing multi-modal data (e.g.
            images).
        lora_request: Optional LoRARequest specifying the LoRA to use. 
43
44
45
46
47
    """
    prompt: str
    prompt_len: int
    expected_output_len: int
    multi_modal_data: Optional[MultiModalDataDict] = None
48
    lora_request: Optional[LoRARequest] = None
49
50


51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def _get_prompt_for_image_model(question: str, *, model: str) -> str:
    """Prepend and append special tokens around the question to form a prompt.

    Args:
        question: The input question text to wrap with special tokens
        model: The name of the model being used, to determine which special
            tokens to add

    Returns:
        The formatted prompt string with appropriate special tokens for the
            model

    Raises:
        ValueError: If an unsupported model name is provided
    """
    model = model.lower()
    if "pixtral" in model:
        return f"<s>[INST]{question}\n[IMG][/INST]"
    raise ValueError(f"Unsupported model {model}")


72
73
74
75
76
@cache
def lora_path_on_disk(lora_path: str) -> str:
    return get_adapter_absolute_path(lora_path)


77
lora_tokenizer_cache: dict[int, AnyTokenizer] = {}
78
79
80
81


def get_random_lora_request(
        args: argparse.Namespace
82
) -> tuple[LoRARequest, Optional[AnyTokenizer]]:
83
84
85
86
87
88
89
90
91
92
    global lora_tokenizer_cache
    lora_id = random.randint(1, args.max_loras)
    lora_request = LoRARequest(lora_name=str(lora_id),
                               lora_int_id=lora_id,
                               lora_path=lora_path_on_disk(args.lora_path))
    if lora_id not in lora_tokenizer_cache:
        lora_tokenizer_cache[lora_id] = get_lora_tokenizer(lora_request)
    return lora_request, lora_tokenizer_cache[lora_id]


93
def sample_requests(tokenizer: PreTrainedTokenizerBase,
94
                    args: argparse.Namespace) -> list[SampleRequest]:
95

96
97
98
99
    dataset_path: str = args.dataset
    num_requests: int = args.num_prompts
    fixed_output_len: Optional[int] = args.output_len
    model: str = args.model
100
101
    if fixed_output_len is not None and fixed_output_len < 4:
        raise ValueError("output_len too small")
102

103
104
105
106
    # Load the dataset.
    with open(dataset_path) as f:
        dataset = json.load(f)
    # Filter out the conversations with less than 2 turns.
107
    dataset = [data for data in dataset if len(data["conversations"]) >= 2]
108
109
    # Shuffle the dataset.
    random.shuffle(dataset)
110

111
    # Filter out sequences that are too long or too short
112
    filtered_dataset: list[SampleRequest] = []
113
114
115
    for data in tqdm(dataset,
                     total=len(filtered_dataset),
                     desc="sampling requests"):
116
117
118
        if len(filtered_dataset) == num_requests:
            break

119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
        # Only keep the first two turns of each conversation.
        prompt = data["conversations"][0]["value"]
        completion = data["conversations"][1]["value"]

        multi_modal_data: Optional[MultiModalDataDict] = None
        if "image" in data:
            multi_modal_data = multi_modal_data or {}
            image_path = data["image"]
            # TODO(vllm-project/vllm/issues/9778): Support multiple images.
            assert isinstance(image_path,
                              str), "Only support single image input"
            try:
                multi_modal_data["image"] = Image.open(image_path).convert(
                    "RGB")
            except FileNotFoundError:
                # Ignore datapoint where asset is missing
                continue
            prompt = _get_prompt_for_image_model(question=prompt, model=model)

138
139
140
141
142
143
144
        request_tokenizer = tokenizer
        lora_request: Optional[LoRARequest] = None
        if args.enable_lora:
            lora_request, lora_tokenizer = get_random_lora_request(args)
            if lora_tokenizer:
                request_tokenizer = lora_tokenizer

145
        # Tokenize the prompts and completions.
146
147
        prompt_token_ids = request_tokenizer(prompt).input_ids
        completion_token_ids = request_tokenizer(completion).input_ids
148
        prompt_len = len(prompt_token_ids)
149
150
        output_len = len(completion_token_ids
                         ) if fixed_output_len is None else fixed_output_len
151
152
153
154
155
156
        if prompt_len < 4 or output_len < 4:
            # Prune too short sequences.
            continue
        if prompt_len > 1024 or prompt_len + output_len > 2048:
            # Prune too long sequences.
            continue
157
158
159
        filtered_dataset.append(
            SampleRequest(prompt=prompt,
                          prompt_len=prompt_len,
160
                          expected_output_len=output_len,
161
162
                          multi_modal_data=multi_modal_data,
                          lora_request=lora_request))
163

164
    return filtered_dataset
165
166


Woosuk Kwon's avatar
Woosuk Kwon committed
167
def run_vllm(
168
    requests: list[SampleRequest],
169
    n: int,
170
    engine_args: EngineArgs,
171
    disable_detokenize: bool = False,
172
) -> float:
173
    from vllm import LLM, SamplingParams
174
    llm = LLM(**dataclasses.asdict(engine_args))
175
176
177
178
179
180
    assert all(
        llm.llm_engine.model_config.max_model_len >= (
            request.prompt_len + request.expected_output_len)
        for request in requests), (
            "Please ensure that max_model_len is greater than the sum of"
            " prompt_len and expected_output_len for all requests.")
Zhuohan Li's avatar
Zhuohan Li committed
181
    # Add the requests to the engine.
182
    prompts: list[Union[TextPrompt, TokensPrompt]] = []
183
    sampling_params: list[SamplingParams] = []
184
    for request in requests:
185
        prompts.append(
186
187
188
            TokensPrompt(prompt_token_ids=request.prompt["prompt_token_ids"],
                       multi_modal_data=request.multi_modal_data)
            if "prompt_token_ids" in request.prompt else \
189
190
            TextPrompt(prompt=request.prompt,
                       multi_modal_data=request.multi_modal_data))
191
192
193
        sampling_params.append(
            SamplingParams(
                n=n,
194
                temperature=1.0,
195
196
                top_p=1.0,
                ignore_eos=True,
197
                max_tokens=request.expected_output_len,
198
                detokenize=not disable_detokenize,
199
            ))
200
    lora_requests: Optional[list[LoRARequest]] = None
201
202
    if engine_args.enable_lora:
        lora_requests = [request.lora_request for request in requests]
203

204
205
206
    use_beam_search = False

    if not use_beam_search:
207
        start = time.perf_counter()
208
209
210
211
        llm.generate(prompts,
                     sampling_params,
                     lora_request=lora_requests,
                     use_tqdm=True)
212
213
        end = time.perf_counter()
    else:
214
        assert lora_requests is None, "BeamSearch API does not support LoRA"
215
        prompts = [request.prompt for request in requests]
216
217
        # output_len should be the same for all requests.
        output_len = requests[0][2]
218
219
        for request in requests:
            assert request.expected_output_len == output_len
220
        start = time.perf_counter()
221
222
223
224
225
226
227
        llm.beam_search(
            prompts,
            BeamSearchParams(
                beam_width=n,
                max_tokens=output_len,
                ignore_eos=True,
            ))
228
        end = time.perf_counter()
229
230
231
    return end - start


232
async def run_vllm_async(
233
    requests: list[SampleRequest],
234
    n: int,
235
    engine_args: AsyncEngineArgs,
236
    disable_frontend_multiprocessing: bool = False,
237
    disable_detokenize: bool = False,
238
239
240
241
242
) -> float:
    from vllm import SamplingParams

    async with build_async_engine_client_from_engine_args(
            engine_args, disable_frontend_multiprocessing) as llm:
243
244
245
246
247
248
        assert all(
            llm.model_config.max_model_len >= (request.prompt_len +
                                               request.expected_output_len)
            for request in requests), (
                "Please ensure that max_model_len is greater than the sum of"
                " prompt_len and expected_output_len for all requests.")
249
250

        # Add the requests to the engine.
251
        prompts: list[Union[TextPrompt, TokensPrompt]] = []
252
253
        sampling_params: list[SamplingParams] = []
        lora_requests: list[Optional[LoRARequest]] = []
254
        for request in requests:
255
            prompts.append(
256
257
258
                TokensPrompt(prompt_token_ids=request.prompt["prompt_token_ids"],
                        multi_modal_data=request.multi_modal_data)
                if "prompt_token_ids" in request.prompt else \
259
260
                TextPrompt(prompt=request.prompt,
                           multi_modal_data=request.multi_modal_data))
261
262
263
            sampling_params.append(
                SamplingParams(
                    n=n,
264
                    temperature=1.0,
265
266
                    top_p=1.0,
                    ignore_eos=True,
267
                    max_tokens=request.expected_output_len,
268
                    detokenize=not disable_detokenize,
269
                ))
270
            lora_requests.append(request.lora_request)
271
272
273

        generators = []
        start = time.perf_counter()
274
275
276
277
278
279
        for i, (prompt, sp,
                lr) in enumerate(zip(prompts, sampling_params, lora_requests)):
            generator = llm.generate(prompt,
                                     sp,
                                     lora_request=lr,
                                     request_id=f"test{i}")
280
281
282
283
284
285
286
287
            generators.append(generator)
        all_gens = merge_async_iterators(*generators)
        async for i, res in all_gens:
            pass
        end = time.perf_counter()
        return end - start


288
def run_hf(
289
    requests: list[SampleRequest],
290
291
292
293
    model: str,
    tokenizer: PreTrainedTokenizerBase,
    n: int,
    max_batch_size: int,
294
    trust_remote_code: bool,
295
    disable_detokenize: bool = False,
296
) -> float:
297
298
    llm = AutoModelForCausalLM.from_pretrained(
        model, torch_dtype=torch.float16, trust_remote_code=trust_remote_code)
299
300
301
    if llm.config.model_type == "llama":
        # To enable padding in the HF backend.
        tokenizer.pad_token = tokenizer.eos_token
302
303
304
    llm = llm.cuda()

    pbar = tqdm(total=len(requests))
305
    start = time.perf_counter()
306
    batch: list[str] = []
307
308
309
310
311
312
313
314
315
316
317
    max_prompt_len = 0
    max_output_len = 0
    for i in range(len(requests)):
        prompt, prompt_len, output_len = requests[i]
        # Add the prompt to the batch.
        batch.append(prompt)
        max_prompt_len = max(max_prompt_len, prompt_len)
        max_output_len = max(max_output_len, output_len)
        if len(batch) < max_batch_size and i != len(requests) - 1:
            # Check if we can add more requests to the batch.
            _, next_prompt_len, next_output_len = requests[i + 1]
318
319
            if (max(max_prompt_len, next_prompt_len) +
                    max(max_output_len, next_output_len)) <= 2048:
320
321
322
323
                # We can add more requests to the batch.
                continue

        # Generate the sequences.
324
325
        input_ids = tokenizer(batch, return_tensors="pt",
                              padding=True).input_ids
326
327
        llm_outputs = llm.generate(
            input_ids=input_ids.cuda(),
328
            do_sample=True,
329
330
331
332
333
334
            num_return_sequences=n,
            temperature=1.0,
            top_p=1.0,
            use_cache=True,
            max_new_tokens=max_output_len,
        )
335
336
337
        if not disable_detokenize:
            # Include the decoding time.
            tokenizer.batch_decode(llm_outputs, skip_special_tokens=True)
338
339
340
341
342
343
        pbar.update(len(batch))

        # Clear the batch.
        batch = []
        max_prompt_len = 0
        max_output_len = 0
344
    end = time.perf_counter()
345
346
347
    return end - start


348
def run_mii(
349
    requests: list[SampleRequest],
350
351
352
353
    model: str,
    tensor_parallel_size: int,
    output_len: int,
) -> float:
354
355
    from mii import client, serve
    llm = serve(model, tensor_parallel=tensor_parallel_size)
356
    prompts = [request.prompt for request in requests]
357
358

    start = time.perf_counter()
359
    llm.generate(prompts, max_new_tokens=output_len)
360
    end = time.perf_counter()
361
362
    client = client(model)
    client.terminate_server()
363
364
365
    return end - start


366
def save_to_pytorch_benchmark_format(args: argparse.Namespace,
367
                                     results: dict[str, Any]) -> None:
368
369
370
371
372
373
374
375
376
377
378
379
380
    pt_records = convert_to_pytorch_benchmark_format(
        args=args,
        metrics={
            "requests_per_second": [results["requests_per_second"]],
            "tokens_per_second": [results["tokens_per_second"]],
        },
        extra_info={
            k: results[k]
            for k in ["elapsed_time", "num_requests", "total_num_tokens"]
        })
    if pt_records:
        # Don't use json suffix here as we don't want CI to pick it up
        pt_file = f"{os.path.splitext(args.output_json)[0]}.pytorch.json"
381
        write_to_json(pt_file, pt_records)
382
383


384
385
386
387
388
def main(args: argparse.Namespace):
    print(args)
    random.seed(args.seed)

    # Sample the requests.
389
390
391
    tokenizer = AutoTokenizer.from_pretrained(
        args.tokenizer, trust_remote_code=args.trust_remote_code)
    if args.dataset is None:
392
393
394
        vocab_size = tokenizer.vocab_size
        requests = []
        for _ in range(args.num_prompts):
395
396
397
398
399
400
401
402

            request_tokenizer = tokenizer
            lora_request: Optional[LoRARequest] = None
            if args.enable_lora:
                lora_request, lora_tokenizer = get_random_lora_request(args)
                if lora_tokenizer:
                    request_tokenizer = lora_tokenizer

403
404
405
406
407
            # Synthesize a prompt with the given input length.
            candidate_ids = [
                random.randint(0, vocab_size - 1)
                for _ in range(args.input_len)
            ]
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430

            candidate_prompt = {"prompt_token_ids": candidate_ids}

            if not args.skip_tokenizer_init:
                # As tokenizer may add additional tokens like BOS, we need
                # to try different lengths to get the desired input length.
                for _ in range(5):  # Max attempts to correct
                    candidate_prompt = request_tokenizer.decode(candidate_ids)
                    tokenized_len = len(
                        request_tokenizer.encode(candidate_prompt))

                    if tokenized_len == args.input_len:
                        break

                    # Adjust length based on difference
                    diff = args.input_len - tokenized_len
                    if diff > 0:
                        candidate_ids.extend([
                            random.randint(100, vocab_size - 100)
                            for _ in range(diff)
                        ])
                    else:
                        candidate_ids = candidate_ids[:diff]
431
432
433
            requests.append(
                SampleRequest(prompt=candidate_prompt,
                              prompt_len=args.input_len,
434
435
                              expected_output_len=args.output_len,
                              lora_request=lora_request))
436
    else:
437
        requests = sample_requests(tokenizer, args)
438

439
440
    is_multi_modal = any(request.multi_modal_data is not None
                         for request in requests)
Woosuk Kwon's avatar
Woosuk Kwon committed
441
    if args.backend == "vllm":
442
        if args.async_engine:
443
444
445
446
447
448
            elapsed_time = uvloop.run(
                run_vllm_async(
                    requests,
                    args.n,
                    AsyncEngineArgs.from_cli_args(args),
                    args.disable_frontend_multiprocessing,
449
                    args.disable_detokenize,
450
                ))
451
        else:
452
            elapsed_time = run_vllm(requests, args.n,
453
454
                                    EngineArgs.from_cli_args(args),
                                    args.disable_detokenize)
455
456
    elif args.backend == "hf":
        assert args.tensor_parallel_size == 1
457
        elapsed_time = run_hf(requests, args.model, tokenizer, args.n,
458
459
                              args.hf_max_batch_size, args.trust_remote_code,
                              args.disable_detokenize)
460
461
462
    elif args.backend == "mii":
        elapsed_time = run_mii(requests, args.model, args.tensor_parallel_size,
                               args.output_len)
463
464
    else:
        raise ValueError(f"Unknown backend: {args.backend}")
465
466
467
468
    total_num_tokens = sum(request.prompt_len + request.expected_output_len
                           for request in requests)
    total_output_tokens = sum(request.expected_output_len
                              for request in requests)
469
470
471
472
473
    if is_multi_modal:
        print("\033[91mWARNING\033[0m: Multi-modal request detected. The "
              "following metrics are not accurate because image tokens are not"
              " counted. See vllm-project/vllm/issues/9778 for details.")
        # TODO(vllm-project/vllm/issues/9778): Count molti-modal token length.
Woosuk Kwon's avatar
Woosuk Kwon committed
474
    print(f"Throughput: {len(requests) / elapsed_time:.2f} requests/s, "
475
476
          f"{total_num_tokens / elapsed_time:.2f} total tokens/s, "
          f"{total_output_tokens / elapsed_time:.2f} output tokens/s")
477

478
479
480
481
482
483
484
485
486
487
488
    # Output JSON results if specified
    if args.output_json:
        results = {
            "elapsed_time": elapsed_time,
            "num_requests": len(requests),
            "total_num_tokens": total_num_tokens,
            "requests_per_second": len(requests) / elapsed_time,
            "tokens_per_second": total_num_tokens / elapsed_time,
        }
        with open(args.output_json, "w") as f:
            json.dump(results, f, indent=4)
489
        save_to_pytorch_benchmark_format(args, results)
490

491
492

if __name__ == "__main__":
493
    parser = FlexibleArgumentParser(description="Benchmark the throughput.")
494
495
    parser.add_argument("--backend",
                        type=str,
496
                        choices=["vllm", "hf", "mii"],
Woosuk Kwon's avatar
Woosuk Kwon committed
497
                        default="vllm")
498
499
    parser.add_argument("--dataset",
                        type=str,
500
                        default=None,
501
                        help="Path to the dataset. The dataset is expected to "
502
503
                        "be a json in form of list[dict[..., conversations: "
                        "list[dict[..., value: <prompt_or_response>]]]]")
504
505
506
507
508
509
510
511
512
    parser.add_argument("--input-len",
                        type=int,
                        default=None,
                        help="Input prompt length for each request")
    parser.add_argument("--output-len",
                        type=int,
                        default=None,
                        help="Output length for each request. Overrides the "
                        "output length from the dataset.")
513
514
515
    parser.add_argument("--n",
                        type=int,
                        default=1,
516
                        help="Number of generated sequences per prompt.")
517
518
519
    parser.add_argument("--num-prompts",
                        type=int,
                        default=1000,
520
                        help="Number of prompts to process.")
521
522
523
    parser.add_argument("--hf-max-batch-size",
                        type=int,
                        default=None,
524
                        help="Maximum batch size for HF backend.")
525
526
527
528
529
    parser.add_argument(
        '--output-json',
        type=str,
        default=None,
        help='Path to save the throughput results in JSON format.')
530
531
532
533
534
535
536
537
    parser.add_argument("--async-engine",
                        action='store_true',
                        default=False,
                        help="Use vLLM async engine rather than LLM class.")
    parser.add_argument("--disable-frontend-multiprocessing",
                        action='store_true',
                        default=False,
                        help="Disable decoupled async engine frontend.")
538
539
540
541
542
    parser.add_argument(
        "--disable-detokenize",
        action="store_true",
        help=("Do not detokenize the response (i.e. do not include "
              "detokenization time in the measurement)"))
543
544
545
546
547
548
549
550
    # LoRA
    parser.add_argument(
        "--lora-path",
        type=str,
        default=None,
        help="Path to the lora adapters to use. This can be an absolute path, "
        "a relative path, or a Hugging Face model identifier.")

551
    parser = AsyncEngineArgs.add_cli_args(parser)
552
    args = parser.parse_args()
553
554
555
556
557
558
559
    if args.tokenizer is None:
        args.tokenizer = args.model
    if args.dataset is None:
        assert args.input_len is not None
        assert args.output_len is not None
    else:
        assert args.input_len is None
560
561
    if args.enable_lora:
        assert args.lora_path is not None
562

Woosuk Kwon's avatar
Woosuk Kwon committed
563
    if args.backend == "vllm":
564
565
566
567
568
        if args.hf_max_batch_size is not None:
            raise ValueError("HF max batch size is only for HF backend.")
    elif args.backend == "hf":
        if args.hf_max_batch_size is None:
            raise ValueError("HF max batch size is required for HF backend.")
569
570
        if args.quantization is not None:
            raise ValueError("Quantization is only for vLLM backend.")
571
572
573
        if args.enable_lora is not None:
            raise ValueError("LoRA benchmarking is only supported for vLLM"
                             " backend")
574
575
576
577
578
579
580
581
582
583
584
585
    elif args.backend == "mii":
        if args.dtype != "auto":
            raise ValueError("dtype must be auto for MII backend.")
        if args.n != 1:
            raise ValueError("n must be 1 for MII backend.")
        if args.quantization is not None:
            raise ValueError("Quantization is only for vLLM backend.")
        if args.hf_max_batch_size is not None:
            raise ValueError("HF max batch size is only for HF backend.")
        if args.tokenizer != args.model:
            raise ValueError("Tokenizer must be the same as the model for MII "
                             "backend.")
586
587
588
        if args.enable_lora is not None:
            raise ValueError("LoRA benchmarking is only supported for vLLM"
                             " backend")
589
    main(args)