Skip to content

[Performance]: use Python array to replace Python list for zero-copy tensor creation #6879

Description

@youkaichao

Proposal to improve performance

For flexibility, lots of code in vLLM uses Python list.

The memory layout for a Python list of [1, 2, 3, 4, 5], is:

----
PyObject pointer --> PyLong(1)
----
PyObject pointer --> PyLong(2)
----
PyObject pointer --> PyLong(3)
----
PyObject pointer --> PyLong(4)
----
PyObject pointer --> PyLong(5)
----

This is because a Python list can hold arbitrary Python object.

When we use torch.tensor([1, 2, 3, 4, 5], dtype=torch.int, device="cuda"), there's two copy operation happening:

  1. PyTorch has to collect all the data from scattered memory into a continuous memory area, i.e. a CPU memory segment holding 1, 2, 3, 4, 5 consecutively (40 bytes)
  2. PyTorch launches an operation to copy the CPU memory to GPU memory, wraps it into a GPU tensor

There is a better alternative in Python, called array.array. It is very similar to vector type in C++, which can hold variable length data with the same type. Since the memory layout is already compact, we can directly create pytorch tensor from it, without copying, and then copy it to GPU. i.e., we can reduce the copy in step 1.

Here is some microbenchmark:

import array
import torch

# print header
print("N\tlist\tarray")

for N in [100, 1000, 10000, 100000, 1000000]:
    list_data = list(range(N))
    array_data = array.array('l', list_data)

    def create_from_list():
        tensor = torch.tensor(list_data, dtype=torch.int, device='cuda')
        torch.cuda.synchronize()
        return tensor

    def create_from_array():
        tensor = torch.frombuffer(array_data, dtype=torch.int).to('cuda')
        torch.cuda.synchronize()
        return tensor

    import time

    for _ in range(10):
        # warmup
        create_from_list()
    start = time.time()
    for _ in range(100):
        create_from_list()
    elapsed_list = (time.time() - start) / 100 * 1000 # ms

    for _ in range(10):
        create_from_array()
    start = time.time()
    for _ in range(100):
        create_from_array()
    elapsed_array = (time.time() - start) / 100 * 1000 # ms

    print(f"{N}\t{elapsed_list:.3f}\t{elapsed_array:.3f}")

The output is:

N list to GPU (ms) array to GPU (ms)
100 0.020 0.016
1000 0.046 0.017
10000 0.300 0.024
100000 2.793 0.071
1000000 27.219 0.512

As we can see, use array to copy to GPU is always faster. When the input is large, the difference is even larger.

However, how can we get an array object? If we do array_data = array.array('l', list_data) , it is another copy, and will not give us any benefit.

The answer is, we should try to start with array, and use array.append / array.extend to replace list.append / list.extend. Then, we should replace torch.tensor(data, dtype=torch.int, device="cuda") to torch.frombuffer(data, dtype=torch.int).to(device="cuda").

This will require rewrite lots of the code in prepare-input and block table preparation, one of the main performance bottleneck.

cc @comaniac for prepare input
cc @alexm-neuralmagic @cadedaniel for block manager

Report of performance regression

No response

Misc discussion on performance

No response

Your current environment (if you think it is necessary)

The output of `python collect_env.py`

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performancePerformance-related issuesstaleOver 90 days of inactivity

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions