There are a couple of ways to verify this optimization is happening. From inside the process you can query the executors, e.g. by using this utility function (it’ll only tell you whether f is JITted, doesn’t work on `getattr``):
import _opcode
import types
def is_jitted(f: types.FunctionType) -> bool:
for i in range(0, len(f.__code__.co_code), 2):
try:
_opcode.get_executor(f.__code__, i)
except RuntimeError:
# This isn't a JIT build:
return False
except ValueError:
# No executor found:
continue
return True
return False
From the outside, you can generate debug traces like this:
$ PYTHON_LLTRACE=2 ~/projects/jit_cpython/python -c "class Object:
def __init__(self):
self.foo = 42
self.bar = 24
obj = Object()
def f(obj: object, attribute: str):
return getattr(obj, attribute)
for _ in range(1_000_000):
f(obj, 'foo')
f(obj, 'bar')
" > lltrace.txt
This will create a file with lines like:
Tracing <module> (<string>:1) at byte offset 68 at chain depth 0
1 ADD_TO_TRACE: _START_EXECUTOR (0, target=55, operand0=0x721685e005ce, operand1=0)
2 ADD_TO_TRACE: _MAKE_WARM (0, target=0, operand0=0, operand1=0)
0x721685e00490 55: JUMP_BACKWARD(23) 0
3 ADD_TO_TRACE: _CHECK_VALIDITY (0, target=55, operand0=0, operand1=0)
4 ADD_TO_TRACE: _SET_IP (0, target=55, operand0=0x721685e005ce, operand1=0)
5 ADD_TO_TRACE: _CHECK_PERIODIC (0, target=55, operand0=0, operand1=0)
Trace continuing (fitness=2494)
0x721685e00490 34: FOR_ITER_RANGE(21) 0
6 ADD_TO_TRACE: _CHECK_VALIDITY (0, target=34, operand0=0, operand1=0)
7 ADD_TO_TRACE: _SET_IP (0, target=34, operand0=0x721685e005a4, operand1=0)
8 ADD_TO_TRACE: _ITER_CHECK_RANGE (21, target=34, operand0=0, operand1=0)
9 ADD_TO_TRACE: _GUARD_NOT_EXHAUSTED_RANGE (21, target=58, operand0=0, operand1=0)
10 ADD_TO_TRACE: _ITER_NEXT_RANGE (21, target=34, operand0=0, operand1=0)
Trace continuing (fitness=2485)
[...]
Asking Claude to analyze the debug output, the calls to getattr are never fully optimized. The lookup for getattr is optimized, the callable becomes a known constant, the call to f is inlined, etc. But what you’d like to see happen doesn’t. Let me quote Claude:
“”"
Why
Python/optimizer_bytecodes.c has constant-folding/specialization rules for exactly six builtins — isinstance, len, type, str, tuple, list.append. getattr is not among them, and the generic rule is a black box:
op(_CALL_BUILTIN_FAST, (callable, self_or_null, args[oparg] -- callable, self_or_null, args[oparg])) {
callable = sym_new_not_null(ctx);
}
This is true even though the optimizer does know the name statically here: f is inlined into the module trace with 'foo'/'bar' pushed as _LOAD_CONST_INLINE_BORROW. The information is available; nothing consumes it.
“”"