Skip to content

Commit 151f8cd

Browse files
committed
eval: suppress the depth-0 heuristic batch-axis fallback under a batched objective
The batched evaluator's candidate_axes() falls back, at depth 0 on a node carrying no accepted optimizer annotation, to batching the largest contracted batchable index (batch_axis()). That legacy single-axis convenience predates the per-node batch-axis annotations the batched (peak) objectives now emit. Under such an objective the annotations are authoritative: an un-annotated node means "do not batch here", so the fallback wrongly batches nodes the peak-threshold objective declined -- measured on aux-only water-8 as 30 of 37 aux triggers, +34% product ops, and a small accuracy perturbation from the per-batch screening relaxation. Add BatchPolicy::suppress_heuristic_fallback, read only by the runtime evaluator (make_evaluator -> make_batched_custom_evaluator); default false preserves the legacy behavior byte-for-byte. Also add a behavior-neutral BatchAxes trace line (depth / picked axis+kind / annotation / result indices), gated on log::printing(), to diagnose which batch axes are annotation- vs heuristic-driven.
1 parent 874bfb4 commit 151f8cd

2 files changed

Lines changed: 71 additions & 12 deletions

File tree

SeQuant/core/batch_policy.hpp

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ struct BatchPolicy {
2626
/// yet contracted at no node -- is eligible for batching; its per-slice size
2727
/// comes from \c batch_target_size(ix) like any batchable index. Default
2828
/// false = no spectator batching (byte-identical to non-spectator behavior).
29+
/// Necessary but not sufficient: spectator axes are emitted only under a
30+
/// TIME-FIRST objective (DenseTimeSpaceBatched) and only when the selected
31+
/// root's modeled peak exceeds \c peak_threshold. Spectator batching is
32+
/// therefore currently unavailable under the space-first objectives.
2933
bool batch_spectator_indices = false;
3034

3135
/// If true, restrict batching to persistent (amplitude-independent) subtrees,
@@ -39,6 +43,18 @@ struct BatchPolicy {
3943
/// Read identically by the single-term optimizer and the runtime evaluator.
4044
bool persistent_only = false;
4145

46+
/// If true, the runtime batched evaluator does NOT apply its depth-0
47+
/// heuristic fallback (batching the largest contracted batchable index when a
48+
/// node carries no accepted optimizer annotation). Set this when a batched
49+
/// objective is active: the optimizer then emits per-node batch-axis
50+
/// annotations that are AUTHORITATIVE, so an un-annotated node means "do not
51+
/// batch here" and the legacy single-axis fallback would wrongly override
52+
/// that (batching nodes the peak-threshold objective declined). Default false
53+
/// preserves the legacy single-axis convenience for callers/objectives that
54+
/// do not annotate. Read only by the runtime evaluator (make_evaluator); the
55+
/// optimizer ignores it.
56+
bool suppress_heuristic_fallback = false;
57+
4258
/// Footprint multiplier for the in-flight batch contribution that co-resides
4359
/// with a batch-accumulated intermediate (K += contribution). 0 = ignore
4460
/// (default); ~1 = full contribution materialized; backend-specific (TA's
@@ -48,11 +64,22 @@ struct BatchPolicy {
4864
/// batchable index.
4965
double accumulation_factor = 0.0;
5066

51-
/// Peak-memory budget in BYTES for the batched objective. The single-term
52-
/// optimizer minimizes flops among schedules whose modeled peak is <=
53-
/// peak_threshold, falling back to min-peak (best effort) when none fit.
54-
/// Default +infinity => every schedule feasible => min flops => no batching.
55-
/// This is the *enable* trigger for batching (a finite value turns it on).
67+
/// Peak-memory budget in BYTES for the batched objectives. Its meaning
68+
/// DIFFERS between them:
69+
///
70+
/// - SPACE-FIRST (DenseSpaceTimeBatched): a hard feasibility gate. The
71+
/// single-term optimizer minimizes flops among schedules whose modeled peak
72+
/// is <= peak_threshold, falling back to min-peak (best effort) when none
73+
/// fit. Default +infinity => every schedule feasible => min flops => no
74+
/// batching, i.e. here a finite value is the *enable* trigger for batching.
75+
///
76+
/// - TIME-FIRST (DenseTimeSpaceBatched): NOT a feasibility gate. Root
77+
/// selection ignores it entirely (peak breaks exact flop ties only), so it
78+
/// can neither constrain the schedule's peak nor enable CONTRACTED-axis
79+
/// batching (which is emitted regardless). Its ONLY effect is to trigger
80+
/// EXTERNAL (spectator) axis emission, together with
81+
/// \c batch_spectator_indices: axes are emitted iff the selected root's
82+
/// modeled peak exceeds this threshold.
5683
double peak_threshold = std::numeric_limits<double>::infinity();
5784
};
5885

SeQuant/core/eval/eval.hpp

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,9 +1361,11 @@ template <typename F, typename IndexPredicate = accept_any_index,
13611361
F le, std::function<std::size_t(Index const&)> target_batch_size,
13621362
IndexPredicate accept = {}, ScopeGuardFactory make_scope_guard = {},
13631363
IsVolatile is_volatile = {}, bool persistent_only = false,
1364-
std::size_t depth = 0, PeakSink peak = nullptr) {
1364+
std::size_t depth = 0, PeakSink peak = nullptr,
1365+
bool suppress_heuristic_fallback = false) {
13651366
return [le = std::move(le), target_batch_size = std::move(target_batch_size),
13661367
accept, is_volatile, persistent_only, depth, peak,
1368+
suppress_heuristic_fallback,
13671369
make_scope_guard](auto const& node, auto& cache) -> ResultPtr {
13681370
// Runaway backstop: nesting re-enters this evaluator on the per-batch
13691371
// scratch (see the reinstall below), incrementing depth once per nested
@@ -1381,12 +1383,19 @@ template <typename F, typename IndexPredicate = accept_any_index,
13811383
// scratch would re-batch inner nodes of an unannotated subtree (extra,
13821384
// unintended work) rather than realizing the optimizer's chosen multi-axis
13831385
// nesting.
1384-
auto candidate_axes = [&accept,
1385-
depth](auto const& n) -> container::svector<Index> {
1386+
auto candidate_axes = [&accept, depth, suppress_heuristic_fallback](
1387+
auto const& n) -> container::svector<Index> {
13861388
container::svector<Index> out;
13871389
for (auto const& entry : n->batch_axes())
13881390
if (accept(entry.first)) out.push_back(entry.first);
1389-
if (out.empty() && depth == 0)
1391+
// Depth-0 heuristic fallback: when the node carries no accepted
1392+
// annotation, batch the largest contracted batchable index (legacy
1393+
// single-axis behavior). Under a batched objective the optimizer's
1394+
// annotations are authoritative -- an empty annotation means "do not
1395+
// batch this node" -- so suppress_heuristic_fallback (BatchPolicy) skips
1396+
// this fallback to honor the optimizer's peak-threshold-gated decision.
1397+
// Default false = byte-identical (fallback active).
1398+
if (out.empty() && depth == 0 && !suppress_heuristic_fallback)
13901399
if (auto const h = batch_axis(n, accept)) out.push_back(*h);
13911400
return out;
13921401
};
@@ -1448,6 +1457,28 @@ template <typename F, typename IndexPredicate = accept_any_index,
14481457
break;
14491458
}
14501459

1460+
// DEBUG (behavior-neutral): log the trigger's depth, picked axis + kind,
1461+
// and its full batch_axes() annotation + result indices, to diagnose nested
1462+
// re-batching of a single aux axis. Emitted only when tracing is on.
1463+
if (log::printing()) {
1464+
std::string annot;
1465+
for (auto const& [ix, knd] : node->batch_axes()) {
1466+
annot += toUtf8(ix.full_label());
1467+
annot += (knd == AxisKind::External ? ":ext " : ":con ");
1468+
}
1469+
std::string res;
1470+
for (auto const& ix : node->canon_indices()) {
1471+
res += toUtf8(ix.full_label());
1472+
res += " ";
1473+
}
1474+
log::log(
1475+
"BatchAxes",
1476+
std::format("depth={} picked={}:{} nbatch={} annot=[{}] result=[{}]",
1477+
depth, toUtf8(K.full_label()),
1478+
picked_kind == AxisKind::External ? "ext" : "con",
1479+
batches.size(), annot, res));
1480+
}
1481+
14511482
if (picked_kind == AxisKind::External) {
14521483
// SCATTER branch. K survives to node's result as a free spectator mode,
14531484
// so the per-block partials are disjoint slices of one result (not
@@ -1502,7 +1533,7 @@ template <typename F, typename IndexPredicate = accept_any_index,
15021533
bs.cache.set_custom_evaluator(make_batched_custom_evaluator(
15031534
std::function<ResultPtr(node_t const&)>{le_g}, target_batch_size,
15041535
accept, make_scope_guard, is_volatile, persistent_only, depth + 1,
1505-
peak));
1536+
peak, suppress_heuristic_fallback));
15061537
ResultPtr part = evaluate(node, le_g, bs.cache);
15071538
// Pre-size on the first block (learns the result's non-axis extents and
15081539
// kind from the block partial; the external axis is widened to full).
@@ -1655,7 +1686,7 @@ template <typename F, typename IndexPredicate = accept_any_index,
16551686
bs.cache.set_custom_evaluator(make_batched_custom_evaluator(
16561687
std::function<ResultPtr(node_t const&)>{le_g}, target_batch_size,
16571688
accept, make_scope_guard, is_volatile, persistent_only, depth + 1,
1658-
peak));
1689+
peak, suppress_heuristic_fallback));
16591690
ResultPtr part = evaluate(*mem, le_g, bs.cache);
16601691
if (!acc[m])
16611692
acc[m] = std::move(part);
@@ -1751,7 +1782,8 @@ template <class F, class ScopeGuardFactory = make_no_scope_guard>
17511782
return make_batched_custom_evaluator(
17521783
std::move(yielder), std::move(target), std::move(accept),
17531784
std::move(make_scope_guard), std::move(is_volatile_node),
1754-
policy.persistent_only, /*depth=*/0, peak);
1785+
policy.persistent_only, /*depth=*/0, peak,
1786+
policy.suppress_heuristic_fallback);
17551787
}
17561788

17571789
} // namespace sequant

0 commit comments

Comments
 (0)