Skip to content

Augmented assignment double-evaluates RHS for subscript and attribute targets #1233

Closed

Description

@shi-eric

Description

GH-1230 / commit 7af3419 fixed double evaluation of the RHS in augmented assignments for simple name targets (x += expr). However, the same bug still exists for subscript and attribute targets that fall back to make_new_assign_statement().

The root cause is in emit_AugAssign (codegen.py). rhs = adj.eval(node.value) evaluates the RHS expression at line 3416, but make_new_assign_statement() constructs a synthetic ast.Assign node containing the raw AST node.value, which gets re-evaluated when adj.eval(new_node) processes the BinOp:

def make_new_assign_statement():
    new_node = ast.Assign(targets=[lhs], value=ast.BinOp(lhs, node.op, node.value))
    adj.eval(new_node)  # re-evaluates node.value from raw AST

rhs = adj.eval(node.value)  # first evaluation (result unused by fallback paths)

Affected code paths

Every make_new_assign_statement() call site in emit_AugAssign double-evaluates:

  1. Attribute targets (s.field += expr) — the ast.Attribute TODO path (line 3531)
  2. Non-atomic array subscripts (arr_int16[i] += expr) — dtype in non_atomic_types (line 3433)
  3. Composite non-atomic subscripts (arr_vec_uint8[i] += expr) — vec/mat/quat of non-atomic scalars (line 3445)
  4. Unsupported operators on array subscripts (arr[i] *= expr, arr[i] **= expr) — no atomic implementation (lines 3484, 3506, 3523)
  5. wp.adjoint[var] += expr in custom grad functions (line 3422)
  6. Generic fallback for any other LHS type (line 3535)

Reproduction

All three scripts below print counter = 2 (expected 1):

Attribute target: s.value += side_effect_func()
import warp as wp

@wp.struct
class State:
    value: float

@wp.func
def side_effect_add(counter: wp.array(dtype=int), a: float, b: float) -> float:
    wp.atomic_add(counter, 0, 1)
    return a + b

@wp.kernel
def kernel(counter: wp.array(dtype=int), result: wp.array(dtype=float)):
    s = State()
    s.value = 1.0
    s.value += side_effect_add(counter, 2.0, 3.0)
    result[0] = s.value

counter = wp.zeros(1, dtype=int)
result = wp.zeros(1, dtype=float)
wp.launch(kernel, dim=1, inputs=[counter, result])
print(f"counter = {counter.numpy()[0]}  (expected 1)")
print(f"result  = {result.numpy()[0]}  (expected 6.0)")
Non-atomic subscript: arr_int16[i] += side_effect_func()
import warp as wp

@wp.func
def side_effect_inc(counter: wp.array(dtype=int), val: wp.int16) -> wp.int16:
    wp.atomic_add(counter, 0, 1)
    return val

@wp.kernel
def kernel(counter: wp.array(dtype=int), data: wp.array(dtype=wp.int16)):
    i = wp.tid()
    data[i] += side_effect_inc(counter, wp.int16(1))

counter = wp.zeros(1, dtype=int)
data = wp.array([wp.int16(2)], dtype=wp.int16)
wp.launch(kernel, dim=1, inputs=[counter, data])
print(f"counter = {counter.numpy()[0]}  (expected 1)")
print(f"data[0] = {data.numpy()[0]}  (expected 3)")
Unsupported op on array subscript: arr[i] *= side_effect_func()
import warp as wp

@wp.func
def side_effect_mul(counter: wp.array(dtype=int), val: float) -> float:
    wp.atomic_add(counter, 0, 1)
    return val

@wp.kernel
def kernel(counter: wp.array(dtype=int), data: wp.array(dtype=float)):
    i = wp.tid()
    data[i] *= side_effect_mul(counter, 2.0)

counter = wp.zeros(1, dtype=int)
data = wp.array([1.0], dtype=float)
wp.launch(kernel, dim=1, inputs=[counter, data])
print(f"counter = {counter.numpy()[0]}  (expected 1)")
print(f"data[0] = {data.numpy()[0]}  (expected 2.0)")

Proposed fix

The root cause is that make_new_assign_statement() conflates RHS evaluation with LHS assignment by constructing a synthetic AST that re-walks everything. The clean fix is to separate these concerns:

  1. Extract an assign_to_target(lhs_node, value_var) method from emit_Assign that performs only the LHS dispatch and assignment, given an already-evaluated Var.

    emit_Assign currently does two things: (a) evaluate the RHS via adj.eval(node.value), then (b) dispatch on the LHS target type (ast.Name, ast.Subscript, ast.Attribute) to perform the store. Step (b) is exactly what emit_AugAssign's fallback paths need.

  2. Replace make_new_assign_statement() entirely. Instead of constructing a synthetic AST, compute the binary operation result directly and call the new helper:

    # In emit_AugAssign, where make_new_assign_statement() is currently called:
    op_name = builtin_operators[type(node.op)]
    target = adj.eval(lhs)
    result = adj.add_builtin_call(op_name, [target, rhs])
    adj.assign_to_target(lhs, result)
  3. Refactor emit_Assign to use the same helper:

    def emit_Assign(adj, node):
        # ... validation, RHS evaluation ...
        rhs = adj.eval(node.value)
        adj.assign_to_target(node.targets[0], rhs)

This eliminates double evaluation for all target types, removes the need to construct synthetic AST nodes, and makes the code easier to reason about — emit_AugAssign becomes "evaluate both sides once, compute the operation, assign the result" for every target type uniformly.

Activity

  1. added theissue type on Feb 17, 2026
  2. added this to the 1.12.1 milestone on Feb 17, 2026
  3. self-assigned this
    on Feb 25, 2026
  4. added 3 commits that reference this issue on Mar 10, 2026
    2f32f32
    1ae4d4a
    aa13695
  5. added a commit that references this issue on Mar 23, 2026
    066a85b
  6. shi-eric commented on Mar 23, 2026

    @shi-eric
    ContributorAuthor

    Fixed in bbef4b3, thanks @daedalus5 !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions