// -----------------------------------------------------------------------------
// Shortest-path Plan A coverage search (do-nothing-plan.md "Next Step 2").
-double
-scan_next_covering_debt(const PlanSearchInputs &in,
- const BeesRewritePolicy &policy,
- size_t dedupe_ops, size_t copy_ops, size_t matched_blocks)
+CostReport
+scan_next_debt_fields(uint64_t dst_ref_count, size_t frag_count, size_t copy_ops,
+ bool dst_compressed, uint64_t dst_phys_size,
+ uint64_t good_blocks, uint64_t clone_alignment)
{
- // Mirror scan_next_dst_debt + CostReport::space_debt exactly so the
- // search's debt equals scan_next_dst_debt() of the ChosenPlan the
- // caller builds from the returned covering.
- //
- // F (fragment count) = rewrite_ops() = dedupe + copy + hole ops.
- const size_t frag_count = dedupe_ops + copy_ops + in.m_hole_ops;
+ CostReport c;
// refs_added = dst_ref_count * (F - 1); guard F <= 1 so it can't wrap.
- uint64_t refs_added = 0;
if (frag_count > 1) {
- refs_added = in.m_dst_ref_count
+ c.m_refs_added = dst_ref_count
* (static_cast<uint64_t>(frag_count) - 1);
}
// refs_created = dst_ref_count * F: every dst reference re-written once
- // per fragment. This is the FILE_EXTENT_SAME work the plan performs,
- // including the F=1 whole-extent case where refs_added is zero. Weighted
- // by ref_op_cost (default 0) in space_debt().
- const uint64_t refs_created = in.m_dst_ref_count
- * static_cast<uint64_t>(frag_count);
+ // per fragment — the FILE_EXTENT_SAME work, charged even for the F=1
+ // whole-extent case where refs_added is zero. Weighted by ref_op_cost.
+ c.m_refs_created = dst_ref_count * static_cast<uint64_t>(frag_count);
// physical_freed: a compressed dst is removed wholesale (phys_size);
// an uncompressed dst frees good_blocks * blocksize.
- const size_t good_blocks =
- in.m_zero_like_blocks + in.m_unreachable_blocks + matched_blocks;
- uint64_t physical_freed;
- if (in.m_dst_compressed) {
- physical_freed = in.m_dst_phys_size;
- } else {
- physical_freed = static_cast<uint64_t>(good_blocks)
- * in.m_clone_alignment;
- }
+ c.m_physical_freed = dst_compressed
+ ? dst_phys_size
+ : good_blocks * clone_alignment;
// extents_delta: +1 if the plan copies anything (all copies share one
- // tempfile extent), -1 if the dst is fully freed (no unmatched data
- // left). For a complete covering copy_ops > 0 iff some data block is
- // unmatched, so bad_blocks == 0 iff copy_ops == 0.
- int64_t extents_delta = (copy_ops > 0) ? 1 : 0;
- if (copy_ops == 0) {
- extents_delta -= 1;
- }
+ // tempfile extent), -1 if the dst is fully freed (no unmatched data left).
+ c.m_extents_delta = (copy_ops > 0) ? 1 : -1;
- CostReport c;
- c.m_refs_added = refs_added;
- c.m_refs_created = refs_created;
- c.m_extents_delta = extents_delta;
- c.m_physical_freed = physical_freed;
+ return c;
+}
+
+double
+scan_next_covering_debt(const PlanSearchInputs &in,
+ const BeesRewritePolicy &policy,
+ size_t dedupe_ops, size_t copy_ops, size_t matched_blocks)
+{
+ // F (fragment count) = rewrite_ops() = dedupe + copy + hole ops.
+ const size_t frag_count = dedupe_ops + copy_ops + in.m_hole_ops;
+ const uint64_t good_blocks = static_cast<uint64_t>(in.m_zero_like_blocks)
+ + in.m_unreachable_blocks + matched_blocks;
+ const auto c = scan_next_debt_fields(in.m_dst_ref_count, frag_count,
+ copy_ops, in.m_dst_compressed, in.m_dst_phys_size,
+ good_blocks, in.m_clone_alignment);
return c.space_debt(policy);
}
scan_next_plan_shortest_path(const PlanSearchInputs &in,
const BeesRewritePolicy &policy);
+/// THE single computation of the four do-nothing space-debt input fields
+/// (do-nothing-plan.md §4), from raw scalars. Both debt paths marshal their
+/// inputs here — the search via scan_next_covering_debt, the plan-cost path via
+/// scan_next_dst_debt — so the two can never drift (a field added to one but not
+/// the other silently drops the term from selection; that is how ref-op-cost
+/// went inert once). Returns a CostReport with only the four space_debt input
+/// fields set (space_debt() not yet applied); the caller either sums several of
+/// these before pricing (Plan B) or prices immediately (the search).
+/// refs_added = dst_ref_count * (F - 1), 0 when F <= 1 (no fragmentation)
+/// refs_created = dst_ref_count * F (FILE_EXTENT_SAME work; F=1 counts)
+/// physical_freed = compressed ? dst_phys_size : good_blocks * clone_alignment
+/// extents_delta = +1 if any copy (one shared tempfile extent), else -1 (dst
+/// fully freed). copy_ops > 0 iff unmatched data remains, so
+/// this single test matches the old bad_blocks==0 form.
+/// @p good_blocks is the already-summed matched + zero-like + unreachable count.
+CostReport
+scan_next_debt_fields(uint64_t dst_ref_count, size_t frag_count, size_t copy_ops,
+ bool dst_compressed, uint64_t dst_phys_size,
+ uint64_t good_blocks, uint64_t clone_alignment);
+
/// Compute the signed space-debt (do-nothing-plan.md §4) of a covering with
-/// the given operation/block counts, using the same formula as
-/// CostReport::space_debt + scan_next_dst_debt. Exposed for the search's
-/// terminal-state evaluation and for test reconciliation.
+/// the given operation/block counts. Thin wrapper: marshals the search's
+/// inputs into scan_next_debt_fields() (the shared builder) and prices the
+/// result. Exposed for the search's terminal-state evaluation and for tests.
double
scan_next_covering_debt(const PlanSearchInputs &in,
const BeesRewritePolicy &policy,
return rv;
}
-/// Per-dst raw inputs for the do-nothing space-debt model
-/// (do-nothing-plan.md §4). Aggregated across all dsts of a plan into
-/// CostReport's m_refs_added / m_refs_created / m_extents_delta /
-/// m_physical_freed, then combined by CostReport::space_debt() into the
-/// m_space_debt ordering key. Must mirror scan_next_covering_debt
-/// (bees-plan.cc) field-for-field — the search and the plan-cost path share
-/// one space_debt() formula, so a term set in one but not the other (as
-/// m_refs_created once was) silently drops out of orientation selection.
-struct ScanNextDstDebt {
- uint64_t m_refs_added = 0;
- uint64_t m_refs_created = 0;
- int64_t m_extents_delta = 0;
- uint64_t m_physical_freed = 0;
-};
-
-/// Compute the do-nothing debt inputs contributed by deduping/rewriting
-/// one dst extent, given the ChosenPlan composed for it.
+/// Compute the do-nothing debt inputs (do-nothing-plan.md §4) contributed by
+/// deduping/rewriting one dst extent, given the ChosenPlan composed for it.
+/// Returns a CostReport with only the four space_debt input fields set: Plan A
+/// prices it directly, Plan B sums several across candidates before pricing.
+/// The field arithmetic lives in the shared scan_next_debt_fields() (bees-plan)
+/// so this plan-cost path and the search (scan_next_covering_debt) marshal into
+/// one computation and cannot drift.
///
/// Fragment count F is sourced from the composed SuperExtent leaf count,
/// which equals ChosenPlan::rewrite_ops() (scan_next_compose_plan_tree
/// debt) than the unsplit edge, so the debt-minimizing selector never
/// places it on a chosen plan. rewrite_ops() therefore matches what
/// btrfs materializes wherever the debt is consulted.
-static ScanNextDstDebt
+static CostReport
scan_next_dst_debt(BeesExtentLayer &layer,
const BeesExtent &dst, const ChosenPlan &cp)
{
- ScanNextDstDebt rv;
-
- // Fragment count: SuperExtent leaf count for this dst.
- const auto frag_count = cp.rewrite_ops();
-
- // refs_added = dst_ref_count * (F - 1). F >= 1 for any non-empty
- // plan; guard the F == 0 edge (no ops) so the subtraction can't wrap.
- const auto dst_ref_count = dst.refs(layer)->size();
- if (frag_count > 1) {
- rv.m_refs_added = static_cast<uint64_t>(dst_ref_count)
- * (static_cast<uint64_t>(frag_count) - 1);
- }
-
- // refs_created = dst_ref_count * F: every dst reference is re-written once
- // per fragment, the actual FILE_EXTENT_SAME work. Unlike refs_added (net
- // metadata growth, F-1 per ref) this is charged even for a whole-extent
- // (F == 1) dedupe, so it is NOT guarded by frag_count > 1 — mirrors
- // scan_next_covering_debt (bees-plan.cc). This is the term ref-op-cost
- // weights; without it the plan-level cost that drives orientation
- // selection never prices the references a dedupe moves, so draining a
- // high-ref extent as dst looks free.
- rv.m_refs_created = static_cast<uint64_t>(dst_ref_count)
- * static_cast<uint64_t>(frag_count);
-
- // physical_freed: a compressed dst is removed wholesale (the
- // compressed-dst-with-copy reject forbids retaining any fragment),
- // so the recovered space is exactly its phys_size. An uncompressed
- // dst is 1:1 logical:physical, so it is (blocks freed * blocksize),
- // where good_blocks() counts matched + zero_like + unreachable.
+ // Marshal this dst's ChosenPlan into the shared builder:
+ // frag_count F = cp.rewrite_ops() (SuperExtent leaf count for this dst)
+ // copy_ops = cp.copy_ops(); for a complete covering copy_ops > 0
+ // iff unmatched data remains, so it stands in for the
+ // old bad_blocks() > 0 extents_delta test exactly. All
+ // tempfile copies concatenate into one tempfile extent,
+ // so the +1 is per-plan, not per-copy.
+ // good_blocks = cp.good_blocks() = matched + zero_like + unreachable.
+ // physical_freed = phys_size for a compressed dst (removed wholesale,
+ // the compressed-with-copy reject forbids fragments),
+ // else good_blocks * blocksize.
const bool dst_compressed =
dst.compress_type(layer) != BTRFS_COMPRESS_NONE;
- if (dst_compressed) {
- rv.m_physical_freed = dst.phys_size();
- } else {
- rv.m_physical_freed = static_cast<uint64_t>(cp.good_blocks())
- * static_cast<uint64_t>(BeesContext::s_clone_alignment);
- }
-
- // extents_delta: all tempfile copies for this dst are concatenated
- // into a single tempfile hole by scan_next_materialize_plan_tree, so
- // they collectively create exactly one new extent (+1) regardless of
- // copy_ops count — not one extent per copy op. A dst that is fully
- // freed (no unmatched data left behind) removes one extent (-1). The
- // ref cost is unaffected: each copied fragment still adds its own ref,
- // already accounted for via rewrite_ops() in refs_added above.
- rv.m_extents_delta = (cp.copy_ops() > 0) ? 1 : 0;
- if (cp.bad_blocks() == 0) {
- rv.m_extents_delta -= 1;
- }
-
- return rv;
+ return scan_next_debt_fields(
+ dst.refs(layer)->size(),
+ cp.rewrite_ops(),
+ cp.copy_ops(),
+ dst_compressed,
+ dst.phys_size(),
+ cp.good_blocks(),
+ static_cast<uint64_t>(BeesContext::s_clone_alignment));
}
// -----------------------------------------------------------------------------
}
}
+// scan_next_debt_fields is THE single builder for the four space_debt input
+// fields, shared by the search (scan_next_covering_debt) and the plan-cost path
+// (scan_next_dst_debt) so the two cannot drift. Pin its arithmetic directly —
+// especially refs_created at F=1, whose absence from the plan path made
+// ref-op-cost inert in orientation selection.
+static void
+test_debt_fields_shared_builder()
+{
+ // Whole-extent (F=1) dedupe, compressed dst, no copy: no fragmentation,
+ // but refs_created MUST still be charged (dst_ref_count * 1) — this is the
+ // term that priced the re-canonicalization drain out of selection.
+ {
+ const auto c = scan_next_debt_fields(
+ /*dst_ref_count=*/3000, /*frag_count=*/1, /*copy_ops=*/0,
+ /*dst_compressed=*/true, /*dst_phys_size=*/131072,
+ /*good_blocks=*/32, /*clone_alignment=*/4096);
+ assert(c.m_refs_added == 0); // F=1 -> no metadata growth
+ assert(c.m_refs_created == 3000); // work still priced
+ assert(c.m_physical_freed == 131072); // compressed: whole phys_size
+ assert(c.m_extents_delta == -1); // dst fully freed, no copy
+ }
+ // Fragmented (F=3) dedupe, uncompressed, with copy.
+ {
+ const auto c = scan_next_debt_fields(
+ /*dst_ref_count=*/10, /*frag_count=*/3, /*copy_ops=*/1,
+ /*dst_compressed=*/false, /*dst_phys_size=*/0,
+ /*good_blocks=*/5, /*clone_alignment=*/4096);
+ assert(c.m_refs_added == 10 * (3 - 1)); // dst_ref_count*(F-1)
+ assert(c.m_refs_created == 10 * 3); // dst_ref_count*F
+ assert(c.m_physical_freed == 5 * 4096); // uncompressed: good_blocks*bs
+ assert(c.m_extents_delta == 1); // copy present -> +1 tempfile
+ }
+ // F=0 edge (no ops): refs_added guarded against wrap, refs_created 0.
+ {
+ const auto c = scan_next_debt_fields(999, 0, 0, false, 0, 0, 4096);
+ assert(c.m_refs_added == 0);
+ assert(c.m_refs_created == 0);
+ }
+ // Whole-extent dedupe priced through the shared builder equals what
+ // scan_next_covering_debt computes for the same covering — the two paths
+ // agree by construction now that covering_debt wraps the builder.
+ {
+ auto policy = default_policy();
+ policy.m_ref_op_cost = 100;
+ const auto in = make_inputs(1, { {0, 1} }, /*ref=*/3000);
+ const auto c = scan_next_debt_fields(3000, /*F=*/1, /*copy=*/0,
+ /*compressed=*/false, /*phys=*/0, /*good_blocks=*/1,
+ /*clone_alignment=*/BLOCK);
+ assert(c.space_debt(policy)
+ == scan_next_covering_debt(in, policy, 1, 0, 1));
+ }
+}
+
int
main(int, char **)
{
RUN_A_TEST(test_search_v_budget_greedy_fallback());
RUN_A_TEST(test_search_v_budget_greedy_abandons_over_limit());
RUN_A_TEST(test_ref_op_cost_prices_whole_extent_dedupe());
+ RUN_A_TEST(test_debt_fields_shared_builder());
RUN_A_TEST(test_search_prefers_high_ref_src());
return 0;
}