@@ -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