Skip to content

[mypyc] Coerce between signed and unsigned native ints without boxing - #22471

Open
Jahongir-Qurbonov wants to merge 3 commits into
python:masterfrom
Jahongir-Qurbonov:mypyc-unsigned-index-coerce
Open

Jahongir-Qurbonov wants to merge 3 commits into
python:masterfrom
Jahongir-Qurbonov:mypyc-unsigned-index-coerce

Conversation

@Jahongir-Qurbonov

@Jahongir-Qurbonov Jahongir-Qurbonov commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

LowLevelIRBuilder.coerce only had a direct path between native ints of
the same signedness. Coercing an unsigned native int (u8, u16, u32,
u64) to a signed one fell through to the generic box/unbox path, so a
Python int was allocated and freed every time. This happens when an
unsigned value is passed to a primitive that takes i64, for example
indexing bytes:

from mypy_extensions import u64

def f(b: bytes, i: u64) -> int:
    return b[i]

Before this change, the IR for f started with
r0 = box(u64, i); r1 = unbox(i64, r0), and u8/u16/u32 indexes
did the same.

Now the coercion is done inline. If the target type is wider, it's a
zero-extending Extend. Otherwise the value is range checked against
the target's maximum and then truncated. Values that don't fit raise the
same ValueError as before: the u64 -> i64 overflow uses a new
CPyInt64_Overflow helper with the existing "int too large to convert
to i64" message. This is an implicit coercion, so it has to keep the
value. It doesn't wrap around like an explicit i64(x) call.

This also adds the reverse direct path, signed → unsigned (e.g. cast(u64, x) with x: i64, cast(u8, x) with x: i32), which also went through box/unbox before. The value is checked to be non-negative, and when the source type is wider than the target, also checked against the target's maximum; then it's zero-extended or truncated. Out-of-range values raise the same ValueError("int too large or small to convert to uN") as unboxing, using the existing CPyUIntN_Overflow helpers.

Indexing bytes, or str in ord(s[i]) and s[i] == "x", with a u64 index >= 2**63 raised ValueError: int too large to convert to i64 (from the coercion to the i64 index the specialized primitives take), while CPython raises IndexError. Such a value is always out of range, so it now branches to the existing IndexError block instead; this replaces the overflow check emitted by the coercion, so there's no extra overhead. Smaller unsigned types always fit in i64 and are unaffected.

I added irbuild tests for bytes indexing with u64 and u8 indexes,
and run tests for u8, u32 and u64 indexes that cover edge values:
in range, just out of range, 2**63 - 1, 2**63 and 2**64 - 1.

The later commits add irbuild tests for cast() from signed to unsigned types and run tests covering boundaries (0, -1, the target's maximum and maximum + 1, the source's minimum and maximum) for several width combinations, and bytes/str index tests now expect IndexError for u64 values >= 2**63.

LowLevelIRBuilder.coerce only had a direct path between native ints of
the same signedness. Coercing an unsigned native int (u8, u16, u32,
u64) to a signed one, for example when indexing bytes with a u64 (the
index primitives take an i64), fell through to the generic box/unbox
path and allocated a Python int every time.

Zero-extend when the target type is wider. Otherwise range check the
value against the target's maximum and truncate. Values that don't fit
raise the same ValueError as before; add CPyInt64_Overflow for the
u64 -> i64 case, with the same message as i64 unboxing.
Coercing a signed native int (i8, i16, i32, i64) to an unsigned one,
for example via cast(u64, x) where x is an i64, still went through the
generic box/unbox path and allocated a Python int every time.

Add the reverse of the unsigned -> signed direct path: check that the
value is non-negative and, if the target type is narrower than the
source type, that it doesn't exceed the target's maximum. Then
zero-extend or truncate. Values that don't fit raise the same
ValueError as unboxing ("int too large or small to convert to uN").

Signed comparisons are used, since the C backend doesn't emit unsigned
casts for i8/i16 operands of unsigned comparison ops.
Indexing bytes, or str in `ord(s[i])` and `s[i] == "x"`, uses
specialized primitives that take an i64 index. A u64 index was coerced
to i64, so a value >= 2**63 raised "ValueError: int too large to
convert to i64", while CPython raises IndexError.

Such a value is always out of range, so branch to the existing
IndexError block instead of coercing. This replaces the overflow check
emitted by the coercion, so there is no additional overhead. Smaller
unsigned types always fit in i64 and are unaffected.
@Jahongir-Qurbonov Jahongir-Qurbonov changed the title [mypyc] Coerce unsigned native ints to signed ones without boxing [mypyc] Coerce between signed and unsigned native ints without boxing Nov 14, 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