Memory bandwidth with NumPy float64 arrays
In this article
Copying a 1024MB float64 array with NumPy 2.4.1 took a 120.50ms median, equivalent to 16.60GB/s when counting both reads and writes.
Copy stayed around 19GB/s as the size grew, but sum did not follow a smooth pattern: it was 22.98GB/s at 16MB, 8.85GB/s at 64MB, and 25.13GB/s at 256MB. I had expected a fourfold increase in array size to mean roughly a fourfold increase in time, so the fact that sum took only 7.06ms to 9.95ms from 64MB to 256MB was unexpected.
The 16MB case appears to benefit considerably from cache. The 1024MB case should be reading through main memory. The boundary is not clean: running Python on ARM64 Windows on a Surface Pro and calling NumPy array operations mixes CPU cores, cache, memory, and library implementation. This did not isolate those components. I decided to read it as the apparent bandwidth when running NumPy array copies and sums in this environment.
The timestamp in the measurement log was June 3, 2026, 20:17 JST. It was run once on 2026-06-03, so these figures are only the values from that run, not an average across days.
Copy and sum float64 arrays
The subject was a float64 array. I tested four sizes—16MB, 64MB, 256MB, and 1024MB—and ran copy and sum five times for each. I used the median so one unusually fast run would not dominate.
sizes_mb = [16, 64, 256, 1024]
for size_mb in sizes_mb:
a = numpy.ones(size_mb * 1024 * 1024 // 8, dtype=numpy.float64)
b = numpy.empty_like(a)
numpy.copyto(b, a)
a.sum()
copy_GBps assumes both a read and a write, so it doubles the array size for the calculation. A 16MB copy therefore counts as reading 16MB and writing 16MB. sum_GBps counts only reading the array. Even this assumption changes how the result looks, and copyto uses an optimized NumPy path, so it should not be treated as raw DRAM bandwidth. The units match, but the underlying work is slightly different.
This was also not a finely controlled measurement of warm-up or threads. I called NumPy 2.4.1 from an ARM64 Python 3.12.10 environment and used the times as they came out. I did not determine which SIMD path was enabled. I intend to investigate that later, but have stopped here for now.
Keep all five runs visible
I left all five runs_ms values in the table so the wide 2.04ms-to-9.68ms spread for the 64MB sum remains visible instead of being hidden by the median. I did not want to erase an inconvenient fluctuation.
| Size | Operation | All five runs_ms |
median_ms |
GB/s |
|---|---|---|---|---|
| 16MB | copy | 1.49 / 1.22 / 1.86 / 2.04 / 1.60 | 1.60ms | 19.53 |
| 16MB | sum | 0.57 / 0.78 / 0.72 / 0.68 / 0.66 | 0.68ms | 22.98 |
| 64MB | copy | 6.91 / 6.27 / 3.97 / 6.80 / 4.22 | 6.27ms | 19.94 |
| 64MB | sum | 9.68 / 7.21 / 7.06 / 2.04 / 5.07 | 7.06ms | 8.85 |
| 256MB | copy | 24.47 / 23.78 / 32.60 / 27.13 / 25.80 | 25.80ms | 19.38 |
| 256MB | sum | 9.95 / 9.26 / 14.97 / 9.75 / 13.44 | 9.95ms | 25.13 |
| 1024MB | copy | 105.43 / 98.29 / 139.27 / 120.50 / 123.03 | 120.50ms | 16.60 |
| 1024MB | sum | 59.55 / 58.66 / 48.60 / 45.66 / 50.08 | 50.08ms | 19.97 |
Copy is comparatively easy to read. It took 1.60ms at 16MB, 6.27ms at 64MB, 25.80ms at 256MB, and 120.50ms at 1024MB. The first three points are close to fourfold steps. The last step grows by about 4.7x from 256MB to 1024MB, while GB/s falls from 19.38 to 16.60. That is where the weight of main memory begins to show.
Sum was stranger. It was fast at 0.68ms for 16MB. At 64MB it grew to 7.06ms, about 10.4x the time for a fourfold size increase. But at 256MB it stayed at 9.95ms, only about 1.4x the 64MB time. At 1024MB it returned to 50.08ms, about 5.0x the 256MB time.
It would be too simplistic to explain this uneven shape with only “it fits in cache.” A 16MB sum took 0.68ms and a 1024MB sum took 50.08ms: a 64x size increase produced about a 73.6x time increase. That is not perfectly proportional, so cache, prefetching, NumPy's internal loops, and Windows scheduling are a safer combined explanation.
I almost discarded the 64MB sum
My biggest mistaken assumption was that sum would always be lighter than copy and would grow smoothly with size. I casually thought that reading alone had to be faster. In practice, the 64MB sum fell to 8.85GB/s at 7.06ms, while the 256MB case recovered to 25.13GB/s at 9.95ms.
This is not a major error, but the first time I saw the table I wanted to discard the 64MB sum as a measurement mistake. One of its five runs was 2.04ms, while another was 9.68ms. The variation is large. The JSON still contains all five values, however. Removing only inconvenient rows would turn the experiment into an opinion, so I kept them.
The stability of copy was also slightly surprising. From 16MB through 256MB, the values were close: 19.53GB/s, 19.94GB/s, and 19.38GB/s. It fell to 16.60GB/s at 1024MB, but the degradation was easier to read than sum's. Because copyto uses an optimized path, the operation as seen from Python may simply be running cleanly. There was not enough evidence to conclude which SIMD instructions were used or what the effective memory-channel value was. That needs a different measurement.
Suspect how much cache remains
A 0.68ms sum at 16MB does not feel like reading only from main memory. The array may still be in the cache hierarchy from earlier access, allowing the sum to read it there. Conversely, 1024MB cannot fit in cache. The 50.08ms time feels more like genuinely reading the complete array.
The apparent reversal between 64MB and 256MB is harder to explain. The five 64MB sum runs varied widely, and its median was not low. The 256MB runs stayed between 9.26ms and 14.97ms, with a 9.95ms median. Even when I thought I was changing only the array size, ordering, the preceding copy, and OS state could change what remained in cache. This is a pitfall worth remembering.
Before calling a NumPy calculation “memory-bandwidth bound,” the target size needs to be varied. Looking only at 16MB gives a fast 22.98GB/s world. Looking only at 64MB gives a slow 8.85GB/s world. Mixing in 1024MB finally produces 19.97GB/s, a value closer to main memory. I need to disrupt the order in the next run to investigate why the values move this much.
Next time, change the order
The operational conclusion is simple: it is not safe to extrapolate a large array's processing time directly from a small array. A 0.68ms sum at 16MB does not justify estimating 1024MB at 43.52ms. The measured value was 50.08ms.
It is also not safe to assume that 19GB/s will continue forever for copy. It dropped to 16.60GB/s at 1024MB. That difference should matter for code copying many large arrays.
Next, I would put slice assignment and a.copy() in the same table as NumPy copyto. I would also change the order of the sum tests and add a step that approximates explicit cache eviction. For now, I recorded the apparent bandwidth of NumPy 2.4.1 and stopped before reading into the implementation, so I am avoiding stronger claims. The next run should disrupt the order.