Skip to content

Commit 98eb8fd

Browse files
authored
[mypyc] Use __index__ for range() arguments in for loops (#22101)
`for i in range(x)` is compiled to a loop that compares the counter with `x` directly. When an argument isn't statically an `int` (for example `Any`, `SupportsIndex` or a class with `__index__`), that comparison is a generic `<` with the original object, but `range()` converts its arguments with `__index__`. As a result, compiled loops: - raise `TypeError: '<' not supported between instances of 'int' and 'X'` for objects that define `__index__`, or `TypeError: int object expected; got X` when such an object is the start value - accept floats: with `x: Any = 2.5`, `for i in range(x)` loops over 0, 1 and 2 instead of raising `TypeError: 'float' object cannot be interpreted as an integer` This converts such arguments once with `PyNumber_Index` before the loop, like `range()` does, so results and error messages match Python. Arguments that are already `int`, `bool` or fixed-width ints compile the same as before. Since the loop then compares unboxed ints, it's also faster. On Python 3.12, a loop over `range(n)` with `n: Any` goes from about 25 ns to 3 ns per iteration (2.7 ns with `n: int`).
1 parent 75e370b commit 98eb8fd

4 files changed

Lines changed: 184 additions & 1 deletion

File tree

‎mypyc/irbuild/for_helpers.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,15 @@
5454
bool_rprimitive,
5555
c_pyssize_t_rprimitive,
5656
int_rprimitive,
57+
is_bool_or_bit_rprimitive,
5758
is_dict_rprimitive,
5859
is_fixed_width_rtype,
5960
is_immutable_rprimitive,
6061
is_list_rprimitive,
6162
is_sequence_rprimitive,
6263
is_short_int_rprimitive,
6364
is_str_rprimitive,
65+
is_tagged,
6466
is_tuple_rprimitive,
6567
object_pointer_rprimitive,
6668
object_rprimitive,
@@ -80,7 +82,7 @@
8082
dict_value_iter_op,
8183
)
8284
from mypyc.primitives.exc_ops import no_err_occurred_op, propagate_if_error_op
83-
from mypyc.primitives.generic_ops import aiter_op, anext_op, iter_op, next_op
85+
from mypyc.primitives.generic_ops import aiter_op, anext_op, index_op, iter_op, next_op
8486
from mypyc.primitives.list_ops import (
8587
list_append_op,
8688
list_get_item_int64_op,
@@ -1122,6 +1124,8 @@ class ForRange(ForGenerator):
11221124

11231125
def init(self, start_reg: Value, end_reg: Value, step: int) -> None:
11241126
builder = self.builder
1127+
start_reg = self.convert_arg(start_reg)
1128+
end_reg = self.convert_arg(end_reg)
11251129
self.start_reg = start_reg
11261130
self.end_reg = end_reg
11271131
self.step = step
@@ -1139,6 +1143,25 @@ def init(self, start_reg: Value, end_reg: Value, step: int) -> None:
11391143
self.index_target: Register | AssignmentTarget = builder.get_assignment_target(self.index)
11401144
builder.assign(self.index_target, builder.read(self.index_reg, self.line), self.line)
11411145

1146+
def convert_arg(self, value: Value) -> Value:
1147+
"""Convert a range() argument to an int using __index__, like range() does."""
1148+
if (
1149+
is_tagged(value.type)
1150+
or is_fixed_width_rtype(value.type)
1151+
or is_bool_or_bit_rprimitive(value.type)
1152+
):
1153+
return value
1154+
if (
1155+
isinstance(value.type, RInstance)
1156+
and value.type.class_ir.is_ext_class
1157+
and value.type.class_ir.has_method("__index__")
1158+
):
1159+
# Directly call the __index__ method on native classes that have it.
1160+
index = self.builder.gen_method_call(value, "__index__", [], int_rprimitive, self.line)
1161+
else:
1162+
index = self.builder.call_c(index_op, [value], self.line)
1163+
return self.builder.coerce(index, int_rprimitive, self.line)
1164+
11421165
def gen_condition(self) -> None:
11431166
builder = self.builder
11441167
line = self.line

‎mypyc/primitives/generic_ops.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@
178178
priority=0,
179179
)
180180

181+
# operator.index(obj)
182+
index_op = custom_op(
183+
arg_types=[object_rprimitive],
184+
return_type=object_rprimitive,
185+
c_function_name="PyNumber_Index",
186+
error_kind=ERR_MAGIC,
187+
)
188+
181189
# obj1[obj2]
182190
py_get_item_op = method_op(
183191
name="__getitem__",

‎mypyc/test-data/irbuild-statements.test‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,73 @@ L3:
7878
L4:
7979
return 1
8080

81+
[case testForInRangeAnyArgs]
82+
from typing import Any
83+
84+
def f(a: Any, b: Any) -> None:
85+
for i in range(a, b):
86+
pass
87+
[out]
88+
def f(a, b):
89+
a, b, r0 :: object
90+
r1 :: int
91+
r2 :: object
92+
r3, r4, i :: int
93+
r5 :: bit
94+
r6 :: int
95+
L0:
96+
r0 = PyNumber_Index(a)
97+
r1 = unbox(int, r0)
98+
r2 = PyNumber_Index(b)
99+
r3 = unbox(int, r2)
100+
r4 = r1
101+
i = r4
102+
L1:
103+
r5 = int_lt r4, r3
104+
if r5 goto L2 else goto L4 :: bool
105+
L2:
106+
i = r4
107+
L3:
108+
r6 = CPyTagged_Add(r4, 2)
109+
r4 = r6
110+
goto L1
111+
L4:
112+
return 1
113+
114+
[case testForInRangeNativeIndexArg]
115+
class C:
116+
def __index__(self) -> int:
117+
return 2
118+
119+
def f(c: C) -> None:
120+
for i in range(c): # type: ignore[arg-type]
121+
pass
122+
[out]
123+
def C.__index__(self):
124+
self :: __main__.C
125+
L0:
126+
return 4
127+
def f(c):
128+
c :: __main__.C
129+
r0, r1, i :: int
130+
r2 :: bit
131+
r3 :: int
132+
L0:
133+
r0 = c.__index__()
134+
r1 = 0
135+
i = r1
136+
L1:
137+
r2 = int_lt r1, r0
138+
if r2 goto L2 else goto L4 :: bool
139+
L2:
140+
i = r1
141+
L3:
142+
r3 = CPyTagged_Add(r1, 2)
143+
r1 = r3
144+
goto L1
145+
L4:
146+
return 1
147+
81148
[case testBreak]
82149
def f() -> None:
83150
n = 0

‎mypyc/test-data/run-loops.test‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,91 @@ def test_range_object() -> None:
572572
except ValueError as e:
573573
assert "range() arg 3 must not be zero" in str(e)
574574

575+
[case testRangeLoopIndexArgs]
576+
from typing import Any
577+
578+
from testutil import assertRaises
579+
580+
def loop_end(end: Any) -> list[int]:
581+
result = []
582+
for i in range(end):
583+
result.append(i)
584+
return result
585+
586+
def loop(start: Any, end: Any) -> list[int]:
587+
return [i for i in range(start, end)]
588+
589+
def loop_down(start: Any) -> list[int]:
590+
return [i for i in range(start, 0, -1)]
591+
592+
def loop_down_to(start: Any, end: Any) -> list[int]:
593+
return [i for i in range(start, end, -1)]
594+
595+
def loop_bool(b: bool) -> list[int]:
596+
return [i for i in range(b)]
597+
598+
class NativeIndex:
599+
def __init__(self, n: int) -> None:
600+
self.n = n
601+
602+
def __index__(self) -> int:
603+
return self.n
604+
605+
class NativeIndexSub(NativeIndex):
606+
def __index__(self) -> int:
607+
return self.n + 1
608+
609+
def loop_native(start: NativeIndex, end: NativeIndex) -> list[int]:
610+
# The test fixtures only accept int arguments for range()
611+
return [i for i in range(start, end)] # type: ignore[arg-type]
612+
613+
def test_index_args() -> None:
614+
from index_types import Index
615+
assert loop_end(Index(3)) == [0, 1, 2]
616+
assert loop(Index(1), Index(3)) == [1, 2]
617+
assert loop_down(Index(2)) == [2, 1]
618+
619+
def test_big_index_args() -> None:
620+
from index_types import Index
621+
# Ints >= 2**62 or < -2**62 don't fit in a short int, so these loops cross
622+
# between short and heap-allocated values
623+
n = 2**62
624+
assert loop(Index(n - 1), Index(n + 2)) == [n - 1, n, n + 1]
625+
assert loop(Index(-n - 2), Index(-n + 1)) == [-n - 2, -n - 1, -n]
626+
assert loop_down_to(Index(n + 1), Index(n - 2)) == [n + 1, n, n - 1]
627+
assert loop_down_to(Index(-n + 1), Index(-n - 2)) == [-n + 1, -n, -n - 1]
628+
assert loop_end(Index(-n - 1)) == []
629+
630+
def test_native_index_args() -> None:
631+
assert loop_native(NativeIndex(1), NativeIndex(3)) == [1, 2]
632+
assert loop_native(NativeIndexSub(1), NativeIndex(4)) == [2, 3]
633+
n = 2**62
634+
assert loop_native(NativeIndex(n - 1), NativeIndex(n + 1)) == [n - 1, n]
635+
636+
def test_int_args() -> None:
637+
assert loop_end(3) == [0, 1, 2]
638+
assert loop(True, 3) == [1, 2]
639+
n = 2**100
640+
assert loop(n, n + 2) == [n, n + 1]
641+
assert loop_bool(True) == [0]
642+
assert loop_bool(False) == []
643+
644+
def test_invalid_args() -> None:
645+
with assertRaises(TypeError, "'float' object cannot be interpreted as an integer"):
646+
loop_end(2.5)
647+
with assertRaises(TypeError, "'str' object cannot be interpreted as an integer"):
648+
loop(0, "3")
649+
with assertRaises(TypeError, "'float' object cannot be interpreted as an integer"):
650+
loop_down(2.0)
651+
652+
[file index_types.py]
653+
class Index:
654+
def __init__(self, n: int) -> None:
655+
self.n = n
656+
657+
def __index__(self) -> int:
658+
return self.n
659+
575660
[case testNamedTupleLoop]
576661
from collections.abc import Iterable
577662
from typing import NamedTuple, Any

0 commit comments

Comments
 (0)