pythonconstantsbytecodeobject-pooling

Why does `x = 1234`, `y = 1234`, `x is y` return False in the REPL, but True in a stand-alone script?


I know there is something called interning in python, so basically

x, y = 1, 1
print(x is y) # True
x = 1234
y = 1234
print(x is y) # False

However when I wrap it into a script and run with python command it prints True twice. My guess is there are some optimizations under the hood but I cannot find any reference of them. Could someone explain what causes such behaviour and how to run that script without it?

I am on Ubuntu 20 and use CPython, version Python 3.9.9+ [GCC 9.3.0] on linux if that matters.


Solution

  • It’s called constant pooling, and it’s a pretty standard technique when implementing interpreters.

    >>> def f():
    ...     x = 1234
    ...     y = 1234
    ...     return x is y
    ...
    >>> f()
    True
    >>> import dis
    >>> dis.dis(f)
      2           0 LOAD_CONST               1 (1234)
                  2 STORE_FAST               0 (x)
    
      3           4 LOAD_CONST               1 (1234)
                  6 STORE_FAST               1 (y)
    
      4           8 LOAD_FAST                0 (x)
                 10 LOAD_FAST                1 (y)
                 12 IS_OP                    0
                 14 RETURN_VALUE
    

    Each closed (self-contained) piece of bytecode carries a constant pool with it. When the compiler parses a suite as a single unit, literals found in the code at compile time are added into the pool; when the same value is encountered again, the constant pool slot is reused. When the function bytecode is later executed, the values are loaded from the pool onto the value stack, and then manipulated there. Here, both instances of the literal 1234 end up as reads from the same pool slot 1 (slot 0 is reserved for None). Because they read from the same slot, they end up reading the same object, which is of course, the same as itself.

    Pooling can be applied not only to literals, but also to values obtained by constant folding:

    >>> def g():
    ...     x = 4
    ...     y = 2 + 2
    ...     return x is y
    ...
    >>> dis.dis(g)
      2           0 LOAD_CONST               1 (4)
                  2 STORE_FAST               0 (x)
    
      3           4 LOAD_CONST               1 (4)
                  6 STORE_FAST               1 (y)
    
      4           8 LOAD_FAST                0 (x)
                 10 LOAD_FAST                1 (y)
                 12 IS_OP                    0
                 14 RETURN_VALUE
    

    At the REPL prompt, every prompt triggers a separate compilation, which does not share a constant pool with any other; doing otherwise would arguably amount to having a memory leak. As such, number literals that are not otherwise interned end up referring to different objects when they are provided at different prompts.

    >>> x = 1234
    >>> y = 1234
    >>> id(x)
    140478281905648
    >>> id(y)
    140478281906160
    >>> x is y
    False
    

    Constant pooling is pretty fundamental to the design of CPython and cannot be disabled as such. After all, the bytecode has no way to refer to a hardcoded value other than by referring to the constant pool. There is also no option that disables reusing constant pool slots for already-encountered values. But if you’re crazy enough…

    def deadpool(func):
        import dis
        import opcode
        import functools
    
        new_cpool = [None]
        new_bcode = bytearray(func.__code__.co_code)
        _Func = type(lambda: 0)
    
        def pool(value):
            idx = len(new_cpool)
            new_cpool.append(value)
            return idx
    
        def clone(val):
            if isinstance(val, int):
                return int(str(val))
            return val
    
        op_EXTENDED_ARG = opcode.opmap['EXTENDED_ARG']
        op_LOAD_CONST = opcode.opmap['LOAD_CONST']
    
        insn_ext = None
        for insn in dis.get_instructions(func):
            if insn.opcode == op_LOAD_CONST:
                idx = pool(clone(func.__code__.co_consts[insn.arg]))
                assert idx < 256 or (idx < 65536 and had_ext)
                new_bcode[insn.offset + 1] = idx & 0xff
                if insn_ext:
                    new_bcode[insn_ext.offset + 1] = idx >> 8
                insn_ext = None
            elif insn.opcode == op_EXTENDED_ARG:
                assert insn_ext is None
                insn_ext = insn
            else:
                insn_ext = None
    
        return functools.wraps(func)(_Func(
            func.__code__.replace(
                co_code=bytes(new_bcode),
                co_consts=tuple(new_cpool)
            ),
            func.__globals__,
            func.__name__,
            func.__defaults__,
            func.__closure__
        ))
    
    def f():
        x = 1234
        y = 1234
        return x is y
    
    @deadpool
    def g():
        x = 1234
        y = 1234
        return x is y
    
    print(f())   # True
    print(g())   # False
    

    …you can re-write the bytecode so that each constant load refers to a different slot, and then attempt to put a distinct, though otherwise indistinguishable object in each slot. (The above is just a proof-of-concept; there are some corner cases on which it fails, which would be much more laborious to cover fully.)

    The above can be made to run in PyPy with only slight modifications. The results, however, will be different, because PyPy does not expose the identity of integer objects and always compares them by value, even when using the is operator. And after all, why should it not? As the other answer rightly points out, identity of primitives is an implementation detail with which you should not be concerned when writing ordinary code, and even most extraordinary code.