@@ -992,7 +992,7 @@ int64_t evaluate_adstack_size_expr(const SerializedSizeExpr &expr, Program *prog
992992namespace {
993993
994994// Diagnose-time leaf reader: resolves an `ExternalTensorRead` against the captured
995- // `Program ::DiagnoseLaunchSnapshot` and the program's `Device::map` interface. Returns -1 on any failure
995+ // `AdStackCache ::DiagnoseLaunchSnapshot` and the program's `Device::map` interface. Returns -1 on any failure
996996// (missing arg in snapshot, unrecognised primitive type, mapping failure) so the caller can substitute the
997997// `?` placeholder for that stack while keeping the rest of the message intact.
998998//
@@ -1004,7 +1004,7 @@ namespace {
10041004int64_t read_diagnose_external_tensor (const SerializedSizeExprNode &node,
10051005 const std::vector<int64_t > &resolved_indices,
10061006 Program *prog,
1007- const Program ::DiagnoseLaunchSnapshot &snapshot) {
1007+ const AdStackCache ::DiagnoseLaunchSnapshot &snapshot) {
10081008 if (node.arg_id_path .empty ()) {
10091009 return -1 ;
10101010 }
@@ -1124,7 +1124,7 @@ int64_t evaluate_node_for_diagnose(const SerializedSizeExpr &expr,
11241124 int32_t node_idx,
11251125 std::unordered_map<int32_t , int64_t > &bound_vars,
11261126 Program *prog,
1127- const Program ::DiagnoseLaunchSnapshot &snapshot) {
1127+ const AdStackCache ::DiagnoseLaunchSnapshot &snapshot) {
11281128 if (node_idx < 0 || static_cast <std::size_t >(node_idx) >= expr.nodes .size ()) {
11291129 return -1 ;
11301130 }
@@ -1257,14 +1257,242 @@ int64_t evaluate_adstack_size_expr_for_diagnose(const SerializedSizeExpr &expr,
12571257 if (expr.nodes .empty () || prog == nullptr ) {
12581258 return -1 ;
12591259 }
1260- const Program ::DiagnoseLaunchSnapshot *snapshot = prog->get_diagnose_snapshot ();
1260+ const AdStackCache ::DiagnoseLaunchSnapshot *snapshot = prog->adstack_cache (). get_diagnose_snapshot ();
12611261 if (snapshot == nullptr ) {
12621262 return -1 ;
12631263 }
12641264 std::unordered_map<int32_t , int64_t > bound_vars;
12651265 return evaluate_node_for_diagnose (expr, static_cast <int32_t >(expr.nodes .size () - 1 ), bound_vars, prog, *snapshot);
12661266}
12671267
1268+ uint32_t AdStackCache::register_adstack_sizing_info (const void *identity_key,
1269+ const std::string &kernel_name,
1270+ int task_id_in_kernel,
1271+ std::vector<int > allocated_max_sizes,
1272+ std::vector<SerializedSizeExpr> size_exprs) {
1273+ std::lock_guard<std::mutex> lk (adstack_sizing_info_registry_mutex_);
1274+ // Idempotent re-registration: same `identity_key` yields the same id across re-compiles and updates the
1275+ // entry's metadata + size_exprs in place. The key is just an opaque dedup token - the registry never
1276+ // dereferences it; all data needed by the diagnose path is copied into the entry below.
1277+ auto it = adstack_sizing_info_id_by_ptr_.find (identity_key);
1278+ if (it != adstack_sizing_info_id_by_ptr_.end ()) {
1279+ auto &entry = adstack_sizing_info_registry_[it->second ];
1280+ entry.kernel_name = kernel_name;
1281+ entry.task_id_in_kernel = task_id_in_kernel;
1282+ entry.allocated_max_sizes = std::move (allocated_max_sizes);
1283+ entry.size_exprs = std::move (size_exprs);
1284+ return it->second ;
1285+ }
1286+ uint32_t id = static_cast <uint32_t >(adstack_sizing_info_registry_.size ());
1287+ AdStackSizingInfoEntry entry;
1288+ entry.identity_key = identity_key;
1289+ entry.kernel_name = kernel_name;
1290+ entry.task_id_in_kernel = task_id_in_kernel;
1291+ entry.allocated_max_sizes = std::move (allocated_max_sizes);
1292+ entry.size_exprs = std::move (size_exprs);
1293+ adstack_sizing_info_registry_.push_back (std::move (entry));
1294+ adstack_sizing_info_id_by_ptr_.emplace (identity_key, id);
1295+ return id;
1296+ }
1297+
1298+ void AdStackCache::update_adstack_sizing_info_size_exprs (uint32_t id, std::vector<SerializedSizeExpr> size_exprs) {
1299+ std::lock_guard<std::mutex> lk (adstack_sizing_info_registry_mutex_);
1300+ if (id == 0 || id >= adstack_sizing_info_registry_.size ()) {
1301+ return ;
1302+ }
1303+ adstack_sizing_info_registry_[id].size_exprs = std::move (size_exprs);
1304+ }
1305+
1306+ std::optional<AdStackCache::AdStackSizingInfoEntry> AdStackCache::lookup_adstack_sizing_info (uint32_t id) const {
1307+ std::lock_guard<std::mutex> lk (adstack_sizing_info_registry_mutex_);
1308+ if (id == 0 || id >= adstack_sizing_info_registry_.size ()) {
1309+ return std::nullopt ;
1310+ }
1311+ return adstack_sizing_info_registry_[id];
1312+ }
1313+
1314+ std::string AdStackCache::diagnose_adstack_overflow_message (uint32_t task_id) const {
1315+ return diagnose_adstack_overflow (task_id).message ;
1316+ }
1317+
1318+ AdStackCache::AdStackOverflowDiagnosis AdStackCache::diagnose_adstack_overflow (uint32_t task_id) const {
1319+ std::string identity_block;
1320+ std::string disambiguation_block;
1321+ // Cause classifier: when the synchronous re-run produces required > allocated for ANY stack, the most likely
1322+ // cause is an untracked tensor mutation (DLPack-bypass etc.). When all required <= allocated, the pre-pass
1323+ // undersized the bound (Quadrants bug). When we cannot re-evaluate (e.g. no captured launch snapshot, or a
1324+ // leaf type the diagnose evaluator does not support) we fall through to the static dual-cause body.
1325+ enum class Cause { Unknown, DLPackBypass, QuadrantsBug };
1326+ Cause cause = Cause::Unknown;
1327+
1328+ if (task_id != 0 ) {
1329+ auto entry_opt = lookup_adstack_sizing_info (task_id);
1330+ if (entry_opt.has_value ()) {
1331+ const auto &entry = *entry_opt;
1332+ identity_block = " Offending task: kernel `" + entry.kernel_name + " ` offload task #" +
1333+ std::to_string (entry.task_id_in_kernel ) + " ; per-stack allocated max_size = [" ;
1334+ for (size_t i = 0 ; i < entry.allocated_max_sizes .size (); ++i) {
1335+ if (i != 0 ) {
1336+ identity_block += " , " ;
1337+ }
1338+ identity_block += std::to_string (entry.allocated_max_sizes [i]);
1339+ }
1340+ identity_block += " ].\n " ;
1341+
1342+ // Synchronous sizer rerun: walk each stack's `SerializedSizeExpr` and evaluate against the live host /
1343+ // SNode state. Stacks whose tree contains an `ExternalTensorShape` or `ExternalTensorRead` leaf go
1344+ // through the snapshot-based `evaluate_adstack_size_expr_for_diagnose` (see its declaration for the
1345+ // `Device::map` design rationale). Pure host-resolvable trees go through the standard host evaluator.
1346+ // The disambiguation is best-effort: if every stack's tree resolves we get a precise classification;
1347+ // otherwise we report what we have and fall back to the static dual-cause hint.
1348+ if (!entry.size_exprs .empty ()) {
1349+ std::vector<int64_t > required_sizes;
1350+ std::vector<bool > required_known;
1351+ size_t any_grew = 0 ;
1352+ size_t any_unknown = 0 ;
1353+ size_t total = std::min (entry.size_exprs .size (), entry.allocated_max_sizes .size ());
1354+ for (size_t i = 0 ; i < total; ++i) {
1355+ const auto &expr = entry.size_exprs [i];
1356+ bool host_resolvable = true ;
1357+ for (const auto &node : expr.nodes ) {
1358+ auto k = static_cast <SizeExpr::Kind>(node.kind );
1359+ if (k == SizeExpr::Kind::ExternalTensorShape || k == SizeExpr::Kind::ExternalTensorRead) {
1360+ host_resolvable = false ;
1361+ break ;
1362+ }
1363+ }
1364+ int64_t v = -1 ;
1365+ if (host_resolvable && !expr.nodes .empty ()) {
1366+ // Pure host-resolvable: SNode field loads, constants, arithmetic. `ctx == nullptr` is safe because
1367+ // every leaf we kept is host-resolvable; ETS / ETR are the only kinds that touch ctx and we
1368+ // filtered them out.
1369+ SizeExprLaunchScope scope;
1370+ v = evaluate_adstack_size_expr (expr, prog_, nullptr );
1371+ } else if (!expr.nodes .empty ()) {
1372+ // Tree contains ETR / ETS leaves. The diagnose evaluator resolves them through the captured launch
1373+ // snapshot (`Device::map`-based ndarray reads). On failure (no snapshot, allocation cannot be
1374+ // mapped, unsupported dtype) the helper returns -1 and we fall through to the `?` placeholder.
1375+ int64_t diag = evaluate_adstack_size_expr_for_diagnose (expr, prog_);
1376+ if (diag >= 0 ) {
1377+ v = diag;
1378+ }
1379+ }
1380+ required_sizes.push_back (v);
1381+ required_known.push_back (!expr.nodes .empty () && v >= 0 );
1382+ if (required_known.back () && static_cast <size_t >(v) > entry.allocated_max_sizes [i]) {
1383+ ++any_grew;
1384+ }
1385+ if (!required_known.back ()) {
1386+ ++any_unknown;
1387+ }
1388+ }
1389+ if (any_grew > 0 ) {
1390+ cause = Cause::DLPackBypass;
1391+ } else if (any_unknown == 0 && total > 0 ) {
1392+ cause = Cause::QuadrantsBug;
1393+ }
1394+ // Only print the rerun line when at least one stack's bound resolves to a real value. With every leaf
1395+ // unresolved the line would be `required = [?, ?, ...]` which adds zero signal beyond the dual-cause
1396+ // body that follows; the omission keeps the message focused on actionable content.
1397+ if (any_unknown < total) {
1398+ disambiguation_block = " Synchronous sizer rerun: required max_size = [" ;
1399+ for (size_t i = 0 ; i < required_sizes.size (); ++i) {
1400+ if (i != 0 ) {
1401+ disambiguation_block += " , " ;
1402+ }
1403+ if (required_known[i]) {
1404+ disambiguation_block += std::to_string (required_sizes[i]);
1405+ } else {
1406+ disambiguation_block += " ?" ;
1407+ }
1408+ }
1409+ disambiguation_block += " ]." ;
1410+ if (any_unknown > 0 ) {
1411+ disambiguation_block +=
1412+ " (`?` = sizer rerun could not resolve this stack's bound against the captured "
1413+ " launch state)." ;
1414+ }
1415+ disambiguation_block += " \n " ;
1416+ }
1417+ }
1418+ }
1419+ }
1420+
1421+ std::string body;
1422+ if (cause == Cause::DLPackBypass) {
1423+ body =
1424+ " Cause (sync sizer rerun): a tensor backing a data-dependent loop bound was mutated outside "
1425+ " Quadrants's tracking - typically a DLPack zero-copy mutation through a torch tensor sharing "
1426+ " storage with a Quadrants ndarray, or a raw pointer write through a non-torch DLPack consumer. "
1427+ " The cached adstack capacity was sized against the value before the mutation. Recovery: route "
1428+ " the mutation through Quadrants APIs (`Ndarray.write` / `fill` / kernel writes) so the cache "
1429+ " invalidates correctly, OR set a generous initial cap if a workload-change milestone genuinely "
1430+ " grew capacity. Restart the iteration / training loop from a clean state.\n " ;
1431+ } else if (cause == Cause::QuadrantsBug) {
1432+ body =
1433+ " Cause (sync sizer rerun): the freshly-computed required size does not exceed the allocated "
1434+ " size for any stack - this is a Quadrants bug. The pre-pass resolved the alloca to a bound "
1435+ " tighter than the actual runtime push count: either the enclosing loop shape is outside the "
1436+ " current `SizeExpr` grammar, or the Bellman-Ford analyzer undercounted the forward-pass "
1437+ " accumulation. Please file with the kernel IR (`QD_DUMP_IR=1`).\n " ;
1438+ } else {
1439+ body =
1440+ " Two possible causes (synchronous sizer rerun was not conclusive - some `SizeExpr` trees "
1441+ " depend on ndarray contents that are not host-resolvable without a per-launch context, or the "
1442+ " task-id slot was empty so the registry pointer could not be confirmed live):\n "
1443+ " 1. A tensor backing a data-dependent loop bound was mutated outside Quadrants's tracking "
1444+ " (typically a DLPack zero-copy mutation through a torch tensor sharing storage with a "
1445+ " Quadrants ndarray, or a raw pointer write through a non-torch DLPack consumer). The cached "
1446+ " adstack capacity was sized against the value before the mutation. Recovery: route the "
1447+ " mutation through Quadrants APIs (`Ndarray.write` / `fill` / kernel writes) so the cache "
1448+ " invalidates correctly, OR set a generous initial cap if a workload-change milestone "
1449+ " genuinely grew capacity. Restart the iteration / training loop from a clean state.\n "
1450+ " 2. (Quadrants bug) the pre-pass resolved the alloca to a bound tighter than the actual "
1451+ " runtime push count - the enclosing loop shape is outside the current `SizeExpr` grammar, or "
1452+ " the Bellman-Ford analyzer undercounted the forward-pass accumulation. Please file with the "
1453+ " kernel IR (`QD_DUMP_IR=1`).\n " ;
1454+ }
1455+ AdStackOverflowDiagnosis result;
1456+ result.message = identity_block + disambiguation_block + body +
1457+ " Note: kernel state may be inconsistent post-overflow; do not retry the same "
1458+ " step without addressing the cause and restarting from a clean state." ;
1459+ // Flag the cache as confirmed-invalid only when the sync rerun positively identified DLPack-bypass (`required
1460+ // > allocated` for at least one stack with every leaf resolved against the live snapshot). Unknown is a rare
1461+ // fallback now that the snapshot-based evaluator handles ndarray-bound leaves; treating it as
1462+ // confirmed-bypass would silently retry against a possibly-broken cache. Quadrants-bug is excluded for the
1463+ // same reason - the next launch would re-run the same wrong sizer and produce the same wrong bound.
1464+ result.confirmed_invalid_cache = (cause == Cause::DLPackBypass);
1465+ return result;
1466+ }
1467+
1468+ void AdStackCache::capture_diagnose_snapshot (const LaunchContextBuilder &ctx) {
1469+ std::lock_guard<std::mutex> lk (diagnose_snapshot_mutex_);
1470+ diagnose_snapshot_.data_ptrs .clear ();
1471+ diagnose_snapshot_.dev_alloc_types .clear ();
1472+ diagnose_snapshot_.shapes .clear ();
1473+ // Pull just the data-pointer slot for each arg; the grad-pointer slot is irrelevant to size_expr leaves.
1474+ for (const auto &kv : ctx.array_ptrs ) {
1475+ if (kv.first .ptr_type == TypeFactory::DATA_PTR_POS_IN_NDARRAY ) {
1476+ diagnose_snapshot_.data_ptrs [kv.first .arg_id ] = kv.second ;
1477+ }
1478+ }
1479+ diagnose_snapshot_.dev_alloc_types = ctx.device_allocation_type ;
1480+ // Mirror the per-arg shape vectors `LaunchContextBuilder` populated alongside the args-buffer writes. Going
1481+ // through this side map rather than `args_type->get_element_offset` avoids the spurious "Cannot treat as
1482+ // TensorType" diagnostics emitted when an axis lookup overruns the actual rank, and keeps the diagnose path
1483+ // independent of `args_type` lifetime.
1484+ for (const auto &kv : ctx.ndarray_shapes ) {
1485+ std::vector<int32_t > shape32 (kv.second .begin (), kv.second .end ());
1486+ diagnose_snapshot_.shapes [kv.first ] = std::move (shape32);
1487+ }
1488+ diagnose_snapshot_.valid = true ;
1489+ }
1490+
1491+ const AdStackCache::DiagnoseLaunchSnapshot *AdStackCache::get_diagnose_snapshot () const {
1492+ std::lock_guard<std::mutex> lk (diagnose_snapshot_mutex_);
1493+ return diagnose_snapshot_.valid ? &diagnose_snapshot_ : nullptr ;
1494+ }
1495+
12681496void clip_effective_rows_by_loop_trip_count (std::size_t &effective_rows,
12691497 const StaticAdStackBoundExpr &bound_expr,
12701498 std::size_t dispatched_threads_ceiling,
0 commit comments