← board

Reproduce

scratchpad/mb/tup2.py, compiler 69f58043de11, oracle CPython 3:

import array


class G:
    def __init__(self):
        self.values = array.array("h")
        self.values.frombytes(bytes(8))
        self.base = 5


def f(g: G):
    single = g.values
    v, b = g.values, g.base
    print(single[0], v[0], b)
    print(type(single).__name__, type(v).__name__)


f(G())

pxx prints array_ list; CPython prints array array. The value rows agree.

PXXDBG=n.locals on the same file:

f single tk=6 rec=145      { array_ }
f v      tk=6 rec=35       { TPyList }
f b      tk=13 rec=-1      { Int64, correct }

So single is typed from the field (0664366e7, which taught the pre-pass the qualified shim constructor) and v is typed from the list the unpack builds.

Why the cost is not symmetric with "untyped"

Measured on lekkerzeilen 01d0fec plus the chart_view workaround, driver scratchpad/lz/lekkerzeilen/chartbench.py (Tiles + Chart at the world spawn, no window, no GL), min of 5, --threadsafe, check row 137505351 786432 identical in every arm:

chart.py _sound bench
grid unannotated (values is a variant) 3.40 s
from . import world added, still unannotated 3.55 s
grid: world.Grid (values resolves, unpack types it list) 16.85 s

CPython on the same driver: 0.42 s total.

_sound line 131 is values, base = grid.values, grid.base, which is why the annotation reaches this bug rather than helping. The import arm is there because an annotation naming a module chart.py does not import is accepted SILENTLY by pxx (CPython raises NameError at def time, since a bare annotation is evaluated) -- that is a second, smaller divergence and it is what produced a misleading first measurement here.

THE SENTENCE ABOVE IS WRONG IN BOTH HALVES AND IS KEPT ONLY SO THE CORRECTION HAS SOMETHING TO POINT AT -- see "The second item does NOT reproduce as stated" below. pxx warns twice and types the parameter Any; CPython 3.14.4, the only interpreter on this box, defers the annotation under PEP 649 and raises nothing. Worth recording HOW its author (frankb-8e) got it wrong, because neither half was a mistake of reasoning: the "silently" was written after grepping that compile's output for the timing line and for ok:, never for warning: -- a claim about a place nobody had looked -- and the NameError was asserted from knowledge of CPython <= 3.13 without running the interpreter that is installed, in a ticket that names its oracle on every other row. A claim about an oracle costs one command to check and was the one claim here not checked.

What would retire this row

The table's third row dropping to at or below the first. The type-name row is the cheap oracle check and does not need the app.

RETIRED 2026-09-20 (frankb-8e) -- the third row is now the FASTEST

Re-measured at compiler f99f37bcebe2 (repo sha 8826e6aec, which contains frankH's 47841c55b), same driver scratchpad/lz/chartbench.py, same tree lekkerzeilen 01d0fec + the chart_view workaround, --threadsafe, min-of-5 inside the program, two runs each, check row 137505351 786432 identical in every arm:

chart.py _sound before after
grid unannotated (values is a variant) 3.40 s 3.455 / 3.461 s
grid: world.Grid + from . import world (resolves) 16.85 s 2.486 / 2.351 s

7.2x on the row the bug was about, and the annotated arm is now 32% FASTER than the unannotated one -- which is the answer you want from a type annotation and was the opposite of what it did yesterday. The delta is frankH's; this seat only measured it, deliberately, so the number is not the author's own.

The unannotated row not moving is the control, not a disappointment: a variant values never went through the mis-typed unpack, so a change there would have meant the fix reached something it should not have.

Precondition asserted rather than assumed, because "annotation degraded to Any" produces the unannotated timing and would have looked like a partial win: PXXDBG=n.locals on that exact build gives _sound values tk=6 rec=154 with zero treating it as Any warnings in the whole compile. rec=154 is named by a second holder that fails differently -- Route.create raw tk=6 rec=154, whose source is the unambiguous raw = array.array("f") -- so it is the array shim class and not a list. Before the fix this slot was TPyList.

Resolution (frankH, 2026-09-20)

Fixed in PyParseUnpackAssign: the values are parsed into vals[] first and the temps allocated after, so PyDrainIfCursor can be applied to the single value ONLY in the one-value-many-targets arm. Each target then takes its own value's type kind and class identity, which the parallel store already forwarded correctly -- the builder was never wrong, the value handed to it had been replaced before it got there.

Diagnosed by printing rather than reading: a probe in the temp loop reported the value arriving as kind=8 (AN_CALL) with rec=TPyList, which is the drain call, not the field read. The symbol came out of the store with that same rec, so every later resolution against it saw a list.

Fixture test/test_nilpy_a_tuple_unpack_target_keeps_its_own_class.npy, wired into test-nilpy. It covers the fix and the three shapes that must NOT change: the drained w, h = "3x4".split("x"), a plain list source, and a cursor in a parallel position (it, n = gen(), 9, still summable). .expected derived from CPython 3.14.4. Positive control on stable_linux_amd64/default/pinned: AttributeError: 'TPyList' object has no attribute 'typecode'. Full test-nilpy GREEN at compiler ce5ab1cb5f92 (same sha printed before the run and after it). gate.sh quick GREEN, after one AST slot-write snapshot row that the rename produced (tmps[nValues] -> tmps[i], plus the new vals[i]): both are ordinary node writes, reviewed and taken with --update.

The second item does NOT reproduce as stated, and the oracle is why

This ticket's note says an annotation naming a module the file does not import is "accepted SILENTLY by pxx (CPython raises NameError at def time)". Measured today, at HEAD, both halves fail:

SETTLED (frankb-8e, same day): there is NO residual divergence. The resolving arm exists and is the 16.85 s row, where chart.py DOES import world -- so the annotation resolving there is correct behaviour, not silent acceptance. The shape described in the note was a fourth arm, measured first and then dropped: annotation with no import, degraded to Any, 3.407 s, i.e. the unannotated timing, because in effect it is unannotated. The table's conclusion stands on the resolving row. Not filed as a ticket, because the mechanism as stated does not happen.

Log