Skip to content

Commit 40aad02

Browse files
loops (#106)
* Core: Lower annotated assignment (x: c_int32 = 0) The annotation types the slot, so `x: c_int32 = 0` is an i32 where the literal alone would give an i64; the store goes through the same handle_variable_assignment plain assignment uses. A bare `x: T` makes the slot and binds nothing. The already-bound and global-shadowing bookkeeping that Assign allocation did inline moves into _bind_name, which both statements now call. * Tests: Annotated assignment passes now * Core: Lower if-branches in the function's return type handle_if processed its branches without passing ret_type down, so a return inside an if fell back to process_stmt's i64 default: a -> c_int32 function got `ret i64 7`, which llc rejects. The branches now go through process_block, which threads ret_type and stops at a statement that ends the block instead of emitting after its terminator. * Core: Lower for-over-range and while loops, with break and continue `for i in range(...)` steps a hidden induction counter, allocated in the entry block next to the loop variable, and copies it into the variable each iteration, so rebinding the variable in the body leaves the trip count alone as in Python (and the loop stays visibly bounded for the verifier). The counter is 64-bit, signed unless the bounds make C's arithmetic unsigned. Bounds are evaluated once, before the loop; the step must be a nonzero integer literal, because its sign picks the loop test. `while` re-evaluates its test at the top of each iteration. `break` and `continue` branch through a loop-target stack on the compilation context; outside a loop they are a SyntaxError, as in Python. A loop's else-branch runs on normal exit only. Anything but range() as the iterable is rejected with NotImplementedError. The allocation pass now descends into loop bodies and counts helper temps in loop headers. Checked against clang -O2 on tests/c-form/loops.bpf.c: the constant-bound cases fold to the same ret, and a helper-bounded loop gets the same rotated loop. * Tests: Loops pass now, plus the cases the lowering promises - passing_tests/loops/: the seven range/while/break/continue/nested tests move over from failing_tests/, joined by rebinding the loop variable, a negative step, loop else-branches, and a helper-bounded loop over a @bpfglobal that survives -O2 as a real loop for the verifier. - failing_tests/loops/: break outside a loop, and a non-literal range step. - for_map_items stays xfail: map iteration needs its own design. - c-form/loops.bpf.c: the clang reference the lowering was checked against. * Tests: Indent the loop C reference with spaces The remove-tabs pre-commit hook rejects tab indentation outside docs/ and Makefiles, which failed the Format job. * Core: Fix loop edge cases the lowering got wrong - `pass` is accepted as a statement, the natural body of a loop run for its side effects. - Falling off the end of a function returns 0 in the function's own return type. After `while True` that block is unreachable, but a c_int32 function still emitted `ret i64 0`, which llc rejected. - A range() step other than 1 could wrap the counter past INT64_MAX or UINT64_MAX back inside the range and loop forever. The step block now ends the loop when the distance left to stop, exact as an unsigned number, is no longer than the step. That is a normal exit, so the else-branch runs. Steps of 1 cannot overshoot and keep the plain loop, so the helper-bounded loop the verifier walks is unchanged. - A step of 2**64 or more no longer truncates to 0: it is a compile error, since it does not fit the counter. Steps from 2**63 up are emitted by their bits. - The counter's signedness folds each bound into a signed 64-bit type rather than joining the bounds alone, so only a c_uint64 bound makes it unsigned: range(-1, n) over a c_uint32 n runs 4 times, as in C with an __s64 counter. * Tests: Run loop programs on the host, plus adversarial loop cases Levels 1 and 2 only prove a loop compiles; one can still run the wrong number of times, or forever. test_loops_run.py JIT-compiles each helper-free passing_tests/loops case on the host (framework/host_jit.py, in a subprocess with a timeout) and checks its return value against what CPython returns for the same body. - passing_tests/loops/: pass bodies, `while True` exited by a return in a c_int32 function, step overflow past INT64_MAX, INT64_MIN and UINT64_MAX, a c_uint32 stop with a negative start, and loop variables that must stay one local (a nested same-name loop, a read after the loop). - failing_tests/loops/range_step_too_wide.py: a 2**64 step is a compile error. - reuse_loop_var_signedness.py is xfail in test_loops_run.py: every local gets one slot typed by its first binding, so a second loop's `i` inherits the first loop's unsigned type. `x = n_u64; x = -3` does the same, so the fix is general, not loop-specific.
1 parent 4662fe6 commit 40aad02

30 files changed

Lines changed: 1090 additions & 89 deletions

‎pythonbpf/allocation_pass.py‎

Lines changed: 222 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .expr import VmlinuxHandlerRegistry
99
from pythonbpf.type_deducer import ctypes_to_ir, is_ctypes, IntTy, signedness, byte_size
1010
from pythonbpf.expr.type_inference import infer_int_type
11+
from pythonbpf.expr.operators import usual_arithmetic_conversions
1112
from pythonbpf.maps import BPFMapType
1213

1314
logger = logging.getLogger(__name__)
@@ -49,57 +50,231 @@ def handle_assign_allocation(compilation_context, builder, stmt, local_sym_tab):
4950
)
5051
continue
5152

52-
var_name = target.id
53+
_bind_name(
54+
compilation_context,
55+
stmt,
56+
target,
57+
local_sym_tab,
58+
lambda var_name: _allocate_for_value(
59+
builder, var_name, rval, local_sym_tab, compilation_context
60+
),
61+
)
5362

54-
# Already bound in this scope: a parameter, an earlier assignment, or a
55-
# `global` declaration (whose slot is the GlobalVariable). No slot needed.
56-
if var_name in local_sym_tab:
57-
logger.debug(f"'{var_name}' already bound, no allocation needed")
58-
continue
5963

60-
# Not declared `global`, yet named like one: Python creates a local
61-
# that shadows the global for the whole function body, and leaves the
62-
# global untouched. Do the same.
63-
shadows_global = var_name in compilation_context.bpf_globals
64-
if shadows_global:
65-
logger.info(
66-
f"'{var_name}' is assigned without a 'global' declaration, so it "
67-
f"is a local shadowing the @bpfglobal of the same name"
68-
)
64+
def handle_ann_assign_allocation(compilation_context, builder, stmt, local_sym_tab):
65+
"""Handle memory allocation for annotated assignment (`x: c_int32 = 0`).
6966
70-
# Determine type and allocate based on rval
71-
if isinstance(rval, ast.Call):
72-
_allocate_for_call(
73-
builder, var_name, rval, local_sym_tab, compilation_context
74-
)
75-
elif isinstance(rval, ast.Constant):
76-
_allocate_for_constant(builder, var_name, rval, local_sym_tab)
77-
elif isinstance(rval, ast.BinOp):
78-
_allocate_for_binop(
79-
builder, var_name, rval, local_sym_tab, compilation_context
80-
)
81-
elif isinstance(rval, ast.Name):
82-
# Variable-to-variable assignment (b = a)
83-
_allocate_for_name(
84-
builder, var_name, rval, local_sym_tab, compilation_context
85-
)
86-
elif isinstance(rval, ast.Attribute):
87-
# Struct field-to-variable assignment (a = dat.fld)
88-
_allocate_for_attribute(
89-
builder, var_name, rval, local_sym_tab, compilation_context
90-
)
91-
else:
92-
logger.warning(
93-
f"Unsupported assignment value type for {var_name}: {type(rval).__name__}"
94-
)
67+
The annotation, not the value, types the slot: `x: c_int32 = 0` is an i32
68+
even though the literal alone would give an i64. With no value
69+
(`x: c_int64`) the slot is still made, and the name stays unbound until
70+
something assigns it, as in Python.
71+
"""
72+
logger.info(f"Handling annotated assignment for allocation: {ast.dump(stmt)}")
9573

96-
if shadows_global and var_name in local_sym_tab:
97-
# Where the binding ends, so that a read above it is reported the
98-
# way Python reports it. end_lineno, not lineno, so a read on a
99-
# continuation line of a multi-line binding counts as above it too.
100-
local_sym_tab[var_name].shadows_global_from = (
101-
getattr(stmt, "end_lineno", None) or target.lineno
102-
)
74+
if not isinstance(stmt.target, ast.Name):
75+
raise SyntaxError(
76+
f"annotated assignment on line {stmt.lineno} must target a plain "
77+
f"name, got {type(stmt.target).__name__}"
78+
)
79+
80+
ir_type = annotation_to_ir(stmt.annotation, stmt.lineno)
81+
_bind_name(
82+
compilation_context,
83+
stmt,
84+
stmt.target,
85+
local_sym_tab,
86+
lambda var_name: local_sym_tab.__setitem__(
87+
var_name,
88+
LocalSymbol(_allocate_with_type(builder, var_name, ir_type), ir_type),
89+
),
90+
)
91+
92+
93+
def handle_for_allocation(compilation_context, builder, stmt, local_sym_tab):
94+
"""Handle memory allocation for `for <name> in range(...)`.
95+
96+
Two slots, both in the entry block so a loop never grows the stack: the
97+
loop variable, and a hidden induction counter the loop actually steps.
98+
Keeping them apart is what makes rebinding the loop variable in the body
99+
leave the trip count alone, as in Python -- and a counter the body cannot
100+
touch is what keeps the loop visibly bounded for the verifier.
101+
"""
102+
start, stop, step = parse_range(stmt)
103+
104+
# range() yields Python ints; like any undeclared local they are 64-bit,
105+
# signed unless the bounds make C's arithmetic unsigned. That arithmetic
106+
# is the counter's against each bound, so it starts from a signed 64-bit
107+
# counter, and only an unsigned 64-bit bound turns it unsigned: every
108+
# narrower unsigned bound fits in the signed counter, as in C.
109+
bound_types = [
110+
infer_int_type(bound, local_sym_tab, compilation_context)
111+
for bound in (start, stop)
112+
if bound is not None
113+
]
114+
signed = True
115+
if all(ty is not None for ty in bound_types):
116+
common = IntTy(64, True)
117+
for ty in bound_types:
118+
common = usual_arithmetic_conversions(common, ty)
119+
signed = signedness(common)
120+
if not signed and step < 0:
121+
raise SyntaxError(
122+
f"range() on line {stmt.lineno} counts down over unsigned bounds; "
123+
f"the counter would wrap instead of stopping"
124+
)
125+
loop_ty = IntTy(64, signed)
126+
127+
counter = range_counter_name(stmt)
128+
local_sym_tab[counter] = LocalSymbol(
129+
_allocate_with_type(builder, counter, loop_ty), loop_ty
130+
)
131+
_bind_name(
132+
compilation_context,
133+
stmt,
134+
stmt.target,
135+
local_sym_tab,
136+
lambda var_name: local_sym_tab.__setitem__(
137+
var_name,
138+
LocalSymbol(_allocate_with_type(builder, var_name, loop_ty), loop_ty),
139+
),
140+
)
141+
142+
143+
def range_counter_name(stmt):
144+
"""Symbol-table name of a range loop's hidden induction counter, unique per
145+
loop so nested loops each get their own."""
146+
return f"__range_idx_{stmt.lineno}_{stmt.col_offset}"
147+
148+
149+
def parse_range(stmt):
150+
"""Split `for <name> in range(...)` into (start, stop, step): start is an
151+
expression or None (meaning 0), stop an expression, step a nonzero int.
152+
153+
step has to be known at compile time, because its sign decides whether the
154+
loop runs while the counter is below stop or above it.
155+
"""
156+
it = stmt.iter
157+
if not (
158+
isinstance(it, ast.Call)
159+
and isinstance(it.func, ast.Name)
160+
and it.func.id == "range"
161+
):
162+
raise NotImplementedError(
163+
f"for loop on line {stmt.lineno}: only range(...) can be iterated "
164+
f"so far, got {ast.unparse(it)}"
165+
)
166+
if not isinstance(stmt.target, ast.Name):
167+
raise NotImplementedError(
168+
f"for loop on line {stmt.lineno}: only a plain name can be the loop "
169+
f"variable, got {ast.unparse(stmt.target)}"
170+
)
171+
if it.keywords or not 1 <= len(it.args) <= 3:
172+
raise SyntaxError(
173+
f"range() on line {stmt.lineno} takes 1 to 3 positional arguments"
174+
)
175+
176+
if len(it.args) == 1:
177+
return None, it.args[0], 1
178+
start, stop = it.args[0], it.args[1]
179+
if len(it.args) == 2:
180+
return start, stop, 1
181+
182+
step_node = it.args[2]
183+
negate = isinstance(step_node, ast.UnaryOp) and isinstance(step_node.op, ast.USub)
184+
literal = step_node.operand if negate else step_node
185+
if not (
186+
isinstance(literal, ast.Constant)
187+
and isinstance(literal.value, int)
188+
and not isinstance(literal.value, bool)
189+
):
190+
raise SyntaxError(
191+
f"range() step on line {stmt.lineno} must be an integer literal, "
192+
f"got {ast.unparse(step_node)}"
193+
)
194+
step = -literal.value if negate else literal.value
195+
if step == 0:
196+
raise ValueError(f"range() arg 3 must not be zero (line {stmt.lineno})")
197+
if abs(step) >= 1 << 64:
198+
raise ValueError(
199+
f"range() step on line {stmt.lineno} does not fit the 64-bit "
200+
f"loop counter: {ast.unparse(step_node)}"
201+
)
202+
return start, stop, step
203+
204+
205+
def annotation_to_ir(annotation, lineno):
206+
"""IR type for a ctypes annotation, written `c_int32` or `ctypes.c_int32`."""
207+
if isinstance(annotation, ast.Name):
208+
name = annotation.id
209+
elif isinstance(annotation, ast.Attribute):
210+
name = annotation.attr
211+
else:
212+
name = None
213+
if name is None or not is_ctypes(name):
214+
raise SyntaxError(
215+
f"unsupported annotation on line {lineno}: {ast.unparse(annotation)} "
216+
f"(annotate locals with a ctypes integer type such as c_int64)"
217+
)
218+
return ctypes_to_ir(name)
219+
220+
221+
def _bind_name(compilation_context, stmt, target, local_sym_tab, allocate):
222+
"""What every statement that binds a bare name shares, around the
223+
statement-specific `allocate(var_name)` that makes the slot.
224+
225+
A name already bound needs no new slot. A name that is also a @bpfglobal
226+
but was not declared `global` becomes a local shadowing it, and records
227+
where its binding ends so a read above it is reported as Python would.
228+
"""
229+
var_name = target.id
230+
231+
# Already bound in this scope: a parameter, an earlier assignment, or a
232+
# `global` declaration (whose slot is the GlobalVariable). No slot needed.
233+
if var_name in local_sym_tab:
234+
logger.debug(f"'{var_name}' already bound, no allocation needed")
235+
return
236+
237+
# Not declared `global`, yet named like one: Python creates a local
238+
# that shadows the global for the whole function body, and leaves the
239+
# global untouched. Do the same.
240+
shadows_global = var_name in compilation_context.bpf_globals
241+
if shadows_global:
242+
logger.info(
243+
f"'{var_name}' is assigned without a 'global' declaration, so it "
244+
f"is a local shadowing the @bpfglobal of the same name"
245+
)
246+
247+
allocate(var_name)
248+
249+
if shadows_global and var_name in local_sym_tab:
250+
# Where the binding ends, so that a read above it is reported the
251+
# way Python reports it. end_lineno, not lineno, so a read on a
252+
# continuation line of a multi-line binding counts as above it too.
253+
local_sym_tab[var_name].shadows_global_from = (
254+
getattr(stmt, "end_lineno", None) or target.lineno
255+
)
256+
257+
258+
def _allocate_for_value(builder, var_name, rval, local_sym_tab, compilation_context):
259+
"""Allocate a slot for `var_name = rval`, typed from the value."""
260+
if isinstance(rval, ast.Call):
261+
_allocate_for_call(builder, var_name, rval, local_sym_tab, compilation_context)
262+
elif isinstance(rval, ast.Constant):
263+
_allocate_for_constant(builder, var_name, rval, local_sym_tab)
264+
elif isinstance(rval, ast.BinOp):
265+
_allocate_for_binop(builder, var_name, rval, local_sym_tab, compilation_context)
266+
elif isinstance(rval, ast.Name):
267+
# Variable-to-variable assignment (b = a)
268+
_allocate_for_name(builder, var_name, rval, local_sym_tab, compilation_context)
269+
elif isinstance(rval, ast.Attribute):
270+
# Struct field-to-variable assignment (a = dat.fld)
271+
_allocate_for_attribute(
272+
builder, var_name, rval, local_sym_tab, compilation_context
273+
)
274+
else:
275+
logger.warning(
276+
f"Unsupported assignment value type for {var_name}: {type(rval).__name__}"
277+
)
103278

104279

105280
def _allocate_for_call(builder, var_name, rval, local_sym_tab, compilation_context):

‎pythonbpf/context.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,12 @@ def __init__(self, module: ir.Module):
8282
# Current function context (optional, if needed globally during function processing)
8383
self.current_func = None
8484

85+
# Enclosing loops of the statement being lowered, innermost last, as
86+
# (continue target, break target) blocks.
87+
self.loop_stack: list[tuple[ir.Block, ir.Block]] = []
88+
8589
def reset(self):
8690
"""Reset state between functions if necessary, though new context per compile is preferred."""
8791
self.scratch_pool.reset()
8892
self.current_func = None
93+
self.loop_stack = []

0 commit comments

Comments
 (0)