# CPython 3.11+ Bytecode Specialization Guide

## Background: The Adaptive Interpreter (PEP 659)

Starting with CPython 3.11, the interpreter uses a **specializing adaptive interpreter**. After a bytecode instruction executes ~8 times at the same code location, CPython replaces it with a **specialized** variant that is optimized for the types it has seen.

For example, `BINARY_OP` (generic addition) becomes `BINARY_OP_ADD_INT` if both operands were always integers. The specialized version skips type checking and dispatches directly to the C-level integer addition.

**Why this matters for optimization**: Inspecting bytecode *after warmup* reveals which operations the interpreter has (or hasn't) been able to specialize. Unspecialized (ADAPTIVE) opcodes on hot paths indicate type instability or patterns that prevent optimization — these are concrete refactoring targets.

## How to Inspect

```bash
# Analyze a module's bytecode
$RUNNER -c "import dis; import mymodule; dis.dis(mymodule)"
# Or for a specific function:
$RUNNER -c "import dis; import mymodule; dis.dis(mymodule.target_function)"
```

The analysis:
1. Optionally runs tests (for correctness verification)
2. Imports the module and attempts in-process warmup
3. Uses `dis.get_instructions(code, adaptive=True)` to read specialized bytecodes
4. Reports which opcodes got specialized and which remain ADAPTIVE

## Opcode Families and Their Specialized Forms

### LOAD_ATTR — Attribute Access

| Opcode | Meaning | Performance |
|--------|---------|-------------|
| `LOAD_ATTR_ADAPTIVE` | Not yet specialized — type varies or insufficient executions | Slow — full MRO lookup each time |
| `LOAD_ATTR_INSTANCE_VALUE` | Instance attribute on class with `__dict__` | Fast — direct dict offset |
| `LOAD_ATTR_SLOT` | Instance attribute on class with `__slots__` | Fastest — fixed memory offset |
| `LOAD_ATTR_MODULE` | Module-level attribute (e.g., `math.sqrt`) | Fast — cached module dict version |
| `LOAD_ATTR_CLASS` | Class attribute (not instance) | Fast — cached type version |
| `LOAD_ATTR_WITH_HINT` | Instance attribute with dict key hint | Fast — uses hint to skip hash lookup |
| `LOAD_ATTR_METHOD_WITH_VALUES` | Bound method on instance | Fast — avoids creating bound method object |
| `LOAD_ATTR_METHOD_NO_DICT` | Method on slotted class | Fastest method dispatch |
| `LOAD_ATTR_METHOD_LAZY_DICT` | Method on class that hasn't created `__dict__` yet | Fast |

**Optimization signals**:
- `LOAD_ATTR_ADAPTIVE` remaining → the attribute target type varies. Check if different types flow through the same code path. Consider adding type consistency or `__slots__`.
- `LOAD_ATTR_INSTANCE_VALUE` → already specialized for dict-based access. Adding `__slots__` would upgrade this to `LOAD_ATTR_SLOT`.
- `LOAD_ATTR_SLOT` → already optimal for attribute access.

### STORE_ATTR — Attribute Store

| Opcode | Meaning |
|--------|---------|
| `STORE_ATTR_ADAPTIVE` | Not specialized — type instability |
| `STORE_ATTR_INSTANCE_VALUE` | Store to `__dict__`-based instance |
| `STORE_ATTR_SLOT` | Store to `__slots__`-based instance |
| `STORE_ATTR_WITH_HINT` | Store with dict key hint |

**Optimization signals**:
- `STORE_ATTR_INSTANCE_VALUE` → adding `__slots__` upgrades to `STORE_ATTR_SLOT`
- `STORE_ATTR_ADAPTIVE` → inconsistent object types being assigned to

### BINARY_OP — Arithmetic/Concatenation

| Opcode | Meaning |
|--------|---------|
| `BINARY_OP_ADAPTIVE` | Not specialized — mixed types |
| `BINARY_OP_ADD_INT` | Integer addition |
| `BINARY_OP_ADD_FLOAT` | Float addition |
| `BINARY_OP_ADD_UNICODE` | String concatenation |
| `BINARY_OP_SUBTRACT_INT` | Integer subtraction |
| `BINARY_OP_SUBTRACT_FLOAT` | Float subtraction |
| `BINARY_OP_MULTIPLY_INT` | Integer multiplication |
| `BINARY_OP_MULTIPLY_FLOAT` | Float multiplication |
| `BINARY_OP_INPLACE_ADD_UNICODE` | In-place string concatenation (`+=`) |

**Optimization signals**:
- `BINARY_OP_ADAPTIVE` → mixed numeric types. If `int + float` is common, consider normalizing to float early.
- `BINARY_OP_ADD_UNICODE` or `BINARY_OP_INPLACE_ADD_UNICODE` in a loop → string concatenation. Even though it's specialized, `.join()` is still algorithmically better for building strings.
- `BINARY_OP_ADD_INT` → fully optimized integer arithmetic. No action needed.

### BINARY_SUBSCR — Indexing/Subscription

| Opcode | Meaning |
|--------|---------|
| `BINARY_SUBSCR_ADAPTIVE` | Container or key type varies |
| `BINARY_SUBSCR_LIST_INT` | `list[int]` — direct C array access |
| `BINARY_SUBSCR_TUPLE_INT` | `tuple[int]` — direct C array access |
| `BINARY_SUBSCR_DICT` | `dict[key]` — hash lookup |
| `BINARY_SUBSCR_STR_INT` | `str[int]` — character access |

**Optimization signals**:
- `BINARY_SUBSCR_ADAPTIVE` → the container being indexed changes type. Pin the container type.
- `BINARY_SUBSCR_LIST_INT` → already optimal for list access. If the list is read-only, consider tuple for `BINARY_SUBSCR_TUPLE_INT` (same speed, less memory).

### CALL — Function/Method Calls

| Opcode | Meaning |
|--------|---------|
| `CALL_ADAPTIVE` | Call target varies |
| `CALL_PY_EXACT_ARGS` | Call to Python function with exact positional args |
| `CALL_PY_WITH_DEFAULTS` | Call to Python function using default args |
| `CALL_BUILTIN_CLASS` | Call to `int()`, `list()`, `dict()`, etc. |
| `CALL_BUILTIN_FAST_WITH_KEYWORDS` | C builtin accepting kwargs |
| `CALL_NO_KW_BUILTIN_FAST` | C builtin, no kwargs (fastest) |
| `CALL_NO_KW_LIST_APPEND` | `list.append()` — inlined |
| `CALL_NO_KW_LEN` | `len()` — inlined |
| `CALL_NO_KW_ISINSTANCE` | `isinstance()` — inlined |
| `CALL_NO_KW_METHOD_DESCRIPTOR_FAST` | Method like `str.join()`, `list.sort()` |
| `CALL_BOUND_METHOD_EXACT_ARGS` | Bound method with exact args |

**Optimization signals**:
- `CALL_ADAPTIVE` → the callable at this site changes between invocations (e.g., a callback parameter that varies). Consider specializing the call site or using `if/elif` dispatch instead of a generic callable.
- `CALL_PY_EXACT_ARGS` → already optimal for Python-to-Python calls.
- Presence of `CALL_NO_KW_LIST_APPEND` → the interpreter has inlined `list.append`. Good.

### COMPARE_OP — Comparisons

| Opcode | Meaning |
|--------|---------|
| `COMPARE_OP_ADAPTIVE` | Operand types vary |
| `COMPARE_OP_INT_FLOAT` | Comparing int to float |
| `COMPARE_OP_INT_INT` | Integer comparison |
| `COMPARE_OP_FLOAT_FLOAT` | Float comparison |
| `COMPARE_OP_STR_STR` | String comparison |

**Optimization signals**:
- `COMPARE_OP_ADAPTIVE` → types being compared vary. Ensure consistent types in sort keys, loop conditions, etc.

### FOR_ITER — Iterator Protocol

| Opcode | Meaning |
|--------|---------|
| `FOR_ITER_ADAPTIVE` | Iterator type varies |
| `FOR_ITER_LIST` | Iterating over a list |
| `FOR_ITER_TUPLE` | Iterating over a tuple |
| `FOR_ITER_RANGE` | Iterating over a range |
| `FOR_ITER_GEN` | Iterating over a generator |

**Optimization signals**:
- `FOR_ITER_ADAPTIVE` → the iterable type changes. If a function sometimes receives a list and sometimes a generator, the for loop can't specialize.
- `FOR_ITER_LIST` / `FOR_ITER_TUPLE` / `FOR_ITER_RANGE` → already optimal.

### UNPACK_SEQUENCE — Tuple/List Unpacking

| Opcode | Meaning |
|--------|---------|
| `UNPACK_SEQUENCE_ADAPTIVE` | Sequence type varies |
| `UNPACK_SEQUENCE_LIST` | Unpacking a list |
| `UNPACK_SEQUENCE_TUPLE` | Unpacking a tuple |
| `UNPACK_SEQUENCE_TWO_TUPLE` | Unpacking exactly 2-element tuple (optimized) |

### LOAD_GLOBAL — Global/Builtin Lookups

| Opcode | Meaning |
|--------|---------|
| `LOAD_GLOBAL_ADAPTIVE` | Module globals dict is unstable |
| `LOAD_GLOBAL_MODULE` | Module-level global |
| `LOAD_GLOBAL_BUILTIN` | Builtin name (len, range, etc.) |

**Optimization signals**:
- `LOAD_GLOBAL_ADAPTIVE` is rare — it means the module's `__dict__` is being modified at runtime (e.g., monkey-patching, dynamic imports).

## 3.12+ Changes (PEP 709)

Python 3.12 inlines comprehensions (list/set/dict comprehensions no longer create a separate frame). Evidence in bytecode:
- **Before 3.12**: Comprehensions show `MAKE_FUNCTION` + `CALL` (creating and calling an inner function)
- **3.12+**: Comprehension body is inlined — no `MAKE_FUNCTION`/`CALL` overhead
- `COPY_FREE_VARS` disappears from comprehension code objects

This means `map(c_func, iterable)` vs `[c_func(x) for x in iterable]` has negligible difference on 3.12+.

## Interpreting the Report

### Specialization Rate

The script reports a **specialization rate**: `specialized / (specialized + adaptive)`.

| Rate | Meaning |
|------|---------|
| **90-100%** | Excellent — code is type-stable, interpreter is fully optimized |
| **70-89%** | Good — a few polymorphic sites, investigate the ADAPTIVE ones |
| **50-69%** | Mixed — significant type instability, likely optimization opportunities |
| **< 50%** | Poor — code may not have been warmed up, or types are highly polymorphic |

### Low rate does NOT always mean "fix it"

- Functions that are only called once (setup, initialization) will have low rates — this is expected and not worth fixing
- The rate only matters for **hot path** functions confirmed by profiling
- Some ADAPTIVE opcodes are on cold branches (error handling, rare conditions) — ignore those

### What to do with ADAPTIVE opcodes on hot paths

1. **LOAD_ATTR_ADAPTIVE** → Add `__slots__`, use consistent types, avoid dynamic attributes
2. **BINARY_OP_ADAPTIVE** → Normalize types early (e.g., always convert to float before arithmetic)
3. **CALL_ADAPTIVE** → Replace callback patterns with direct calls or `if/elif` dispatch
4. **BINARY_SUBSCR_ADAPTIVE** → Use consistent container types
5. **FOR_ITER_ADAPTIVE** → Accept/convert to a specific iterable type at function entry
6. **COMPARE_OP_ADAPTIVE** → Ensure consistent types in comparisons

## Limitations

- **In-process warmup is limited**: The script can only warm up zero-arg functions automatically. Functions needing complex setup get their ADAPTIVE counts from an un-warmed state. Running the test suite in-process would give better results but is complex to set up.
- **Subprocess tests don't transfer**: Running `pytest` in a subprocess warms up that process's bytecode, not ours. The subprocess run is for correctness verification only.
- **Code objects are per-import**: Re-importing a module creates fresh code objects. The specialization state is on the code object from the running process.
- **Specialization can deopt**: Even specialized opcodes can deoptimize if they encounter an unexpected type. A function that was 100% specialized during warmup may deopt at runtime with different data.
- **Not all opcodes specialize**: Many opcodes (LOAD_FAST, STORE_FAST, POP_TOP, etc.) don't have specialized forms — they're already optimal. Only "interesting" opcodes that interact with Python's type system have ADAPTIVE/specialized variants.
