← board

A promo-int local in a generator truncates to 32 bits on i386 and arm32

The repro

def gen(k):
    n = 0
    i = 0
    while i < k:
        n = n + i * 1000000007
        yield n
        i = i + 1

for v in gen(6):
    print(v)
4th value 5th 6th
CPython / x86-64 / aarch64 6000000042 10000000070 15000000105
i386, arm32 1705032746 5705032774 6410065513

6000000042 - 2^32 = 1705032746 exactly, and the first value to exceed 2^32 is the first one wrong. The truncation is not cumulative in the obvious way — the 5th and 6th are each computed from a truncated predecessor.

Where it is NOT

So the suspect is the generator instance's checkpoint/restore of a promotable-int local: a promo slot is a machine word plus a tier, and a 32-bit target that saves or restores only the word loses exactly the heap tier — which is the observable.

The guard this needs

The repro above, wired on i386, arm32, aarch64 and x86-64 with CPython's output as the expectation. Do not fold it into the managed-local yield test — that one deliberately carries no promo-int local for exactly this reason, and its comment says so.

Correction — my own premise was wrong, and x86-64 was NOT correct

This ticket was filed with "aarch64 and x86-64 are correct". They are not. I measured 6000000042, which is past 2^32 and not past 2^63, so it left the inline tier on a 32-bit target and stayed inside it on a 64-bit one. The instrument answered honestly about the value I gave it; the value was the problem. Measured 2026-09-04, same day, pin v403 unchanged:

def gen(k):
    n = 1
    i = 0
    while i < k:
        n = n * 1000003
        yield n
        i = i + 1

x86-64 goes wrong from the 5th value (-4442939197172260078964685 against 1000015000090000270000405000243), and so does aarch64. The defect is on every target and only the THRESHOLD moves. This is the shape CLAUDE.md records under "nothing observably differs is a claim about one target" — except inverted: I had TWO targets disagreeing and read that as a target split, when it was one bug with a width-dependent trigger. A cross-target difference is not evidence of a target-specific cause.

The "same arithmetic at module scope is correct" half of the original claim holds and is what rules out the bignum tier itself: all eight values print correctly at module scope on x86-64 AND i386.

Resolved — a {tag, payload} pair needs a blob-copied slot region

pasparser_stmt.inc's slot allocator has arms for by-ref, variant, string and record, and then a fall-through that gives everything else one word. A promotable int is 8 bytes (tyPromoInt32) or 16 (tyPromoInt64) and fell into it, so the checkpoint persisted the payload and dropped the tag.

The fix is the variant arm's shape: reserve (TypeSlotSize(tk) + 7) div 8 words and blob-copy through SlBlob/SlUnblob in SLSaveLocals / SLRestoreLocals. Bitwise is refcount-neutral, and the ordering constraint the variant arm states applies unchanged — exactly one of the frame copy and the instance copy is live at a time.

The promotable-int family already had a rule that should have caught this, and it is quoted in TypeSlotSize's own comment: "a promotable int is a {tag, payload} struct, not a machine word ... anything that must handle it asks for it by name — an unhandled site errors instead of miscompiling." This site did not ask and did not error, because a fall-through accepts everything. So the fix also makes that arm state its claim:

    if TypeSlotSize(tk) > 8 then Error(... 'is wider than the one-word
      persistent slot this arm gives it' ...);

A kind wider than a word reaching there is the same defect in a different type (tyExtended is the next one in line), and the family's contract is that it errors rather than miscompiling.

The test is test/test_nilpy_generator_promo_int_survives_yield.npy, wired on x86-64, i386, aarch64 and arm32, carrying BOTH thresholds so no target's inline width can make it vacuous. The pin control fires on all four — which is the correction above, made assertable.

Log