Skip to content

util: equation()'s label walk has no visited set, so a shared derivation costs one walk per path #602

Description

@Yaraslaut

Found while implementing #582's step limit, and filed separately because it is a
different defect with different evidence: #582 is about a derivation that is
long, this one is about a derivation that is shared, and the cost here is
exponential rather than quadratic.

EquationRenderer::assignLabels (include/morph/detail/quantity_equation.hpp)
is the one walk in equation() with no visited set. countRefs has seen;
render stops at a cK placeholder, a name, or an atom. assignLabels stops
at none of those — it labels a placeholder and then descends through it anyway:

if (!frame.expandThis && isPlaceholder(node) && !labelIndex.contains(node)) {
    labelIndex.emplace(node, placeholderOrder.size() + 1);
    placeholderOrder.push_back(node);
}
// Both the labelled and the unlabelled arm descend exactly when the
// node is not an atom, so the two cases share one exit.
if (isAtomNode(*node)) {
    continue;
}
pending.push_back(LabelFrame{.node = node->right.get(), .expandThis = false});
pending.push_back(LabelFrame{.node = node->left.get(), .expandThis = false});

So a node reachable by k displayed paths is walked k times, not once. The
derivation is a DAG, not a tree — the spec says so ("the derivation is a
shared DAG", docs/spec/util/quantity_type.md) — so the path count can be
exponential in the node count.

Verification status: reproduced

Revision: master be64026a. Reproduced with a standalone harness, clang 22.1.8,
-O1, no sanitizers. q = q + q repeated k times: k + 1 distinct nodes,
2^k root-to-leaf paths.

k=20 nodes=21 equation=0.006s lines=23 first=7 chars
k=22 nodes=23 equation=0.025s lines=25 first=7 chars
k=24 nodes=25 equation=0.099s lines=27 first=7 chars
k=26 nodes=27 equation=0.723s lines=29 first=7 chars
k=28 nodes=29 equation=2.653s lines=31 first=7 chars
k=30 nodes=31 equation=10.322s lines=33 first=7 chars

Four times the time for two more nodes — 2^k, cleanly. A 31-node derivation
takes 10.3 s to render.
The output it produces is 33 lines, none longer than
about 50 characters:

  [0] c1 + c1
  [1]     = 536870912 + 536870912
  [2]     = 1073741824
  [3] where c1 = c2 + c2 = 268435456 + 268435456 = 536870912
  [4]       c2 = c3 + c3 = 134217728 + 134217728 = 268435456

That linear output next to exponential time is what localises the defect without
a profiler: every walk that emits text is bounded here, so the time is being
spent in the one walk that emits nothing. Confirmed by mutation — adding a
visited set to assignLabels alone (no other change, step limit explicitly
disabled so it cannot be what helps) takes k=30 to 0.000 s with byte-identical
output
, and k=60 — 2^60 paths — also renders instantly:

k=30 nodes=31 equation=0.000s lines=33 first=7 chars    (step limit unlimited)
k=60 nodes=61 equation=0.000s lines=63 first=7 chars    (step limit unlimited)

Why the fix is safe

The walk exists to assign cK labels in first-appearance order. A second visit
to a node can only re-assign labels it already holds (the code guards that with
!labelIndex.contains(node)) and re-walk a subtree it already walked, so
skipping it changes nothing observable — which the byte-identical output above
is the evidence for.

Reachability

q + q is contrived; the realistic shape is the one the spec's own example
builds — a sub-value reused in several places, (x - y) / x — nested. Each
level of nesting doubles the walk. Whether a real application nests reuse deeply
enough to notice is not established here: this is reproduced as a cost
property of the code, not observed in a caller.

What would change the verdict

  • Close when assignLabels carries a visited set and a nested-reuse
    derivation renders in time proportional to its node count.
  • Re-open if any other equation() walk is found to lack one.

Not verified

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: utilSubsystem: utilbugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions