Skip to content

gh-145858: Remove DELETE_DEREF bytecode instruction#148185

Open
Siyet wants to merge 4 commits intopython:mainfrom
Siyet:gh-145858-remove-delete-deref
Open

gh-145858: Remove DELETE_DEREF bytecode instruction#148185
Siyet wants to merge 4 commits intopython:mainfrom
Siyet:gh-145858-remove-delete-deref

Conversation

@Siyet
Copy link
Copy Markdown

@Siyet Siyet commented Apr 6, 2026

Summary

Remove the DELETE_DEREF opcode by replacing it in the compiler with the
equivalent sequence LOAD_DEREF + POP_TOP + PUSH_NULL + STORE_DEREF.

This frees up one opcode slot. Since deletion of closure variables is
practically never used, the extra instructions have no measurable cost.

Changes

  • Python/codegen.c: codegen_nameop() now emits a 4-instruction sequence
    instead of DELETE_DEREF for Del context on deref variables
  • Python/bytecodes.c: Remove DELETE_DEREF instruction definition;
    adapt STORE_DEREF to handle NULL stackref (needed for cell clearing)
  • Python/flowgraph.c: Remove DELETE_DEREF from offset fixup switch
  • Doc/library/dis.rst: Remove opcode documentation
  • Include/internal/pycore_magic_number.h: Bump PYC_MAGIC_NUMBER 3663 → 3664
  • All generated files regenerated

Test plan

  • test_scope, test_dis, test_compile, test_compiler_assemble,
    test_peepholer, test_opcodes — all pass
  • 14 test modules (1465 tests) — all pass on free-threading build
  • Custom stress tests (100K iterations): basic del, reassign after del,
    refcount/leak detection, nested closures, 8-thread concurrent del
  • Benchmark: stable (stdev < 1%)

Stress test script

stress_delete_deref.py
"""
Stress test for DELETE_DEREF removal.
Tests that del on closure variables works correctly with the new
LOAD_DEREF + POP_TOP + PUSH_NULL + STORE_DEREF sequence.
"""
import sys
import threading
import timeit
import statistics
import gc
import weakref

ITERATIONS = 100_000

def test_basic_del_deref():
    """Basic: del a closure variable and verify UnboundLocalError."""
    errors = 0
    for _ in range(ITERATIONS):
        x = 42
        def inner():
            return x
        del x
        try:
            inner()
            errors += 1
        except NameError:
            pass
    assert errors == 0, f"Expected NameError but got value {errors} times"

def test_del_deref_with_reassign():
    """Del then reassign closure variable."""
    for _ in range(ITERATIONS):
        x = "hello"
        def inner():
            return x
        del x
        try:
            inner()
            assert False, "Should have raised"
        except NameError:
            pass
        x = "world"
        assert inner() == "world"

def test_del_deref_refcount():
    """Verify that del properly releases the object (no leaks)."""
    destroyed = [0]
    class Tracer:
        def __del__(self):
            destroyed[0] += 1

    for _ in range(ITERATIONS):
        obj = Tracer()
        ref = weakref.ref(obj)
        def inner():
            return obj
        del obj
        gc.collect()
        assert ref() is None, "Object was not released after del"

    assert destroyed[0] == ITERATIONS, f"Leak detected: only {destroyed[0]}/{ITERATIONS} destroyed"

def test_del_deref_nested():
    """Nested closures with del."""
    for _ in range(ITERATIONS // 10):
        x = [1, 2, 3]
        def level1():
            def level2():
                return x
            return level2
        fn = level1()
        assert fn() == [1, 2, 3]
        del x
        try:
            fn()
            assert False
        except NameError:
            pass

def test_del_deref_threaded():
    """Concurrent del on closure variables."""
    errors = []
    def worker(thread_id):
        try:
            for _ in range(ITERATIONS // 10):
                val = thread_id
                def capture():
                    return val
                assert capture() == thread_id
                del val
                try:
                    capture()
                    errors.append(f"Thread {thread_id}: expected NameError")
                except NameError:
                    pass
        except Exception as exc:
            errors.append(f"Thread {thread_id}: {exc}")

    threads = [threading.Thread(target=worker, args=(tid,)) for tid in range(8)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    assert not errors, f"Thread errors: {errors}"

def test_del_deref_in_loop():
    """Del in a tight loop to stress the new instruction sequence."""
    for _ in range(ITERATIONS):
        values = []
        x = 99
        def reader():
            return x
        values.append(reader())
        del x
        try:
            reader()
        except NameError:
            values.append("deleted")
        x = 100
        values.append(reader())
        assert values == [99, "deleted", 100]

if __name__ == "__main__":
    tests = [
        ("basic_del_deref", test_basic_del_deref),
        ("del_deref_with_reassign", test_del_deref_with_reassign),
        ("del_deref_refcount", test_del_deref_refcount),
        ("del_deref_nested", test_del_deref_nested),
        ("del_deref_threaded", test_del_deref_threaded),
        ("del_deref_in_loop", test_del_deref_in_loop),
    ]

    print(f"Python {sys.version}")
    print(f"Running stress tests ({ITERATIONS} iterations each)...\n")

    failed = 0
    for name, test_fn in tests:
        try:
            test_fn()
            print(f"  PASS: {name}")
        except Exception as exc:
            print(f"  FAIL: {name} -- {exc}")
            failed += 1

    print(f"\nResults: {len(tests) - failed}/{len(tests)} passed")
    if failed:
        sys.exit(1)
    else:
        print("All stress tests PASSED")

Benchmark script

bench_delete_deref.py
"""Benchmark for del on closure variables."""
import timeit
import statistics

ITERATIONS = 100_000

setup = """
def make():
    x = 42
    def inner():
        return x
    del x
    try:
        inner()
    except NameError:
        pass
"""

times = timeit.repeat("make()", setup=setup, number=ITERATIONS, repeat=50)
mean = statistics.mean(times)
stdev = statistics.stdev(times)
print(f"del closure var ({ITERATIONS} iters x 50 rounds):")
print(f"  mean:  {mean:.4f}s")
print(f"  stdev: {stdev:.4f}s")
print(f"  min:   {min(times):.4f}s")
print(f"  max:   {max(times):.4f}s")

Results (free-threading debug build, dedicated server, taskset -c 0)

Stress tests: 6/6 PASSED
Benchmark: 0.4066s ± 0.0036s (min=0.4000, max=0.4144)

📚 Documentation preview 📚: https://cpython-previews--148185.org.readthedocs.build/

Replace DELETE_DEREF with the sequence LOAD_DEREF + POP_TOP + PUSH_NULL + STORE_DEREF in the compiler. Adapt STORE_DEREF to handle NULL stackref for cell clearing.
@bedevere-app
Copy link
Copy Markdown

bedevere-app bot commented Apr 6, 2026

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@@ -1 +1,2 @@
# HTML IDs excluded from the check-html-ids.py check.
library/dis.html: opcode-DELETE_DEREF
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest instead having a 'CPython bytecode changes' section in the what's new, like we had for 3.14, and redirecting to there, but this isn't my area so I'll leave it to the experts.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thanks! Added a "CPython bytecode changes" section to Doc/whatsnew/3.15.rst following the 3.14 pattern. Kept removed-ids.txt as well since it's needed for the CI HTML ID check independently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants