TL;DR: the effect is caused by a combination of multiple technical details. It not only due to a branch prediction issue (as stated in the comment), but also due to the Numpy implementation which is not optimized for this use-case combined with poor compiler optimizations (especially GCC/MSVC). Compiling Numpy with Clang fixes the issue.
Under the hood
To perform this operation, Numpy calls the function generic_wrapped_legacy_loop
with another function called INT_remainder
passed in argument. The later computes the modulus for a single array item. It is generated from a relatively convoluted generic code (hard to locate) which can be found here. In your case, the function can be basically simplified to the following code:
static void INT_remainder(char **args, npy_intp const *dimensions, npy_intp const *steps, void *NPY_UNUSED(func)
{
BINARY_LOOP {
const int in1 = *(int*)ip1; // An item of the input array
const int in2 = *(int*)ip2; // The divisor which is set to 1
// Overflow check
if (NPY_UNLIKELY((in2 == 0) || (in1 == NPY_MIN_INT && in2 == -1))) {
FLAG_IF_DIVIDEBYZERO(in2);
*(int *)op1 = 0;
}
else
{
// handle mixed case the way Python does
const int rem = in1 % in2;
if ((in1 > 0) == (in2 > 0) || rem == 0) {
*(int*)op1 = rem;
}
else {
*(int*)op1 = rem + in2;
}
}
}
}
The first condition is an overflow check which should never happen in your case and it should be cheap since it is defined as being unlikely. In the second part, rem
should be always 0 in your case since in2
is 1. Thus, the if
condition is always true. However, it is executed lazily (from the left to the right) as defined in the C/C++ standard. This means in1 > 0
and in2 > 0
are evaluated first and then the equality, and finally rem == 0
only if the first is not true. The thing is in1 > 0
is sometimes true and sometimes false. The outcome of the in1 > 0
condition is dependent of the input array which is unpredictable. This is a problem for performance due to branch prediction. For more information, please read What is Branch Prediction?.
The second use-case (a2
) is faster because the condition is more often true so your CPU can predict that the value will be likely be true and speculatively take the resulting associated conditional branch. Meanwhile, in the first case, the CPU cannot predict the outcome of the condition so you pay the overhead of unpredictable conditional branch (e.g. pipeline stall).
This effect can be confirmed by profiling the assembly code of Numpy on my machine.
Notes and discussion
The fact that branch prediction is an issue here is sad here, because the outcome of the condition is eventually always true! If the condition was rem == 0 || (in1 > 0) == (in2 > 0)
, then there would not be any performance issue in this case. However, the issue would be with positive random integers modulo 2 (which is more frequent)...
**While (in1 > 0) == (in2 > 0)
is supposed to be a branch-less implementation, the target compiler choose to generate conditional branches in this case resulting in the observed effect. It would not be an issue with a branch-less assembly code. A branch-less implementation is often a bit slower than a well-predicted conditional branch and (significantly) faster than a miss-predicted conditional branch. Thus, it seems a good idea here.
Experimental solution
The target compiler is GCC (10.2) on my machine running on Linux. GCC tends to unfortunately generate such a code. pip
packages (like the one of Numpy) are generally built with GCC on Linux and AFAIK MSVC on Windows (generating a code often worst than GCC -- especially here). However, one can compile Numpy with another compiler. It turns out that Clang seems to generate a better code in this case (see on GodBolt). You can now easily build Numpy 2.0 with Clang and test that using the following commands:
# Packages Debian testing: sudo apt install virtualenv libpython3.13-dev build-essential pkg-config clang
cd /tmp
virtualenv venv
./venv/bin/pip install ipython meson ninja cython spin
export PATH=$PWD/venv/bin:$PATH
git clone https://github.com/numpy/numpy.git
cd numpy
git checkout v2.0.0
git submodule update --init
export CC=/usr/bin/clang
export CXX=/usr/bin/clang++
spin ipython
Here are the results on my machine (i5-9600KF CPU):
GCC 13.2.0 (i.e. with conditional branches):
0.7204837069984933
0.2300826290011173
0.7204179290001775
0.23008121000020765
Clang 16.0.6 (i.e. without conditional branches):
0.31062674399800017
0.3103496809999342
0.31044369300070684
0.3100759939989075
As expected, Clang produces a branch-less assembly code which is faster than the version using conditional branch of GCC when they are miss-predicted but not otherwise. It does not have the observed performance issue.
Note that Clang is the default compiler on MacOS so users running on MacOS should not observe this problem.
Please note that both implementation are sub-optimal for integers since they do not benefit from SIMD instruction (though this is not easy because there is no div
/idiv
instructions on mainstream CPUs). Not to mention one can just set the final array to 0 in this very-specific (unlikely) use-case.