Skip to content

gh-143732: Specialize __setitem__ dunder method for STORE_SUBSCR - #2

Closed
johng wants to merge 1 commit into
mainfrom
gh-143732-store-subscr-py-dunder-v2
Closed

gh-143732: Specialize __setitem__ dunder method for STORE_SUBSCR#2
johng wants to merge 1 commit into
mainfrom
gh-143732-store-subscr-py-dunder-v2

Conversation

@johng

@johng johng commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Reworked take on python#156033, which was closed with:

This does not implement the specialization of STORE_SUBSCR for __setitem__ implemented in Python, as described in the issue.

Single commit, rebased on upstream 24e5a55ccb. This fork's main has been fast-forwarded to the same commit, so the diff is exactly this change.

(Supersedes #1, which targeted a temporary base branch and was auto-closed by the squash.)

Why the old approach didn't satisfy the issue

The previous PR called the dunder through an ordinary re-entrant vectorcall (_PyObject_VectorcallTstate). pythongh-143732 asks these specializations to jump directly into the method, using LOAD_ATTR_GETATTRIBUTE_OVERRIDDEN as the template, so the JIT can trace through the call. A vectorcall gets the interpreter win but not the inlined frame, which is the actual point.

The obstacle

You cannot simply push the __setitem__ frame the way BINARY_OP_SUBSCR_GETITEM pushes __getitem__:

  • _RETURN_VALUE is (retval -- res). A returning frame always pushes exactly one value onto the caller.
  • BINARY_OP_SUBSCR_GETITEM is declared pops 2 / pushes 0; the +1 arrives on return, netting −1 to match generic BINARY_OP.
  • STORE_SUBSCR must net −3. A frame-pushing member pops 3, declares 0 pushed, gains +1 on return → −2. Off by one, with no fourth operand to absorb it.

__setitem__ returns a None that nothing consumes.

Approach

A shim frame (_Py_SetItemCleanup) is pushed underneath the __setitem__ frame, exactly as CALL_ALLOC_AND_ENTER_INIT uses _Py_InitCleanup for __init__. The dunder returns into the shim; the shim's sole instruction EXIT_SETITEM discards that value and pops the shim without pushing, keeping the net effect at −3.

The guard and frame push are deliberately a single uop. Splitting them, mirroring _BINARY_OP_SUBSCR_CHECK_FUNC, puts the looked-up function on the caller's value stack as a temporary; STORE_SUBSCR already occupies three stack slots, so that extra slot overruns co_stacksize and trips ASSERT_WITHIN_STACK_BOUNDS:

Stack overflow (depth = 6) at Python/generated_cases.c.h:12933

BINARY_OP_SUBSCR_GETITEM survives it because BINARY_OP occupies only two. Worth knowing this is invisible to both a release build and the code generators — only --with-pydebug catches it.

The function is cached on the heap type (_spec_cache.setitem, mirroring _spec_cache.getitem) rather than in the inline cache, so STORE_SUBSCR's cache size stays at 1 — the old PR grew it to 3, bloating every STORE_SUBSCR.

Tier 1 only; tier 2 / JIT support is deliberately left for a follow-up. executor_cases.c.h, optimizer_cases.c.h and pycore_uop_ids.h are untouched.

Testing

Built --with-pydebug and verified:

Check Result
test_opcache 82/82, incl. 4 new sub-tests
14 targeted suites 4,369 tests pass
Specialization c[i] = vSTORE_SUBSCR_PY_DUNDER
Traceback ['<module>', '__setitem__'] — shim invisible
sys.settrace sees only __setitem__
Deopt on rebinding __setitem__ correct old→new dispatch
Deep recursion clean RecursionError
Refcounts flat (delta 2 over 1000 stores)
*args / defaults / kw-only correctly refused

That last row is why assert(fcode->co_argcount == 3) is an assert rather than a deopt, matching _BINARY_OP_SUBSCR_CHECK_FUNC.

Open items before this goes upstream

🤖 Generated with Claude Code

@johng
johng force-pushed the gh-143732-store-subscr-py-dunder-v2 branch 3 times, most recently from 7817364 to 8b9f2cf Compare August 28, 2026 11:28
…BSCR`

Add STORE_SUBSCR_PY_DUNDER, which enters a Python `__setitem__` directly
rather than calling it through PyObject_SetItem, following the template
pythongh-143732 asks for.

Unlike BINARY_OP_SUBSCR_GETITEM, the frame for the dunder cannot simply be
pushed. _RETURN_VALUE is `(retval -- res)`, so a returning frame always
pushes exactly one value onto the caller: BINARY_OP_SUBSCR_GETITEM is
declared pops 2 / pushes 0 and the +1 arrives on return, netting -1 to match
generic BINARY_OP. STORE_SUBSCR must net -3, so a frame-pushing member would
pop 3, declare 0 pushed and gain +1 on return, netting -2. `__setitem__`
returns a None that nothing consumes.

So a shim frame (_Py_SetItemCleanup) is pushed underneath the `__setitem__`
frame, in the same way CALL_ALLOC_AND_ENTER_INIT uses _Py_InitCleanup for
`__init__`. The dunder returns into the shim, whose sole instruction
EXIT_SETITEM discards that value and pops the shim without pushing a result,
keeping the net stack effect at -3.

The guard and the frame push are deliberately a single uop. Splitting them,
mirroring _BINARY_OP_SUBSCR_CHECK_FUNC, puts the looked-up function on the
caller's value stack as a temporary; STORE_SUBSCR already occupies three
stack slots, so that extra slot overruns co_stacksize and trips
ASSERT_WITHIN_STACK_BOUNDS. BINARY_OP_SUBSCR_GETITEM survives it because
BINARY_OP occupies only two. The tier two abstract interpreter therefore
recovers the function from the container's type via _spec_cache.setitem,
exactly as _BINARY_OP_SUBSCR_CHECK_FUNC does, rather than from a stack
symbol.

Tier two support is included: EXIT_SETITEM reuses _GUARD_IP_RETURN_VALUE and
_GUARD_CODE_VERSION_RETURN_VALUE because its post-pop state matches
_RETURN_VALUE's, the shim predicate in optimizer_analysis.c is generalised to
cover both trampolines, and _Py_SetItemCleanup is added to
has_space_for_executor() so insert_executor() cannot write ENTER_EXECUTOR
into a statically allocated const code object. Without this, a hot loop
containing `x[k] = v` would end its trace at the store, which plain
STORE_SUBSCR did not.

The function is cached on the heap type (_spec_cache.setitem, mirroring
_spec_cache.getitem) rather than in the inline cache, so STORE_SUBSCR's cache
size is unchanged. The cost is paid in heap type size instead: the pointers
in _specialization_cache are grouped ahead of the version fields so the two
uint32_t versions share one word of padding, which holds the struct at 32
bytes rather than 40, i.e. 8 bytes per heap type rather than 16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@johng
johng force-pushed the gh-143732-store-subscr-py-dunder-v2 branch from 8b9f2cf to 926227d Compare August 28, 2026 11:49
@johng johng closed this Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant