Can the CPython JIT optimise `getattr` with constant arguments?

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.
“”"