Skip to content

PERF: visit the inner dimension of transposed copies in cache-sized chunks - #32672

Open
beatakouchnir wants to merge 6 commits into
numpy:mainfrom
beatakouchnir:perf/blocked-strided-copy
Open

beatakouchnir wants to merge 6 commits into
numpy:mainfrom
beatakouchnir:perf/blocked-strided-copy

Conversation

@beatakouchnir

@beatakouchnir beatakouchnir commented Sep 17, 2026 •

Copy link
Copy Markdown

PR summary

Fixes #32453

Copying a transposed C-order (rows, 128) float64 array into a contiguous destination makes the inner loop stride through the source at 1024 bytes, touching one cache line per element while using only 8 bytes of it; the next outer pass revisits the same lines 8 bytes over. While all rows lines fit in the private cache, each is fetched once and reused across 16 (8 on x86) outer passes; once they don't, each line is re-fetched per element it holds, so the copy reads 16x (8x) more from memory than it writes. C→F has the same problem on the destination side via write-allocate.

transposed_copy_chunk detects this pattern when, after sorting and coalescing, either operand's inner stride is at least a cache line (128 bytes) and its next-outer stride is smaller; if the inner length times line size also exceeds 256 KiB, the inner dimension is processed in 2048-element chunks, each running the full outer iteration with shape_it[0] and the data pointers adjusted, so a chunk's lines stay cache-resident. The transfer function, outer iteration order, overlap, and threading handling are unchanged, and non-matching, 1-D, or short copies take the old path as a single chunk. raw_array_wheremasked_assign_array and NpyIter-based copies are untouched.

Copy throughput in GB/s (bytes per nanosecond), main → this branch, best of 3 runs with a 256 MiB cache flush before each, single core on an M5 Max:

size F->C, (rows, 128) float64 C->F, same array F->C, square
16 MiB 4.8 -> 7.0 5.6 -> 6.5 19.8 -> 23.2
64 MiB 2.0 -> 6.6 1.9 -> 6.6 7.1 -> 8.9
256 MiB 1.2 -> 6.6 1.2 -> 6.7 5.0 -> 7.7

Sizes of 4 MiB and below are unchanged within noise.

ASV, interleaved, three rounds, main vs the pushed head:

Benchmark (nbytes) main branch ratio
bench_core.TransposedCopy.time_f_to_c(262144) 20.4 ± 0.2 µs 20.5 ± 0.2 µs 1.00
bench_core.TransposedCopy.time_f_to_c(4194304) 618 ± 10 µs 594 ± 20 µs 0.96
bench_core.TransposedCopy.time_f_to_c(67108864) 35.3 ± 4 ms 11.3 ± 0.1 ms 0.32
bench_core.TransposedCopy.time_c_to_f(262144) 20.4 ± 0.3 µs 20.1 ± 0.6 µs 0.99
bench_core.TransposedCopy.time_c_to_f(4194304) 624 ± 9 µs 617 ± 20 µs 0.99
bench_core.TransposedCopy.time_c_to_f(67108864) 33.2 ± 3 ms 11.2 ± 0.1 ms 0.34

First time contributor introduction

I'm an applied ML scientist and have been using numpy for over a decade.

AI Disclosure

Claude was used to write the code and take the measurements; I have reviewed the code.

beatakouchnir added a commit to beatakouchnir/numpy that referenced this pull request Sep 17, 2026
@mattip

mattip commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

What are the units for the numbers in the table? Can you run our benchmarks run the benchmarks using ASV and show the improvement on a sample of the relevant ones?

@beatakouchnir

Copy link
Copy Markdown
Author

@mattip, it's GB/s; apologies for leaving it out. I've updated the PR body with it as well as with the ASV measurements.

@ikrommyd

Copy link
Copy Markdown
Member

Hi @crusaderky, in the issue you said you had vibe coded something. Is this similar to what you had done? And also is there any change you could potentially run the benchmark that gave you the plots in the issue using this PR branch?

Comment on lines +78 to +87
* After the raw iterator sorts and coalesces the axes, a transposed copy
* (F->C, C->F, or any operand whose inner stride jumps between cache lines
* while its next outer stride stays within one) touches one cache line per
* inner element and then touches the very same lines again on every outer
* iteration. Once that line set outgrows the private cache, every line is
* fetched once per element it holds, and the copy runs at memory bandwidth
* divided by the number of elements per line. Splitting the inner dimension
* into chunks whose line set fits in cache keeps the lines resident across
* the outer iterations. The 1-D transfer function is unchanged; only the
* order in which the raw iteration visits the array changes.

@ngoldbaum ngoldbaum Sep 17, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sort of paragraph comment is typical of LLM-generated code. Please rewrite all comments in this PR in your own words and try to limit yourself to comments that would be useful for a future reader. That means comments shouldn't narrate the implementation (the code should speak for itself IMO) and shouldn't discuss how the codebase used to work. Comments should be reserved for documenting non-obvious non-local facts that aid understanding.

LLM-generated text is very difficult to digest for humans, try to make sure you understand it well enough to explain it in your own words.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your feedback, @ngoldbaum. I have updated the code comments and release note to only carry non-obvious, non-local information.

beatakouchnir added a commit to beatakouchnir/numpy that referenced this pull request Sep 17, 2026
@ngoldbaum

Copy link
Copy Markdown
Member

Can you merge with or rebase on current main. That should fix the test crashes.

@ngoldbaum ngoldbaum left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The performance improvement is real but IMO this code change is in the wrong spot.

It would be better to add a tiled traversal mode to the iterator machinery in NumPy. Code paths could then internally opt in to the tiled mode.

I also think that this needs much more careful testing on a wide variety of hardware before merging.

I also want to caution you about opening vibe-coded PRs in projects you don't regularly contribute to. Especially for big, possibly risky changes like this it's much better to have a discussion about the pros and cons of various designs before just going and writing code. LLMs make it way too easy to write code that solves a problem but not necessarily the problem.

Comment on lines +81 to +82
#define NPY_COPY_CACHE_LINE 128 /* covers both 64- and 128-byte lines */
#define NPY_COPY_CHUNK_BUDGET (256 * 1024) /* 256 KiB chosen by measurement */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't be at all surprised to learn that these choices are hardware-dependent and need tuning. This whole PR needs validation on a broad range of hardware IMO.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point; I'm only able to test on my own M5 Max, but happy to change these if other contributors can provide their measurements.

Comment on lines +202 to +204
if (ndim >= 2) {
chunk = transposed_copy_chunk(n_inner, src_strides_it, dst_strides_it);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing this for all dtypes leads to observable behavior changes:

import numpy as np

for dtype in (object, np.dtypes.StringDType()):
    src = np.full((4097, 16), 1, dtype=dtype).T
    src[0, 2048] = "bad"
    dst = np.zeros(src.shape, dtype=np.int64)
    try:
        np.copyto(dst, src, casting="unsafe")
    except ValueError:
        print(np.count_nonzero(dst))

# Before: 2048, 2048
# This PR: 32768, 32768

you should probably limit this optimization to operations that are known statically to be safe (e.g. only numeric built-in dtypes perhaps). Future work could enable the optimization for more dtypes after auditing for issues like this.

@beatakouchnir beatakouchnir Sep 18, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed; chunking is now limited to built-in numeric and bool dtypes.

beatakouchnir and others added 5 commits September 18, 2026 08:20
…hunks

A transposed copy (F->C, C->F, or any operand whose inner stride crosses
cache lines while its next outer stride stays within one) touches one line
per inner element and touches the same lines again on every outer
iteration. Once that line set outgrows the private cache, each line is
fetched once per element it holds and the copy runs at memory bandwidth
divided by the elements per line: on an M5 Max, out[:] = a.T on a
(rows, 128) float64 array falls from 12.7 GB/s at 256 KiB to 1.2 GB/s at
256 MiB while a C->C copy holds 60-80 GB/s.

raw_array_assign_array now visits the inner dimension in chunks of 2048
elements when either operand shows that stride pattern and the line set
would exceed 256 KiB, so the lines stay resident across the outer
iterations. The 1-D transfer function and the raw iteration over the
outer dimensions are unchanged; a copy that does not match the pattern
takes the previous single-chunk path. Measured on the same machine:
6.6 GB/s at 256 MiB for both F->C and C->F (5.6x), square transposes
5.0 -> 7.7 GB/s, sizes below 4 MiB unchanged. A budget sweep from 64 KiB
to 1 MiB put 256 KiB within 10% of the best for the reporter's shape and
best for square shapes.

Tests cover both directions at inner lengths around the chunk boundary
for seven dtypes including object and void, negative strides and offsets,
a three-dimensional non-coalescable case, and object reference counts.

Closes numpygh-32453.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
The chunk loop wrote shape_it[0] and the data pointers per chunk even when
there was one chunk, which cost a few nanoseconds per inner call on small
and in-cache copies (2-D tiny copy +6%, 100x100 transpose +10% in an A/B
against main). The iteration is now a static inline helper; the unchunked
case calls it once with the original arguments, and only real chunking
pays for the bookkeeping. transposed_copy_chunk also tests the cheap size
condition before the stride pattern.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…iplying, which can overflow a 32-bit npy_intp
@ikrommyd

ikrommyd commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

It would be better to add a tiled traversal mode to the iterator machinery in NumPy. Code paths could then internally opt in to the tiled mode.

I don't want to take the discussion some place it shouldn't be, so about tiled traversal that should be a separate issue but I just wanted to say that adding tilted traversal would probably be hard to implement and review so if this is the ONLY consumer ever, I'd bet that it's probably not worth it.

At the same time tiled traversal could enable other copy entry points to use it and can also be used on elementwise ufuncs of mixed layouts like a + b.T or np.add(a, b.T, out=c). So from that stand point it may be worth the effort. Anyways, that's all I wanted to say, should probably open an issue about this idea.

A cast that can fail part-way (object, StringDType, same-value casting)
now takes the original loop, so the elements written before the error
are the same as before numpygh-32672. The release note says numeric arrays.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@beatakouchnir
beatakouchnir force-pushed the perf/blocked-strided-copy branch from 46897bf to 33f0a1c Compare September 18, 2026 15:46
@ngoldbaum

ngoldbaum commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

but I just wanted to say that adding tilted traversal would probably be hard to implement and review so if this is the ONLY consumer ever, I'd bet that it's probably not worth it.
)

At the same time tiled traversal could enable other copy entry points to use it and can also be used on elementwise ufuncs of mixed layouts like a + b.T or np.add(a, b.T, out=c). So from that stand point it may be worth the effort. Anyways, that's all I wanted to say, should probably open an issue about this idea.

I personally don't think we should spend effort replacing use of numpy's interation infrastructure with one-off hacks. Instead we should improve the infrastructure. It's not just this once place that has this problem: any operation that needs an iterator over a transposed source has this issue.

@beatakouchnir

Copy link
Copy Markdown
Author

Rebased on current main and fixed the partial-write issue. I'm only able to measure on my own M5 Max hardware but I agree that more measurements are needed and am happy to incorporate measurements from other contributors.

Regarding the approach, I followed the copy-specific fast path suggestion made by @seberg in #32453, but if the maintainers choose to go in a different direction, I'm happy to close this PR and contribute my measurements to the issue.

@ngoldbaum

Copy link
Copy Markdown
Member

I think given Sebastian said it might be an interesting vibe experiment I'd also like to see someone try the more general approach. Maybe a minimal version of that could allow someone to tackle this more generally later?

@seberg

seberg commented Sep 20, 2026

Copy link
Copy Markdown
Member

I would be very curious if you can get this into NpyIter. Seeing this does make me think that a "chunk-last-dimension" approach may make also sense for NpyIter (with a special inner-loop for that)!

That said, this code path doesn't actually hit NpyIter so this does actually make sense to me (I didn't remember how often it may hit with certainty anymore).

That said, I believe this code needs to do a bit deeper; making this nice isn't just a quick vibe-code but needs thought. My main two things to consider:

  • While some N-dim cases will coalesce to 2-d, I think it should deal with things better. Right now it looks like a 3-D fortran to 2-D copy is likely unimproved.
  • It may not really matter in practice, but this forces the new code even in cases where it is unnecessary, I think. You should show C->C (not actual C-order arrays because they will coalesc to 1-D!, something like arr[::2, :] being copied.).

So, I think that probably means a helper/option for create-sorted-strides that is necessary to get the best performance here by just chunking up the last dimension (in many cases!).

@beatakouchnir

beatakouchnir commented Sep 21, 2026 •

Copy link
Copy Markdown
Author

I can take a stab at the helper if I can get consensus that this is the preferred approach.

I just locally tested a sorted-strides helper for 3-D - 6-D copies, currently unimproved by this PR, and saw speedups of 2.1-3.8x:

dims shape main this PR this PR + axis reorder speedup vs main
3-D 257 x 257 x 257 4.7 GB/s 3.7 GB/s 13.3 GB/s 2.8x
4-D 61 x 59 x 63 x 57 2.7 GB/s 2.9 GB/s 10.2 GB/s 3.8x
5-D 23 x 21 x 25 x 27 x 29 2.2 GB/s 2.3 GB/s 6.1 GB/s 2.8x
6-D 11 x 13 x 15 x 17 x 19 x 21 2.2 GB/s 2.3 GB/s 4.7 GB/s 2.1x

(F->C copies of float64 arrays, 70-130 MiB each, M5 Max; C->F within 10% of each row)

The C->C copy speed is unchanged from main within noise:

strided C->C copy main this PR ratio
small (64,64)[::2,:] 522 ns 499 ns 0.96
small (64,64)[:,::2] 763 ns 753 ns 0.99
medium (512,512)[::2,:] 19.97 us 20.75 us 1.04
3-D (32,32,32)[::2,:,:] 2.27 us 2.29 us 1.01
wide (4096,8192)[::2,:] 3.54 ms 3.54 ms 1.00

One limitation I have discovered on my machine is that source strides that are a multiple of the page size (16 KiB), a 4096 x 4096 float64 transpose being the common case, don't see a speedup without padding:

F→C copy, float64, 128 MiB source column stride main (GB/s) this PR (GB/s)
4095 × 4095 32760 B 6.4 11.4
4096 × 4096 32768 B (2 pages) 2.3 2.5
4097 × 4097 32776 B 6.2 10.0
4096 × 4096 view of a 4104 × 4096 F-ordered array 32832 B 5.7 16.7
4096 × 4096 view of a 4112 × 4096 F-ordered array 32896 B 5.3 6.5
4096 × 4096 view of a 4224 × 4096 F-ordered array 33792 B 4.4 6.5

This is unchanged from main, not a regression, and a fix needs buffered tiling rather than chunking, so it's outside the scope of this PR.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PERF: F->C order conversion thrashes the L3 cache

5 participants