Skip to content

Commit cddb61d

Browse files
committed
[Fixup] Erase ndarray_data_gen on Ndarray destroy + parametrize cache-invalidation test over qd.ndarray and field
1 parent 32ba946 commit cddb61d

3 files changed

Lines changed: 77 additions & 30 deletions

File tree

quadrants/program/adstack_size_expr_eval.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ class AdStackCache {
126126
void bump_ndarray_data_gen(void *devalloc_ptr) {
127127
++ndarray_data_gen_[devalloc_ptr];
128128
}
129+
// Drop a per-DeviceAllocation entry. Called from `Ndarray::~Ndarray()` so the holder address can be reused by a
130+
// future allocation without inheriting the destroyed ndarray's stale generation. Leftover snapshots in
131+
// `per_task_ad_stack_cache_` / `llvm_per_task_ad_stack_cache_` referencing the dropped key fall back to gen=0
132+
// on the next lookup (their stored snapshot will not match), which forces a fresh sizer dispatch and self-heals.
133+
void erase_ndarray_data_gen(void *devalloc_ptr) {
134+
ndarray_data_gen_.erase(devalloc_ptr);
135+
}
129136

130137
private:
131138
std::unordered_map<const SerializedSizeExpr *, SizeExprCacheEntry> size_expr_cache_;

quadrants/program/ndarray.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ Ndarray::Ndarray(DeviceAllocation &devalloc,
9898

9999
Ndarray::~Ndarray() {
100100
if (prog_) {
101+
// Drop any cached `ndarray_data_gen` entry keyed by `&ndarray_alloc_` before the holder address can be reused
102+
// by a future Ndarray allocation. Without this, a new Ndarray that happens to occupy the same heap address
103+
// would pick up the destroyed ndarray's last generation counter and could falsely match a cache snapshot.
104+
prog_->adstack_cache().erase_ndarray_data_gen(const_cast<DeviceAllocation *>(&ndarray_alloc_));
101105
ndarray_alloc_.device->dealloc_memory(ndarray_alloc_);
102106
}
103107
}

tests/python/test_adstack.py

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,65 +2089,101 @@ def compute(a: qd.types.ndarray(dtype=qd.i32, ndim=1)):
20892089
assert x.grad[i] == pytest.approx(expected, rel=1e-5)
20902090

20912091

2092+
@pytest.mark.parametrize(
2093+
"trip_count_source",
2094+
["qd_ndarray", "field"],
2095+
ids=["qd_ndarray", "field"],
2096+
)
20922097
@test_utils.test(require=qd.extension.adstack)
2093-
def test_adstack_metadata_cache_invalidates_on_qd_ndarray_host_mutation():
2094-
# Pins per-task adstack metadata cache invalidation against host-side ndarray writes. A reverse-mode kernel
2095-
# whose inner trip count is `range(n[i])` over a `qd.ndarray` populates the cache on the first launch with
2096-
# `max_size = n[i]` evaluated against the current ndarray contents. A subsequent host-side mutation via
2097-
# `Ndarray::write` (no kernel involvement) must bump `ndarray_data_gen` so the next launch evicts the cache
2098-
# entry and re-runs the sizer against the new values.
2098+
def test_adstack_metadata_cache_invalidates_on_host_mutation(trip_count_source):
2099+
# Pins per-task adstack metadata cache invalidation against host-side mutation of the structure that supplies
2100+
# the inner trip count. A reverse-mode kernel whose inner range is `range(n[i])` populates the cache on the
2101+
# first launch with `max_size = n[i]` evaluated against the current contents. A subsequent host-side mutation
2102+
# via `Ndarray::write` (qd.ndarray case) or via the `SNodeRwAccessorsBank` writer kernel (field case) must
2103+
# bump the matching generation counter so the next launch evicts the entry and re-runs the sizer.
20992104
#
2100-
# Internal details: the cache key on every backend is `(AdStackSizingInfo *, snode_write_gen, ndarray_data_gen)`.
2101-
# Without the host-mutation bump, the second launch sees the same key, returns the stale `max_size` for the
2102-
# previous ndarray contents, and the reverse pass walks the wrong number of inner iters per outer iteration.
2103-
# On Metal the symptom is heap reads from out-of-bounds slots that produce garbage gradients (e.g. `x.grad[k]`
2104-
# in the dozens instead of the analytical `0.8`); on CPU the host-eval path replays observed reads against the
2105-
# mutated ndarray and recovers without the explicit gen bump, so the test asserts gradient values rather than
2106-
# an overflow trap to catch the bug on every backend uniformly.
2105+
# Internal details: the cache key on every backend is `(AdStackSizingInfo *, snode_write_gen[snode_ids],
2106+
# ndarray_data_gen[devalloc])`. The qd.ndarray path goes through `ndarray_data_gen` keyed by the
2107+
# `DeviceAllocation` holder address; the field path goes through `snode_write_gen` keyed by `SNode::id`.
2108+
# Without the bump on either side the second launch sees the same key, returns the stale `max_size` for the
2109+
# previous contents, and the reverse pass walks the wrong number of inner iters per outer iteration. On Metal
2110+
# the symptom is heap reads from out-of-bounds slots that produce garbage gradients (e.g. `x.grad[k]` in the
2111+
# dozens instead of the analytical `0.8`); on CPU the host-eval path replays observed reads and recovers
2112+
# without the explicit gen bump, so the test asserts gradient values rather than an overflow trap to catch the
2113+
# bug on every backend uniformly.
21072114
N = 4
21082115
N_X = 16
21092116

21102117
x = qd.field(qd.f32, shape=(N_X,), needs_grad=True)
21112118
loss = qd.field(qd.f32, shape=(), needs_grad=True)
21122119

2113-
@qd.kernel
2114-
def compute(n: qd.types.ndarray(dtype=qd.i32, ndim=1)):
2115-
for i_e in range(n.shape[0]):
2116-
accum = 0.0
2117-
for j in range(n[i_e]):
2118-
accum = accum + x[j] * x[j]
2119-
loss[None] += accum
2120+
if trip_count_source == "qd_ndarray":
2121+
n_obj = qd.ndarray(qd.i32, shape=(N,))
21202122

2121-
n_arr = qd.ndarray(qd.i32, shape=(N,))
2122-
for i in range(N):
2123-
n_arr[i] = 8
2123+
@qd.kernel
2124+
def compute(n: qd.types.ndarray(dtype=qd.i32, ndim=1)):
2125+
for i_e in range(n.shape[0]):
2126+
accum = 0.0
2127+
for j in range(n[i_e]):
2128+
accum = accum + x[j] * x[j]
2129+
loss[None] += accum
2130+
2131+
def set_n(val):
2132+
for i in range(N):
2133+
n_obj[i] = val
2134+
2135+
def call_compute():
2136+
compute(n_obj)
21242137

2138+
def call_compute_grad():
2139+
compute.grad(n_obj)
2140+
2141+
else:
2142+
n_obj = qd.field(qd.i32, shape=(N,))
2143+
2144+
@qd.kernel
2145+
def compute():
2146+
for i_e in range(N):
2147+
accum = 0.0
2148+
for j in range(n_obj[i_e]):
2149+
accum = accum + x[j] * x[j]
2150+
loss[None] += accum
2151+
2152+
def set_n(val):
2153+
for i in range(N):
2154+
n_obj[i] = val
2155+
2156+
def call_compute():
2157+
compute()
2158+
2159+
def call_compute_grad():
2160+
compute.grad()
2161+
2162+
set_n(8)
21252163
for i in range(N_X):
21262164
x[i] = 0.1
21272165
loss[None] = 0.0
2128-
compute(n_arr)
2166+
call_compute()
21292167
loss.grad[None] = 1.0
21302168
for i in range(N_X):
21312169
x.grad[i] = 0.0
2132-
compute.grad(n_arr)
2170+
call_compute_grad()
21332171
qd.sync()
21342172
assert loss[None] == pytest.approx(N * 8 * 0.01, rel=1e-5)
21352173
for k in range(8):
21362174
assert x.grad[k] == pytest.approx(N * 2 * 0.1, rel=1e-5)
21372175
for k in range(8, N_X):
21382176
assert x.grad[k] == pytest.approx(0.0, abs=1e-7)
21392177

2140-
for i in range(N):
2141-
n_arr[i] = 16
2142-
2178+
set_n(16)
21432179
for i in range(N_X):
21442180
x[i] = 0.1
21452181
loss[None] = 0.0
2146-
compute(n_arr)
2182+
call_compute()
21472183
loss.grad[None] = 1.0
21482184
for i in range(N_X):
21492185
x.grad[i] = 0.0
2150-
compute.grad(n_arr)
2186+
call_compute_grad()
21512187
qd.sync()
21522188
assert loss[None] == pytest.approx(N * 16 * 0.01, rel=1e-5)
21532189
for k in range(N_X):

0 commit comments

Comments
 (0)