Skip to content

Support discrete compound assignments in reverse mode - #2000

Open
Elvand-Lie wants to merge 3 commits into
vgvassilev:masterfrom
Elvand-Lie:fix/reverse-mode-discrete-compound-assign
Open

Support discrete compound assignments in reverse mode#2000
Elvand-Lie wants to merge 3 commits into
vgvassilev:masterfrom
Elvand-Lie:fix/reverse-mode-discrete-compound-assign

Conversation

@Elvand-Lie

@Elvand-Lie Elvand-Lie commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Description

ReverseModeVisitor entered its assignment path for all Clang assignment operators but explicitly handled only =, +=, -=, *=, and /=. Remainder, bitwise, and shift compound assignments (%=, &=, |=, ^=, <<=, >>=) fell through to an llvm_unreachable.

This PR adds discrete compound assignment support in reverse mode:

  1. Primal Result Materialization & Lvalue Reference Preservation: (L op= R) is evaluated exactly once in the forward pass inside an lvalue-reference declaration T& _ref = (L op= R);. A primal result override returns this reference directly as the forward expression result, preserving C++ lvalue semantics for nested operations.
  2. Reverse Value Snapshot: Snapshot the converted post-assignment value T _t = _ref; (or clad::tape<T> inside loops) using GlobalStoreAndRef for use in the reverse sweep.
  3. No Operator Reconstruction: Eliminates binary operator reconstruction during the reverse sweep, preventing double execution and avoiding Clad shared AST node integrity violations.
  4. Clean Adjoint Storage: Restructures oldValue adjoint storage to avoid emitting unused _r_d declarations for discrete operations.
  5. Coverage: Adds tests in test/Gradient/CompoundBitwiseOps.C covering all six operators, nested assignments under parent *=, narrowing conversions, loops, and side-effectful RHS expressions.

@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@Elvand-Lie
Elvand-Lie force-pushed the fix/reverse-mode-discrete-compound-assign branch from 5569902 to cc3eaaf Compare August 20, 2026 17:14
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Elvand-Lie
Elvand-Lie marked this pull request as draft August 20, 2026 17:37
@Elvand-Lie
Elvand-Lie force-pushed the fix/reverse-mode-discrete-compound-assign branch from cc3eaaf to 9e1b524 Compare August 21, 2026 07:42
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@Elvand-Lie
Elvand-Lie force-pushed the fix/reverse-mode-discrete-compound-assign branch from 9e1b524 to ef391da Compare August 21, 2026 08:41
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@Elvand-Lie
Elvand-Lie force-pushed the fix/reverse-mode-discrete-compound-assign branch 3 times, most recently from d1a541e to 0f0d489 Compare August 21, 2026 09:58
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@Elvand-Lie
Elvand-Lie force-pushed the fix/reverse-mode-discrete-compound-assign branch from 0f0d489 to 0ee78f8 Compare August 21, 2026 10:15
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

ReverseModeVisitor entered its assignment path for all Clang assignment
operators but handled only =, +=, -=, *=, and /=. Remainder, bitwise,
and shift compound assignments fell through to an llvm_unreachable.

Add explicit handling for %=, &=, |=, ^=, <<=, and >>= as discrete
derivative boundaries. Preserve the primal compound assignment result
as a forward lvalue-reference declaration (_ref) while snapshotting its
converted post-assignment value (_t) for the reverse sweep. Use a primal
result override to return the materialized forward reference directly,
eliminating binary operator reconstruction that previously caused double
execution and shared AST node assertions.

Restructure old-value adjoint storage to avoid emitting unused declarations.

Add generated-code and runtime regression coverage for all six operators,
nested assignments under parent compound assignment, narrowing conversions,
loops, and side-effectful RHS expressions.

Signed-off-by: Tempris Admin <elvandlie@gmail.com>
@Elvand-Lie
Elvand-Lie force-pushed the fix/reverse-mode-discrete-compound-assign branch from 0ee78f8 to 16aeeb6 Compare August 21, 2026 12:29
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@Elvand-Lie
Elvand-Lie marked this pull request as ready for review August 22, 2026 11:28
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

}

// CHECK-LABEL: f_and_grad
// CHECK: int &_ref0 = n &= 7;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make sure we check all the bodies with CHECK-NEXT? Also the tests should be tight (almost surgical).

Expr* assign_zero = BuildOp(BO_Assign, CloneNode(ResultRef), zero);
addToCurrentBlock(assign_zero, direction::reverse);
}
Rdiff = Visit(R, static_cast<Expr*>(nullptr));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Rdiff = Visit(R, static_cast<Expr*>(nullptr));
Rdiff = Visit(R);

Comment on lines +3395 to +3397
} else if (opCode == BO_RemAssign || opCode == BO_AndAssign ||
opCode == BO_OrAssign || opCode == BO_XorAssign ||
opCode == BO_ShlAssign || opCode == BO_ShrAssign) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} else if (opCode == BO_RemAssign || opCode == BO_AndAssign ||
opCode == BO_OrAssign || opCode == BO_XorAssign ||
opCode == BO_ShlAssign || opCode == BO_ShrAssign) {
} else if (isDiscreteAssign) {

else if (!isPointerOp)
oldValue = StoreAndRef(CloneNode(ResultRef), direction::reverse, "_r_d",
/*forceDeclCreation=*/true);
if (!isDiscreteAssign) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If !isDiscreteAssign do we really need to pass through the other code?

utils::getNonConstType(Ldiff.getExpr()->getType(), m_Sema);
QualType referenceType = m_Context.getLValueReferenceType(valueType);
Expr* forwardResult =
StoreAndRef(assignExpr, referenceType, direction::forward, "_ref",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Binding a reference to the result of the assignment does not work when the left side is a bit-field, and the user sees a compiler error in code they did not write.

int &_ref0 = n %= y; is fine for an ordinary variable. For s.flags &= mask; where flags is a bit-field, it is ill-formed: a non-const lvalue reference cannot bind to a bit-field ([class.bit]/5). The generated code will not compile, and the error will point into clad's output rather than at the line the user wrote.

Bit-fields are not common in code people differentiate, so this may be worth no more than a guard that falls back to the old path when ResultRef refers to one, which FieldDecl::isBitField() answers.

} else if (opCode == BO_RemAssign || opCode == BO_AndAssign ||
opCode == BO_OrAssign || opCode == BO_XorAssign ||
opCode == BO_ShlAssign || opCode == BO_ShrAssign) {
if (ResultRef && !isPointerOp) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This decides whether the adjoint gets zeroed, and only one side of it is ever taken by a test.

Every function in the new test assigns to a plain local int: n %= y, n &= 7, mask %= 4. None uses an array element, a pointer dereference, or a struct member, so isPointerOp is false every time and the zeroing always happens. The path where it is skipped is not exercised at all.

That matters because skipping the zero is what would leave a stale adjoint behind, and a stale adjoint gives a wrong gradient rather than a crash. A function doing arr[i] %= y and then reading arr[i], with a CHECK-EXEC on the result, would say whether the skip is right.

StoreAndRef(assignExpr, referenceType, direction::forward, "_ref",
/*forceDeclCreation=*/true);
valueForRevPass =
GlobalStoreAndRef(CloneNode(forwardResult), valueType, "_t",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this stored value ever read for a discrete operation?

The adjoint of the left side is set to zero rather than propagated, and the right side is visited with a null adjoint, so as far as I can tell nothing in the reverse sweep needs the post-assignment value. If that is right, force=true inside a loop pushes one value per iteration onto the tape and never pops it, which costs memory proportional to the trip count for nothing.

I could not confirm this either way from the diff, since the test's CHECK lines do not cover the generated loop body. If the value is needed, a sentence saying what reads it would help; if it is not, dropping the store would make the branch smaller.

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.

2 participants