Overhead. Each operation translates to Python bytecode, and the Python interpreter performs a full loop of the core for each bytecode instruction.
The humble
a + b
is LOAD_FAST a
LOAD_FAST b
BINARY_ADD
each of which gets painstakenly executed by the corresponding completely static handler which yields something along the lines of: fetch the bytecode
jump to the handler
access the function locals
push the value for `a` (which TBF is just an offset into an array) onto the stack
increment the bytecode index
fetch the bytecode
jump to the handler
access the function locals
push the value for `b` onto the stack
increment the bytecode index
fetch the bytecode
jump to the handler
popp both values off the stack
dereference the type of `a`
look for the pointer to the add method
check if it's set
call it with `a` and `b`
which performs various runtime typechecks (e.g. are both parameters objects and integers) and does the actual addition
push the result back onto the stack
increment the bytecode index
Assuming a hot loop, a JIT might literally just emit an assembly-level add r10, r11
or whatever register it allocated to those locals.An other component is that these are likely comparing apples and pears: CPython uses infinite-precision integer arithmetics. And due to not using a JIT it has no way to even remotely optimise any of that away. Infinite precision arithmetics are pretty expensive as they require lots of overflow checking.
You want to be either full JIT compiled code, or vice versa have as much interpreter functions, and libraries written in native code, and style the API to avoid loops.
So it can emit extremely efficient instructions based on this assumption, while CPython struggles along with infinite precision numbers.
Function calls will have a much lower overhead also since it will just be a single `call` instruction.