Skip to content

[MNT] Switch from np.column_stack() to np.vstack().T for performance #31130

Closed as not planned

Description

@scottshambaugh

From the discussion in #31001 (comment), it looks like np.column_stack() is generally a slow operation compared to np.vstack().T. This is because the former has to interleave elements in memory whereas the second does contiguous memory copies and returns a view.

It's unclear to me how many of these operations are driving time spent in the hot paths of the code, but we should see a performance improvement by switching things over.

Marking this as an easy first issue since it's largely a find-and-replace.

10,000 elements: 10 runs x 10,000 iterations

With broadcast:
- `np.column_stack(np.broadcast_arrays(x, y))`: 36.47 us
- `np.vstack(np.broadcast_arrays(x, y)).T`: 27.67 us
- `np.empty + assign`: 30.09 us

Without broadcast:
- `np.column_stack([x, y])`: 20.63 us
- `np.vstack((x, y)).T`: 13.18 us
"""Benchmark different array combination methods."""

import timeit
import numpy as np

N = 10_000
NUMBER = 10_000
REPEAT = 10

x = np.linspace(0, 1, N)
y = np.float64(2.0)

# Pre-matched arrays for non-broadcast cases
x_full = x
y_full = np.full(N, 2.0)


def broadcast_column_stack():
    return np.column_stack(np.broadcast_arrays(x, y))

def broadcast_vstack_T():
    return np.vstack(np.broadcast_arrays(x, y)).T

def broadcast_empty_assign():
    out = np.empty((N, 2))
    bx, by = np.broadcast_arrays(x, y)
    out[:, 0] = bx
    out[:, 1] = by
    return out

def no_broadcast_column_stack():
    return np.column_stack([x_full, y_full])

def no_broadcast_vstack_T():
    return np.vstack((x_full, y_full)).T


def bench(func):
    times = timeit.repeat(func, number=NUMBER, repeat=REPEAT)
    return min(times) / NUMBER


if __name__ == "__main__":
    print(f"{N:,} elements: {REPEAT} runs x {NUMBER:,} iterations")

    broadcast_cases = [
        ("np.column_stack(np.broadcast_arrays(x, y))", broadcast_column_stack),
        ("np.vstack(np.broadcast_arrays(x, y)).T", broadcast_vstack_T),
        ("np.empty + assign", broadcast_empty_assign),
    ]

    no_broadcast_cases = [
        ("np.column_stack([x, y])", no_broadcast_column_stack),
        ("np.vstack((x, y)).T", no_broadcast_vstack_T),
    ]

    print("\nWith broadcast:")
    for label, func in broadcast_cases:
        t = bench(func)
        print(f"- `{label}`: {t * 1e6:.2f} us")

    print("\nWithout broadcast:")
    for label, func in no_broadcast_cases:
        t = bench(func)
        print(f"- `{label}`: {t * 1e6:.2f} us")

Activity

  1. added
    Difficulty: Easyhttps://matplotlib.org/devdocs/devel/contribute.html#good-first-issues
    🌱 Good first issueOpen a pull request against these issues if there are no active ones!
    on Feb 10, 2026
  2. github-actions commented on Feb 10, 2026

    @github-actions

    Good first issue - notes for new contributors

    This issue is suited to new contributors because it does not require understanding of the
    Matplotlib internals. To get started, please see our contributing
    guide
    .

    We do not assign issues. Check the Development section in the sidebar for linked pull
    requests (PRs). If there are none, feel free to start working on it. If there is an open PR, please
    collaborate on the work by reviewing it rather than duplicating it in a competing PR.

    If something is unclear, please reach out on any of our communication
    channels
    .

  3. scottshambaugh commented on Feb 10, 2026

    @scottshambaugh
    ContributorAuthor

    Hid one automatically generated comment from @AiGentsy. This is a low priority, easier task which is better used for human contributors to learn how to contribute.

  4. changed the title [-][MNT] Switch from np.column_stack()` to `np.vstack().T` for performance[/-] [+][MNT] Switch from `np.column_stack()` to `np.vstack().T` for performance[/+] on Feb 10, 2026
  5. timhoffm commented on Feb 11, 2026

    @timhoffm
    Member

    In a quick test

    Code
    import numpy as np
    
    size = []
    colstack_times = []
    vstack_times = []
    for n in [3, 10, 30, 100, 300, 1000, 3000, 10000, 30000, 100000]:
        print(n)
        a = np.random.random(n)
        b = np.random.random(n)
        size.append(n)
        t = %timeit -o np.column_stack([a, b])
        colstack_times.append(t.average)
        t = %timeit -o np.vstack([a, b]).T
        vstack_times.append(t.average)
    
    import matplotlib.pyplot as plt
    
    plt.loglog(size, colstack_times, "o:", label="column_stack()")
    plt.loglog(size, vstack_times, "o:", label="vstack().T")
    plt.xlabel("array size")
    plt.ylabel("time")
    plt.legend()

    the gain is not so clear. It depends on the array size.

    Image

    Only larger arrays (N>=3000) benefit. While one cound argue that small arrays are fast anyway, I find the result not compelling enough to run through the codebase and change everything.

    Context from the numpy issue tracker:

    I also want to note that numpy/numpy#8082 (comment) measured 10 years ago that column_stack() was a bit faster than vstack().T. So performance characteristics can change over time and I do not think it's worth chasing microsecond performance gains in such a scenario. If we see the need for such mirco-optimization, IMHO we should limit this to hot spots and add performance tests for different implementations so that we note when our "optimized solution" does not perform well anymore.

  6. anntzer commented on Feb 11, 2026

    @anntzer
    Contributor

    FWIW I observe a smaller gain for column_stack for small arrays, and a switch at a lower value (100-300 elements), but I agree we should test this properly if we make that change.
    Image

  7. scottshambaugh commented on Feb 11, 2026

    @scottshambaugh
    ContributorAuthor

    I think I have an older machine than most of you, my results are not as smooth :). I personally find a 30-50% boost at large n pretty compelling, but have no issues with closing this and just making changes in the hot paths where we find them.

    Putting together a benchmark suite has been on my personal TODO for a while. I'm not sure that it would be useful for catching behavior changes like this however where total impact on runtime from any given call is on the order of a percent or two. That'll get largely washed out in the noise for E2E plotting times, even if more targeted inspections like we are doing here show a difference. Part of why I've been bundling performance improvements - a single change can be hard to justify even if they stack up in the aggregate.

    Image
  8. timhoffm commented on Feb 11, 2026

    @timhoffm
    Member

    I'm not surprised that you see different numbers. These micro-performance aspects depend on many variables (Python version, numpy version, CPU architecture (e.g. apple silicon vs x86), CPU features like SIMD instructions, CPU cache size). It's really difficult to get a clear recommendation which one is better.

    I therefore believe we should focus on other kinds of performance optimizations like preventing unnecessary or duplicate work or extensive python loops. These improvements are clearer and more persistent.

  9. scottshambaugh commented on Feb 11, 2026

    @scottshambaugh
    ContributorAuthor

    Works for me!

  10. jklymak commented on Feb 11, 2026

    @jklymak
    Member

    Note that we have a benchmark suite. https://matplotlib.org/mpl-bench/

    But it is probably not routinely run, whereas I think it should be run more often to make sure we are not regressing on speed

  11. Borda commented on Feb 12, 2026

    @Borda
  12. removed
    Difficulty: Easyhttps://matplotlib.org/devdocs/devel/contribute.html#good-first-issues
    🌱 Good first issueOpen a pull request against these issues if there are no active ones!
    on Feb 12, 2026
  13. locked and limited conversation to collaborators on Feb 12, 2026
  14. story645 commented on Feb 12, 2026

    @story645
    Member

    Per the reasons for the closure of this PR, it's unclear whether there are consistent performance gains from this change; therefore we will not be accepting PRs that target this issue.
    If someone runs a bench marking suite (such as mpl-bench) and shows clear consistent gains such that this issue is worth reopening, let us know about the performance gains on discourse.matplotlib.org.

    Comments on our AI policy should be directed to https://discourse.matplotlib.org/t/ai-policy-discussion/26116

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions