NumPy 2.4.1 matmul dipped at 512: 74.77 GFLOPS
In this article
When I measured float64 matrix multiplication with NumPy 2.4.1, the results were 78.03 GFLOPS for 256x256, 74.77 GFLOPS for 512x512, 135.83 GFLOPS for 1024x1024, and 149.66 GFLOPS for 2048x2048.
I expected the results to look steadily faster as the size increased, but that expectation was wrong. The 256x256 median was only 0.43ms, while 512x512 took 3.59ms. It is natural for elapsed time to increase with the amount of computation, but in GFLOPS terms, only 512x512 formed a dip below 256x256. It rose again at 1024x1024, which looked strange if you expected a monotonic curve.
That non-monotonic shape was the most interesting part. Small matrices are strongly affected by call overhead and timer resolution. It is dangerous to read 78.03 GFLOPS at 256x256 as “smaller is more efficient”; seeing the short 0.43ms should instead have made me question the measurement itself. I only recognized that afterward.
The measurement log is dated June 14, 2026, with the timestamp 2026-06-14T20:17:54. This article focuses on the benchmark results themselves; why only the intermediate size dipped is left as a task for next time.
Multiplying square matrices with @
I measured @ between square matrices, in other words NumPy matrix multiplication. The type was float64 only. I changed n to 256, 512, 1024, and 2048, ran each size five times, and took the median.
GFLOPS was calculated with this formula.
GFLOPS = 2 * n^3 / seconds / 1e9
Matrix multiplication performs many multiply-add operations, so I used the common approximation of 2 * n^3 FLOP. seconds is the median elapsed time. For example, if n is 2048 and the median is 114.79ms, the formula shows how many floating-point operations were completed in that time.
Python was 3.12.10 and NumPy was 2.4.1. The machine was a Surface Pro 11th Edition running Windows on ARM64, measured in its normal everyday state. This was not an experiment with power and temperature fully controlled, so read it as a log of how my environment looked, not as a table of absolute performance.
scipy-openblas underneath
config.show_config showed that NumPy was using the scipy-openblas BLAS. It was OpenBLAS 0.3.30, with the configuration string NO_AFFINITY ARMV8 MAX_THREADS=24. LAPACK also pointed to the same scipy-openblas.
Whether NumPy @ is fast or slow depends less on the speed of a Python loop than on the BLAS kernel selected underneath. In this log, both the build side and the host side were aarch64, and the SIMD baseline listed NEON, NEON_FP16, NEON_VFPV4, and ASIMD.
At least this was not a case of calling BLAS through x64 emulation; that part has been separated out. On the other hand, not found listed ASIMDH P, ASIMDDP, and ASIMDH M. I discarded the loose assumption that ARM64 automatically means every maximum instruction is used. The log shows that OpenBLAS is running as ARMV8, but it does not tell us how far it can select a kernel tuned for Snapdragon X.
The table where only 512 looks like a dip
Here are all five runs. The median looks neat, but the same size also varied considerably.
| n | runs_ms | median_ms | GFLOPS |
|---|---|---|---|
| 256 | 0.35 / 0.52 / 0.43 / 0.37 / 0.56 | 0.43 | 78.03 |
| 512 | 5.16 / 3.59 / 5.75 / 2.26 / 1.99 | 3.59 | 74.77 |
| 1024 | 24.37 / 12.33 / 15.16 / 21.64 / 15.81 | 15.81 | 135.83 |
| 2048 | 130.34 / 114.79 / 93.62 / 105.87 / 140.4 | 114.79 | 149.66 |
The variation at 512x512 was especially visible. The shortest run was 1.99ms and the longest was 5.75ms, quite far apart for the same operation. It is weak evidence for saying “512 is slow” based only on 74.77 GFLOPS calculated from the 3.59ms median. Not fixing the thread count was a problem: OpenBLAS still had room to parallelize on its own.
Even so, the overall shape still has a dip. At 1024x1024, the median was 15.81ms and 135.83 GFLOPS; at 2048x2048, it reached 149.66 GFLOPS from a 114.79ms median. Only 512x512 fell below the 78.03 GFLOPS at 256x256. Those are the numbers.
Question the measurement before celebrating a high number
When I first saw 78.03 GFLOPS, I thought 256x256 was doing surprisingly well. Looking again at the 0.43ms elapsed time, however, showed how short this was as a benchmark. Call overhead, array state, timer resolution, and the thread state immediately beforehand can all be mixed in. It was a mistake to treat that lightly.
The log cannot tell whether the 512x512 dip came from an internal OpenBLAS threshold, thread startup or partitioning, or simply too few runs. Writing “the cause is this” would be wrong. What I can say is that NumPy 2.4.1 with OpenBLAS 0.3.30 looked non-monotonic in these five measurements.
Not fixing the thread count was another major oversight. Since the output says MAX_THREADS=24, it would be unreasonable to turn this into a per-core GFLOPS claim. That condition should have been decided before measuring; noticing it afterward was a significant failure.
I did not measure float32 either. For machine-learning or image-processing use, float32 may be the more relevant case, but this log is float64 only. Mixing in why 512 looked like a dip would make that a separate article.
Fix the thread count next time
I want to repeat the measurement with the thread count fixed: one thread, OpenBLAS-managed, and, if possible, several fixed values separately. Without that, it is impossible to distinguish whether the 512x512 dip is about kernel selection or parallelization startup.
I do not intend to decide that NumPy is simply fast or slow from this result. But NumPy 2.4.1 installed normally with pip on Windows ARM64 processed matrix multiplication using the ARMV8 configuration of OpenBLAS 0.3.30, reaching 149.66 GFLOPS at 2048x2048. That is what was actually measured.
The caution is not to celebrate a benchmark that is too short. The 0.43ms at 256x256 looks fast, but measurement mechanics contribute a lot. Before interpreting the 74.77 GFLOPS dip at 512x512, the next step is to check whether it reproduces under the same conditions.