]> git.hungrycats.org Git - bees/commitdiff
bees-plan: add V-budget greedy fallback to the coverage search
authorZygo Blaxell <bees@furryterror.org>
Thu, 2 Jul 2026 21:22:15 +0000 (17:22 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:13 +0000 (00:04 -0400)
The shortest-path coverage DP in scan_next_plan_shortest_path is O(V^2) in
the boundary count V (and worse when the state-collapse fast path is off).
Because a plan holds the dst extent's Exclusion for the whole search, a
single high-V extent does not merely burn one worker's CPU — it stalls
every task queued behind that extent.  Production has seen an 8-hour
single-extent grind (128M extent, collapse disabled by total-max set above
the per-type caps), during which two of four workers were absorbed and
throughput collapsed to zero.

Bound the worst case: when the boundary count exceeds BEES_PLAN_V_BUDGET
(8192, the empirical knee from test/bench-bees-plan.cc), abandon the exact
search for a linear-time greedy maximal-reach minimal-interval cover.  The
greedy minimizes dedupe-op count — hence fragment count and refs_added, the
dominant debt term — and fills uncovered gaps with copy slices, then reuses
scan_next_covering_debt so the returned debt is identical in kind to the
DP's.  scan_next_rewrite_acceptable remains the sole final authority; the
greedy re-checks the same per-type and total limits and returns nullopt when
no limit-satisfying cover results (the intended "abandon this extent"
outcome for extents too fragmented to plan cheaply).

The gate keys on V, so behavior is unchanged for every input below the
budget: the existing unit tests (brute-force oracle included) pass as-is.
On the dense, fully-coverable extents that actually caused the stall the
greedy returns the *same* cover as the DP (minimal-interval is optimal when
there is no dedupe-vs-copy tradeoff) ~1000x faster: a saturated 128M-extent
search fell from 21 s to 13 ms in the bench.

PROTOTYPE: the threshold is a file-scope constant.  Productionizing it means
a rewrite-policy key (rewrite.plan-max-vertices) wired across bees.h,
bees-config-v2.cc, bees-context.cc, and docs/config-file.md.

Assisted-by: Claude-Code:claude-opus-4-8
src/bees-plan.cc

index 2f91a4a1cddf48c574122937e8896872cf41bc72..794648437e167ab13febc31ad978dc9ee4f5db06 100644 (file)
@@ -103,6 +103,137 @@ scan_next_covering_debt(const PlanSearchInputs &in,
        return c.space_debt(policy);
 }
 
+// Above this many boundary vertices (V), the exact O(V^2) shortest-path DP is
+// abandoned for a linear-time greedy minimal-interval cover.  test/bench-bees-
+// plan.cc puts the DP's worst-case knee (collapse on) at V ~ 8192-16384;
+// beyond it a single dense extent can occupy a worker — and, because it holds
+// that dst's Exclusion the whole time, starve every task queued behind it —
+// for seconds to minutes (production has seen an 8-hour single-extent grind
+// with the collapse fast path disabled).  The greedy minimizes dedupe-op count
+// (hence fragment count and refs_added / debt) but ignores dedupe-vs-copy debt
+// tradeoffs, so it returns a valid, limit-satisfying, but possibly-suboptimal
+// cover in O(M log M + V).
+//
+// PROTOTYPE: the threshold is a file-scope constant here; productionizing it
+// means a rewrite-policy key (e.g. rewrite.plan-max-vertices) wired across
+// bees.h / bees-config-v2.cc / bees-context.cc / docs/config-file.md per the
+// "update all docs when adding a config option" rule.
+static constexpr size_t BEES_PLAN_V_BUDGET = 8192;
+
+// Total boundary-vertex count across all data regions — the DP's actual cost
+// driver V (region ends plus distinct in-region match endpoints).  Cheap
+// (O(R*M + V log V)); gates the greedy fallback.  Mirrors the per-region vertex
+// construction in the DP below so the count matches the walk exactly.
+static size_t
+scan_next_plan_boundary_count(const PlanSearchInputs &in)
+{
+       size_t total = 0;
+       for (const auto &region : in.m_data_regions) {
+               vector<uint64_t> verts{ region.m_begin, region.m_end };
+               for (const auto &m : in.m_matches) {
+                       if (m.m_dst_end <= region.m_begin
+                           || m.m_dst_begin >= region.m_end) continue;
+                       if (m.m_dst_begin > region.m_begin
+                           && m.m_dst_begin < region.m_end) verts.push_back(m.m_dst_begin);
+                       if (m.m_dst_end > region.m_begin
+                           && m.m_dst_end < region.m_end) verts.push_back(m.m_dst_end);
+               }
+               sort(verts.begin(), verts.end());
+               verts.erase(unique(verts.begin(), verts.end()), verts.end());
+               total += verts.size();
+       }
+       return total;
+}
+
+// Greedy fallback for high-V extents: a maximal-reach minimal-interval cover.
+// Walks each data region left to right; at each position it extends by the
+// match reaching farthest from at-or-before that position (which minimizes the
+// number of dedupe ops, hence fragments and refs_added), and fills any
+// uncovered gap with one copy slice.  Op counts accumulate across regions (F is
+// whole-extent); a copy run never spans the hole between regions.  Returns
+// nullopt when the resulting cover cannot satisfy the rewrite limits (mirroring
+// the DP's "every path pruned" outcome) or when a compressed dst has a gap no
+// dedupe covers (copy edges are not permitted on a compressed dst).  The debt
+// is computed by scan_next_covering_debt, identical to the DP's terminal
+// evaluation, so the caller's acceptable()/executor path is unchanged.
+static optional<PlanSearchResult>
+scan_next_plan_greedy(const PlanSearchInputs &in,
+       const BeesRewritePolicy &policy)
+{
+       // block_count matching the DP (ceil division; spans are block-aligned in
+       // practice so this equals exact division, but stay identical to the DP).
+       const auto block_count = [&](uint64_t begin, uint64_t end) -> size_t {
+               return static_cast<size_t>(
+                       (end - begin + in.m_sums_block_size - 1) / in.m_sums_block_size);
+       };
+
+       // Matches in ascending dst_begin order for the reach sweep.
+       vector<size_t> by_begin(in.m_matches.size());
+       for (size_t i = 0; i < by_begin.size(); ++i) by_begin[i] = i;
+       sort(by_begin.begin(), by_begin.end(), [&](size_t x, size_t y) {
+               return in.m_matches[x].m_dst_begin < in.m_matches[y].m_dst_begin;
+       });
+
+       PlanSearchResult result;
+       size_t dedupe_ops = 0;
+       size_t copy_ops   = 0;
+       size_t matched_blocks = 0;
+
+       for (const auto &region : in.m_data_regions) {
+               uint64_t pos      = region.m_begin;
+               size_t   mp       = 0;                    // sweep cursor into by_begin
+               uint64_t best_end = region.m_begin;       // running max clamped reach
+               size_t   best_idx = in.m_matches.size();
+               while (pos < region.m_end) {
+                       // Absorb matches now eligible (dst_begin <= pos); keep the running
+                       // argmax of clamped dst_end.  Monotone across the region -> O(M+V).
+                       while (mp < by_begin.size()
+                              && in.m_matches[by_begin[mp]].m_dst_begin <= pos) {
+                               const size_t   idx = by_begin[mp];
+                               const auto    &m   = in.m_matches[idx];
+                               ++mp;
+                               if (m.m_dst_end <= region.m_begin
+                                   || m.m_dst_begin >= region.m_end) continue;
+                               const uint64_t end = min(m.m_dst_end, region.m_end);
+                               if (end > best_end) {
+                                       best_end = end;
+                                       best_idx = idx;
+                               }
+                       }
+                       if (best_end > pos) {
+                               // Dedupe edge [pos, best_end) served by match best_idx.
+                               result.m_selected_matches.push_back(best_idx);
+                               matched_blocks += block_count(pos, best_end);
+                               ++dedupe_ops;
+                               pos = best_end;
+                       } else {
+                               // Gap: no match covers pos.  A compressed dst forbids copies.
+                               if (in.m_dst_compressed) return nullopt;
+                               uint64_t gap_end = region.m_end;
+                               if (mp < by_begin.size()) {
+                                       const uint64_t nb = in.m_matches[by_begin[mp]].m_dst_begin;
+                                       if (nb > pos && nb < gap_end) gap_end = nb;
+                               }
+                               result.m_copy_slices.push_back(
+                                       PlanSearchRegion{ pos, gap_end });
+                               ++copy_ops;
+                               pos = gap_end;
+                       }
+               }
+       }
+
+       // Same limits the DP enforces during the walk (scan_next_rewrite_acceptable
+       // remains the sole final authority; this only keeps the fallback honest).
+       if (dedupe_ops > policy.m_dedupe_max) return nullopt;
+       if (copy_ops   > policy.m_copy_max)   return nullopt;
+       if (dedupe_ops + copy_ops + in.m_hole_ops > policy.m_total_max) return nullopt;
+
+       result.m_matched_blocks = matched_blocks;
+       result.m_debt = scan_next_covering_debt(in, policy,
+               dedupe_ops, copy_ops, matched_blocks);
+       return result;
+}
+
 optional<PlanSearchResult>
 scan_next_plan_shortest_path(const PlanSearchInputs &in,
        const BeesRewritePolicy &policy)
@@ -124,6 +255,14 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
                return nullopt;
        }
 
+       // V-budget: the exact DP below is O(V^2) in the boundary count and holds
+       // the dst Exclusion for its whole duration, so a high-V extent can wedge a
+       // worker (and everything queued behind that extent).  Above the budget,
+       // abandon the exact search for the linear greedy cover.
+       if (scan_next_plan_boundary_count(in) > BEES_PLAN_V_BUDGET) {
+               return scan_next_plan_greedy(in, policy);
+       }
+
        // Per-edge ref debit shared by every dedupe and copy edge.
        const double K = static_cast<double>(in.m_dst_ref_count)
                * static_cast<double>(policy.ref_cost);