Skip to content

Commit e7de926

Browse files
committed
[Refactor] Move adstack-overflow registry + diagnose classifier + launch snapshot from Program into AdStackCache
1 parent cefe86c commit e7de926

9 files changed

Lines changed: 418 additions & 411 deletions

File tree

quadrants/codegen/llvm/codegen_llvm.cpp

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include "quadrants/ir/snode.h"
1212
#include "quadrants/ir/statements.h"
1313
#include "quadrants/ir/transforms.h"
14+
#include "quadrants/program/adstack_size_expr_eval.h"
1415
#include "quadrants/program/extension.h"
1516
#include "quadrants/runtime/program_impls/llvm/llvm_program.h"
1617
#include "quadrants/codegen/llvm/struct_llvm.h"
@@ -1950,9 +1951,9 @@ std::string TaskCodeGenLLVM::init_offloaded_task_function(OffloadedStmt *stmt, s
19501951
// visited. Metadata + size_exprs are filled in at `finalize_offloaded_task_function` time below
19511952
// (idempotent re-registration on the same identity_key updates the entry in place). Identity
19521953
// key here is the raw `&current_task->ad_stack` address; the registry never derefs it.
1953-
uint32_t id = prog->register_adstack_sizing_info(static_cast<const void *>(&current_task->ad_stack), kernel_name,
1954-
task_codegen_id, /*allocated_max_sizes=*/{},
1955-
/*size_exprs=*/{});
1954+
uint32_t id = prog->adstack_cache().register_adstack_sizing_info(
1955+
static_cast<const void *>(&current_task->ad_stack), kernel_name, task_codegen_id, /*allocated_max_sizes=*/{},
1956+
/*size_exprs=*/{});
19561957
current_task->ad_stack.registry_id = id;
19571958
}
19581959

@@ -2046,9 +2047,9 @@ void TaskCodeGenLLVM::finalize_offloaded_task_function() {
20462047
// into the registry so the diagnose path can walk them without dereferencing the launcher's
20472048
// unstable `OffloadedTask::ad_stack` pointer (freed by `current_task = nullptr` after
20482049
// by-value `offloaded_tasks.push_back(*current_task)`).
2049-
uint32_t id = prog->register_adstack_sizing_info(static_cast<const void *>(&current_task->ad_stack), kernel_name,
2050-
task_codegen_id, std::move(allocated_max_sizes),
2051-
current_task->ad_stack.size_exprs);
2050+
uint32_t id = prog->adstack_cache().register_adstack_sizing_info(
2051+
static_cast<const void *>(&current_task->ad_stack), kernel_name, task_codegen_id,
2052+
std::move(allocated_max_sizes), current_task->ad_stack.size_exprs);
20522053
current_task->ad_stack.registry_id = id;
20532054
}
20542055
}

quadrants/program/adstack_size_expr_eval.cpp

Lines changed: 232 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -992,7 +992,7 @@ int64_t evaluate_adstack_size_expr(const SerializedSizeExpr &expr, Program *prog
992992
namespace {
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 {
10041004
int64_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+
12681496
void 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

Comments
 (0)