Skip to content

JIT: stale recorded_values leak when specialized op deopts #148571

Description

@NekoAsakura

Bug report

Bug description:

  1. TRACE_RECORD fills recorded_values before dispatching the instruction body

cpython/Python/bytecodes.c

Lines 6366 to 6372 in 52a7f1b

const _PyOpcodeRecordEntry *record_entry = &_PyOpcode_RecordEntries[opcode];
for (int i = 0; i < record_entry->count; i++) {
_Py_RecordFuncPtr doesnt_escape = _PyOpcode_RecordFunctions[record_entry->indices[i]];
doesnt_escape(frame, stack_pointer, oparg, &tracer->prev_state.recorded_values[i]);
}
tracer->prev_state.recorded_count = record_entry->count;
DISPATCH_GOTO_NON_TRACING();

At this point, recorded_values[0..record_entry->count-1] hold the values recorded for the specialised opcode that is about to run. For LOAD_ATTR_CLASS_WITH_METACLASS_CHECK in #148394, the record entry is _RECORD_TOS, so recorded_values[0] holds the raw TOS (PyObject * itself). Unlike _RECORD_TOS_TYPE stores Py_TYPE(tos), which is always a PyTypeObject *.

  1. The specialised body hits EXIT_IF when its guard fails. Then EXIT_IF / DEOPT_IF compile down to a plain goto through JUMP_TO_PREDICTED, with no cleanup in the emitted code:

self.emit(f"JUMP_TO_PREDICTED({self.jump_prefix}{family_name});\n")

# define JUMP_TO_PREDICTED(name) goto PREDICTED_##name;

Nothing on this path touches tracer->prev_state.recorded_values[].

  1. _SPECIALIZE_LOAD_ATTR calls the adaptive specializer at the same pc.

Call chain:

_SPECIALIZE_LOAD_ATTR
  → _Py_Specialize_LoadAttr
      → specialize(instr, LOAD_ATTR_SLOT)
          → set_opcode(instr, LOAD_ATTR_SLOT)
              → instr->op.code = opcode;

Once _Py_Specialize_LoadAttr returns, the pc that previously held LOAD_ATTR_CLASS_WITH_METACLASS_CHECK (181) now holds, for example, LOAD_ATTR_SLOT (191). Control then falls through to DISPATCH_SAME_OPARG(), which re-reads the opcode byte and dispatches the new specialisation at the same pc:

#define DISPATCH_SAME_OPARG() \
{ \
opcode = next_instr->op.code; \
PRE_DISPATCH_GOTO(); \
DISPATCH_GOTO_NON_TRACING(); \
}

Because that dispatch uses DISPATCH_GOTO_NON_TRACING, the new specialisation runs without re-entering TRACE_RECORD.

  1. After the re-specialised op completes, the next TRACE_RECORD translates against a stale picture.

It reads prev_state.instr->op.code. prev_state.instr is still the pointer set in step 1; only the byte at that address has changed.

  1. Translate feeds the stale recording into the new opcode's uops

Inside translate, each uop in the macro expansion of the current opcode byte (e.g. 191 = LOAD_ATTR_SLOT) that carries HAS_RECORDS_VALUE_FLAG is handed recorded_values[record_idx] as its operand0. There is no check that the recording actually came from the opcode currently being translated:

cpython/Python/optimizer.c

Lines 949 to 954 in 52a7f1b

else if (_PyUop_Flags[uop] & HAS_RECORDS_VALUE_FLAG) {
PyObject *recorded_value = tracer->prev_state.recorded_values[record_idx];
tracer->prev_state.recorded_values[record_idx] = NULL;
record_idx++;
operand = (uintptr_t)recorded_value;
}

LOAD_ATTR_SLOT begins with _RECORD_TOS_TYPE, which expects a PyTypeObject:

cpython/Python/bytecodes.c

Lines 2883 to 2890 in 52a7f1b

macro(LOAD_ATTR_SLOT) =
unused/1 +
_RECORD_TOS_TYPE +
_GUARD_TYPE_VERSION +
_LOAD_ATTR_SLOT + // NOTE: This action may also deopt
POP_TOP +
unused/5 +
_PUSH_NULL_CONDITIONAL;

But the value still sitting in recorded_values[0] is whatever _RECORD_TOS stored for the old 181 — Boom.


The only existing cleanup in _PyJit_FinalizeTracing runs when tracing ends — far too late for a deopt that occurs mid-trace:

cpython/Python/optimizer.c

Lines 1126 to 1129 in 52a7f1b

for (int i = 0; i < MAX_RECORDED_VALUES; i++) {
Py_CLEAR(tracer->prev_state.recorded_values[i]);
}
tracer->prev_state.recorded_count = 0;

CPython versions tested on:

CPython main branch

Operating systems tested on:

Linux

Linked PRs

Activity

  1. NekoAsakura commented on Apr 14, 2026

    @NekoAsakura
    MemberAuthor

    typedef struct _PyJitTracerPreviousState {
    int instr_oparg;
    int instr_stacklevel;
    _Py_CODEUNIT *instr;
    PyCodeObject *instr_code; // Strong
    struct _PyInterpreterFrame *instr_frame;
    PyObject *recorded_values[MAX_RECORDED_VALUES]; // Strong, may be NULL
    int recorded_count;
    } _PyJitTracerPreviousState;

    I think we can add a recorded_opcode here to check the invariant.

  2. Fidget-Spinner commented on Apr 15, 2026

    @Fidget-Spinner
    Member

    @NekoAsakura we actually check for deopt in the trace translator, and should already be protecting against this. You can add the clearing of recorded values to this branch https://github.com/python/cpython/blob/main/Python/optimizer.c#L665

    The fact this is allowed through means the deopt detection is not working?

  3. NekoAsakura commented on Apr 15, 2026

    @NekoAsakura
    MemberAuthor

    I've confirmed via instrumentation that the leak exists; let me look into why this deopt detection isn't being triggered.

  4. NekoAsakura commented on Apr 15, 2026

    @NekoAsakura
    MemberAuthor

    set_counter((_Py_BackoffCounter *)instr + 1, adaptive_counter_cooldown());

    cpython/Python/optimizer.c

    Lines 673 to 676 in d14e31e

    if (backoff != adaptive_counter_cooldown().value_and_backoff &&
    backoff != trigger_backoff_counter().value_and_backoff) {
    OPT_STAT_INC(trace_immediately_deopts);
    opcode = _PyOpcode_Deopt[opcode];

    It doesn't consider re-specialisation at a pc that was already being traced.

  5. Fidget-Spinner commented on Apr 15, 2026

    @Fidget-Spinner
    Member

    re-specialization is fine (we need that to support polymorphism)

  6. Fidget-Spinner commented on Apr 15, 2026

    @Fidget-Spinner
    Member

    A possible fix is to ensure that all recording ops are same throughout a family (we can verify this in the cases generator)

  7. NekoAsakura commented on Apr 15, 2026

    @NekoAsakura
    MemberAuthor

    Sorry I'm not quite following. Are you saying we'd have to migrate every LOAD_ATTR_* to _RECORD_TOS and rework the downstream guards to derive the type themselves? Wouldn't that bump up the maintenance overhead a fair bit, and mean that every future specialisation wanting a different record shape triggers another family-wide migration?

  8. NekoAsakura commented on Apr 15, 2026

    @NekoAsakura
    MemberAuthor

    I think we can add a recorded_opcode here to check the invariant.

    My original idea is set it at record and check if tracer->prev_state.recorded_opcode != opcode in translate.

  9. cocolato commented on Apr 15, 2026

    @cocolato
    Member

    I'm concerned that adding recorded_opcode will only help us exits correctly and won't enable re-specialization.

  10. Fidget-Spinner commented on Apr 15, 2026

    @Fidget-Spinner
    Member

    Sorry I'm not quite following. Are you saying we'd have to migrate every LOAD_ATTR_* to _RECORD_TOS and rework the downstream guards to derive the type themselves? Wouldn't that bump up the maintenance overhead a fair bit, and mean that every future specialisation wanting a different record shape triggers another family-wide migration?

    Yes but have a stronger invariant: We record the exact same way for every op in a specialization family. We need this to handle respecialization correctly.

    Eg, just specify base LOAD_ATTR as RECORD_TOS, and then that automatically means everything in family(LOAD_ATTR) has that recording op. We then call recording_func[_PyOpcode_Deopt[opcode]].

    This is required to safely handle the respecialization to another op, and also ensure we decref properly. If we ensure uniformity across the whole family and make it specializaion agnostic, it will handle specialization.

  11. Fidget-Spinner commented on Apr 15, 2026

    @Fidget-Spinner
    Member
  12. NekoAsakura commented on Apr 15, 2026

    @NekoAsakura
    MemberAuthor

    I'm concerned that adding recorded_opcode will only help us exits correctly and won't enable re-specialization.

    I think DISPATCH_SAME_OPARG is the only site that mutates the byte at the current pc while tracing, so we can slot a small function in there. Bail early if the new opcode matches what we already recorded, otherwise drop the stale snapshot and rebuild it for the new opcode via _PyOpcode_RecordFunctions against the current stack. That keeps the trace going with re-specialisation.

    #define DISPATCH_SAME_OPARG() \
    { \
    opcode = next_instr->op.code; \
    PRE_DISPATCH_GOTO(); \
    DISPATCH_GOTO_NON_TRACING(); \
    }

  13. Fidget-Spinner commented on Apr 15, 2026

    @Fidget-Spinner
    Member

    We likely can't slot a function in DISPATCH_SAME_OPARG, as that will slow down the normal (non-tracing interpreter).

  14. NekoAsakura commented on Apr 15, 2026

    @NekoAsakura
    MemberAuthor

    Oh I think IS_JIT_TRACING() is cheap enough, and we'd only call that function when tracing.

  15. Fidget-Spinner commented on Apr 15, 2026

    @Fidget-Spinner
    Member

    Hmm, when I wrote the initial tracing frontend, Mark was quite adamant about not having IS_JIT_TRACING() checks over the normal interpreter code. At the time I didn't really appreciate it but I now think he's right. As far as possible, the normal interpreter's behaviour should not be modified for tracing. We want to check for less state and reason about things easier.

  16. NekoAsakura commented on Apr 15, 2026

    @NekoAsakura
    MemberAuthor

    Fair point. I'll wait until you've had enough time to talk it through.

  17. markshannon commented on Apr 16, 2026

    @markshannon
    Member

    As @Fidget-Spinner says, this is one of the cases (like stack effect) where we must ensure that all instructions in a family act the same.
    All family members will need to pre-record (in TRACE_RECORD) the same set of values, and then process them differently when adding uops to the trace.

    We must not record values in the trace when we only need the type, so we would need to extract the actual value we need when storing into the trace.

    In the example above, we would record the TOS value for all members of the LOAD_ATTR family, and then process that differently for each uop.
    The _RECORD_TOS in LOAD_ATTR_CLASS we would simply copy the value into the uop.
    The _RECORD_TOS_TYPE in LOAD_ATTR_SLOT, we would need to extract the class, incref the class and decref the value.

    This is going to be a bit slower (an extra incref and decref in many cases), but should be robust.

  18. NekoAsakura commented on Apr 16, 2026

    @NekoAsakura
    MemberAuthor

    So I'm thinking: add _RECORD_TOS to base LOAD_ATTR and override all LOAD_ATTR_* entries in generate_recorder_tables to use the base's recording. Then in translation, if a uop expects a type, convert it:

    PyObject *type = (PyObject *)Py_TYPE(recorded_value);
    Py_INCREF(type);
    Py_DECREF(recorded_value);
    recorded_value = type;
    

    cpython/Python/optimizer.c

    Lines 949 to 950 in 0fcf2b7

    else if (_PyUop_Flags[uop] & HAS_RECORDS_VALUE_FLAG) {
    PyObject *recorded_value = tracer->prev_state.recorded_values[record_idx];

    Does that sound right to you?

  19. NekoAsakura commented on Apr 16, 2026

    @NekoAsakura
    MemberAuthor

    Affected: TO_BOOL, BINARY_OP, SEND, STORE_ATTR, LOAD_SUPER_ATTR, LOAD_ATTR, FOR_ITER, CALL, CALL_KW, CALL_FUNCTION_EX.

    import re
    text = open("Python/bytecodes.c").read()
    families = {n: [x.strip() for x in body.split(',') if x.strip()]
                for n, body in re.findall(r'family\((\w+),[^)]+\)\s*=\s*\{([^}]+)\}', text)}
    macro_recs = {n: tuple(re.findall(r'_RECORD_\w+', body))
                  for n, body in re.findall(r'macro\((\w+)\)\s*=\s*([^;]+);', text)}
    fmt = lambda r: ", ".join(r) or "(none)"
    for fam, members in families.items():
        shapes = {macro_recs.get(n, ()) for n in [fam] + members}
        if len(shapes) < 2: continue
        base = macro_recs.get(fam, ())
        print(f"\n=== {fam} ({len(shapes)} shapes) ===\n  base: {fmt(base)}")
        for mem in members:
            rec = macro_recs.get(mem, ())
            print(f"  {mem}: {fmt(rec)}{'  <--' * (rec != base)}")
    
  20. cocolato commented on Apr 17, 2026

    @cocolato
    Member

    So I'm thinking: add _RECORD_TOS to base LOAD_ATTR and override all LOAD_ATTR_* entries in generate_recorder_tables to use the base's recording. Then in translation, if a uop expects a type, convert it:

    PyObject *type = (PyObject *)Py_TYPE(recorded_value);
    Py_INCREF(type);
    Py_DECREF(recorded_value);
    recorded_value = type;
    

    cpython/Python/optimizer.c

    Lines 949 to 950 in 0fcf2b7

    else if (_PyUop_Flags[uop] & HAS_RECORDS_VALUE_FLAG) {
    PyObject *recorded_value = tracer->prev_state.recorded_values[record_idx];

    Does that sound right to you?

    That makes sense to me. Would you want to work on this?

  21. cocolato commented on Apr 17, 2026

    @cocolato
    Member

    I think you need a dispatch table like this:

    record_trace_transforms[MAX_UOP_ID] = {
        [_RECORD_TOS_TYPE] = record_trace_transform_to_type,
        [_RECORD_NOS_GEN_FUNC] = record_trace_transform_gen_func,
    };
    

    Because for the FOR_ITER family we need _RECORD_NOS_GEN_FUNC,
    and for the SEND family, we might need to add _RECORD_3OS.
    Then you can convert them when storing them into the trace

    transform = record_trace_transforms[uop];
    if (transform != NULL && recorded_value != NULL) {
        recorded_value = transform(recorded_value);
    }
    
  22. added a commit that references this issue on Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)topic-JITtype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions