|
31 | 31 | def _empty_past_feeds(per_layer_n_heads: list, d_head: int) -> dict: |
32 | 32 | feeds = {"past_len": np.array(0, dtype=np.int64)} |
33 | 33 | for i, nh in enumerate(per_layer_n_heads): |
34 | | - feeds[f"past_K_{i}"] = np.zeros((nh, 0, d_head), dtype=np.float32) |
35 | | - feeds[f"past_V_{i}"] = np.zeros((nh, 0, d_head), dtype=np.float32) |
| 34 | + feeds[f"past_K_{i}"] = np.zeros((0, nh, d_head), dtype=np.float32) |
| 35 | + feeds[f"past_V_{i}"] = np.zeros((0, nh, d_head), dtype=np.float32) |
36 | 36 | return feeds |
37 | 37 |
|
38 | 38 |
|
39 | 39 | def _discover_meta(session): |
| 40 | + # past_K_i is sequence-major (n_past, n_heads, d_head): heads on axis 1. |
40 | 41 | inputs = {inp.name: inp for inp in session.get_inputs()} |
41 | 42 | n_layers = sum(1 for name in inputs if name.startswith("past_K_")) |
42 | | - per_layer_n_heads = [int(inputs[f"past_K_{i}"].shape[0]) for i in range(n_layers)] |
| 43 | + per_layer_n_heads = [int(inputs[f"past_K_{i}"].shape[1]) for i in range(n_layers)] |
43 | 44 | d_head = int(inputs["past_K_0"].shape[2]) |
44 | 45 | return n_layers, per_layer_n_heads, d_head |
45 | 46 |
|
@@ -123,7 +124,7 @@ def test_headless_onnx_chunked_decode_matches_full_prefill(): |
123 | 124 | n_layers, per_layer_n_heads, d_head = _discover_meta(session) |
124 | 125 | out_names = ["outputs"] |
125 | 126 | for i in range(n_layers): |
126 | | - out_names += [f"new_K_{i}", f"new_V_{i}"] |
| 127 | + out_names += [f"delta_K_{i}", f"delta_V_{i}"] |
127 | 128 |
|
128 | 129 | # Full prefill (ground truth) |
129 | 130 | feeds = {"inputs": inputs_np} |
@@ -164,7 +165,7 @@ def test_headless_onnx_decode_step_matches_full_prefill(): |
164 | 165 | n_layers, per_layer_n_heads, d_head = _discover_meta(session) |
165 | 166 | out_names = ["outputs"] |
166 | 167 | for i in range(n_layers): |
167 | | - out_names += [f"new_K_{i}", f"new_V_{i}"] |
| 168 | + out_names += [f"delta_K_{i}", f"delta_V_{i}"] |
168 | 169 |
|
169 | 170 | # Full prefill |
170 | 171 | feeds = {"inputs": inputs_np} |
@@ -192,6 +193,84 @@ def test_headless_onnx_decode_step_matches_full_prefill(): |
192 | 193 | ), f"decode seam diff: {np.abs(full_outputs[-1] - decode_out[0]).max():.6f}" |
193 | 194 |
|
194 | 195 |
|
| 196 | +# --------------------------------------------------------------------------- |
| 197 | +# Test 2b: the causal mask follows the BOUND past_K shape, not past_len. |
| 198 | +# This is the load-bearing invariant of the in-place (delta) cache: the |
| 199 | +# runtime binds past_K = cache[:cache_len] (a contiguous prefix of a larger |
| 200 | +# owned buffer), so the mask geometry must come from the bound seq-dim, and |
| 201 | +# binding the whole allocation would attend to the dead tail. |
| 202 | +# --------------------------------------------------------------------------- |
| 203 | + |
| 204 | + |
| 205 | +def test_headless_onnx_mask_follows_bound_cache_shape(): |
| 206 | + out, pos = _build_sample_graph() |
| 207 | + a_vals = torch.tensor([[3.0], [5.0], [-2.0], [0.0], [4.0]]) |
| 208 | + b_vals = torch.tensor([[4.0], [-1.0], [3.0], [7.0], [2.0]]) |
| 209 | + inputs_np = torch.cat([a_vals, b_vals], dim=1).numpy().astype(np.float32) |
| 210 | + n = 4 # rows committed to the cache before the decode step |
| 211 | + |
| 212 | + with tempfile.TemporaryDirectory() as tmpdir: |
| 213 | + onnx_path = _export(out, pos, tmpdir) |
| 214 | + session = onnxruntime.InferenceSession(onnx_path) |
| 215 | + n_layers, per_layer_n_heads, d_head = _discover_meta(session) |
| 216 | + out_names = ["outputs"] |
| 217 | + for i in range(n_layers): |
| 218 | + out_names += [f"delta_K_{i}", f"delta_V_{i}"] |
| 219 | + |
| 220 | + feeds = {"inputs": inputs_np} |
| 221 | + feeds.update(_empty_past_feeds(per_layer_n_heads, d_head)) |
| 222 | + full_outputs = session.run(["outputs"], feeds)[0] |
| 223 | + |
| 224 | + # Prefill n rows from empty: the deltas ARE the n-row cache (seq-major). |
| 225 | + feeds = {"inputs": inputs_np[:n]} |
| 226 | + feeds.update(_empty_past_feeds(per_layer_n_heads, d_head)) |
| 227 | + results = session.run(out_names, feeds) |
| 228 | + cache_k = [results[1 + 2 * i] for i in range(n_layers)] |
| 229 | + cache_v = [results[1 + 2 * i + 1] for i in range(n_layers)] |
| 230 | + |
| 231 | + def decode(past_k, past_v, past_len): |
| 232 | + feeds = { |
| 233 | + "inputs": inputs_np[n : n + 1], |
| 234 | + "past_len": np.array(past_len, dtype=np.int64), |
| 235 | + } |
| 236 | + for i in range(n_layers): |
| 237 | + feeds[f"past_K_{i}"] = past_k[i] |
| 238 | + feeds[f"past_V_{i}"] = past_v[i] |
| 239 | + return session.run(["outputs"], feeds)[0] |
| 240 | + |
| 241 | + # (1) Exact n-row past reproduces row n of the full prefill. |
| 242 | + exact = decode(cache_k, cache_v, n) |
| 243 | + assert np.allclose(full_outputs[n], exact[0], atol=1e-3) |
| 244 | + |
| 245 | + # Allocate a larger buffer; [:n] is the real cache, the tail is garbage. |
| 246 | + m = n + 3 |
| 247 | + big_k, big_v = [], [] |
| 248 | + for i in range(n_layers): |
| 249 | + nh = per_layer_n_heads[i] |
| 250 | + bk = np.full((m, nh, d_head), 7.0, dtype=np.float32) |
| 251 | + bv = np.full((m, nh, d_head), -3.0, dtype=np.float32) |
| 252 | + bk[:n] = cache_k[i] |
| 253 | + bv[:n] = cache_v[i] |
| 254 | + big_k.append(bk) |
| 255 | + big_v.append(bv) |
| 256 | + |
| 257 | + # (1') Binding the contiguous prefix VIEW big[:n] is bit-equal — the |
| 258 | + # owned-cache in-place pattern is sound. |
| 259 | + sliced = decode([bk[:n] for bk in big_k], [bv[:n] for bv in big_v], n) |
| 260 | + assert np.allclose( |
| 261 | + exact[0], sliced[0], atol=1e-6 |
| 262 | + ), f"prefix-slice bind diverged: {np.abs(exact[0] - sliced[0]).max():.6e}" |
| 263 | + |
| 264 | + # (2) Binding the WHOLE buffer (past_len=n) attends to the dead tail — |
| 265 | + # the mask spans m, not n — so it must differ. This is why the runtime |
| 266 | + # binds cache[:cache_len], never the full allocation. |
| 267 | + full_buf = decode(big_k, big_v, n) |
| 268 | + assert not np.allclose(exact[0], full_buf[0], atol=1e-3), ( |
| 269 | + "binding the full allocation matched the prefix bind — the mask is " |
| 270 | + "not following the bound past_K shape" |
| 271 | + ) |
| 272 | + |
| 273 | + |
195 | 274 | # --------------------------------------------------------------------------- |
196 | 275 | # Test 3: OnnxHeadlessModule.step API threads the cache correctly |
197 | 276 | # --------------------------------------------------------------------------- |
@@ -241,9 +320,9 @@ def test_onnx_headless_module_empty_past_shape(): |
241 | 320 | assert len(past_K) == module._n_layers |
242 | 321 | assert len(past_V) == module._n_layers |
243 | 322 | for i, K in enumerate(past_K): |
244 | | - assert K.shape == (module._per_layer_n_heads[i], 0, module._d_head) |
| 323 | + assert K.shape == (0, module._per_layer_n_heads[i], module._d_head) |
245 | 324 | for i, V in enumerate(past_V): |
246 | | - assert V.shape == (module._per_layer_n_heads[i], 0, module._d_head) |
| 325 | + assert V.shape == (0, module._per_layer_n_heads[i], module._d_head) |
247 | 326 |
|
248 | 327 |
|
249 | 328 | # --------------------------------------------------------------------------- |
|
0 commit comments