Skip to content

Commit 5884fad

Browse files
authored
[AutoDiff] Adstack chain-clone safety: mutated-SNode leaf reject + load_top consumer-aware guard (#634)
1 parent 501c11c commit 5884fad

5 files changed

Lines changed: 412 additions & 106 deletions

File tree

quadrants/transforms/auto_diff/auto_diff.cpp

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,14 @@ void auto_diff(IRNode *root, const CompileConfig &config, AutodiffMode autodiff_
3737
replace_local_var_with_stacks(ib, config.ad_stack_size);
3838
type_check(root, config);
3939

40-
// Disabled: `RecomputableChainAnalyzer` admits `GlobalLoadStmt` as a recomputable interior node without
41-
// verifying SNode read-only-ness. When the chain reaches a `GlobalLoadStmt` of an SNode written elsewhere in
42-
// the same kernel execution, `BackupSSA`'s reverse-side re-clone reads post-write state instead of the iter-k
43-
// value the original `AdStackLoadTopStmt` would have returned, silently corrupting gradients on MPM-style
44-
// kernels that mix per-particle reads with grid writes. Re-enable once the chain analyzer rejects
45-
// mutated-SNode loads (or the cost model gates on read-only-verified leaves).
46-
// eliminate_recomputable_ad_stack_pushes(ib);
40+
// Drop AdStackAllocas whose pushed value is recomputable from already-stack-backed allocas + args + const +
41+
// loop-index. Trades forward-pass adstack memory traffic (one push + one load per iter per intermediate spill)
42+
// for cloned arithmetic in the reverse scope, which `BackupSSA::generic_visit` generates on demand via the same
43+
// RecomputableChainCloner path. Must run after `replace_local_var_with_stacks` (so the analyzer sees the
44+
// AdStackAlloca shape, not the alloca-with-store shape promote_ssa_to_local_var emits) and before
45+
// `make_adjoint` (so the reverse pass is generated against the cleaned forward IR with no spurious push/load
46+
// scaffolding).
47+
eliminate_recomputable_ad_stack_pushes(ib);
4748
type_check(root, config);
4849

4950
make_adjoint(ib);

quadrants/transforms/auto_diff/auto_diff_common.h

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -79,28 +79,69 @@ class NonLinearOps {
7979
// reverse-direction loop's index, which matches the forward iteration the reverse is currently processing).
8080
// Side-effect-free interior ops: UnaryOp, BinaryOp, TernaryOp, MatrixPtr, GlobalPtr, ExternalPtr.
8181
//
82-
// `GlobalLoadStmt` and `ExternalPtrAccessStmt`-style reads are intentionally not recomputable: the underlying global
83-
// memory may be mutated mid-kernel by a sibling task, so reading at a different IR position can yield a different
84-
// value. `LocalLoadStmt` similarly aliases mutable allocas; the reverse pass must read its forward value through the
85-
// dedicated spill machinery.
82+
// `GlobalLoadStmt` is admitted as a recomputable interior node only when its source SNode is not mutated anywhere in
83+
// the surrounding IB (per `mutated_snodes`); a re-clone at the reverse cursor of a load whose SNode is also written
84+
// observes post-write state instead of the iter-k value the forward chain consumed. Loads whose source is not a
85+
// `GlobalPtrStmt` (e.g. `ExternalPtrStmt`, `GlobalTemporaryStmt`) are rejected outright since their write set is not
86+
// collected here. `LocalLoadStmt` aliases mutable allocas; the reverse pass reads its forward value through the
87+
// dedicated spill path.
8688
//
87-
// Caller passes a `cache` to share memoization across multiple queries on the same DAG (diamond shapes).
89+
// Caller passes a `cache` to share memoization across multiple queries on the same DAG (diamond shapes) and the
90+
// `mutated_snodes` set produced by `collect_mutated_snodes(ib)`.
8891
class RecomputableChainAnalyzer {
8992
public:
90-
static bool is_recomputable(Stmt *stmt, std::unordered_map<Stmt *, bool> &cache) {
93+
// Set of SNodes written somewhere in the IB. `GlobalStoreStmt`, `AtomicOpStmt`, and `SNodeOpStmt` destinations are
94+
// tracked; `MatrixPtrStmt` is chased one level to its origin so post-`lower_matrix_ptr` shapes are captured.
95+
static std::unordered_set<SNode *> collect_mutated_snodes(Block *ib) {
96+
std::unordered_set<SNode *> result;
97+
auto add_dest = [&](Stmt *dest) {
98+
if (dest == nullptr)
99+
return;
100+
if (auto *mp = dest->cast<MatrixPtrStmt>())
101+
dest = mp->origin;
102+
if (dest == nullptr)
103+
return;
104+
if (auto *gp = dest->cast<GlobalPtrStmt>()) {
105+
if (gp->snode != nullptr)
106+
result.insert(gp->snode);
107+
} else if (auto *mgp = dest->cast<MatrixOfGlobalPtrStmt>()) {
108+
for (auto *s : mgp->snodes)
109+
if (s != nullptr)
110+
result.insert(s);
111+
}
112+
};
113+
auto stmts = irpass::analysis::gather_statements(ib, [](Stmt *) { return true; });
114+
for (auto *s : stmts) {
115+
if (auto *gs = s->cast<GlobalStoreStmt>()) {
116+
add_dest(gs->dest);
117+
} else if (auto *ao = s->cast<AtomicOpStmt>()) {
118+
add_dest(ao->dest);
119+
} else if (auto *so = s->cast<SNodeOpStmt>()) {
120+
if (so->snode != nullptr)
121+
result.insert(so->snode);
122+
}
123+
}
124+
return result;
125+
}
126+
127+
static bool is_recomputable(Stmt *stmt,
128+
std::unordered_map<Stmt *, bool> &cache,
129+
const std::unordered_set<SNode *> &mutated_snodes) {
91130
auto it = cache.find(stmt);
92131
if (it != cache.end())
93132
return it->second;
94133
// Tentatively false to break cycles in pathological IR (real SSA DAGs are acyclic, but the cache also serves as a
95134
// visited set during recursion).
96135
cache[stmt] = false;
97-
bool result = check(stmt, cache);
136+
bool result = check(stmt, cache, mutated_snodes);
98137
cache[stmt] = result;
99138
return result;
100139
}
101140

102141
private:
103-
static bool check(Stmt *stmt, std::unordered_map<Stmt *, bool> &cache) {
142+
static bool check(Stmt *stmt,
143+
std::unordered_map<Stmt *, bool> &cache,
144+
const std::unordered_set<SNode *> &mutated_snodes) {
104145
// Recomputable leaves: ConstStmt, ArgLoadStmt, AdStackLoadTopStmt, AdStackAllocaStmt. LoopIndexStmt is
105146
// intentionally excluded - cloning a LoopIndexStmt copies the reference to the forward RangeForStmt, but the cloned
106147
// consumer lives inside the reverse RangeForStmt (a separate stmt with its own loop index), so the cloned read
@@ -134,15 +175,16 @@ class RecomputableChainAnalyzer {
134175
stmt->is<ConstStmt>()) {
135176
return true;
136177
}
137-
// GlobalLoadStmt as recomputable: the load reads a SNode value via GlobalPtrStmt. The cloned chain in the reverse
138-
// pass re-issues the same load - safe iff the global is not mutated between the forward chain evaluation and the
139-
// cloned re-read. Within a single kernel execution, forward writes complete before reverse runs, so the reverse
140-
// re-read sees the kernel's final post-write state. If a global is mutated by the forward and then re-read by the
141-
// reverse clone, the values can differ.
142-
//
143-
// Chain leaves that pass this branch are typically reads of kernel-input parameters not written by the kernel; for
144-
// those, the re-read returns the same value as the forward read. The pass does not statically verify SNode
145-
// read-only-ness, and mutated-global cases would silently produce wrong gradients.
178+
// A `GlobalLoadStmt` chain leaf is recomputable only when its `GlobalPtrStmt` source resolves to an SNode the IB
179+
// never writes; a reverse-side re-clone of a write-aliased SNode read returns post-write state. Non-`GlobalPtrStmt`
180+
// sources (e.g. `ExternalPtrStmt`, `GlobalTemporaryStmt`) are rejected because their write set is not collected.
181+
if (auto *gl = stmt->cast<GlobalLoadStmt>()) {
182+
auto *src = gl->src;
183+
auto *gp = src ? src->cast<GlobalPtrStmt>() : nullptr;
184+
if (gp == nullptr || gp->snode == nullptr || mutated_snodes.count(gp->snode)) {
185+
return false;
186+
}
187+
}
146188
bool is_interior = stmt->is<UnaryOpStmt>() || stmt->is<BinaryOpStmt>() || stmt->is<TernaryOpStmt>() ||
147189
stmt->is<MatrixPtrStmt>() || stmt->is<GlobalPtrStmt>() || stmt->is<ExternalPtrStmt>() ||
148190
stmt->is<GlobalLoadStmt>();
@@ -153,7 +195,7 @@ class RecomputableChainAnalyzer {
153195
for (auto *op : operands) {
154196
if (op == nullptr)
155197
continue;
156-
if (!is_recomputable(op, cache))
198+
if (!is_recomputable(op, cache, mutated_snodes))
157199
return false;
158200
}
159201
return true;

quadrants/transforms/auto_diff/eliminate_recomputable_pushes.cpp

Lines changed: 63 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ class EliminateRecomputableAdStackPushes {
8787
stacks.push_back(s->as<AdStackAllocaStmt>());
8888
}
8989

90+
// SNodes written in the IB; gates `GlobalLoadStmt` chain leaves in `is_recomputable`. The pass body only erases
91+
// adstack stmts, so the write-stmt set is invariant across one outer iteration and we can recompute it per pass.
92+
auto mutated_snodes = RecomputableChainAnalyzer::collect_mutated_snodes(ib);
93+
9094
bool modified = false;
9195
std::unordered_map<Stmt *, bool> recomputable_cache;
9296

@@ -158,7 +162,7 @@ class EliminateRecomputableAdStackPushes {
158162
}
159163

160164
Stmt *pushed_val = body_push->v;
161-
if (!RecomputableChainAnalyzer::is_recomputable(pushed_val, recomputable_cache)) {
165+
if (!RecomputableChainAnalyzer::is_recomputable(pushed_val, recomputable_cache, mutated_snodes)) {
162166
continue;
163167
}
164168
// Loop-carried self-reference: when the pushed value's transitive operand DAG includes an AdStackLoadTopStmt of
@@ -238,80 +242,42 @@ class EliminateRecomputableAdStackPushes {
238242
continue;
239243
}
240244

241-
// Reverse-position correctness for chain leaves pushed in the same block as S.
242-
//
243-
// After elimination, every reverse stmt that consumed `load_top(S)` instead consumes the chain (cloned by
244-
// `BackupSSA::generic_visit` at the consumer's reverse position). The cloned chain reads `load_top(T)` for each
245-
// chain leaf T, where the read happens at the consumer's reverse cursor.
246-
//
247-
// `MakeAdjoint` visits forward stmts in REVERSE order, emitting at a cursor that advances per visit. For a
248-
// forward stmt at position P, its reverse emissions land "later" in reverse block iff P is smaller. T's pop is
249-
// emitted when `MakeAdjoint` visits T's body push at position P_T. The cloned chain at the consumer's reverse
250-
// position reads `load_top(T)` POST-pop iff T's pop has fired by then, i.e. iff visit(P_T) precedes visit(C_pos),
251-
// i.e. iff P_T > C_pos for every consumer C of S's load_tops.
245+
// Reverse-position correctness for chain leaves.
252246
//
253-
// Forward chain evaluates at S's push position P_S, before any of T's same-block body pushes (since we already
254-
// required P_T > P_S via the dominance check above for S's loads). So forward chain reads T's pre-iter-k-push
255-
// value. Reverse clone post-pop also returns T's pre-iter-k-push value (= iter k INPUT for loop-carried T).
256-
// Match.
247+
// Each chain leaf `AdStackLoadTopStmt(T)` is re-cloned by `BackupSSA::generic_visit` at the consumer's reverse
248+
// position; the cloned `load_top(T)` reads `T`'s top at that reverse cursor, which must equal what the original
249+
// load_top read in forward iter k. `MakeAdjoint` emits `T`'s pop for each forward push at the reverse cursor
250+
// mirroring that push's forward position, so by consumer-reverse only the pops for `T`-pushes with forward
251+
// position > P_cons have fired. The reverse top therefore equals `T`'s last forward push at-or-before P_cons,
252+
// while the forward chain at the load_top's own forward position P_lt reads `T`'s last forward push
253+
// at-or-before P_lt. They match iff no `T`-push falls strictly between P_lt and P_cons in document order.
257254
//
258-
// Without this check, the canonical Fibonacci-style shape (`p, q = q, p + q; b[q] += a[q]`) where S stores `p +
259-
// q` and chain leaves p_stack / q_stack are pushed AFTER S but BEFORE the GlobalPtr index consumer would silently
260-
// corrupt gradients: the reverse clone at the GlobalPtr's adjoint emission reads p_stack and q_stack PRE-pop
261-
// (iter k POST-push values), summing to a different value than the forward chain at P_S used.
262-
//
263-
// Chain leaves T whose stacks have NO body push in S's containing block are stable within the iter (their tops do
264-
// not change during the iter), so neither forward nor reverse evaluation order shifts their values. Excluded from
265-
// this check.
266-
auto find_consumers_of_load_tops = [&](std::vector<int> &out_positions) {
267-
std::function<void(Block *, int)> walk = [&](Block *b, int container_pos_in_push_block) {
268-
for (size_t i = 0; i < b->statements.size(); i++) {
269-
Stmt *s = b->statements[i].get();
270-
int effective_pos = (b == push_block) ? static_cast<int>(i) : container_pos_in_push_block;
271-
for (auto *op : s->get_operands()) {
272-
if (op == nullptr)
273-
continue;
274-
for (auto *lt : load_tops) {
275-
if (op == lt) {
276-
out_positions.push_back(effective_pos);
277-
break;
278-
}
279-
}
280-
}
281-
if (auto *if_s = s->cast<IfStmt>()) {
282-
if (if_s->true_statements)
283-
walk(if_s->true_statements.get(), effective_pos);
284-
if (if_s->false_statements)
285-
walk(if_s->false_statements.get(), effective_pos);
286-
} else if (auto *rf = s->cast<RangeForStmt>()) {
287-
if (rf->body)
288-
walk(rf->body.get(), effective_pos);
289-
} else if (auto *sf = s->cast<StructForStmt>()) {
290-
if (sf->body)
291-
walk(sf->body.get(), effective_pos);
292-
}
293-
}
294-
};
295-
walk(push_block, -1);
296-
};
297-
std::vector<int> consumer_positions;
298-
find_consumers_of_load_tops(consumer_positions);
299-
int max_consumer_pos = -1;
300-
for (int p : consumer_positions) {
301-
if (p > max_consumer_pos)
302-
max_consumer_pos = p;
255+
// A single preorder index over the IB lets the check compare positions across nested blocks in one integer
256+
// compare; pushes inside `IfStmt`, `RangeForStmt`, and `StructForStmt` bodies are reached via the visitor walk
257+
// and slot in between the container's preorder and the next sibling's preorder.
258+
std::unordered_map<Stmt *, int> preorder;
259+
{
260+
auto all = irpass::analysis::gather_statements(ib, [](Stmt *) { return true; });
261+
preorder.reserve(all.size());
262+
for (size_t i = 0; i < all.size(); i++)
263+
preorder[all[i]] = static_cast<int>(i);
303264
}
265+
auto preorder_of = [&](Stmt *s) -> int {
266+
auto it = preorder.find(s);
267+
return it == preorder.end() ? -1 : it->second;
268+
};
304269

305-
// Collect all distinct AdStackAllocaStmt referenced via AdStackLoadTopStmt leaves in pushed_val's chain.
306-
std::unordered_set<AdStackAllocaStmt *> chain_leaf_stacks;
270+
// Collect every `AdStackLoadTopStmt` reachable from `pushed_val`'s recomputable chain. Each one has its own
271+
// forward position P_lt - the position at which the cloned chain in reverse will read its target stack's top
272+
// for the corresponding forward iter - so the check is per-load_top, not per-stack.
273+
std::vector<AdStackLoadTopStmt *> chain_load_tops;
307274
std::unordered_set<Stmt *> walked_for_leaves;
308275
std::function<void(Stmt *)> collect_leaves = [&](Stmt *s) {
309276
if (walked_for_leaves.count(s))
310277
return;
311278
walked_for_leaves.insert(s);
312279
if (auto *lt = s->cast<AdStackLoadTopStmt>()) {
313-
if (auto *as = lt->stack->cast<AdStackAllocaStmt>())
314-
chain_leaf_stacks.insert(as);
280+
chain_load_tops.push_back(lt);
315281
return;
316282
}
317283
if (s->is<AdStackAllocaStmt>() || s->is<ArgLoadStmt>() || s->is<ConstStmt>())
@@ -323,20 +289,40 @@ class EliminateRecomputableAdStackPushes {
323289
};
324290
collect_leaves(pushed_val);
325291

292+
int max_consumer_preorder = -1;
293+
auto consumer_users = irpass::analysis::gather_statements(ib, [&](Stmt *s) {
294+
for (auto *op : s->get_operands()) {
295+
for (auto *lt : load_tops) {
296+
if (op == lt)
297+
return true;
298+
}
299+
}
300+
return false;
301+
});
302+
for (auto *u : consumer_users) {
303+
int p = preorder_of(u);
304+
if (p > max_consumer_preorder)
305+
max_consumer_preorder = p;
306+
}
307+
326308
bool reverse_safe = true;
327-
for (auto *T : chain_leaf_stacks) {
328-
// Find T's body pushes (non-init) in push_block. Only same-block pushes can interfere: pushes in outer blocks
329-
// happen once per outer iteration, so their tops are stable across the inner iter.
309+
for (auto *lt : chain_load_tops) {
310+
auto *T = lt->stack ? lt->stack->cast<AdStackAllocaStmt>() : nullptr;
311+
if (T == nullptr) {
312+
reverse_safe = false;
313+
break;
314+
}
315+
int lt_pre = preorder_of(lt);
330316
AdStackPushStmt *T_init = find_init_push(T);
331-
for (size_t i = 0; i < push_block->statements.size(); i++) {
332-
Stmt *s = push_block->statements[i].get();
333-
if (auto *p = s->cast<AdStackPushStmt>()) {
334-
if (p->stack == T && p != T_init) {
335-
if (static_cast<int>(i) <= max_consumer_pos) {
336-
reverse_safe = false;
337-
break;
338-
}
339-
}
317+
auto T_pushes = irpass::analysis::gather_statements(ib, [&](Stmt *s) {
318+
auto *p = s->cast<AdStackPushStmt>();
319+
return p != nullptr && p->stack == T && p != T_init;
320+
});
321+
for (auto *p : T_pushes) {
322+
int p_pre = preorder_of(p);
323+
if (p_pre > lt_pre && p_pre <= max_consumer_preorder) {
324+
reverse_safe = false;
325+
break;
340326
}
341327
}
342328
if (!reverse_safe)

0 commit comments

Comments
 (0)