Writing codes in local namespace is slower than in global in PyPy3

Viewed 99

I tested the codes below with time function of bash.

# test_local.py
def main():
    n = 10 ** 7
    mod = 10 ** 9 + 7
    x = 0
    for i in range(n):
        x += i
        x %= mod


if __name__ == "__main__":
    main()
# test_global.py
n = 10 ** 7
mod = 10 ** 9 + 7
x = 0
for i in range(n):
    x += i
    x %= mod
Results:
python3 test_local.py  1.03s user 0.02s system 91% cpu 1.139 total
python3 test_global.py  1.92s user 0.01s system 98% cpu 1.956 total

pypy3 test_local.py  0.26s user 0.12s system 36% cpu 1.034 total
pypy3 test_global.py  0.13s user 0.03s system 97% cpu 0.161 total

Env:
CPython3 (3.8.2), PyPy3 (7.3.0)

Why is test_local.py slower than test_global.py in PyPy3, although the result is the opposite in CPython?


Update
Following the answer by Armin Rigo, I tried another code below. It works faster while being kept most parts in main().

#  test_global_constant.py
MOD = 10 ** 9 + 7


def main():
    n = 10 ** 7
    x = 0
    for i in range(n):
        x += i
        x %= MOD


if __name__ == "__main__":
    main()
Results:
python3 test_global_constant.py  1.08s user 0.01s system 99% cpu 1.099 total

pypy3 test_global_constant.py  0.12s user 0.03s system 95% cpu 0.164 total
1 Answers

The answer is probably in the "%" operator. PyPy has got a JIT that looks at one loop at a time (including all calls done by that loop, if any). In the first version of the code, the loop is compiled using "x %= mod" where the value of "mod" is not known to be a constant---it's just a value that comes from earlier in the function. It might look constant, but isn't completely: if you run the programs using, say, some debugging hooks, then you could conceivably have changed its value, before even entering the loop---i.e. before the JIT kicks in. For this reason, the JIT doesn't optimize for local variables being constant; additionally, this is to some extent the rare case: local variables are commonly not constant.

On the other hand, in the second case, the "x %= mod" uses the global variable "mod". Global variables are more commonly constants (for example, most global variables are actually functions or classes or numeric constants). So the JIT in PyPy contains special code to support that. Global variables are internally more complicated than local variables: they will remember if they have been changed, and as long as they haven't, then they will record which pieces of assembler have been generated assuming they are constant. So, as long as you don't change "mod", then the "x %= mod" will be compiled by the JIT assuming "mod" is exactly the constant 1000000007.

Why does it make a difference? Because division and modulo by a constant are replaced with clever code using a multiplication instead, using a well-known trick. GCC or any good C compiler uses a similar trick too. If you are interested in the details, the code replacing division by a constant with something based on multiplication is here (UINT_MUL_HIGH does "(x * y) >> 64" with unsigned 64-bit numbers x and y, which is a single assembler instruction): https://foss.heptapod.net/pypy/pypy/blob/branch/default/rpython/jit/metainterp/optimizeopt/intdiv.py

Related